原生 JavaScript 實現 state 狀態管理系統

陳澤輝發表於2019-03-02

Build a state management system with vanilla JavaScript | CSS-Tricks

在軟體工程中,狀態管理已經不是什麼新鮮概念,但是在 JavaScript 語言中比較流行的框架都在使用相關概念。傳統意義上,我們會保持 DOM 本身的狀態甚至宣告該狀態為全域性變數。不過現在,我們有很多狀態管理的寵兒供我們選擇。比如 Redux,MobX 以及 Vuex,使得跨元件的狀態管理更為方便。這對於一些響應式的框架非常適用,比如 React 或者 Vue。

然而,這些狀態管理庫是如何實現的?我們能否自己創造一個?先不討論這些,最起碼,我們能夠真實地瞭解狀態管理的通用機制和一些流行的 API。

在開始之前,需要具備 JavaScript 的基礎知識。你應該知道資料型別的概念,瞭解 ES6 相關語法及功能。如果不太瞭解,去這裡學習一下。這篇文章並不是要替代 Redux 或者 MobX。在這裡我們進行一次技術探索,各持己見就好。

前言

在開始之前,我們先看看需要達到的效果。

架構設計

使用你最愛的 IDE,建立一個資料夾:

~/Documents/Projects/vanilla-js-state-management-boilerplate/
複製程式碼

專案結構類似如下:

/src
├── .eslintrc
├── .gitignore
├── LICENSE
└── README.md
複製程式碼

Pub/Sub

下一步,進入 src 目錄,建立 js 目錄,下面建立 lib目錄,並建立 pubsub.js

結構如下:

/js
├── lib
└── pubsub.js
複製程式碼

開啟 pubsub.js 因為我們將要實現一個 訂閱/釋出 模組。全稱 “Publish/Subscribe”。在我們應用中,我們會建立一些功能模組用於訂閱我們命名的事件。另一些模組會發布相應的事件,通常應用在一個相關的負載序列上。

Pub/Sub 有時候很難理解,如何去模擬呢?想象一下你工作在一家餐廳,你的使用者有一個發射裝置和一個選單。假如你在廚房工作,你知道什麼時候服務員會清除發射裝置(下單),然後讓大廚知道哪一個桌子的發射裝置被清除了(下單)。這就是一條對應桌號的點菜執行緒。在廚房裡面,一些廚子需要開始作業。他們是被這條點菜執行緒訂閱了,直到菜品完成,所以廚子知道自己要做什麼菜。因此,你手底下的廚師都在為相同的點菜執行緒(稱為 event),去做對應的菜品(稱為 callback)。

原生 JavaScript 實現 state 狀態管理系統

上圖是一個直觀的解釋。

PubSub 模組會預載入所有的訂閱並執行他們各自的回撥函式。只需要幾行程式碼就能夠建立一個非常優雅地響應流。

pubsub.js 中新增如下程式碼:

export default class PubSub {
  constructor() {
    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 是一個字串型別, 用於指定唯一的 event 名字用於回撥。如果沒有匹配的 event 在 events 集合中,那麼我們建立一個空陣列用於之後的檢查。然後我們將回撥方法 push 到這個 event 集合中。如果存在 event 集合,將回撥函式直接 push 進去。最後返回集合長度。

現在我們需要獲取對應的訂閱方法,猜猜接下來是什麼?你們知道的:是 publish 方法。新增如下程式碼:

publish(event, data = {}) {

  let self = this;

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

  return self.events[event].map(callback => callback(data));
} 
複製程式碼

這個方法首先檢查傳遞的 event 是否存在。如果不存在,返回空陣列。如果存在,那麼遍歷集合中的方法,並將 data 傳遞進去執行。如果沒有回撥方法,那也 ok,因為我們建立的空陣列也會適用於 subscribe 方法。

這就是 PubSub。接下來看看是什麼!

核心的儲存物件 Store

現在我們已經有了訂閱/釋出模型,我們想要建立這個應用的依賴:Store。我們一點一點來看。

先看一下這個儲存物件是用來幹什麼的。

Store 是我們的核心物件。每次引入 @import store from `../lib/store.js`, 你將會在這個物件中儲存你編寫的狀態位。這個 state 的集合,包含我們應用的所有狀態,它有一個 commit 方法我們稱為 mutations,最後有一個 dispatch 方法我們稱為 actions。在這個核心實現的細節中,應該有一個基於代理(Proxy-based)的系統,用來監聽和廣播在 PubSub 模型中的狀態變化。

我們建立一個新的資料夾 storejs 下面。然後再建立一個 store.js 的檔案。你的 js 目錄看起來應該是如下的樣子:

/js
└── lib
    └── pubsub.js
└──store
    └── store.js
複製程式碼

開啟 store.js 並且引入 訂閱/釋出 模組。如下:

import PubSub from `../lib/pubsub.js`;
複製程式碼

這在 ES6 語法中很常見,非常具有辨識性。

下一步,開始建立物件:

export default class Store {
  constructor(params) {
    let self = this;
  }
}
複製程式碼

這裡有一個自我宣告。我們需要建立預設的 stateactions,以及 mutations。我們也要加入 status 元素用來判定 Store 物件在任意時刻的行為:

self.actions = {};
self.mutations = {};
self.state = {};
self.status = `resting`;
複製程式碼

在這之後,我們需要例項化 PubSub,繫結我們的 Store 作為一個 events 元素:

self.events = new PubSub();
複製程式碼

接下來我們需要尋找傳遞的 params 物件是否包含 actions 或者 mutations。當 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`,這段程式碼將會執行。及時在我們的上下文環境中,改變資料並列印。我們可以釋出一個 stateChange 事件到 PubSub 模組。任何訂閱的事件的回撥函式會執行,我們檢查 Store 的 status,當前的狀態應該是 mutation,這意味著狀態已經被更新了。我們可以新增一個警告去提示開發者非 mutation 狀態下更新資料的風險。

Dispatch 和 commit

我們已經將核心的元素新增到 Store 中了,現在我們新增兩個方法。dispatch 用於執行 actionscommit 用於執行 mutations。程式碼如下:

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,如果存在,設定 status,並且執行 action。 commit 方法很相似。

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;
}
複製程式碼

建立一個基礎元件

我們建立一個列表去實踐狀態管理系統:

~/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 事件讓我們的程式得以響應。每次 state 變化都會觸發 render 方法。

基於這個基礎元件,然後建立其他元件。

建立我們的元件

建立一個列表:

~/Documents/Projects/vanilla-js-state-management-boilerplate/src/js/component/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 });
      });
    });
  }
};
複製程式碼

建立一個計陣列件:

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>
    `;
  }
}
複製程式碼

建立一個 status 元件:

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}`;
  }
}
複製程式碼

檔案目錄結構如下:

/src
├── js
│   ├── components
│   │   ├── count.js
│   │   ├── list.js
│   │   └── status.js
│   ├──lib
│   │  ├──component.js
│   │  └──pubsub.js
└───── store
│      └──store.js
└───── main.js
複製程式碼

完善狀態管理

我們已經得到前端元件和主要的 Store。現在需要一個初始狀態,一些 actionsmutations。在 store 目錄下,建立一個新的 state.js 檔案:

~/Documents/Projects/vanilla-js-state-management-boilerplate/src/js/store/state.js
複製程式碼
export default {
  items: [
    `I made this`,
    `Another thing`
  ]1
};
複製程式碼

繼續建立 actions.js

export default {
  addItem(context, payload) {
    context.commit(`addItem`, payload);
  },
  clearItem(context, payload) {
    context.commit(`clearItem`, payload);
  }
};
複製程式碼

繼續建立 mutation.js

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

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

    return state;
  }
};
複製程式碼

最後建立 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
});
複製程式碼

最後的整合

最後我們將所有程式碼整合到 main.js中,還有 index.html 中:

~/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`);
複製程式碼

到此一切準備就緒,下面新增互動:

formElement.addEventListener(`submit`, evt => {
  evt.preventDefault();

  let value = inputElement.value.trim();

  if(value.length) {
    store.dispatch(`addItem`, value);
    inputElement.value = ``;
    inputElement.focus();
  }
});
複製程式碼

新增渲染:

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

countInstance.render();
listInstance.render();
statusInstance.render();
複製程式碼

至此完成了一個狀態管理的系統。

相關文章