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();
複製程式碼
至此完成了一個狀態管理的系統。