如何使用不到50行code实现小而美的依赖收集库并运用到微信小程序中?

59,863 阅读11分钟

一 背景

现代web开发,大多数都遵循着视图与逻辑分离的开发原则,一反面使得代码更加易懂且易扩展,另一方面带来的问题就是如何优雅的管理数据。因而,社区诞生了很多优秀的状态管理库,比如为React而生的Redux,专为Vue服务的Vuex,还有不限定框架的Mobx等等。在为使用这些库提升开发效率而叫好的同时,我觉得我们也应该从内部去真正的了解它们的核心原理,就比如今天这篇文章的主题依赖收集,就是其中的一个很大的核心知识。这篇文章将会带您一步一步的以最少的代码去实现一个小而美的依赖收集库,同时给您展现如何将这个库运用到小程序中去实现跨页面的状态共享。

二 实现过程

1. 基本原理

依赖收集的基本原理可以概括为以下3步:

  1. 创建一个可观察(observable)对象
  2. 视图或者函数(effect)引用这个对象的某个属性,触发依赖收集
  3. 改变数据,视图或者函数自动更新或运行

我们要实现的例子:

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

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

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

// logs: 1
// logs: 2
ob.a = 2;

下面开始我将一步一步的进行实现过程讲解

2. 创建一个可观察对象

首先,我们需要创建一个可观察对象,其本质就是将传入的对象进行代理,并且返回这个代理对象,这里我们使用es6Proxy来修改对象的一些行为,从而实现在返回真正对象前作一些拦截操作。

我们定义了一个名叫observable方法来代理对象,代码如下:

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