使用 VanillaJS 从头开始​​构建类似 React 的状态管理系统。

2025-06-09

使用 VanillaJS 从头开始​​构建类似 React 的状态管理系统。

背景

我已经使用 React 8 个月了,我可以很有信心地说,我可以轻松地制作和构建 React 应用程序。

但是,由于我是通过 React 进入 Web 开发领域的,所以我不确定自己是否能用 Vanilla JS 来表达这一点。因此,我顿悟了,想要了解一些基础知识,并为自己发起了一项名为“ 30 天 Vanilla JS ”的活动。

我坚信学习是通过行动和以结果为导向的任务进行的,所以我一直在寻找我可以构建的新的小项目(1-4 小时)。

类似于 React 的状态管理系统。

这是这次活动的第三天,我想构建一个类似 React 的状态管理系统,但要非常精简。它应该遵循单向数据流。起初我几乎不知道该如何构建它,但随着我不断尝试,它变得越来越容易。

我们将采用一个简单的应用程序,以便我们可以专注于状态管理系统,因此我们将构建一个待办事项应用程序,如下所示

既然我能做到,那么任何新手都能做到。我们开始吧。

设计

下面是我尝试构建的单向流程,我们需要做三件事:

  1. 捕捉用户动作。

  2. 调度这些用户操作来设置新状态

  3. 一旦设置了状态,就重建视图。

让我们从相反的顺序开始。首先,让我们构建一个机制,让我们的页面知道状态何时更新,并自行重建。

状态

我们首先需要一个事件,该事件会在状态更新时立即触发。因此,让我们创建一个如下所示的事件:

let stateUpdated = new Event('stateUpdate');

一旦我们有了事件,我们将需要定义一个状态和状态设置器。

function StateManager(initialState) {
    this.state =  initialState   
}

我们定义一个名为 StateManager 的函数/类,它接受组件的初始状态并进行设置。

现在让我们编写一个接受新状态的方法。

function StateManager(initialState) {
    this.state =  initialState
    //
    const setStateInternal = (newState) => {
        console.log(`In the setting. Setting state now with value ${JSON.stringify(newState)}.`)
        this.state = newState;
        console.log(`New state is ${JSON.stringify(this.state)}`);
    }
}

目前,我将状态设置器保留在内部,因为我不希望任何人直接调用此方法,因为请记住,我们的设置器也需要分派事件,以便组件得到更新/重新生成。

function StateManager(initialState) {
    this.state =  initialState
    //
    const setStateInternal = (newState) => {
        console.log(`In the setting. Setting state now with value ${JSON.stringify(newState)}.`)
        this.state = newState;
        console.log(`New state is ${JSON.stringify(this.state)}`);
    }


    // public state setter.
    this.setState = new Proxy(setStateInternal, {
        apply: function(target, thisArgs, argumentList){
            console.log(arguments)
            console.log('Now setting the state');
            target(...argumentList);
            let eventFired  = dispatchEvent(stateUpdated);
            console.log(`Event Fired : ${eventFired}`);
        }
    });


}

查看上面的 this.setState ,它是 setStateInternal 的代理,用于分发事件(倒数第二行)。我们只需调用 dispatchEvent 函数来分发我们在第一步中创建的事件。

如果您不了解代理,您可以查看本教程

简而言之,代理是 Javascript 对象的一种中间件,假设您正在调用一个函数或设置一个对象的属性,您可以在该函数调用或属性分配之前/之后执行某个操作。

无需代理也可以轻松实现这一点,但我想学习和使用它,所以就在这里。

或者,您可以使用只调用 setStateInternal 并分派事件的函数,如上图倒数第二行所示。

现在,我们的状态定义已经完成,我们应该有一种方法让每个组件创建自己的状态,如下所示:

function createState (initialState) {
    console.log('initializing state')
    let tempState = new StateManager(initialState);

    return tempState;
};

上述函数每次调用时都会为状态创建一个新实例,其中 state 和 setState 作为公共成员。

我们的 state.js 现在已经完成。

因为我正在构建一个待办事项应用程序,所以我将我的新文件称为

todo.js

让我们首先在 JS 文件中创建不同的视图/组件,如下所示:

  1. TODO_ITEM

这将是我们最低级别的组件,它将代表一个 TODO_ITEM。

 const TODO_NEW_ITEMS = (item, deletionAction) => {
     console.log(`In todo items : ${item}`)
     return `
        <div id="todo-item" class= "todo-item" data-id=${item.id}>
            <p id='todo-text'>${item.value}</p>
            <button id="delTodo" onclick=${deletionAction}(this)>DEL</button>
        </div>
     `
 }

它从我们的状态中获取项目详情以及删除操作/完成操作。我们很快就会发现这一点。简而言之,它返回一个 HTML 的视图/字符串表示。

你是不是已经感受到 JSX 的魅力了?写下这段代码的时候,我欣喜若狂。
注意上面代码中 deleteAction 后面的 ()。记住,在 HTML 中,我们需要调用函数,而不是像 React 那样直接传递引用。

类似地,我们将编写完成项目的组件/视图。

 const TODO_COMPLETED_ITEMS =(item) => {
     return `
        <div id="todo-completed-item" class= "todo-completed-item" data-id=${item.id}>
            <p id='todo-completed-text'>${item.value}</p>
        </div>
     `
 }

它并不完全遵循 DRY 原则,但由于时间紧迫,我还是继续进行单独的声明。

现在是时候编写已完成的 TODO_COMPONENT

const TODO_PAGE = (state) => {

    return ` <div class="todo-container">
    <div class="todo-items">
    ${
        state.items.map(item=>{
            if (!item.completed){
                return TODO_NEW_ITEMS(item, state.events.deleteTodo);
            }

        }).join('\n')
    }
    </div>
    <form class="todo-input-container" action='javascript:' ">
      <div class="todo-input">
        <input id="newTodo" type="text" name="newTodo" value="${state.currentItem}"  placeholder="Add to do item" onkeyup="${todoState.state.events.recordTodo}(this)" />
      </div>
      <div class="todo-add">
        <button type='button' id="addTodo" name="addTodo" onclick="${todoState.state.events.insertTodoItem}(this)" >ADD</button>
      </div>
    </form>
    <div class='todo-completed'>
    ${
        state.items.map(item=>{
            if (item.completed){
                return TODO_COMPLETED_ITEMS(item);
            }

        }).join('\n')
    }
    </div>
  </div>`
 }

我知道这很多,但让我们逐一分解。

a. TODO_PAGE 接受完成状态作为输入

b. 它有一个新待办事项部分,如下所示,因此它会查看状态的 items 属性并循环它并调用我们的 TODO_NEW_ITEMS 组件。

类似地,在上面的代码的末尾,我们也有待完成的项目组件代码。


<div class="todo-items">
    ${
        state.items.map(item=>{
            if (!item.completed){
                return TODO_NEW_ITEMS(item, state.events.deleteTodo);
            }

        }).join('\n')
    }
    </div>

c. 下一段代码是用于写入 Todo 组件的文本框和用于将其提交到待办事项列表的按钮。

 <form class="todo-input-container" action='javascript:' ">
      <div class="todo-input">
        <input id="newTodo" type="text" name="newTodo" value="${state.currentItem}"  placeholder="Add to do item" onkeyup="${todoState.state.events.recordTodo}(this)" />
      </div>
      <div class="todo-add">
        <button type='button' id="addTodo" name="addTodo" onclick="${todoState.state.events.insertTodoItem}(this)" >ADD</button>
      </div>
    </form>

现在我们已经定义了我们的组件,现在是时候定义我们的初始状态和动作了。

我们知道我们的状态应该具有以下属性

  1. items:待办事项列表,包含文本、标识符以及是否已完成。

  2. events:需要执行的操作/事件列表。正如您在上面的代码中看到的,我们也需要将操作传递给组件。

  3. currentItem:用户正在尝试保存的当前项目。

  4. target:我们操作发生所在的元素。后面我会解释为什么需要这个。现在,你可以忽略它。

下面是初始状态的代码,记住下面的todoState不是一个状态,而是我们的 StateManager 对象。它有两个成员:state 和 todoState:

let todoInitialstate = {
    items: [],
    currentItem: '',
    events: {
        recordTodo: 'recordTodo',
        insertTodoItem:'insertTodoItem',
        deleteTodo: 'deleteTodo',
    },
    target:{}
};

let todoState= createState(todoInitialstate);

正如您上面看到的,有 3 个事件是必需的。

  1. recordTodo -> 用于记录用户在添加 Todo 时输入的内容。下面是简单的代码。对于熟悉 React 的人来说,这很容易理解。
function recordTodo(target) {
    //todoItemsSpace.appendChild(todoItem(event.target.value));
    // state.currentItem = event.target.value;
    console.log(`event fired with state value ${JSON.stringify(todoState.state)}`);
    console.log(target)
    // updateState(state);
    // rough.innerHTML = event.target.value
    todoState.setState({
        ...todoState.state,
        currentItem : target.value,
        target: target
    })
}

你会注意到,它接受的是目标而不是事件作为输入。这是 HTML 和 JavaScript 的工作方式。有两种方法可以附加事件

a. 像我上面那样,在 HTML 中附加它。如果在 HTML 中传递此方法,则会将目标 HTML 元素传递给 JavaScript 函数

b. 当您使用 JavaScript 中的 addEventListener 函数添加事件监听器时,您将获得 Event 作为参数。

如果我遗漏了什么,请纠正,但这就是我观察到的。

另外,在上面代码的最后一行,我们简单地调用 set state 函数,它将设置适当的状态并触发事件。我们将看到如何通过监听 stateUpdate 事件来刷新此组件。

  1. 插入待办事项
function insertTodoItem(target){
    console.log('insertTodoItem')
    console.log('Adding todo npow.')


    let id = Date.now();
    let tempState = todoState.state;
    tempState.items.push({
        id: id,
        value: tempState.currentItem,
        completed: false
    })

    tempState.currentItem = '';
    tempState.target = target;
    todoState.setState(tempState);

}

请耐心等待,我们快完成了。我们已经创建了状态、状态管理器、组件和操作。

现在是时候看看如何重新生成视图了。你还记得吗,我们在 stateUpdate 事件触发时生成视图。所以,我们先来听听这个事件。

window.addEventListener('stateUpdate', generateView);

现在我们正在监听这个事件,让我们定义generateView函数。

function generatePage(){

    let main_Page =  TODO_PAGE(todoState.state);

    document.getElementById('root').innerHTML = main_Page;

    let element = todoState.state.target;
    if(element.type == 'text'){
        document.getElementById(element.id).setSelectionRange(element.selectionStart, element.selectionEnd)

        document.getElementById(element.id).focus(); 
    }
}

第一行获取 TODO_PAGE 组件的 HTML 字符串。

在第二行,我们在 HTML 文件中查找根元素,并渲染此 HTML 字符串。我们的 HTML 页面与 React 非常相似,我将在下面分享。

从第三行我们可以看到我使用了 target,并且我承诺过,我会解释为什么我们需要 target。考虑一个场景,

当你设置 innerHTML 时会发生什么

我正在尝试添加一个待办事项组件,当我输入时,它将触发 recordTodo 操作,该操作将更新状态并依次重新渲染视图,如上面的代码所示。

现在,随着页面重新渲染,我们将失去输入待办事项的文本框的焦点。我们不仅需要保持焦点,还需要恢复光标位置,以使页面看起来流畅且无闪烁。

因此,我只是将焦点重置回事件实际发生的元素,并且还恢复了该光标位置。

就这样,我们完成了。下面是最小的 HTML 文件:

index.html
_____________________________

<html>
    <head>
        <title>Todo - State Managed App</title>
        <script async src='./state.js'></script>
        <script  async src='./todo.js'></script>
        <link rel= 'stylesheet' type='text/css' href="./index.css" />
    </head>

    <body>
        <div id='root'>

        </div>
    </body>
</html>

要查看完整代码,请访问我的30 天 Vanilla-JS代码库。如果你和我一样是初学者,可以点赞关注我 30 天 Vanilla JS 的学习思路。

如果您是一位专家,请支持我,提出一些可以在 1-4 小时内完成并且可以学到一些东西的小型项目的想法。

链接:https://dev.to/logeekal/building-state-management-system-like-react-from-scratch-with-vanillajs-3eon
PREV
如何使用 Nginx 运行 Node.js 服务器
NEXT
哈希表简介(JS 对象底层)