揭开响应式的面纱
Vue2 vs Vue3
技术同历史车轮一同向前且不断创新,如尤大所说,Vue3 从另一种层面也是还清了 Vue2 所欠下的一些技术债。从事前端的小伙伴不出意外对 Vue 的响应式都有所了解,毕竟这个 Vue 的核心竞争力。可能存在的问题就是对响应式实现的原理的认知不同,为此,笔者借此文与你一同揭开响应式的面纱。(本文通过图文、代码的方式来理解响应式原理,阅读时长15~25分钟,如文章中说明有误,请不吝赐教~~~)
本文将围绕以下几个问题展开
- Vue2 和 Vue3 实现响应式的 api 优劣对比?
- 如何简单实现 Vue2 响应式?
- 使用 Object.defineProperty 对 data 中的数据进行遍历劫持?
- 实现 发布-订阅 模式,收集依赖和触发依赖?
- 数组如何实现响应式?以及其他复杂数据类型能否劫持?
- 如何简单实现 Vue3 响应式?
- 复杂数据类型,使用 Proxy 代理对象?
- ref 和 reactive 的原理区别?
- 如何监听数组?以及其他复杂数据类型?
安于现状和改变,则何如?
响应式
响应式: 响应式机制的主要功能就是,可以把普通的 JavaScript 对象封装成为响应式对象,拦截数据的获取和修改操作,实现依赖数据的自动化更新。按照 MVVM 模型来说就是 view 层的数据发生改变,model 层的数据也会同步发生改变;
翻开 Vue3 源码 历史 一查,满篇都写着四个字是‘权衡艺术’。Vue3 从性能的角度,官方统计的数据 Performance 性能比 Vue 2.x 快 1.2~2 倍,其中重大的一部分贡献就来自于响应式的重构 refactor。Vue2 已经很成功了(使用人数、github star),但是从技术上的层面剖析,依旧不太完满。那为什么要重构呢?笔者认为可能是艺术家的小骄傲吧!废话不多说,咱们根据开头提出的问题,逐一击破~~~
Vue2 和 Vue3 实现响应式的 api 优劣对比?
- defineProperty 是 ES5 提供的方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象,在 Vue2 中,通过劫持对象的 getter 和 setter,实现响应式。操作对象是原始数据,兼容性强。缺点就是只能监听已存在的对象,需要对每个属性进行遍历监听,如果嵌套对象,需要深层监听,会造成性能问题,Vue2 也提供了
$set
和$delete
新增和删除新属性的 Api,解决 Object.defineProperty 的缺陷。 -
- Proxy 是 ES6 提供的构造函数,在 Vue3 中,通过代理对象,实现响应式。操作对象是新对象,并且多达13中拦截方式。Proxy 可以拦截属性的访问、赋值、删除等操作,不需要初始化的时候遍历所有属性,另外有多层属性嵌套的话,只有访问某个属性的时候,才会递归处理下一级的属性。可以监听动态新增的属性,可以监听删除的属性 ,可以监听数组的索引和 length 属性。
- Proxy 创建的实例可以用于修改某些操作的默认行为,等同于在语言层面做出修改,所以属于一种“元编程”(meta programming),即对编程语言进行编程。
- Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。
- 代理也是最符合也是最能体现 Vue 响应式核心观点的名词
去做你说的不可能
一千个读者心中就有一千个哈姆雷特。对于 Vue 的核心响应式来说,每个 前端er 说辞都十分接近,但是实现方法去不尽相同。根据费曼学习法
指导,学习需明确目标、自我理解、化整为零、总结提炼
。所以,为了更好的检验自己学习成果,动手实现一个最简模型,这个模型包含核心思想和核心方法。因此,咱们朝着实现一个最简模型目标,策马奔腾向前~~~
最简实现 Vue2 响应式
任务拆分
- 实现对数据劫持
- 使用 Object.defineProperty
- 实现发布订阅模式
- 数据发生改变,更新视图
- 视图发生改变,更新数据
图解
代码
<!-- index.html 示例 demo-->
<div id="app">
<span>姓名 {{name}}</span>
<input type="text" v-model="name" />
</div>
<script>
const vm = new Vue({
el: "#app",
data: {
name: "zs",
},
});
</script>
使用 Object.defineProperty 对 data 中的数据进行遍历劫持
// Vue.js
class Vue {
constructor(obj_instance) {
this.$data = obj_instance.data;
Observer(this.$data);
}
}
function Observer(data_instance) {
// 递归的出口
if (!data_instance || typeof data_instance !== "object") return;
Object.keys(data_instance).forEach((key) => {
defineRective(data_instance, key, data_instance[key]);
});
}
function defineRective(data_instance, key, value) {
Observer(value); // 递归 -- 子属性数据劫持
Object.defineProperty(data_instance, key, {
enumerable: true,
configurable: true,
get() {
console.log(`访问了属性:${key} -> 值:${value}`);
return value;
},
set(newValue) {
console.log(`设置了属性:${key} -> 值:${value}`);
value = newValue;
},
});
}
以上的代码简单的实现了对 data 的进行劫持,在控制台 get 或 set 相应属性就能看到 vm 实例中的 data 会发生相应的改变。
根据任务拆解,接下来就是给 view 层和 model 层搭上一座桥梁。简单实现一个 compiler 解析器,替换界面上的插值表达式,以实现双向数据绑定。
实现发布-订阅模式,收集依赖和触发依赖
在实现发布-订阅模式之前,先实现一个简易版的 HTML 模板解析器,用来替换界面上的插值表达式,利用虚拟的节点对象作为缓冲区,修改完 dom 后再将 Vue 数据应用,最后再渲染页面。
// HTML模板解析器 替换界面上的插值表达式
// 获取页面元素 -> 放入临时内存区域(批量修改 dom)-> 应用 Vue 数据 -> 渲染页面
function Compile(element, vm) {
vm.$el = document.querySelector(element);
const fragment = document.createDocumentFragment();
let child;
// 一个一个添加进去
while ((child = vm.$el.firstChild)) {
fragment.append(child);
}
fragment_complie(fragment, vm);
vm.$el.appendChild(fragment);
}
// 替换文档
function fragment_complie(node, vm) {
// input node.nodeType === 1 h1、span 也是 1
// text node.nodeType === 3
// 替换 插值表达式 -> 文本节点
if (node.nodeType === 3) {
parseInterpolation(node, vm);
return;
}
if (node.nodeType === 1 && node.nodeName === "INPUT") {
parseInput(node, vm);
return;
}
// 循环遍历
node.childNodes.forEach((child) => fragment_complie(child, vm));
}
function parseInterpolation(node, vm) {
const pattern = /\{\{\s*(\S+)\s*\}\}/;
const xxx = node.nodeValue;
const result_regex = pattern.exec(node.nodeValue);
if (result_regex) {
const arr = result_regex[1].split(".");
// 链式调用属性 vm.$data.other.age
const value = arr.reduce((total, current) => total[current], vm.$data);
node.nodeValue = xxx.replace(pattern, value);
}
}
function parseInput(node, vm) {
const attr = Array.from(node.attributes);
attr.forEach((i) => {
if (i.nodeName === "v-model") {
const value = i.nodeValue
.split(".")
.reduce((total, current) => total[current], vm.$data);
console.log("node ===", node);
console.log("value ===", value);
node.value = value;
node.addEventListener("input", (e) => {
// ['other','age']
const arr1 = i.nodeValue.split(".");
// ['other']
const arr2 = arr1.slice(0, arr1.length - 1);
// vm.$data.other
const final = arr2.reduce((total, current) => total[current], vm.$data);
// arr1.length - 1 当前的这个 总是最后一个
final[arr1[arr1.length - 1]] = e.target.value;
});
}
});
}
以上是实现了根据 nodeType 替换插值表达式和为使用了 v-model 的 input 添加事件监听函数。到此为止,搭建桥梁的材料都已经备齐,就是开始搭建了~~~
发布订阅模式
发布者有内容更新的时候就通知到相应的订阅者。例如在学生定购牛奶,学生就是订阅者,牛奶批发商就是发布者,牛奶批发商需将每日新鲜的牛奶送到学生手上。
// 链式调用公共函数
function getValueToChain(value, header) {
return value.split(".").reduce((total, current) => total[current], header);
}
发布者要素
- 维护订阅者数组
- 这是一个收集依赖的过程
- 通知订阅者更新
- 这是一个触发依赖的过程
// 发布者 - 收集和通知订阅者
class Dependency {
constructor() {
// 订阅者数组
this.subscribers = [];
}
// 添加订阅者
addSub(sub) {
this.subscribers.push(sub);
}
// 通知订阅者
notify() {
// 遍历数组 更新依赖
this.subscribers.forEach((sub) => sub.update());
}
}
订阅者要素
- 接受自定义的更新函数
- 创建订阅者的时候,需要通知发布者收集
// 订阅者
class Watcher {
constructor(vm, key, callback) {
this.vm = vm; // Vue 实例
this.key = key; // 需要更新的属性
this.callback = callback; // 如何更新的函数 支持自定义
// 临时属性 给类添加一个临时属性
Dependency.temp = this;
// 触发当前 key 的 getter 不需要保存
getValueToChain(key, vm.$data);
Dependency.temp = null;
}
update() {
const value = getValueToChain(this.key, this.vm.$data);
this.callback(value);
}
}
生成了发布者和订阅者之后,就需要将这两个类插入到之前写的代码里面去。(以下代码省略部分内容)
- 在数据劫持的时候,需要在 get 的时候收集依赖,在 set 的时候触发依赖
- 在模板解析的时候,需要在处理相应节点的时候,增加订阅者
function Observer(data_instance) {
if (!data_instance || typeof data_instance !== "object") return;
// === 新增 ===
const dependency = new Dependency();
Object.keys(data_instance).forEach((key) => {
defineRective(data_instance, key, data_instance[key], dependency);
});
}
function defineRective(data_instance, key, value, dependency) {
Object.defineProperty(data_instance, key, {
enumerable: true,
configurable: true,
get() {
// === 新增 ===
// 订阅者加入依赖实例的数组
Dependency.temp && dependency.addSub(Dependency.temp);
return value;
},
set(newValue) {
value = newValue;
// === 新增 ===
dependency.notify();
// 新属性需重新监听
Observer(newValue);
},
});
}
function parseInterpolation(node, vm) {
if (result_regex) {
const arr = result_regex[1].split(".");
const value = arr.reduce((total, current) => total[current], vm.$data);
node.nodeValue = xxx.replace(pattern, value);
// === 新增 ===
// 创建订阅者
new Watcher(vm, result_regex[1], (newValue) => {
node.nodeValue = xxx.replace(pattern, newValue);
});
}
}
function parseInput(node, vm) {
attr.forEach((i) => {
if (i.nodeName === "v-model") {
// === 新增 ===
// 创建订阅者
new Watcher(vm, i.nodeValue, (newValue) => {
node.value = newValue;
});
}
});
}
将发布订阅模式加到代码后,再去修改 index.html 页面的值就能看到数据已经实现响应式了~~~
数组如何实现响应式?以及其他复杂数据类型能否劫持?
对于数组,因为 Object.defineProperty 的缺陷(无法检测数组/对象的新增、无法检测通过索引改变数组的操作),对于数组已存在的索引,Vue 没有去做检测,总的来说是因为性能和用户体验收益不成正比,尤大给出的解释如下图。官方文档给了 Object.defineProperty 缺陷的解释 ——> 传送门
对于对象而言,每一次的数据变更都会对对象的属性进行一次枚举,一般对象本身的属性数量有限,所以对于遍历枚举等方式产生的性能损耗可以忽略不计,但是对于数组而言呢?数组包含的元素量是可能达到成千上万,假设对于每一次数组元素的更新都触发了枚举/遍历,其带来的性能损耗将与获得的用户体验不成正比,故 Vue2 无法检测数组的变动。
所以,通过下标修改数组的情况,Vue2 是无法被响应式追踪的。又因 Object.defineProperty 也监听不了数组 length 发生变化的情况(push、pop、shift 等对数组进行操作的方法),Vue2 就重写了原型上的 7个方法再进行监听(直接通过下标修改的情况不多,但是通过 length 修改的情况却很常见)。新增和删除也提供了 delete。 另外,Vue2 也无法监听其它复杂数据类型,如 Map、Set 等。
最简实现 Vue3 响应式
Vue3 是通过 monorepo 的方式管理包,即可以看成是多个仓库集成。其中响应式 reactivity 模块是可以单独抽离在其它三方框架使用。
源码中 reactive 模块下有一个 test 文件夹,包含着相应 api 的测试用例,所以我们 Vue3 响应式 demo 通过 TDD 测试驱动开发的模式编写。
编写一个单测
// 使用 jest
import { reactive } from "../reactive";
// describe 是描述一个单测
describe('effect', () => {
it('reactive ', () => {
// reactive 核心
// get 收集依赖
// set 触发依赖
const user = reactive({
age: 10
})
user.age++ // user.age = user.age + 1
// 预计 user.age 的值等于 11
// 单测没通过,这行就会报错
expect(user.age).toBe(11)
});
});
复杂数据类型,使用 Proxy 代理对象?
根据单测编写代码
// reactive.ts
export function reactive(target) {
// 返回一个构造器函数
return createReactiveObject(target);
};
function createReactiveObject(target) {
const proxy = new Proxy(target, {
get(target, key) {
const res = target[key]
console.log(`get key ==> ${target[key]}`);
return res
},
set(target, key, value) {
console.log(`set key ==> ${value}`);
target[key] = value
return target[key]
}
});
return proxy
}
运行单测,看控制台是否是预期输出
ps: vscode 需要安装相应的 jest 插件,并且安转相应 bable,让 jest 支持 esm
结果图
两次 get 一次 set 打印
以上只是实现了对于用户在 reactive 中定义对象,使用 proxy 进行代理。还没有实现响应式。笔者认为实现响应式的核心就是:1、在定义对象的时候,将这些变量放在一个桶里。2、在函数内,如果用到了这个变量,也会把这个函数收集起来。3、一旦变量发生了改变,立即通知所有用到它的函数,重新执行。(ps:废话太多)简言之:收集依赖和触发依赖。
Vue3 通过 Effect 函数来收集副作用函数,en ~~记下笔记📒(副作用指当调用函数时,除了返回可能的函数值之外,还对主调用函数产生附加的影响。 例如修改全局变量(函数外的变量),修改参数等;简言之就是改变了函数作用域外的变量之类的东西)
// effect.ts
let activeEffect
export function effect(fn) {
// 为了方便扩展 实现一个 ReactiveEffect 类
const _effect = new ReactiveEffect(fn)
_effect.run()
};
class ReactiveEffect {
_fn
constructor(fn) {
this._fn = fn
}
run() {
activeEffect = this
this._fn()
}
}
// 上面简单实现了 effect
// 下面就要简单实现 Vue 的依赖地图
const targetMap = new WeakMap()
export function track(target, key) {
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
let dep = depsMap.get(key)
if (!dep) {
dep = new Set()
depsMap.set(key, dep)
}
dep.add(activeEffect)
};
export function trigger(target, key) {
let depsMap = targetMap.get(target)
let dep = depsMap.get(key)
dep.forEach(effect => {
effect.run()
});
};
实现了 effect、track、trigger,之后运行之前的单测,还是不能通过。原因是我们还需要在代理对象的时候 get 添加 track,set 添加 trigger。
import { track, trigger } from "./effect";
export function reactive(target) {
return createReactiveObject(target);
};
function createReactiveObject(target) {
const proxy = new Proxy(target, {
get(target, key) {
const res = target[key]
// === 新增
track(target, key)
return res
},
set(target, key, value) {
target[key] = value
// === 新增
trigger(target, key)
return target[key]
}
});
return proxy
}
接着再调整之前的单测
import { effect } from "../effect";
import { reactive } from "../reactive";
describe('effect', () => {
it('reactive base', () => {
const user = reactive({
name:'zs',
age: 10
})
let nextAge
effect(() => {
nextAge = user.age + 1
})
expect(nextAge).toBe(11)
// update
user.age++ // user.age = user.age + 1
expect(nextAge).toBe(12)
});
});
再运行单测,查看控制台
出现这个,即表示 demo 成功通过了检测~~~
图解 --- 响应式依赖地图
ref 和 reactive 的原理区别?
- ref 的底层原理是利用了属性的 get 和 set
- 常用来定义基本数据类型,如 const name = ref('zs')
- 在 setup 中,我们需要使用 .value 来访问变量
- 在 template 中,模板解析的时候内部自动拆箱,所以不需要 .value
- ref 也能定义复杂数据类型,因为 Vue3 会自动判断,如果是复杂数据类型就依然使用 reactive 定义
- 常用来定义基本数据类型,如 const name = ref('zs')
- reactive 的底层原理是利用了 Proxy 进行代理
如何监听数组?以及其他复杂数据类型?
- Vue3 能够直接监听到数组下标的变化,但是如果 index >= oldIndex 则需要在 set 的时候做一些处理,同时也是重写了能够改变数组长度的方法
- 因为 Proxy 的代理方法多达 13 中,所以 Vue3 能够监听 map、set、WeakMap 和 WeakSet 的变化
数组栈方法 push、pop、shift、unshift 以及 splice 都会隐式的改变数组的 length。例如 push 的时候会先读取 length 然后再设置 length,如果多个 effect 做相同的操作就会引起栈溢出,所以 Vue3 对于以上几个方法设置了先暂停收集,然后再调用原始方法,最后再允许收集这一个过程。另外对于 includes、indexOf、lastIndexOf 在 Vue3 中会涉及到 this 的问题,所以也额外处理了。而在 Vue2 中就重写了数组的七个方法 push、pop、shift、unshift、splice、reverse、sort,对于这几个方法再通过 Object.defineProperty 进行相应的劫持。
前端路漫漫,上下求索之
看完此文后,再去阅读响应式源码部分,或许会轻松一些吧~
说了好多口水话,希望屏幕面前的你不要介意,最后希望你一直做认为对的事,并保持热爱和专注~