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

狀態管理在軟件方面並不新鮮,但在 JavaScript 構建的應用中仍然相對較新。習慣上,咱們會直接將狀態保持在 DOM 上,甚至將其分配給 window 中的全局對象。可是如今,咱們已經有了許多選擇,這些庫和框架能夠幫助咱們管理狀態。像 Redux,MobX 和 Vuex 這樣的庫能夠輕鬆管理跨組件狀態。它大大提高了應用程序的擴展性,而且它對於狀態優先的響應式框架(如 React 或 Vue)很是有用。javascript

這些庫是如何運做的?咱們本身寫個狀態管理會怎麼樣?事實證實,它很是簡單,而且有機會學習一些很是常見的設計模式,同時瞭解一些既有用又能用的現代 API。css

在咱們開始以前,請確保你已掌握中級 JavaScript 的知識。你應該瞭解數據類型,理想狀況下,你應該掌握一些更現代的 ES6+ 語法特性。若是沒有,這能夠幫到你。值得注意的是,我並非說你應該用這個代替 Redux 或 MobX。咱們正在一塊兒開發一個小項目來提高技能,嘿,若是你在意的是 JavaScript 文件規模的大小,那麼它確實能夠應付一個小型應用。html

入門

在咱們深刻研究代碼以前,先看一下咱們正在開發什麼。它是一個彙總了你今天所取得成就的「完成清單」。它將在不依賴框架的狀況下像魔術般更新 UI 中的各類元素。但這並非真正的魔術。在幕後,咱們已經有了一個小小的狀態系統,它等待着指令,並以一種可預測的方式維護單一來源的數據。前端

查看演示java

查看倉庫android

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

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

咱們模版的初始狀態。github

創建項目結構

用你喜歡的文本編輯器打開根目錄。此次對我來講,根目錄是:npm

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

但願這樣想有助於理解。讓咱們繼續!

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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索