实现vue3的响应式源码

秦篆vuevue3大约 6 分钟

上一篇我们说了es6中的代理模式proxy,也提到了它在vue3中的应用,这篇呢我们就来实现vue3响应式的源代码,当然是简易版本。
学习就要知其然而知其所以然。这里呢我们将掠过ref、reactive等api的使用,直窥本质。

reactive函数实现

延续之前的项目结构,在packages/reactivity下新建一个reactive.ts文件,我们先来实现一个reactive函数。

export const reactive = <T extends object>(target:T) => {
    return new Proxy(target, {
        get(target, key, receiver) {
            let res = Reflect.get(target, key, receiver)
            return res
        },
        set(target: T, p: string | symbol, newValue: any, receiver: any): boolean {
            let res = Reflect.set(target, p, newValue, receiver)
            return res
        }
    })
}

按照上一篇文章的内容,reactive返回一个proxy代理对象,我们这里不再关注其它的代理配置项,vue3的源码中呢是将所有的配置项都实现了的,我们搞懂 get和set,其它的原理也是一样。

在reactivity下新建一个index.html文件用作测试,如果你是vscode,可以使用live server插件启动一个服务,如果是webstorm或者其它编辑器甚至是文本编辑器, 我的建议是使用npm install serve安装这个包,然后使用npm run serve启动一个临时服务。

<body>
<div id="app">
</div>
<script type="module">
    import {reactive} from "/packages/reactivity/dist/reactivity.esm-bundler.js";
    const obj = reactive({name: '老罗', age: 18});
</script>
</body>
</html>

import {reactive} from "/packages/reactivity/dist/reactivity.esm-bundler.js";这个js文件是我们上次构建编译环境时教学的内容,如果忘了可以去上一篇看看。

ok,相信上面两段代码呢不需要解释,接下来我们来完善一下代码。首先,我们给出一个简单的方法,可以修改视图:

import {reactive, effect} from "/packages/reactivity/dist/reactivity.esm-bundler.js";

const obj = reactive({name: '老罗', age: 18});

effect(() => {
document.querySelector('#app').innerText = `name: ${obj.name}, age: ${obj.age}`
})

ok,现在我们得到了一个修改视图的方法,接下来的内容就是如何让这个方法随着数据的改变而调用。有同事肯定就有点子了,‘把这个函数在set拦截器中调用不就行了’。哈哈,我一开始也是这样想的。
首先,vue3实现响应式时,考虑了多层嵌套的数据结构,也是为了解决vue2中嵌套的数据类型无法实现响应式的问题。所以他们设计了一种数据结构:

targetsMap
targetsMap

非常巧妙的一个设计,这样就能够保证所有的对象属性都能够被正确的执行副作用函数。接下来我们一起看看怎么实现。

effect、track、trigger

effect

effect用来执行数据绑定的副作用函数,它应该接收一个函数用于改变视图。在上面的代码中,html代码已经给出了简易的方法修改视图。
首先,在packages/reactivity下新建一个effect.ts文件:

let activeEffect: () => void; //收集副作用函数
export const effect = (fn: Function) => {
    const _effect = function (){ //使用闭包,使其能够更快的被gc
        activeEffect = _effect //收集副作用函数
        fn();
    }
    //第一次也要执行,因为数据初始化可能会改变
    _effect()
}

现在我们就将数据对应的修改视图方法收集起来了,但是收集起来没用啊,按照上面给出的数据模型,不是应该为每个对象属性甚至是对象都指定更新逻辑吗。
所以接下来就需要track函数了。

track

同样在effect.ts文件中编写如下代码:

const targetsMap = new WeakMap()
export const track = (target:object,key:string | symbol) =>{ //traget就是对象本身,也就是{name:'老罗',age:18} key则是name、age等
    let depsMap = targetsMap.get(target) //先获取一遍、这里我们后续会提到,大伙可以自己思考一下为什么要先获取
    if (!depsMap) {
        depsMap = new Map()
        targetsMap.set(target,depsMap) //以当前对象为key,按照图中的数据模型放入一个map
    }
    let deps = depsMap.get(key) //同上map
    if (!deps) {
        deps = new Set()
        depsMap.set(key,deps) //同上
    }

    deps.add(activeEffect) //最后的依赖收集就是副作用函数,用set是因为副作用函数可能不止一个,因为数据不止在一个地方使用嘛
}

js小知识

WeakMap\WeakSet是一种弱引用数据类型,什么叫做弱引用。我们以WeakMap为例,我们都知道map的键值可以为任意的数据类型,当期为对象时,WeakMap的当前 引用总会随着该对象的其它引用结束而结束,而不主动触发gc。

回到上面的代码,首先我们按照图例,新建了一个新的数据存储结构targetsMap,这里使用弱引用的原因则是,使后续所有的依赖收集随着对象的gc而消失,提升性能。
随后再以原始对象的key为key,收集了副作用函数。

接下来就是要实现如何将副作用函数在数据发生改变时响应在视图或者其它地方了。

trigger

先实现执行副作用函数的逻辑:

export const trigger = (target:object,key:string | symbol) =>{
    const depsMap = targetsMap.get(target)
    const deps = depsMap.get(key)

    deps.forEach((fns:Function) => {
        fns()
    })
}

非常简单的实现的,当然vue3的源码中做了相当多其它的判断,这里呢我们不关心这些。
ok,刚刚我们提到了,副作用函数应该在什么地方执行,答案当然是在代理对象中,我们来修改一下reactive方法。

import {track, trigger} from "./effect";

export const reactive = <T extends object>(target:T) => {
    return new Proxy(target, {
        get(target, key, receiver) {
            let res = Reflect.get(target, key, receiver)
            track(target, key)
            return res
        },
        set(target: T, p: string | symbol, newValue: any, receiver: any): boolean {
            let res = Reflect.set(target, p, newValue, receiver)
            if (res){
                trigger(target, p)
            }
            return res
        }
    })
}

需要注意的是,一定要先执行对象的操作,再去收集依赖或者触发副作用函数。

这样呢一个简易,但是五脏俱全的响应式就实现好了,我们可以来做一下测试,执行之前教过的构建流程,或者你也可以使用tsc --init将ts文件生成为js文件直接引入。

示例

在packages/reactivity的index.ts中引入effect和reactive,执行打包,得到如下代码:

'use strict';

let activeEffect;
const effect = (fn) => {
  const _effect = function() {
    activeEffect = _effect;
    fn();
  };
  _effect();
};
const targetsMap = /* @__PURE__ */ new WeakMap();
const track = (target, key) => {
  let depsMap = targetsMap.get(target);
  if (!depsMap) {
    depsMap = /* @__PURE__ */ new Map();
    targetsMap.set(target, depsMap);
  }
  let deps = depsMap.get(key);
  if (!deps) {
    deps = /* @__PURE__ */ new Set();
    depsMap.set(key, deps);
  }
  deps.add(activeEffect);
};
const trigger = (target, key) => {
  const depsMap = targetsMap.get(target);
  const deps = depsMap.get(key);
  deps.forEach((fns) => {
    fns();
  });
};

const reactive = (target) => {
  return new Proxy(target, {
    get(target2, key, receiver) {
      let res = Reflect.get(target2, key, receiver);
      track(target2, key);
      return res;
    },
    set(target2, p, newValue, receiver) {
      let res = Reflect.set(target2, p, newValue, receiver);
      if (res) {
        trigger(target2, p);
      }
      return res;
    }
  });
};

exports.effect = effect;
exports.reactive = reactive;
exports.track = track;
exports.trigger = trigger;

上面呢就是一个可以在浏览器环境执行的结果,接下来在html中引入并使用:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app">
    <button id="">change</button>
</div>
<script type="module">
    import {reactive, effect} from "/packages/reactivity/dist/reactivity.esm-bundler.js";

    const obj = reactive({name: '老罗', age: 18});

    effect(() => {
        document.querySelector('#app').innerText = `${obj.name}, ${obj.age}`
    })

    setTimeout(() => {
        obj.name = '帅比'
    }, 1000)
</script>
</body>
</html>

这样之后呢,页面首先会显示老罗,18,一秒后会修改为帅比,18

总结

以上呢就是vue3响应式的实现思路了,虽然很简单,但是源码总是这么的朴实无华。下次我们来说说computed与源码

上次编辑于:
贡献者: luolj