Skip to main content

3 posts tagged with "响应式"

View All Tags

· 10 min read

上一次用 vue 在工作中开发项目是在大四的实习期间,不过那会儿还是清一色的 vue2,最近 vue3 也是正式上线不久,看了下文档发现改动蛮大的

写在前面

vue3 的改变主要有以下几点:

  • 实现响应式的 api 由 defineProperty 改为 proxy
  • 副作用 effect(代替 vue2 的 Observer、Watcher 模块)

其他的还有 Composition API、缓存事件处理函数、Block Tree、拥抱 ts...

详情可以参考 Vue3 对比 Vue2.x 差异性、注意点、整体梳理

Proxy

Proxy 是 vue3 实现响应式更新的基石。这个 api 的作用是代理对象,可以拦截对对象的操作,vue3 主要用到了 Proxy 的两个 api 来实现响应式数据:

// 拦截对象属性的访问
get(target, prop, receiver)
// 拦截对象属性的设置,最后返回一个布尔值
set(target, prop, value, receiver)

但是 Proxy 不支持 IE,所以说 vue3 使用 Proxy 也意味着抛弃 IE

更多内容可以参考 Proxy - MDN

简单实现对象数据的响应式如下:

function createReactiveObject(target) {
if (!isObject(target)) {
return target;
}
const baseHandler = {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
return isObject(res) ? reactive(res) : res;
},
set(target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver);
return res;
},
};
const observed = new Proxy(target, baseHandler);
return observed;
}

可以看到,如果我们访问和增加一个不存在的属性,同样也会触发getset操作。这样就不必使用$set这个 api 了。

当访问的 key 是一个对象时,会触发递归(如果不访问这个对象,就永远不会去做这一步递归操作),比如下面这个例子:

data = {
title: "007",
person: {
name: "",
},
};
data.person.name = "JacksonZhou";

当对data.person.name赋值时,因为会首先访问data.person,所以会触发get,此时就可以将data.person这个对象转换为响应式对象,之后修改 data.person.name 就会触发 set

对比 defineProperty

  1. 更多的拦截操作
  2. 监听对象新增和删除属性。vue2 不能监测对象新增和删除属性,可以用 $set 方法监听
  3. 更好地监听数组。defineProperty 不能监听对数组的 length 属性的修改以及新增的元素。这也是 vue2 需要改写数组原型的原因之一
// 改写数组原型
const arrayProto = Array.prototype;
const subArrProto = Object.create(arrayProto);
const methods = [
"pop",
"shift",
"unshift",
"sort",
"reverse",
"splice",
"push",
];
methods.forEach((method) => {
// 重写原型方法
subArrProto[method] = function () {
arrayProto[method].call(this, ...arguments);
};
// 监听这些方法
Object.defineProperty(subArrProto, method, {});
});
  1. 性能优化。Object.defineProperty 无法一次性监听对象所有属性,必须遍历或者递归来实现使用defineProperty,对所有 key 绑定响应式。当 data 的层级关系很深时,会影响性能;而 proxy 可以实现只在访问对象深层级的属性时收集依赖

Reflect

Reflect 是一个内置的对象,它提供拦截 js 对象操作的方法,这些方法与 proxy handlers 的方法相同,所以 vue3.0 使用 ReflectProxy 来操作响应式对象

参见 Reflect - MDN

WeakMap

这个是 ES6 新增的数据类型,WeakMap 对象其实是一组键值对的映射。vue3 收集的依赖会以 WeakMap 对象的形式存储。相比 Map ,区别在于

  • 键必须是对象
  • 键是弱引用的。如果我们在 WeakMap 中使用一个对象作为键,并且没有其他对这个对象的引用 —— 该对象将会被从内存中自动清除

关于 WeakMap 可以参考这篇好文 《你不知道的 WeakMap》番外篇

响应式更新(reactivity)

先看下整体的响应式更新流程 响应式原理.png

源码的工作流程可以参见这个图

其实整个响应式更新过程主要分为两个阶段,分别是依赖收集和派发通知

依赖收集

其实这就是一个发布订阅,在访问对象(get)时收集依赖。

在 Vue3.0 中引入了 effect 副作用函数(类似于 react hooks 的useEffect),这个函数默认会首先执行一次,此时全局的 effect 会指向这个函数,访问函数中的数据时,会收集这个副作用函数作为数据的依赖;当数据发生变化的时候就会执行所有依赖该数据的副作用函数

这里其实用了栈的数据结构来存储执行中的 effect 函数,可以确保嵌套 effect 函数的正确执行,实现如下:

const activeEffectStacks = []; // 栈
function effect(fn) {
const effect = createReactiveEffect(fn);
effect();
}

function createReactiveEffect(fn) {
const effect = function () {
return run(effect, fn);
};
return effect;
}

function run(effect, fn) {
try {
activeEffectStacks.push(effect); // effect 入栈
fn(); // 此过程访问到响应式数据时,会触发 get 并收集依赖
} finally {
activeEffectStacks.pop(); // effect 出栈
}
}

前面知道,我们会在访问对象时触发依赖收集,源码中主要是通过track函数收集依赖

get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
// 收集依赖,将 key 和 effect 关联起来
track(target, key)
return isObject(res) ? reactive(res) : res
}

对象的依赖集合如下图所示:

vue 中有一个总的 targetMap, 它是一个 WeakMap ,key 是 target(代理的对象), value 是一个 Map ,称之为 depsMap,它是用于管理当前 target 中每个 key 的 deps(依赖/副作用),每个 deps 是一个 Set ,代码表示如下:

// targetMap
{
target: {
key: [effect1, effect2]
},
// ...
}

vue3 通过 track 方法收集依赖,参考源码如下:

const targetsMap = new WeakMap();
function track(target, key) {
const effect = activeEffectStacks[activeEffectStacks.length - 1];
// 确保当前有 effect 在执行
if (effect) {
let depsMap = targetsMap.get(target);
if (!depsMap) {
targetsMap.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
if (!deps.has(effect)) {
// 收集当前执行的 effect 作为依赖
deps.add(effect);
}
}
}

派发通知

更改对象(set)后,会遍历通知之前收集的所有依赖,并更新视图(也就是执行 renderEffect)

set(target, key, value, receiver) {
const hadKey = hasOwn(target, key)
const oldValue = target[key]
const res = Reflect.set(target, key, value, receiver)
if (!hadKey) {
trigger(target, key)
} else if (oldValue !== value) {
trigger(target, key)
}
return res
}

vue3 通过 trigger 函数派发通知,参考源码如下:

function trigger(target, key) {
const depsMap = targetsMap.get(target);
if (depsMap) {
const deps = depsMap.get(key);
if (deps) {
// 依次执行所有依赖该数据的 effect
deps.forEach((effect) => {
effect();
});
}
}
}

其他

重复代理

在 Vue 中是使用了 hash 表来实现避免重复代理的,也就是使用了 WeakMap 来在第一次创建代理后缓存一个映射关系,下一次代理的时候如果之前已经代理过了就直接返回之前的代理

Proxy 操作数组会发生多次 set

如下代码

let arr = [1, 2, 3];
arr.push(4);

默认情况下会执行两次操作,分别是修改下标为 3 的属性值和修改 length 属性

我们只需要屏蔽掉修改 length 属性的操作就可以了

set(target, key, value, receiver) {
const hadKey = hasOwn(target, key)
const oldValue = target[key]
const res = Reflect.set(target, key, value, receiver)
if (!hadKey) {
console.log('添加属性')
} else (oldValue !== value) {
// 添加4之后,其实arr.length已经默认调整为4了,所以不会走到这一步
console.log('修改属性')
}
return res
}
}

· 3 min read

vue2 主要是基于 Object.defineProperty 做的数据双向绑定,这里从 observer、watcher、dep 出发做一个简单的数据绑定实例

主要步骤

  1. observer 递归监听 data 对象的所有属性
  2. watcher 订阅器,视图、指令等需要绑定某个属性时会新建一个订阅器,并加入 Dep
  3. Dep 存放绑定某个属性的所有订阅器,可以添加订阅器和通知变化
  4. 数据更改时,通知 Dep 去执行相应的更新函数

vue原理图示

Dep

订阅器集合,也就是绑定了某个属性的视图或其他对象的集合。

class Dep {
constructor() {
this.deps = [];
}
// 添加订阅器
addDep(dep) {
this.deps.push(dep);
}
// 更新所有订阅器
notify(newData) {
for (let dep of this.deps) {
dep.update(newData);
}
}
}

Observer

递归监听 data 对象的属性

// defineReactive 监听对象属性
function defineReactive(obj, key, val) {
// deps 订阅器集合,作为闭包,方便处理订阅器的添加和更新操作
let deps = new Dep();

Object.defineProperty(obj, key, {
get() {
// 判断是否需要添加订阅器
// Dep.target 用于暂存订阅器
Dep.target && deps.addDep(Dep.target);
},
set(newData) {
// 对比数据,如果变化了就通知订阅器
if (newData !== val) {
deps.notify(newData);
}
},
});
}
function observer(data) {
if (!data || typeof data !== "object") {
return null;
}
for (key in data) {
if (data.hasOwnProperty(key)) {
if (typeof data[key] === "object") {
// 递归监听
observer(data[key]);
}
defineReactive(data, key, data[key]);
}
}
}

Watcher

  • 绑定数据时,需要新建一个 watcher 实例,比如视图绑定、computed、watch
  • 订阅器初始化时,会自动将自己添加进 deps 里
// Watcher 订阅器
class Watcher {
constructor(id, vm, key, cb) {
console.log(`订阅器${id}`);
this.id = id; // 订阅器标识
this.vm = vm; // 当前环境
this.cb = cb; // 更新回调
this.key = key; // 当前监听属性
// 触发getter,将自身添加到属性的订阅器数组deps中
Dep.target = this;
let val = vm.data[key]; // 强制执行 get,加入到订阅器集合
Dep.target = null;
}
update(newData) {
console.log(`订阅器${this.id}数据更新了,newData: ${newData}`);
// this.cb()
}
}

触发更新

// 新增订阅器
this.data = {
name: "jack",
action: "eating",
};
observer(this.data);
new Watcher(1, this, "name");
new Watcher(2, this, "action");
this.data.name = "Tom";

· 6 min read

Vue2.x 不能检测以下数组的变动:1.直接修改数组长度;2.通过数组下标赋值。第一点主要是收到defineProperty的限制,第二点要究其原因,还是得从源码分析。

直接修改数组长度

Vue2.x 利用Object.defineProperty劫持对象的访问器,在属性值发生变化时我们可以获取变化,实现对数据变化的双向绑定。但是Object.defineProperty并不能监控数组长度的变化,因为数组对象中的 length 属性的 configurablefalse,不允许访问器对该属性进行操作

关于数据双向绑定,可以参考基于 defineProperty 和 proxy 实现数据双向绑定

通过数组下标赋值

vue 怎么监听数组

Observer,是实现 vue 响应式的核心模块,用于监听对象和依赖追踪

// Observer 监听对象
var Observer = function Observer(value) {
this.value = value;
this.dep = new Dep();
this.vmCount = 0;
def(value, "__ob__", this);
if (Array.isArray(value)) {
// 将重写的数组方法挂载到数组原型对象上
if (hasProto) {
protoAugment(value, arrayMethods);
} else {
copyAugment(value, arrayMethods, arrayKeys);
}
this.observeArray(value);
} else {
this.walk(value);
}
};

可以看到,observer 遇到数组会走单独的处理逻辑,首先将重写的数组方法挂到数组原型方法上,然后进一步通过observeArray处理数组

// observeArray
Observer.prototype.observeArray = function observeArray(items) {
for (var i = 0, l = items.length; i < l; i++) {
observe(items[i]);
// 如果items[i]是数组,会新建 Observer 对象,然后继续执行observe, 直到遍历完毕
}
};
// observe
function observe(value, asRootData) {
if (!isObject(value) || value instanceof VNode) {
// 如果要观测的数据不是一个对象或者是 VNode 实例,则不做监听
return;
}
var ob;
if (hasOwn(value, "__ob__") && value.__ob__ instanceof Observer) {
// 已经添加过observer实例,直接返回
ob = value.__ob__;
} else if (
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value);
}
if (asRootData && ob) {
ob.vmCount++;
}
return ob;
}

可以看到,vue 并没有对数组的所有元素进行监听,所以通过数组下标修改或增加元素,会发现依赖该数组元素的视图并不会更新

重写数组方法

Vue 2.x 针对数组的部分原型方法做了特殊处理,通过调用这些重写的方法,可以对新增的元素的索引进行监听,确保新增元素是响应式的

var methodsToPatch = [
"push",
"pop",
"shift",
"unshift",
"splice",
"sort",
"reverse",
];

methodsToPatch.forEach(function (method) {
var original = arrayProto[method];
def(arrayMethods, method, function mutator() {
var args = [],
len = arguments.length;
while (len--) args[len] = arguments[len];

var result = original.apply(this, args);
var ob = this.__ob__;
var inserted;
switch (method) {
case "push":
case "unshift":
inserted = args;
break;
case "splice":
inserted = args.slice(2);
break;
}
if (inserted) {
ob.observeArray(inserted);
}
// 重新触发依赖更新
ob.dep.notify();
return result;
});
});

通过源码可以看到,在操作数组时,如果使用 push、unshift、splice 添加或修改元素,会重新触发依赖的更新函数,渲染对应视图或对象

Vue.set

除了上述方法,我们还可以通过Vue.set来实现对数组的更新,并渲染对应视图。贴一波 set 函数源码,源码分析可以看下注释

function set (target, key, val) {
......
if (Array.isArray(target) && isValidArrayIndex(key)) { // 处理数组元素
target.length = Math.max(target.length, key)
// 使用重写过的splice更新数组
target.splice(key, 1, val)
return val
}
if (key in target && !(key in Object.prototype)) {
// 是对象原有属性,赋值即可触发更新函数
target[key] = val
return val
}
const ob = target.__ob__
// __ob__指向target的observer实例
if (target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
)
// 对于已经创建的实例,Vue 不允许动态添加根级别的响应式属性
// 所以会直接返回值
return val
}
// target非响应式对象,不用对属性监听,直接赋值并返回
if (!ob) {
target[key] = val
return val
}
// 给新增的对象属性添加依赖
defineReactive(ob.value, key, val)
// 触发依赖,重新渲染页面
ob.dep.notify()
return val
}