Vue设计原理解读之响应式系统的实现(一)

77 阅读10分钟

近期阅读了霍春阳老师的《Vue.js设计与实现》,该书无一句源码,却把原理讲的非常透彻,为更好的理解vue的原理知识,特写此笔记,强烈推荐大家阅读此书,会有不一样的收获!

webpack配置

首先创建进入一个目录并初始化 npm,然后 在本地安装 webpack,接着安装 webpack-cli(此工具用于在命令行中运行 webpack):

mkdir vuejs-source-code
cd vuejs-source-code
npm init -y
npm install webpack webpack-cli --save-dev

现在创建以下目录结构、文件和内容,在根目录新建dist文件夹,之后webpack打包后的index.js会变成dist文件夹下的main.js:

 webpack-demo
  |- package.json
  |- package-lock.json
+ |- /dist
+   |- index.html
+ |- /src
+   |- index.js

webpack v4 无须任何配置即可运行,然而大多数项目会需要很复杂的设置,因此 webpack 仍然支持 配置文件,这比在终端中手动输入大量命令更加高效。接下来创建一个 webpack 配置文件: webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist'),
  },
};

具体配置见起步 | webpack 中文文档 | webpack中文文档 | webpack中文网 (webpackjs.com)

响应式系统的作用与实现

首先我们认识一下什么叫副作用函数,副作用函数就是会产生副作用的函数

这句话有点绕嘴,其实本质就是,如果有一个全局变量a,函数Fn1改变了a的值,那么Fn1就叫副作用函数,因为可能还有别的函数在使用,比如Fn2是读取这个变量a的函数,一旦Fn1改变了a,运行Fn2时,读取的值就会改变

effect实现简单的响应式系统

下面使用一个effect函数来实现响应式系统,主要核心为两大方面:

  1. 在读取响应式数据时,将副作用函数收集到桶中;
  2. 在设置响应式数据时,将副作用函数从桶中取出来并重新执行。

注:这里有设置和读取两个操作,我们通过proxy来实现

案例: 我们用段代码片段来讲述:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title></title>
  </head>
  <body>
    <div id="root">初始内容</div>
  </body>
</html>

const obj={name:'张三'}
function fn() {
  const dom = document.querySelector("#root");
  dom.innerText = obj.name
}
fn()

obj.name='李四'
fn()

如上面所述,name是一个数据,当数据发生改变,模板重新渲染,那么具体是怎么实现的呢?其实本质就是利用副作用函数实现的,下面我们来剖析一下:

  1. 首先vue肯定会定义一个函数,这个函数会设置模板的内容,比如
function fn() {
  const dom = document.querySelector("#root");
  dom.innerText = obj.name
}

此时这个fn就是一个副作用函数

  1. 我们可以利用proxy对数据进行拦截,当数据读取时,我们把该副作用函数fn收集到桶中
  2. 当该数据发生改变时,将fn从桶中取出来执行,一旦执行,dom上的内容就会发生变化

其实这就是最简单的响应式原理,我们下面把完整代码贴出来:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title></title>
  </head>
  <body>
    <div id="root">初始内容</div>
  </body>
</html>
//首先定义一个数据
const data = { name: "张三" };
// 利用proxy进行拦截
const obj = new Proxy(data, {
  get(target, key) {
    return target[key];
  },
  set(target, key, newVal) {
    target[key] = newVal 
    effect(fn);
    return newVal
  },
});
//这个函数是副作用函数
function fn() {
  const dom = document.querySelector("#root");
  dom.innerText = obj.name;
}

let activeFn;//定义一个全局变量=副作用函数
//用来注册副作用函数的函数
const effect = (fn) => {
  const effectFn = () => {
    activeFn = effectFn;
    fn();
  };
  effectFn();
};
//首先一上来要执行函数,类似于vue中template的渲染,肯定要把读取内容渲染到插值语法中({{}})
effect(fn);
//1s之后,修改obj,此时会触发proxy对象的set拦截函数,因此又会触发副作用函数
setTimeout(() => {
obj.name="李四"
}, 1000);

但是上面函数有个弊端,就是只适用于一个响应式变量,如果有多个呢?

所以我们要设置一个桶,来对应搜集不同的对象及对象中不同的key等。

更加完善的响应式系统

image.png

注:此图来源于霍春阳老师的《vue.js设计与实现》,如侵联删

首先定义一个WeakMap数据结构,能够在响应式数据与副作用函数之间建立更加精确的联系: key是一个个对象,即对n个对象要进行proxy代理,每个对象是一个map,值是一个集合,对应的key就是对象的键,集合中是相应的副作用函数集合

基础实现

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title></title>
  </head>
  <body>
    <div id="root">初始内容</div>
  </body>
</html>
//index.js
import { track, trigger } from "./track";
const data = { name: "张三", age: 0 };
let activeEffectFn; //当前的副作用函数
function fn() {
  const dom = document.querySelector("#root");
  dom.innerText = obj.name + ":" + obj.age + "岁";
}
const effect = (fn) => {
  const effectFn = () => {
    // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
    activeEffectFn = effectFn;

    fn();
  };
  effectFn();
};
// 利用proxy进行拦截
const obj = new Proxy(data, {
  // 在 get 拦截函数内调用 track 函数追踪变化
  get(target, key) {
    track(target, key, activeEffectFn);
    return target[key];
  },
  // 在set内触发对应的函数
  set(target, key, newVal) {
    target[key] = newVal;
    trigger(target, key);
    return newVal;
  },
});
 //为什么这里上来就执行一次呢?因为要一上来就对响应式数据的属性进行拦截,
 // 个人猜测是调用render函数渲染模板的时候,会进行第一次数据的读取然后渲染在template中,
//进而完成属性收集
effect(fn);

setTimeout(() => {
  obj.name = "李四";
  obj.age = 18;
}, 1000);

//track.js
var bucket = new WeakMap(); //用来存储追踪的内容和相关的依赖
/**
 *
 * @param {*} target 要代理的对象
 * @param {*} key    要追踪的属性
 * @param {*} activeEffectFn 当前的副作用函数
 */
const track = (target, key, activeEffectFn) => {
  if (!activeEffectFn) return;
  let depsMap = bucket.get(target);
  // 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型
  // 里面存储着所有与当前 key 相关联的副作用函数:effects
  let deps = depsMap.get(key);
  // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  deps.add(activeEffectFn);
};

/**
 *
 * @param {*} target  代理的对象
 * @param {*} key     追踪的属性
 */
const trigger = (target, key) => {
  // 根据 target 从桶中取得 depsMap,它是 key --> effects
  let depsMap = bucket.get(target);

  if (!depsMap) return;
  // 根据 key 取得所有副作用函数 effects
  let effects = depsMap.get(key);
  // 执行副作用函数
  effects && effects.forEach((fn) => fn());
};
export { track, trigger, bucket };

如上,一个最简单的响应式系统就做好了,其实响应式的流程如下图所示,非常简单: image.png

当如上流程走完之后,响应式数据和副作用函数effect所建立的关系如下:

image.png image.png

缺陷规避

如上我们已经完成了一个初级的响应式系统,但是仍然会有很多缺陷,比如:

  1. 分支切换会造成遗留的副作用函数会导致不必要的更新
  2. 数据自增造成死循环,obj.num++
  3. 嵌套effect时怎么办

如下我们来解决这些问题

1)分支切换与cleanup

假设在.vue文件中:

<template>
  <el-card>首页</el-card>
  <div>{{ obj.isShow ? obj.name : "李四" }}</div>
</template>

<script setup lang="ts">
const obj = reactive({
  isShow: true,
  name: "张三",
});
const name = ref("张三");
setTimeout(() => {
  obj.isShow = false;
}, 1000);
</script>

按照上述的响应式流程,在该案例中,定义了两个响应式数据isShow和name,render函数是一个副作用函数,用来读取isShow和name并且渲染到template上,类似于:

effect(function fn() {
  document.body.innerText = obj.isShow ? obj.name : '李四';
});

其中响应式数据和effect的关系如下:

image.png
  1. 模板初次渲染,会执行effect函数,读取obj.isShow和obj.name
  2. 当更高obj.isShow,会重新触发effect函数
  3. 但是此时不会读取obj.name,即无论之后obj.name如何变化都没用,因为模板上会一直是李四
  4. 此时如果更改obj.name的值,仍然会再触发一次effect函数,这就是遗留的副作用函数

那么我们如何解决呢?

其实很简单,就是在每次副作用函数重新执行时,先把之前收集的依赖全部删除,然后副作用函数重新执行时,会重新收集,此时收集的就是有用的副作用函数

下面我们对代码加以改进:

const track = (target, key, activeEffectFn) => {
  if (!activeEffectFn) return;
  let depsMap = bucket.get(target);
  // 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型
  // 里面存储着所有与当前 key 相关联的副作用函数:effects
  let deps = depsMap.get(key);
  // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  // 把当前激活的副作用函数添加到依赖集合 deps 中
  deps.add(activeEffectFn);

  // deps 就是一个与当前副作用函数存在联系的依赖集合
  // 将其添加到 activeEffect.deps 数组中
  activeEffectFn.deps.push(deps);
};

首先我们在track的时候,加上这行代码activeEffectFn.deps.push(deps)

这里是什么意思呢?

拿我们的例子来说,isShowage是两个属性,对应2个dep,但是副作用函数是同1个activeEffectFn,我们在上面把当前的副作用函数加在了这2个dep中: deps.add(activeEffectFn),换句话说就是这2个dep,每个人都拥有1个副作用函数,此时我们再把这2个dep加在activeEffectFn.deps中,就代表1个副作用函数有多少dep包含它,这里有点绕...

收集之后,我们就可以定义一个cleanUp函数,在每次副作用函数执行时,在每个dep中删掉它

function cleanup(activeEffectFn) {
  // 遍历 effectFn.deps 数组
  for (let i = 0; i < activeEffectFn.deps.length; i++) {
    // deps 是依赖集合
    const deps = activeEffectFn.deps[i];
    // 将 effectFn 从依赖集合中移除
    deps.delete(activeEffectFn);
  }
  // 最后需要重置 effectFn.deps 数组
  activeEffectFn.deps.length = 0;
}
const effect = (fn) => {
  const effectFn = () => {
    // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
    cleanup(effectFn) // 调用 cleanup 函数完成清除工作
    activeEffectFn = effectFn;

    fn();
  };

按如上操作会死循环,因为一个set结构,你在不断增减,会造成死循环,我们下面优化一下:

在tirgger函数中将函数执行改成这样即可:

const effectsToRun = new Set(effects); effectsToRun.forEach((effectFn) => effectFn());

const trigger = (target, key) => {
  // 根据 target 从桶中取得 depsMap,它是 key --> effects
  let depsMap = bucket.get(target);

  if (!depsMap) return;
  // 根据 key 取得所有副作用函数 effects
  let effects = depsMap.get(key);

  // 执行副作用函数
  const effectsToRun = new Set(effects);
  effectsToRun.forEach((effectFn) => effectFn());
};

大功告成,此时就不会产品遗留的副作用函数啦~

2)嵌套effect

vue的渲染函数其实就是典型的副作用函数,因为渲染函数需要读取响应式数据,而包含响应式数据的函数一定的副作用函数,而vue中组件是可以嵌套的,那当A组件嵌套B组件,在执行渲染函数的时候,就会产生嵌套

effect(
function effectFn1(){
effect(function effectFn2() {
/* ... */ 
})
/* ... */
})

下面我们用一个案例来说明:

const data = { name: "张三", age: 0 };
// 利用proxy进行拦截
const obj = new Proxy(data, {
  // 在 get 拦截函数内调用 track 函数追踪变化
  get(target, key) {
    track(target, key, activeEffectFn);
    return target[key];
  },
  // 在set内触发对应的函数
  set(target, key, newVal) {
    target[key] = newVal;
    trigger(target, key);
    return true;
  },
});

effect(() => {
  effect(() => {
    console.log("副作用函数fn2执行", obj.age);
  });
  console.log("副作用函数fn1执行", obj.name);
});

根据上面内容所述,我们期待name和age所对应的关系如下:

image.png

setTimeout(() => {
  obj.name = "李四";
}, 1000);

但是我们执行修改完name之后,发现执行的是fn2函数

image.png

原因如下:我们上面设计的代码是不支持嵌套的,因为我们定义了一个全局变量

 let activeEffectFn; //当前的副作用函数

而一旦函数嵌套,这意味着同一时刻 activeEffectFn 所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffectFn 的值,并且永远不会恢复到原来的值。这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数,这就是问题所在。

解决办法:

3)避免死循环