如何使用不到50行代碼實現一個小而美的依賴收集庫?

一 背景

現代web開發,大多數都遵循着視圖與邏輯分離的開發原則,一反面使得代碼更加易懂且易擴展,另外一方面帶來的問題就是如何優雅的管理數據。於是,社區誕生了不少優秀的狀態管理庫,好比爲React而生的Redux,專爲Vue服務的Vuex,還有不限定框架的Mobx等等。在爲使用這些庫提高開發效率而叫好的同時,我以爲咱們也應該從內部去真正的瞭解它們的核心原理,就好比今天這篇文章的主題依賴收集,就是其中的一個很大的核心知識。這篇文章將會帶您一步一步的以最少的代碼去實現一個小而美的依賴收集庫,同時給您展示如何將這個庫運用到小程序中去實現跨頁面的狀態共享。前端

二 實現過程

1. 基本原理

依賴收集的基本原理能夠歸納爲如下3步:react

  1. 建立一個可觀察(observable)對象
  2. 視圖或者函數(effect)引用這個對象的某個屬性,觸發依賴收集
  3. 改變數據,視圖或者函數自動更新或運行

咱們要實現的例子:git

import { observable, observe } from "micro-reaction";

const ob = observable({
    a: 1
});

observe(() => console.log(ob.a));

// logs: 1
// logs: 2
ob.a = 2;
複製代碼

下面開始我將一步一步的進行實現過程講解es6

2. 建立一個可觀察對象

首先,咱們須要建立一個可觀察對象,其本質就是將傳入的對象進行代理,而且返回這個代理對象,這裏咱們使用es6Proxy來修改對象的一些行爲,從而實如今返回真正對象前做一些攔截操做。github

咱們定義了一個名叫observable方法來代理對象,代碼以下:web

export function observable(obj = {}) {
    return createObservable(obj)
}

function createObservable(obj) {
    const proxyObj = new Proxy(obj, handlers());
    return proxyObj
}
複製代碼

能夠看到observable方法內部就是經過new Proxy(obj,handler)生成一個代理對象,傳參分別是原始對象和代理操做方法handlershandlers返回一個對象,定義了對象的原始方法,例如getset,經過從新定義這兩個方法,咱們能夠修改對象的行爲,從而完成代理操做,咱們來看看handlers方法。小程序

function handlers() {
    return {
        get: (target, key, receiver) => {
            const result = Reflect.get(target, key, receiver);
            return result
        },
        set: (target, key, value, receiver) => {
            const result = Reflect.set(target, key, value, receiver);
            return result
        }
    }
}
複製代碼

如上,咱們在getset方法裏面沒有作任何操做,取值賦值操做都是原樣返回。前端工程化

3. 關聯反作用函數effect

完成了對數據的初始定義,咱們明確下咱們的目的,咱們的最終目的是數據改變,反作用函數 effect自動運行,而這其中的關鍵就是必須有個地方引用咱們建立的代理對象,從而觸發代理對象內部的get或者set方法,方便咱們在這兩個方法內部作一些依賴收集和依賴執行的工做。數組

於是,這裏咱們定義了一個observe方法,參數是一個Function,咱們先看看這個方法的實現:數據結構

export function observe(fn) {
    <!--這一行能夠先忽略,後面會有介紹-->
    storeFns.push(fn);
    <!--Reflect.apply()就至關於fn.call(this.arguments)--> Reflect.apply(fn, this, arguments) } 複製代碼

能夠看到,內部執行了傳入的函數,而咱們傳入的函數是() => console.log(ob.a.b),函數執行,輸出ob.a,引用了代理對象的a屬性值,就觸發了代理對象內部的get方法。 在get方法內部咱們就能夠進行依賴收集。

function handlers() {
    return {
        get: (target, key, receiver) => {
            const result = Reflect.get(target, key, receiver);
            <!--觸發依賴收集--> depsCollect({ target, key }) return result }, set: (target, key, value, receiver) => { const result = Reflect.set(target, key, value, receiver); return result } } } 複製代碼

depsCollect依賴收集方法須要作的操做就是將當前的依賴也就是() => console.log(ob.a)這個函數fn保存起來,那fn怎麼傳過來呢?get方法自己的入參是沒有這個fn的,回顧以前的observe方法,這個方法有傳入fn,其中內部有個storeFns.push(fn)這樣的操做,就是經過一個數組將當前依賴函數臨時收集起來。可光收集沒用,咱們還要和對應的屬性進行映射,以便後續某個屬性變化時,咱們可以找出對應的effect,故咱們定義了一個Map對象來存儲相應的映射關係,那須要怎樣的一個映射關係呢?一個對象有多個屬性,每一個屬性可能都有對應的effect,結構看起來應該是這樣的:

{
    obj:{
        "key-1":fn1,
        "key-2":fn2,
        ....
    }
}
複製代碼

咱們定義了一個全局變量storeReactions來存儲整個映射關係,它的keyobj,就是原始對象,obj的值也是個Map結構,存儲了其屬性和effect的映射關係。咱們的最終目的其實也就是創建一個這樣的關係。理清楚了數據存儲,再來看看咱們的depsCollect方法,其實就是將臨時保存在storeFns裏面的函數取出和屬性key映射。

// 存儲依賴對象
const storeReactions = new WeakMap();
// 中轉數組,用來臨時存儲當前可觀察對象的反應函數,完成收集以後當即釋放
const storeFns = [];
function depsCollect({ target, key }) {
    const fn = storeFns[storeFns.length - 1];
    if (fn) {
        const mapReactions = storeReactions.get(target);
        if (!mapReactions.get(key)) {
            mapReactions.set(key, fn)
        }
    }
}
複製代碼

至此,咱們的依賴收集算是完成了,接下來就是要實現如何監聽數據改變,對應effect自動運行了。

4. 數據變動,effect自動運行

數據變動,就是從新設置數據,相似a=2的操做,就會觸發代理對象裏面的set方法,咱們只須要在set方法裏面取出對應的effect運行便可。

set: (target, key, value, receiver) => {
        const result = Reflect.set(target, key, value, receiver);
        executeReactions({ target, key })
        return result
    }
    
function executeReactions({ target, key }) {
    <!-- 一時看不懂的,回顧下咱們的映射關係 -->
    const mapReactions = storeReactions.get(target);
    if (mapReactions.has(key)) {
        const reaction = mapReactions.get(key);
        reaction();
    }
}
複製代碼

ok,咱們的例子的實現過程講解完了,整個實現過程仍是很清晰的,最後看看咱們的整個代碼,去掉空行不到50行代碼。

const storeReactions = new WeakMap(),storeFns = [];

export function observable(obj = {}) {
  const proxyObj = new Proxy(obj, handlers());
  storeReactions.set(obj, new Map());
  return proxyObj
}

export function observe(fn) {
  if (storeFns.indexOf(fn) === -1) {
    try {
      storeFns.push(fn);
      Reflect.apply(fn, this, arguments)
    } finally {
      storeFns.pop()
    }
  }
}

function handlers() {
  return {
    get: (target, key, receiver) => {
      depsCollect({ target, key })
      return Reflect.get(target, key, receiver)
    },
    set: (target, key, value, receiver) => {
      Reflect.set(target, key, value, receiver)
      executeReactions({ target, key })
    }
  }
}

function depsCollect({ target, key }) {
  const fn = storeFns[storeFns.length - 1];
  if (fn) {
    const mapReactions = storeReactions.get(target);
    if (!mapReactions.get(key)) {
      mapReactions.set(key, fn)
    }
  }
}

function executeReactions({ target, key }) {
  const mapReactions = storeReactions.get(target);
  if (mapReactions.has(key)) {
    const reaction = mapReactions.get(key);
    reaction();
  }
}
複製代碼

5. 多層級數據結構

到目前爲止,咱們實現的還只能觀察單級的對象,若是一個對象的層級深了,相似ob.a.b的結構,咱們的庫就沒法觀察數據的變更,effect也不會自動運行。那如何支持呢?核心原理就是在get方法裏面判斷返回的值,若是返回的值是個對象,就遞歸調用observable方法,遞歸調用完,接着運行observe方法就會構建出完整的一個屬性key和反應effect的映射關係。

function handlers() {
    return {
        get: (target, key, receiver) => {
            const result = Reflect.get(target, key, receiver);
            depsCollect({ target, key })
            if (typeof result === 'object' && result != null && storeFns.length > 0) {
                return observable(result)
            }
            return result
        }
    }
}
複製代碼

回到ob.a.b這樣的結構,此時實際的代理對象應該是這樣的{proxy(proxy(c))},若是這個時候咱們去修改數據,好比ob.a.b = 2這樣。

ob.a.b = 2的運行過程會是怎樣?要知道js這門語言是先編譯後執行的,因此js引擎首先會去分析這段代碼(編譯階段),先分析左邊的表達式ob.a.b,故先會編譯ob.a,觸發了第一次get方法,在get方法中,result獲得的值是個對象,若是按照上述代碼,又去從新觀察這個對象,會致使observe方法中構建好的映射關係丟失,其中就是對象{b:1}keyb對應的fn丟失,由於咱們存儲fn是在observe方法中執行的,那怎麼辦呢?方法是咱們應該在第一次observable方法執行的時候,將每個key對應的代理對象都保存起來,在賦值操做再一次觸發get方法的時候,若是已經代理過,直接返回就行,不須要從新代理。

// 存儲代理對象
const storeProxys = new WeakMap();
export function observable(obj = {}) {
    return storeProxys.get(obj) || createObservable(obj)
}
function createObservable(obj) {
    const proxyObj = new Proxy(obj, handlers());
    storeReactions.set(obj, new Map())
    storeProxys.set(obj, proxyObj)
    return proxyObj
}
function handlers() {
    return {
        get: (target, key, receiver) => {
            const result = Reflect.get(target, key, receiver);
            depsCollect({ target, key })
            <!--若是代理存儲中有某個key對應的代理直接返回便可-->
            const observableResult = storeProxys.get(result);
            if (typeof result === 'object' && result != null && storeFns.length > 0) {
                return observable(result)
            }
            return observableResult || result
        }
    }
}
複製代碼

如此,ob.a.b = 2,控制檯就會依次輸出12,另外說一句,數組也是對象,故動態增長數組的值或者賦值操做都能觸發響應的effect

const ob = observable({
  a: {
    b: 1,
    c: []
  }
});

observe(() => console.log(ob.a.c.join(", ")));
//logs: 2
ob.a.c.push(2);
複製代碼

三 如何結合小程序使用

所有完整代碼我已發佈到個人github中,名字叫作micro-reaction,這個庫徹底無依賴的,純粹的,故能夠爲其它界面框架狀態管理提供能量,因爲小程序跨頁面狀態共享相關的庫很少,故這裏以小程序舉例,如何結合micro-reaction實現跨頁面狀態共享。

1. 核心原理

描述下場景,有兩個頁面AB,全局數據CAB都引用了C,以後,頁面A中某個交互改變了CAB都須要自動渲染頁面。結合咱們的庫,C確定是須要observable的,observe方法傳入的fn是會動態執行的,小程序渲染頁面的方式是setData方法,故observe方法裏面確定執行了setData(),於是只要咱們在observe方法裏面引用C,就會觸發依賴收集,從而在下次C改變以後,setData方法從新運行渲染頁面。

2. 關鍵步驟

首先,咱們須要拿到每一個小程序頁面的this對象,以便自動渲染使用,故咱們須要代理Page方法裏面傳入的參數,咱們定一個了mapToData方法來代理,代碼以下:

<!--全局數據-->
import homeStore from "../../store"
<!--將數據映射到頁面,同時出發依賴收集,保存頁面棧對象-->
import { mapToData } from "micro-reaction-miniprogram"
const connect = mapToData((store) => ({ count: store.credits.count }), 'home')

Page(connect({
  onTap(e) {
    homeStore.credits.count++
  },
  onJump(e) {
    wx.navigateTo({
      url: "/pages/logs/logs"
    })
  }
}))
複製代碼

mapToData方法返回一個函數,function mapToData(fn,name){return function(pageOpt){}} ,這裏用到了閉包,外部函數爲咱們傳入的函數,做用是將全局數據映射到咱們的頁面data中並觸發依賴收集,內部函數傳入的參數爲小程序頁面自己的參數,裏面包含了小程序的生命週期方法,於是咱們就能夠在內部重寫這些方法,並拿到當前頁面對象並存儲起來供下一次頁面渲染使用。

import { STORE_TREE } from "./createStore"
import { observe, observable } from 'micro-reaction';

function mapToData(fn, name) {
  return function (pageOpt) {
    const { onLoad } = pageOpt;
    pageOpt.onLoad = function (opt) {
      const self = this
      const dataFromStore = fn.call(self, STORE_TREE[name], opt)
      self.setData(Object.assign({}, self.data, dataFromStore))

      observe(() => {
        <!--映射方法執行,觸發依賴收集-->
        const dataFromStore = fn.call(self, STORE_TREE[name], opt)
        self.setData(Object.assign({}, self.data, dataFromStore))
      })

      onLoad && onLoad.call(self, opt)
    }
    return pageOpt
  }
}

export { mapToData, observable }
複製代碼

而後,頁面A改變了數據Cobserve方法參數fn自動執行,觸發this.setData方法,從而頁面從新渲染,完整代碼點擊 micro-reaction-miniprogram,也能夠點擊查看在線Demo

四 總結

但願個人文章可以讓您對依賴收集的認識更深,以及如何觸類旁通的學會使用,此外,最近在學習周愛民老師的《JavaScript核心原理解析》這門課程,其中有句話對我觸動很深,引用的是金庸射鵰英雄傳裏面的文本:教而不得其法,學而不得其道,意思就是說,傳授的人沒有用對方法,學習的人就不會學懂,其實我本身對學習的方法也一直都很困惑,前端發展愈來愈快,什麼SSR,什麼serverless,什麼前端工程化,什麼搭建系統各類知識概念愈來愈多,不知道該怎麼學習,說不焦慮是不可能的,但堅信只有一個良好的基礎,理解一些技術的本質,才能在快速發展的前端技術浪潮中,不至於被沖走,與局共勉!

最後,在貼下文章說起的兩個庫,歡迎star試用,提pr,感謝~

依賴收集庫 micro-reaction

小程序狀態管理庫 micro-reaction-miniprogram

相關文章
相關標籤/搜索