近期阅读了霍春阳老师的《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函数来实现响应式系统,主要核心为两大方面:
- 在读取响应式数据时,将副作用函数收集到桶中;
- 在设置响应式数据时,将副作用函数从桶中取出来并重新执行。
注:这里有设置和读取两个操作,我们通过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是一个数据,当数据发生改变,模板重新渲染,那么具体是怎么实现的呢?其实本质就是利用副作用函数实现的,下面我们来剖析一下:
- 首先vue肯定会定义一个函数,这个函数会设置模板的内容,比如
function fn() {
const dom = document.querySelector("#root");
dom.innerText = obj.name
}
此时这个fn就是一个副作用函数
- 我们可以利用proxy对数据进行拦截,当数据读取时,我们把该副作用函数fn收集到桶中
- 当该数据发生改变时,将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等。
更加完善的响应式系统
注:此图来源于霍春阳老师的《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 };
如上,一个最简单的响应式系统就做好了,其实响应式的流程如下图所示,非常简单:
当如上流程走完之后,响应式数据和副作用函数effect所建立的关系如下:
缺陷规避
如上我们已经完成了一个初级的响应式系统,但是仍然会有很多缺陷,比如:
- 分支切换会造成遗留的副作用函数会导致不必要的更新
- 数据自增造成死循环,obj.num++
- 嵌套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的关系如下:
- 模板初次渲染,会执行effect函数,读取obj.isShow和obj.name
- 当更高obj.isShow,会重新触发effect函数
- 但是此时不会读取obj.name,即无论之后obj.name如何变化都没用,因为模板上会一直是李四
- 此时如果更改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)
这里是什么意思呢?
拿我们的例子来说,
isShow和age是两个属性,对应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所对应的关系如下:
setTimeout(() => {
obj.name = "李四";
}, 1000);
但是我们执行修改完name之后,发现执行的是fn2函数
原因如下:我们上面设计的代码是不支持嵌套的,因为我们定义了一个全局变量
let activeEffectFn; //当前的副作用函数
而一旦函数嵌套,这意味着同一时刻 activeEffectFn 所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffectFn 的值,并且永远不会恢复到原来的值。这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数,这就是问题所在。
解决办法: