用户
 找回密码
 立即注册

QQ登录

只需一步,快速开始

扫一扫,登录网站

小程序社区 首页 教程 实战教程 查看内容

如何使用不到50行代码实现一个小而美的依赖收集库?

Rolan 2019-12-2 00:02

现代web开发,大多数都遵循着视图与逻辑分离的开发原则,一反面使得代码更加易懂且易扩展,另一方面带来的问题就是如何优雅的管理数据。因而,社区诞生了很多优秀的状态管理库,比如为React而生的Redux,专为Vue服务 ...

现代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. 创建一个可观察对象

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

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

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

function createObservable(obj) {
    const proxyObj = new Proxy(obj, handlers());
    return proxyObj
}
复制代码

可以看到 observable 方法内部就是通过 new Proxy(obj,handler) 生成一个代理对象,传参分别是原始对象和代理操作方法 handlers , handlers 返回一个对象,定义了对象的原始方法,例如 get 、 set ,通过重新定义这两个方法,我们可以修改对象的行为,从而完成代理操作,我们来看看 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
        }
    }
}
复制代码

如上,我们在 get 和 set 方法里面没有做任何操作,取值赋值操作都是原样返回。

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 来存储整个映射关系,它的 key 是 obj ,就是原始对象, 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} 中 key 为 b 对应的 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 ,控制台就会依次输出 1 和 2 ,另外说一句,数组也是对象,故动态增加数组的值或者赋值操作都能触发响应的 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. 核心原理

描述下场景,有两个页面 A 和 B ,全局数据 C , A 和 B 都引用了 C ,之后,页面 A 中某个交互改变了 C , A 和 B 都需要自动渲染页面。结合我们的库, 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 改变了数据 C , observe 方法参数 fn 自动执行,触发 this.setData 方法,从而页面重新渲染,完整代码点击 micro-reaction-miniprogram ,也可以点击查看 在线Demo

四 总结

希望我的文章能够让您对依赖收集的认识更深,以及如何举一反三的学会使用,此外,最近在学习周爱民老师的《JavaScript核心原理解析》这门课程,其中有句话对我触动很深,引用的是金庸射雕英雄传里面的文本: 教而不得其法,学而不得其道 ,意思就是说,传授的人没有用对方法,学习的人就不会学懂,其实我自己对学习的方法也一直都很困惑,前端发展越来越快,什么 SSR ,什么 serverless ,什么 前端工程化 ,什么 搭建系统 各种知识概念越来越多,不知道该怎么学习,说不焦虑是不可能的,但坚信只有一个良好的基础,理解一些技术的本质,才能在快速发展的前端技术浪潮中,不至于被冲走,与局共勉!

最后,在贴下文章提及的两个库,欢迎star试用,提pr,感谢~

依赖收集库 micro-reaction

小程序状态管理库 micro-reaction-miniprogram

鲜花
鲜花
鸡蛋
鸡蛋
分享至 : QQ空间
收藏
原作者: skinner 来自: 掘金