[干货] 与你一同揭开 Vue 响应式的面纱
揭开响应式的面纱
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 修改的情况却很常见)。新增和删除也提供了 set和set 和 set和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 进行相应的劫持。
前端路漫漫,上下求索之
看完此文后,再去阅读响应式源码部分,或许会轻松一些吧~
说了好多口水话,希望屏幕面前的你不要介意,最后希望你一直做认为对的事,并保持热爱和专注~
转载自:https://juejin.cn/post/7134899531396677646