- 原文地址:Build a state management system with vanilla JavaScript
- 原文做者:ANDY BELL
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:Shery
- 校对者:IridescentMia coconilu
状态管理在软件方面并不新鲜,但在 JavaScript 构建的应用中仍然相对较新。习惯上,咱们会直接将状态保持在 DOM 上,甚至将其分配给 window 中的全局对象。可是如今,咱们已经有了许多选择,这些库和框架能够帮助咱们管理状态。像 Redux,MobX 和 Vuex 这样的库能够轻松管理跨组件状态。它大大提高了应用程序的扩展性,而且它对于状态优先的响应式框架(如 React 或 Vue)很是有用。javascript
这些库是如何运做的?咱们本身写个状态管理会怎么样?事实证实,它很是简单,而且有机会学习一些很是常见的设计模式,同时了解一些既有用又能用的现代 API。css
在咱们开始以前,请确保你已掌握中级 JavaScript 的知识。你应该了解数据类型,理想状况下,你应该掌握一些更现代的 ES6+ 语法特性。若是没有,这能够帮到你。值得注意的是,我并非说你应该用这个代替 Redux 或 MobX。咱们正在一块儿开发一个小项目来提高技能,嘿,若是你在意的是 JavaScript 文件规模的大小,那么它确实能够应付一个小型应用。html
在咱们深刻研究代码以前,先看一下咱们正在开发什么。它是一个汇总了你今天所取得成就的“完成清单”。它将在不依赖框架的状况下像魔术般更新 UI 中的各类元素。但这并非真正的魔术。在幕后,咱们已经有了一个小小的状态系统,它等待着指令,并以一种可预测的方式维护单一来源的数据。前端
查看演示java
查看仓库android
很酷,对吗?咱们先作一些配置工做。我已经整理了一些模版,以便咱们可让这个教程简洁有趣。你须要作的第一件事情是 从 GitHub 上克隆它,或者 下载并解压它的 ZIP 文件。ios
当你下载好了模版,你须要在本地 Web 服务器上运行它。我喜欢使用一个名为 http-server 的包来作这些事情,但你也可使用你想用的任何东西。当你在本地运行它时,你会看到以下所示:git
咱们模版的初始状态。github
用你喜欢的文本编辑器打开根目录。此次对我来讲,根目录是:npm
~/Documents/Projects/vanilla-js-state-management-boilerplate/
复制代码
你应该能够看到相似这样的结构:
/src
├── .eslintrc
├── .gitignore
├── LICENSE
└── README.md
复制代码
接下来,打开 src
文件夹,而后进入里面的 js
文件夹。建立一个名为 lib
的新文件夹。在里面,建立一个名为 pubsub.js
的新文件。
你的 js
目录结构应该是这样的:
/js
├── lib
└── pubsub.js
复制代码
由于咱们准备要建立一个小型的 Pub/Sub 模式(发布/订阅模式),因此请打开 pubsub.js
。咱们正在建立容许应用程序的其余部分订阅具名事件的功能。而后,应用程序的另外一部分能够发布这些事件,一般还会携带一些相关的载荷。
Pub/Sub 有时很难掌握,那举个例子呢?假设你在一家餐馆工做,你的顾客点了一个前菜和主菜。若是你曾经在厨房工做过,你会知道当侍者清理前菜时,他们让厨师知道哪张桌子的前菜已经清理了。这是该给那张桌子上主菜的提示。在一个大厨房里,有一些厨师可能在准备不一样的菜肴。他们都订阅了侍者发出的顾客已经吃完前菜的提示,所以他们本身知道要准备主菜。因此,你有多个厨师订阅了同一个提示(具名事件),收到提示后作不一样的事(回调)。
但愿这样想有助于理解。让咱们继续!
PubSub 模式遍历全部订阅,并触发其回调,同时传入相关的载荷。这是为你的应用程序建立一个很是优雅的响应式流程的好方法,咱们只需几行代码便可完成。
将如下内容添加到 pubsub.js
:
export default class PubSub {
constructor() {
this.events = {};
}
}
复制代码
咱们获得了一个全新的类,咱们将 this.events
默认设置为空对象。this.events
对象将保存咱们的具名事件。
在 constructor 函数的结束括号以后,添加如下内容:
subscribe(event, callback) {
let self = this;
if(!self.events.hasOwnProperty(event)) {
self.events[event] = [];
}
return self.events[event].push(callback);
}
复制代码
这是咱们的订阅方法。你传递一个惟一的字符串 event
做为事件名,以及该事件的回调函数。若是咱们的 events
集合中尚未匹配的事件,那么咱们使用一个空数组建立它,这样咱们没必要在之后对它进行类型检查。而后,咱们将回调添加到该集合中。若是它已经存在,就直接将回调添加到该集合中。咱们返回事件集合的长度,这对于想要知道存在多少事件的人来讲会方便些。
如今咱们已经有了订阅方法,猜猜看接下来咱们要作什么?你知道的:publish
方法。在你的订阅方法以后添加如下内容:
publish(event, data = {}) {
let self = this;
if(!self.events.hasOwnProperty(event)) {
return [];
}
return self.events[event].map(callback => callback(data));
}
复制代码
该方法首先检查咱们的事件集合中是否存在传入的事件。若是没有,咱们返回一个空数组。没有悬念。若是有事件,咱们遍历每一个存储的回调并将数据传递给它。若是没有回调(这种状况不该该出现),也没事,由于咱们在 subscribe
方法中使用空数组建立了该事件。
这就是 PubSub 模式。让咱们继续下一部分!
咱们如今已经有了 Pub/Sub 模块,咱们这个小应用程序的核心模块 Store 类有了它的惟一依赖。如今咱们开始完善它。
让咱们先来概述一下这是作什么的。
Store 是咱们的核心对象。每当你看到 @import store from'../lib/store.js
时,你就会引入咱们要编写的对象。它将包含一个 state
对象,该对象又包含咱们的应用程序状态,一个 commit
方法,它将调用咱们的 >mutations,最后一个 dispatch
函数将调用咱们的 actions。在这个应用和 Store
对象的核心之间,将有一个基于代理的系统,它将使用咱们的 PubSub
模块监视和广播状态变化。
首先在 js
目录中建立一个名为 store
的新目录。在那里,建立一个名为 store.js
的新文件。如今你的 js
目录应该以下所示:
/js
└── lib
└── pubsub.js
└──store
└── store.js
复制代码
打开 store.js
并导入咱们的 Pub/Sub 模块。为此,请在文件顶部添加如下内容:
import PubSub from '../lib/pubsub.js';
复制代码
对于那些常用 ES6 的人来讲,这将是很是熟悉的。可是,在没有打包工具的状况下运行这种代码可能不太容易被浏览器识别。对于这种方法,已经得到了不少浏览器支持!
接下来,让咱们开始构建咱们的对象。在导入文件后,直接将如下内容添加到 store.js
:
export default class Store {
constructor(params) {
let self = this;
}
}
复制代码
这一切都一目了然,因此让咱们添加下一项。咱们将为 state
,actions
和 mutations
添加默认对象。咱们还添加了一个 status
属性,咱们将用它来肯定对象在任意给定时间正在作什么。这是在 let self = this;
后面的:
self.actions = {};
self.mutations = {};
self.state = {};
self.status = 'resting';
复制代码
以后,咱们将建立一个新的 PubSub
实例,它将做为 store
的 events
属性的值:
self.events = new PubSub();
复制代码
接下来,咱们将搜索传入的 params
对象以查看是否传入了任何 actions
或 mutation
。当实例化 Store
对象时,咱们能够传入一个数据对象。其中包括 actions
和 mutation
的集合,它们控制着咱们 store 中的数据流。在你添加的最后一行代码后面添加如下代码:
if(params.hasOwnProperty('actions')) {
self.actions = params.actions;
}
if(params.hasOwnProperty('mutations')) {
self.mutations = params.mutations;
}
复制代码
这就是咱们全部的默认设置和几乎全部潜在的参数设置。让咱们来看看咱们的 Store
对象如何跟踪全部的变化。咱们将使用 Proxy(代理)来完成此操做。Proxy(代理)所作的工做主要是代理 state 对象。若是咱们添加一个 get
拦截方法,咱们能够在每次询问对象数据时进行监控。与 set
拦截方法相似,咱们能够密切关注对象所作的更改。这是咱们今天感兴趣的主要部分。在你添加的最后一行代码以后添加如下内容,咱们将讨论它正在作什么:
self.state = new Proxy((params.state || {}), {
set: function(state, key, value) {
state[key] = value;
console.log(`stateChange: ${key}: ${value}`);
self.events.publish('stateChange', self.state);
if(self.status !== 'mutation') {
console.warn(`You should use a mutation to set ${key}`);
}
self.status = 'resting';
return true;
}
});
复制代码
这部分代码说的是咱们正在捕获状态对象 set
操做。这意味着当 mutation 运行相似于 state.name ='Foo'
时,这个拦截器会在它被设置以前捕获它,并为咱们提供了一个机会来处理更改甚至彻底拒绝它。但在咱们的上下文中,咱们将会设置变动,而后将其记录到控制台。而后咱们用 PubSub
模块发布一个 stateChange
事件。任何订阅了该事件的回调将被调用。最后,咱们检查 Store
的状态。若是它当前不是一个 mutation
,则可能意味着状态是手动更新的。咱们在控制台中添加了一点警告,以便给开发人员一些提示。
这里作了不少事,但我但愿大家开始看到这一切是如何结合在一块儿的,重要的是,咱们如何可以集中维护状态,这要归功于 Proxy(代理)和 Pub/Sub。
如今咱们已经添加了 Store
的核心部分,让咱们添加两个方法。一个是将调用咱们 actions
的 dispatch
,另外一个是将调用咱们 mutation
的 commit
。让咱们从 dispatch
开始,在 store.js
中的 constructor
以后添加这个方法:
dispatch(actionKey, payload) {
let self = this;
if(typeof self.actions[actionKey] !== 'function') {
console.error(`Action "${actionKey} doesn't exist.`); return false; } console.groupCollapsed(`ACTION: ${actionKey}`); self.status = 'action'; self.actions[actionKey](self, payload); console.groupEnd(); return true; } 复制代码
此处的过程是:查找 action,若是存在,则设置状态并调用 action,同时建立日志记录组以使咱们的全部日志保持良好和整洁。记录的任何内容(如 mutation 或 Proxy(代理)日志)都将保留在咱们定义的组中。若是未设置任何 action,它将记录错误并返回 false。这很是简单,并且 commit
方法更加直截了当。
在 dispatch
方法以后添加:
commit(mutationKey, payload) {
let self = this;
if(typeof self.mutations[mutationKey] !== 'function') {
console.log(`Mutation "${mutationKey}" doesn't exist`); return false; } self.status = 'mutation'; let newState = self.mutations[mutationKey](self.state, payload); self.state = Object.assign(self.state, newState); return true; } 复制代码
这种方法很是类似,但不管如何咱们都要本身了解这个过程。若是能够找到 mutation,咱们运行它并从其返回值得到新状态。而后咱们将新状态与现有状态合并,以建立咱们最新版本的 state。
添加了这些方法后,咱们的 Store
对象基本完成了。若是你愿意,你如今能够模块化这个应用程序,由于咱们已经添加了咱们须要的大部分功能。你还能够添加一些测试来检查全部内容是否按预期运行。我不会就这样结束这篇文章的。让咱们实现咱们打算去作的事情,并继续完善咱们的小应用程序!
为了与咱们的 store 通讯,咱们有三个主要区域,根据存储在其中的内容进行独立更新。咱们将列出已提交的项目,这些项目的可视化计数,以及另外一个在视觉上隐藏着为屏幕阅读器提供更准确的信息。这些都作着不一样的事情,但他们都会从共享的东西中受益,以控制他们的本地状态。咱们要作一个基础组件类!
首先,让咱们建立一个文件。在 lib
目录中,继续建立一个名为 component.js
的文件。个人文件路径是:
~/Documents/Projects/vanilla-js-state-management-boilerplate/src/js/lib/component.js
复制代码
建立该文件后,打开它并添加如下内容:
import Store from '../store/store.js';
export default class Component {
constructor(props = {}) {
let self = this;
this.render = this.render || function() {};
if(props.store instanceof Store) {
props.store.events.subscribe('stateChange', () => self.render());
}
if(props.hasOwnProperty('element')) {
this.element = props.element;
}
}
}
复制代码
让咱们来谈谈这段代码吧。首先,咱们要导入 Store
类。这不是由于咱们想要它的实例,而是更多用于检查 constructor
中的一个属性。说到这个,在 constructor
中咱们要看看咱们是否有一个 render 方法。若是这个 Component
类是另外一个类的父类,那么它可能会为 render
设置本身的方法。若是没有设置方法,咱们建立一个空方法来防止事情出错。
在此以后,咱们像上面提到的那样对 Store
类进行检查。咱们这样作是为了确保 store
属性是一个 Store
类实例,这样咱们就能够放心地使用它的方法和属性。说到这一点,咱们订阅了全局 stateChange
事件,因此咱们的对象能够作到响应式。每次状态改变时都会调用 render
函数。
这就是咱们须要为该类所要写的所有内容。它将被用做其余组件类 extend
的父类。让咱们一块儿来吧!
就像我以前说过的那样,咱们要完成三个组件,它们都经过 extend
关键字,继承了基类 Component
。让咱们从最大的一个组件开始开始:项目清单!
在你的 js
目录中,建立一个名为 components
的新文件夹,而后建立一个名为 list.js
的新文件。个人文件路径是:
~/Documents/Projects/vanilla-js-state-management-boilerplate/src/js/components/list.js
复制代码
打开该文件并将这整段代码粘贴到其中:
import Component from '../lib/component.js';
import store from '../store/index.js';
export default class List extends Component {
constructor() {
super({
store,
element: document.querySelector('.js-items')
});
}
render() {
let self = this;
if(store.state.items.length === 0) {
self.element.innerHTML = `<p class="no-items">You've done nothing yet 😢</p>`; return; } self.element.innerHTML = ` <ul class="app__items"> ${store.state.items.map(item => { return ` <li>${item}<button aria-label="Delete this item">×</button></li> ` }).join('')} </ul> `; self.element.querySelectorAll('button').forEach((button, index) => { button.addEventListener('click', () => { store.dispatch('clearItem', { index }); }); }); } }; 复制代码
我但愿有了前面教程,这段代码的含义对你来讲是不言而喻的,可是不管如何咱们仍是要说下它。咱们先将 Store
实例传递给咱们继承的 Component
父类。就是咱们刚刚编写的 Component
类。
在那以后,咱们声明了 render 方法,每次触发 Pub/Sub 的 stateChange
事件时都会调用的这个 render 方法。在这个 render
方法中,咱们会生成一个项目列表,或者是没有项目时的通知。你还会注意到每一个按钮都附有一个事件,而且它们会触发一个 action,而后由咱们的 store 处理 action。这个 action 还不存在,但咱们很快就会添加它。
接下来,再建立两个文件。虽然是两个新组件,但它们很小 —— 因此咱们只是向其中粘贴一些代码便可,而后继续完成其余部分。
首先,在你的 component
目录中建立 count.js
,并将如下内容粘贴进去:
import Component from '../lib/component.js';
import store from '../store/index.js';
export default class Count extends Component {
constructor() {
super({
store,
element: document.querySelector('.js-count')
});
}
render() {
let suffix = store.state.items.length !== 1 ? 's' : '';
let emoji = store.state.items.length > 0 ? '🙌' : '😢';
this.element.innerHTML = `
<small>You've done</small> ${store.state.items.length} <small>thing${suffix} today ${emoji}</small> `; } } 复制代码
看起来跟 list 组件很类似吧?这里没有任何咱们还没有涉及的内容,因此让咱们添加另外一个文件。在相同的 components
目录中添加 status.js
文件并将如下内容粘贴进去:
import Component from '../lib/component.js';
import store from '../store/index.js';
export default class Status extends Component {
constructor() {
super({
store,
element: document.querySelector('.js-status')
});
}
render() {
let self = this;
let suffix = store.state.items.length !== 1 ? 's' : '';
self.element.innerHTML = `${store.state.items.length} item${suffix}`;
}
}
复制代码
与以前同样,这里没有任何咱们还没有涉及的内容,可是你能够看到有一个基类 Component
是多么方便,对吧?这是面向对象编程众多优势之一,也是本教程的大部份内容的基础。
最后,让咱们来检查一下 js
目录是否正确。这是咱们目前所处位置的结构:
/src
├── js
│ ├── components
│ │ ├── count.js
│ │ ├── list.js
│ │ └── status.js
│ ├──lib
│ │ ├──component.js
│ │ └──pubsub.js
└───── store
└──store.js
└──main.js
复制代码
如今咱们已经有了前端组件和主要的 Store
,咱们所要作的就是将它所有链接起来。
咱们已经让 store 系统和组件经过数据来渲染和交互。如今让咱们把应用程序的两个独立部分联系起来,让整个项目一块儿协同工做。咱们须要添加一个初始状态,一些 actions
和一些 mutations
。在 store
目录中,添加一个名为 state.js
的新文件。个人文件路径是:
~/Documents/Projects/vanilla-js-state-management-boilerplate/src/js/store/state.js
复制代码
打开该文件并添加如下内容:
export default {
items: [
'I made this',
'Another thing'
]
};
复制代码
这段代码的含义不言而喻。咱们正在添加一组默认项目,以便在第一次加载时,咱们的小程序将是可彻底交互的。让咱们继续添加一些 actions
。在你的 store
目录中,建立一个名为 actions.js
的新文件,并将如下内容添加进去:
export default {
addItem(context, payload) {
context.commit('addItem', payload);
},
clearItem(context, payload) {
context.commit('clearItem', payload);
}
};
复制代码
这个应用程序中的 actions 很是少。本质上,每一个 action 都会将 payload(关联数据)传递给 mutation,而 mutation 又将数据提交到 store。正如咱们以前所了解的那样,context
是 Store
类的实例,payload
是触发 action 时传入的。说到 mutations,让咱们来添加一些。在同一目录中添加一个名为 mutation.js
的新文件。打开它并添加如下内容:
export default {
addItem(state, payload) {
state.items.push(payload);
return state;
},
clearItem(state, payload) {
state.items.splice(payload.index, 1);
return state;
}
};
复制代码
与 actions 同样,这些 mutations 不多。在我看来,你的 mutations 应该保持简单,由于他们有一个工做:改变 store 的 state。所以,这些例子就像它们最初同样简单。任何适当的逻辑都应该发生在你的 actions
中。正如你在这个系统中看到的那样,咱们返回新版本的 state,以便 Store
的 <code>commit
方法能够发挥其魔力并更新全部内容。有了这个,store 系统的主要模块就位。让咱们经过 index 文件将它们结合到一块儿。
在同一目录中,建立一个名为 index.js
的新文件。打开它并添加如下内容:
import actions from './actions.js';
import mutations from './mutations.js';
import state from './state.js';
import Store from './store.js';
export default new Store({
actions,
mutations,
state
});
复制代码
这个文件把咱们全部的 store 模块导入进来,并将它们结合在一块儿做为一个简洁的 Store
实例。任务完成!
咱们须要作的最后一件事是添加本教程开头的 waaaay 页面 index.html
中包含的 main.js
文件。一旦咱们整理好了这些,咱们就可以启动浏览器并享受咱们的辛勤工做!在 js
目录的根目录下建立一个名为 main.js
的新文件。这是个人文件路径:
~/Documents/Projects/vanilla-js-state-management-boilerplate/src/js/main.js
复制代码
打开它并添加如下内容:
import store from './store/index.js';
import Count from './components/count.js';
import List from './components/list.js';
import Status from './components/status.js';
const formElement = document.querySelector('.js-form');
const inputElement = document.querySelector('#new-item-field');
复制代码
到目前为止,咱们作的就是获取咱们须要的依赖项。咱们拿到了 Store
,咱们的前端组件和几个 DOM 元素。咱们紧接着添加如下代码使表单能够直接交互:
formElement.addEventListener('submit', evt => {
evt.preventDefault();
let value = inputElement.value.trim();
if(value.length) {
store.dispatch('addItem', value);
inputElement.value = '';
inputElement.focus();
}
});
复制代码
咱们在这里作的是向表单添加一个事件监听器并阻止它提交。而后咱们获取文本框的值并修剪它两端的空格。咱们这样作是由于咱们想检查下一步是否会有任何内容传递给 store。最后,若是有内容,咱们将使用该内容做为 payload(关联数据)触发咱们的 addItem
action,而且让咱们闪亮的新 store
为咱们处理它。
让咱们在 main.js
中再添加一些代码。在事件监听器下,添加如下内容:
const countInstance = new Count();
const listInstance = new List();
const statusInstance = new Status();
countInstance.render();
listInstance.render();
statusInstance.render();
复制代码
咱们在这里所作的就是建立组件的新实例并调用它们的每一个 render
方法,以便咱们在页面上得到初始状态。
随着最后的添加,咱们完成了!
打开你的浏览器,刷新并沉浸在新状态管理应用程序的荣耀中。来吧,添加一些相似于**“完成这个使人敬畏的教程”**的条目。很整洁,是吧?
你能够借助咱们一块儿整合的小系统来作不少事情。如下是你本身进一步探索的一些想法:
Proxy
和 Pub/Sub 模式的知识,并进一步学习那些可用于不一样工做的技能感谢你同我一块儿学习状态系统是如何工做的。那些大型的主流状态管理库比咱们所作的事情要复杂,智能得多 —— 但了解这些系统如何运做并揭开它们背后的神秘面纱仍然有用。不管如何,了解 JavaScript 在不使用框架下的强大能力也颇有用。
若是你想要这个小系统的完成版本,请查看这个 GitHub 仓库。你还能够在此处查看演示。
若是你在此基础上进一步开发,我很乐意看到它,因此若是你这样作,请在推特上跟我联络或发表在下面的评论中!
若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。