原生 JavaScript 實現 state 狀態管理系統
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)。

上圖是一個直觀的解釋。
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
模型中的狀態變化。
我們創建一個新的文件夾 store
在 js
下面。然后再創建一個 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; } } 復制代碼
這里有一個自我聲明。我們需要創建默認的 state
,actions
,以及 mutations
。我們也要加入 status
元素用來判定 Store 對象在任意時刻的行為:
self.actions = {};
self.mutations = {};
self.state = {};
self.status = 'resting'; 復制代碼
在這之后,我們需要實例化 PubSub
,綁定我們的 Store
作為一個 events
元素:
self.events = new PubSub(); 復制代碼
接下來我們需要尋找傳遞的 params
對象是否包含 actions
或者 mutations
。當 Store
初始化時,我們將數據傳遞進去。包含一個 actions
和 mutations
的集合,這個集合用來控制存儲的數據:
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
用於執行 actions
,commit
用於執行 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 😢</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 ? '🙌' : '😢'; 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
。現在需要一個初始狀態,一些 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' ]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(); 復制代碼
至此完成了一個狀態管理的系統。