[譯] 使用原生 JavaScript 構建狀態管理系統

shery發表於2018-08-17

狀態管理在軟體方面並不新鮮,但在 JavaScript 構建的應用中仍然相對較新。習慣上,我們會直接將狀態保持在 DOM 上,甚至將其分配給 window 中的全域性物件。但是現在,我們已經有了許多選擇,這些庫和框架可以幫助我們管理狀態。像 Redux,MobX 和 Vuex 這樣的庫可以輕鬆管理跨元件狀態。它大大提升了應用程式的擴充套件性,並且它對於狀態優先的響應式框架(如 React 或 Vue)非常有用。

這些庫是如何運作的?我們自己寫個狀態管理會怎麼樣?事實證明,它非常簡單,並且有機會學習一些非常常見的設計模式,同時瞭解一些既有用又能用的現代 API。

在我們開始之前,請確保你已掌握中級 JavaScript 的知識。你應該瞭解資料型別,理想情況下,你應該掌握一些更現代的 ES6+ 語法特性。如果沒有,這可以幫到你。值得注意的是,我並不是說你應該用這個代替 Redux 或 MobX。我們正在一起開發一個小專案來提升技能,嘿,如果你在乎的是 JavaScript 檔案規模的大小,那麼它確實可以應付一個小型應用。

入門

在我們深入研究程式碼之前,先看一下我們正在開發什麼。它是一個彙總了你今天所取得成就的“完成清單”。它將在不依賴框架的情況下像魔術般更新 UI 中的各種元素。但這並不是真正的魔術。在幕後,我們已經有了一個小小的狀態系統,它等待著指令,並以一種可預測的方式維護單一來源的資料。

檢視演示

檢視倉庫

很酷,對嗎?我們先做一些配置工作。我已經整理了一些模版,以便我們可以讓這個教程簡潔有趣。你需要做的第一件事情是 從 GitHub 上克隆它,或者 下載並解壓它的 ZIP 檔案

當你下載好了模版,你需要在本地 Web 伺服器上執行它。我喜歡使用一個名為 http-server 的包來做這些事情,但你也可以使用你想用的任何東西。當你在本地執行它時,你會看到如下所示:

[譯] 使用原生 JavaScript 構建狀態管理系統

我們模版的初始狀態。

建立專案結構

用你喜歡的文字編輯器開啟根目錄。這次對我來說,根目錄是:

~/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 有時很難掌握,那舉個例子呢?假設你在一家餐館工作,你的顧客點了一個前菜和主菜。如果你曾經在廚房工作過,你會知道當侍者清理前菜時,他們讓廚師知道哪張桌子的前菜已經清理了。這是該給那張桌子上主菜的提示。在一個大廚房裡,有一些廚師可能在準備不同的菜餚。他們都訂閱了侍者發出的顧客已經吃完前菜的提示,因此他們自己知道要準備主菜。所以,你有多個廚師訂閱了同一個提示(具名事件),收到提示後做不同的事(回撥)。

[譯] 使用原生 JavaScript 構建狀態管理系統

希望這樣想有助於理解。讓我們繼續!

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 模式。讓我們繼續下一部分!

Store 物件(核心)

我們現在已經有了 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;
  }
}
複製程式碼

這一切都一目瞭然,所以讓我們新增下一項。我們將為 stateactionsmutations 新增預設物件。我們還新增了一個 status 屬性,我們將用它來確定物件在任意給定時間正在做什麼。這是在 let self = this; 後面的:

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

之後,我們將建立一個新的 PubSub 例項,它將作為 storeevents 屬性的值:

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

接下來,我們將搜尋傳入的 params 物件以檢視是否傳入了任何 actionsmutation。當例項化 Store 物件時,我們可以傳入一個資料物件。其中包括 actionsmutation 的集合,它們控制著我們 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。

Dispatch 和 commit

現在我們已經新增了 Store 的核心部分,讓我們新增兩個方法。一個是將呼叫我們 actionsdispatch,另一個是將呼叫我們 mutationcommit。讓我們從 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 &#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 });
        });
    });
    }
};
複製程式碼

我希望有了前面教程,這段程式碼的含義對你來說是不言而喻的,但是無論如何我們還是要說下它。我們先將 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 ? '&#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,我們所要做的就是將它全部連線起來。

我們已經讓 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。正如我們之前所瞭解的那樣,contextStore 類的例項,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 方法,以便我們在頁面上獲得初始狀態。

隨著最後的新增,我們完成了!

開啟你的瀏覽器,重新整理並沉浸在新狀態管理應用程式的榮耀中。來吧,新增一些類似於**“完成這個令人敬畏的教程”**的條目。很整潔,是吧?

下一步

你可以藉助我們一起整合的小系統來做很多事情。以下是你自己進一步探索的一些想法:

  • 你可以實現一些本地儲存,以保持狀態,即使當你重新載入時
  • 你可以分離出前端模組,只為你的專案提供一個小型狀態系統
  • 你可以繼續開發此應用程式的前端模組並使其看起來很棒。(我真的很想看到你的作品,所以請分享!)
  • 你可以使用一些遠端資料,甚至可以使用 API
  • 你可以整理你所學到的關於 Proxy 和 Pub/Sub 模式的知識,並進一步學習那些可用於不同工作的技能

總結

感謝你同我一起學習狀態系統是如何工作的。那些大型的主流狀態管理庫比我們所做的事情要複雜,智慧得多 —— 但瞭解這些系統如何運作並揭開它們背後的神祕面紗仍然有用。無論如何,瞭解 JavaScript 在不使用框架下的強大能力也很有用。

如果你想要這個小系統的完成版本,請檢視這個 GitHub 倉庫。你還可以在此處檢視演示。

如果你在此基礎上進一步開發,我很樂意看到它,所以如果你這樣做,請在推特上跟我聯絡或發表在下面的評論中!

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章