使用原生 JavaScript 构建状态管理系统

Avatar of Andy Bell
Andy Bell

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 $200 免费积分!

状态管理在软件中并非新鲜事,但在 JavaScript 中构建软件时仍然相对较新。传统上,我们会将状态保存在 DOM 本身中,甚至将其分配给窗口中的全局对象。但现在,我们被各种库和框架的选择所宠坏,它们可以帮助我们解决这个问题。Redux、MobX 和 Vuex 等库使跨组件状态管理变得几乎微不足道。这对于应用程序的弹性非常有用,并且与以状态为先、响应式框架(如 React 或 Vue)一起使用时效果极佳。

这些库是如何工作的?我们自己编写一个需要做些什么?事实证明,这很简单,并且有机会学习一些非常常见的模式,以及了解一些可用的有用现代 API。

在开始之前,建议您具备中级 JavaScript 知识。您应该了解数据类型,理想情况下,您应该掌握一些更现代的 ES6+ JavaScript 功能。如果没有,我们能帮您。还需要注意的是,我并不是说您应该用它来替换 Redux 或 MobX。我们正在共同进行一个小项目来提升技能,嘿,如果您关注 JavaScript 负载的大小,它绝对可以为小型应用程序提供动力。

入门

在深入代码之前,先看看我们正在构建什么。这是一个“已完成列表”,它将您今天取得的成就加起来。它将神奇地更新 UI 的各个元素——完全没有框架依赖项。但这并不是真正的魔法。在幕后,我们有一个小的状态系统,它静候指令,以可预测的方式维护单一的事实来源。

很酷吧?让我们先进行一些管理。我已经创建了一些样板代码,以便我们能够快速完成本教程。您需要做的第一件事是从 GitHub 克隆它,或者 下载 ZIP 存档 并解压缩它。

现在您已经完成了这些操作,您需要在本地 Web 服务器上运行它。我喜欢使用名为 http-server 的软件包来处理这类事情,但您可以使用任何您喜欢的软件包。当您在本地运行它时,您应该会看到类似于此的内容

我们样板代码的初始状态。

设置结构

在您喜欢的文本编辑器中打开根文件夹。这次,我的根文件夹是

~/Documents/Projects/vanilla-js-state-management-boilerplate/

您应该会看到一个类似于此的结构

/src
├── .eslintrc
├── .gitignore
├── LICENSE
└── README.md

发布/订阅

接下来,打开 src 文件夹,然后打开其中的 js 文件夹。创建一个名为 lib 的新文件夹。在其中,创建一个名为 pubsub.js 的新文件。

您的 js 目录的结构应如下所示

/js
├── lib
└── pubsub.js

打开 pubsub.js,因为我们将创建一个小的 发布/订阅模式,即“发布/订阅”的简称。我们正在创建允许应用程序的其他部分订阅命名事件的功能。应用程序的另一个部分可以发布这些事件,通常会附带一些相关的有效负载。

发布/订阅模式有时很难理解,所以我们来举个例子。想象一下,您在一家餐厅工作,您的顾客点了一份开胃菜和主菜。如果您曾在厨房工作过,您就会知道,当服务员清理开胃菜时,他们会让厨师知道哪桌的开胃菜已经清理完毕。这是开始准备该桌主菜的提示。在大型厨房中,可能有几个厨师,他们可能会负责不同的菜肴。他们都订阅了服务员发出的顾客已经吃完开胃菜的提示(命名事件),因此他们知道要执行自己的函数,即准备主菜。因此,您有几个厨师等待相同的提示(命名事件)来执行不同的函数(回调)以相互通信。

希望这样思考可以帮助您理解。让我们继续!

发布/订阅模式遍历所有订阅,并使用该有效负载触发它们的回调。这是为您的应用程序创建非常优雅的响应式流的好方法,我们只需几行代码就可以做到。

将以下内容添加到 pubsub.js

export default class PubSub {
  constructor() {
    this.events = {};
  }
}

我们这里有一个全新的类,我们将 this.events 默认设置为一个空对象。this.events 对象将保存我们的命名事件。

在构造函数的右括号之后,添加以下内容

subscribe(event, callback) {

  let self = this;

  if(!self.events.hasOwnProperty(event)) {
    self.events[event] = [];
  }

  return self.events[event].push(callback);
}

这是我们的 subscribe 方法。您传递一个字符串 event,它是事件的唯一名称,以及一个回调函数。如果我们的 events 集合中还没有匹配的事件,我们会使用一个空数组创建一个事件,这样我们以后就不必进行类型检查。然后,我们将回调推入该集合。如果事件已经存在,该方法只会执行这些操作。我们返回事件集合的长度,因为有人可能想知道事件的数量。

现在我们有了 subscribe 方法,您猜对了,接下来是什么?您知道:publish 方法。在您的 subscribe 方法之后,添加以下内容

publish(event, data = {}) {

  let self = this;

  if(!self.events.hasOwnProperty(event)) {
    return [];
  }

  return self.events[event].map(callback => callback(data));
}

此方法首先检查传递的事件是否在我们的集合中。如果没有,我们返回一个空数组。没有问题。如果有事件,我们将循环遍历每个存储的回调,并将数据传递给它。如果没有回调(这种情况不应该出现),一切正常,因为我们在 subscribe 方法中使用空数组创建了该事件。

这就是发布/订阅模式的全部内容。让我们继续进行下一部分!

核心 Store 对象

现在我们有了发布/订阅模块,我们有了这个小应用程序的核心功能的唯一依赖项:Store。我们现在将开始充实它。

让我们首先概述它在做什么。

Store 是我们的中心对象。每次您看到 @import store from '../lib/store.js 时,您都会引入我们即将编写的对象。它将包含一个 state 对象,该对象反过来包含我们的应用程序状态,一个 commit 方法,它将调用我们的 >mutations,最后是一个 dispatch 函数,它将调用我们的 actions。除此之外,Store 对象的核心是基于 Proxy 的系统,它将使用我们的 PubSub 模块监控和广播状态更改。

首先,在您的 js 目录中创建一个名为 store 的新目录。在其中,创建一个名为 store.js 的新文件。您的 js 目录现在应如下所示

/js
└── lib
    └── pubsub.js
└──store
    └── store.js

打开 store.js 并导入我们的发布/订阅模块。为此,在文件的最开头添加以下内容

import PubSub from '../lib/pubsub.js';

对于那些经常使用 ES6 的人来说,这将非常容易识别。但是,在没有捆绑程序的情况下运行这种代码可能不太容易识别。这种方法已经得到了大量支持

接下来,让我们开始构建我们的对象。在导入之后,将以下内容添加到 store.js

export default class Store {
  constructor(params) {
    let self = this;
  }
}

这都很容易理解,所以让我们添加下一部分。我们将添加 stateactionsmutations 的默认对象。我们还添加了一个 status 元素,我们将用它来确定对象在任何给定时间都在做什么。这在 let self = this; 之后立即添加。

self.actions = {};
self.mutations = {};
self.state = {};
self.status = 'resting';

紧随其后,我们将创建一个新的 PubSub 实例,它将作为 events 元素附加到 Store

self.events = new PubSub();

接下来,我们将搜索传递的 params 对象,以查看是否传递了任何 actionsmutations。当 Store 对象实例化时,我们可以传入一个数据对象。其中可能包含 actionsmutations 的集合,这些集合控制我们存储区中数据的流动。以下代码在您添加的最后一行之后立即添加

if(params.hasOwnProperty('actions')) {
  self.actions = params.actions;
}

if(params.hasOwnProperty('mutations')) {
  self.mutations = params.mutations;
}

我们已经设置了所有默认值,并且几乎所有潜在参数都已设置。让我们看看我们的 `Store` 对象如何跟踪所有更改。我们将使用 Proxy 来做到这一点。Proxy 的作用本质上是代表我们的状态对象。如果我们添加一个 `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` 操作。这意味着当一个变异执行类似 `state.name = 'Foo'` 的操作时,这个陷阱会在它被设置之前捕获它,并为我们提供一个处理更改甚至完全拒绝更改的机会。但在我们的上下文中,我们正在设置更改并将其记录到控制台。然后,我们使用 `PubSub` 模块发布一个 `stateChange` 事件。订阅该事件回调的任何内容都将被调用。最后,我们检查 `Store` 的状态。如果它当前没有运行 `mutation`,可能意味着状态是手动更新的。我们在控制台中添加了一点警告,以便给开发人员一点提示。

这里有很多事情发生,但我希望您开始看到这一切是如何结合在一起的,重要的是,我们如何能够通过 Proxy 和 Pub/Sub 来集中维护状态。

调度和提交

现在我们已经添加了 `Store` 的核心元素,让我们添加两个方法。一个用于调用我们的 `actions`,名为 `dispatch`,另一个用于调用我们的 `mutations`,名为 `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;
}

这里的过程是:查找操作,如果存在,则设置状态并调用操作,同时创建一个日志组,使所有日志整洁有序。任何记录的内容(如变异或 Proxy 日志)都将保存在我们定义的组中。如果没有设置操作,它将记录错误并退出。这非常简单,`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;
}

此方法非常相似,但让我们 comunque 浏览一下该过程。如果找到变异,我们运行它并从其返回值获取我们的新状态。然后,我们将该新状态与我们的现有状态合并,以创建一个我们状态的最新版本。

添加了这些方法后,我们的 `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` 事件,以便我们的对象可以 *react*。这在每次状态更改时都会调用 `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 &#x1f622;</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 });
      });
    });
  }
};

我希望在学习了本教程前面的内容之后,这段代码非常直观,但让我们 comunque 浏览一下。我们首先将 `Store` 实例传递到我们正在扩展的 `Component` 父类中。这就是我们刚刚编写的 `Component` 类。

之后,我们声明了我们的 `render` 方法,该方法在每次 `stateChange` Pub/Sub 事件发生时被调用。在这个 `render` 方法中,我们输出一个项目列表,或者在没有项目时输出一个小提示。您还会注意到,每个按钮都附加了一个事件,它们在我们的存储中调度一个操作。此操作尚不存在,但我们很快就会介绍它。

接下来,创建另外两个文件。这两个是新的组件,但它们非常小——因此我们只粘贴一些代码并继续。

首先,在您的 `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 ? '&#x1f64c;' : '&#x1f622;';

    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`,我们只需将它们连接起来。

我们有商店系统和用于呈现和与数据交互的组件。现在,让我们通过连接应用程序的两个独立端来结束,使整个应用程序协同工作。我们需要添加初始状态、一些 `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);
  }
};

此应用程序中的操作非常简单。本质上,每个操作都将有效负载传递给变异,而变异反过来将数据提交到存储。如我们之前所学,`context` 是 `Store` 类的实例,`payload` 是由调度操作的任何内容传递的。说到变异,让我们添加一些。在同一个目录中添加一个名为 `mutations.js` 的新文件。打开它并添加以下内容

export default {
  addItem(state, payload) {
    state.items.push(payload);

    return state;
  },
  clearItem(state, payload) {
    state.items.splice(payload.index, 1);

    return state;
  }
};

与操作类似,这些变异也很简单。我认为,您的变异应该始终很简单,因为它们只有一个作用:变异商店的状态。因此,这些示例是它们应该具有的复杂程度。任何适当的逻辑都应该发生在您的 `actions` 中。如您所见,对于此系统,我们返回状态的新版本,以便 `Store` 的 `commit` 方法可以发挥其作用并更新所有内容。有了这个,商店系统的主要元素就到位了。让我们使用索引文件将它们粘合在一起。

在同一个目录中,创建一个名为 `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` 实例。工作完成了!

拼图的最后一块

我们需要拼凑的最后一件东西是我们在教程开始时就在 `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();
  }
});

我们在这里做的是向表单添加一个事件监听器并阻止它提交。然后,我们获取文本框的值并去掉它周围的空格。我们这样做是因为我们想要检查是否有任何内容传递到下一个存储。最后,如果有内容,我们用该内容派发我们的 `addItem` 操作,并让我们的全新 `store` 为我们处理它。

让我们在 `main.js` 中添加更多代码。在事件监听器下面,添加以下内容

const countInstance = new Count();
const listInstance = new List();
const statusInstance = new Status();

countInstance.render();
listInstance.render();
statusInstance.render();

我们在这里做的只是创建组件的新实例,并调用每个组件的 `render` 方法,以便我们获得页面上的初始状态。

有了最后一个添加,我们就完成了!

打开你的浏览器,刷新并欣赏你新创建的状态管理应用程序的光芒。试试在里面添加类似“完成了这个很棒的教程”的内容。很不错吧?

下一步

你可以用我们一起构建的小系统做很多事情。以下是一些让你自己进一步发展它的想法

  • 你可以实现一些本地存储来维护状态,即使你在刷新页面时也是如此
  • 你可以提取这个前端,并为你的项目建立一个小的状态系统
  • 你可以继续开发这个应用程序的前端,让它看起来很棒。(我非常有兴趣看到你的作品,所以请分享!)
  • 你可以使用一些远程数据,甚至可能是一个 API
  • 你可以学习关于 `Proxy` 和 Pub/Sub 模式的知识,并进一步发展这些可迁移的技能

总结

感谢你与我一起学习这些状态系统是如何工作的。那些大型的流行系统比我们所做的复杂和智能得多——但了解这些系统是如何工作的以及揭开它们背后的神秘面纱仍然是有用的。了解没有框架的 JavaScript 能有多强大也是有用的。

如果你想要这个小系统的完整版本,请查看这个 GitHub 仓库。你也可以在这里看到演示 这里

如果你在上面进一步开发,我很乐意看到它,所以如果你做了,请在 Twitter 上联系我或在下面的评论中发布!