使用 VanillaJS 从头开始构建类似 React 的状态管理系统。
背景
我已经使用 React 8 个月了,我可以很有信心地说,我可以轻松地制作和构建 React 应用程序。
但是,由于我是通过 React 进入 Web 开发领域的,所以我不确定自己是否能用 Vanilla JS 来表达这一点。因此,我顿悟了,想要了解一些基础知识,并为自己发起了一项名为“ 30 天 Vanilla JS ”的活动。
我坚信学习是通过行动和以结果为导向的任务进行的,所以我一直在寻找我可以构建的新的小项目(1-4 小时)。
类似于 React 的状态管理系统。
这是这次活动的第三天,我想构建一个类似 React 的状态管理系统,但要非常精简。它应该遵循单向数据流。起初我几乎不知道该如何构建它,但随着我不断尝试,它变得越来越容易。
我们将采用一个简单的应用程序,以便我们可以专注于状态管理系统,因此我们将构建一个待办事项应用程序,如下所示
既然我能做到,那么任何新手都能做到。我们开始吧。
设计
下面是我尝试构建的单向流程,我们需要做三件事:
-
捕捉用户动作。
-
调度这些用户操作来设置新状态
-
一旦设置了状态,就重建视图。
让我们从相反的顺序开始。首先,让我们构建一个机制,让我们的页面知道状态何时更新,并自行重建。
状态
我们首先需要一个事件,该事件会在状态更新时立即触发。因此,让我们创建一个如下所示的事件:
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 文件中创建不同的视图/组件,如下所示:
- 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>
现在我们已经定义了我们的组件,现在是时候定义我们的初始状态和动作了。
我们知道我们的状态应该具有以下属性
-
items:待办事项列表,包含文本、标识符以及是否已完成。
-
events:需要执行的操作/事件列表。正如您在上面的代码中看到的,我们也需要将操作传递给组件。
-
currentItem:用户正在尝试保存的当前项目。
-
target:我们操作发生所在的元素。后面我会解释为什么需要这个。现在,你可以忽略它。
下面是初始状态的代码,记住下面的todoState不是一个状态,而是我们的 StateManager 对象。它有两个成员:state 和 todoState:
let todoInitialstate = {
items: [],
currentItem: '',
events: {
recordTodo: 'recordTodo',
insertTodoItem:'insertTodoItem',
deleteTodo: 'deleteTodo',
},
target:{}
};
let todoState= createState(todoInitialstate);
正如您上面看到的,有 3 个事件是必需的。
- 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 事件来刷新此组件。
- 插入待办事项
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