web 前端培训 | 面试中 Vue 的各种原理分享
简介
使用 Vue 开发应用,当我们修改 Vue 组件的 data 的属性后,视图会自动更新,这种特性我们可以称为“响应式”。那么 Vue 是如何实现响应式的呢?即 Vue 是如何实现我们修改 data 属性的值后,视图能够自动更新的呢?
简单地说,Vue 在组件初始化时候,通过发布-订阅模式将 data 的属性设置为响应式,data 的属性作为发布者,Vue 会在渲染时候创建订阅者,订阅者会订阅 data 的属性值变化,进行更新视图的操作。
除了渲染需要订阅 data 的属性的变化,computed 和 watch 也需要订阅 data 属性变化,它们都是通过一个名为“Watcher”的类来实现订阅的。
Vue 通过数据代理技术来实现发布-订阅模式。
下面我们介绍 Vue 中使用到的数据代理技术,并介绍 Vue 组件初始化时候是如何把 data 设置为响应式的,然后介绍一下 computed 和 watch 的实现原理,最后简单介绍一下 Watcher 这个类,最后介绍一下 Vuex 的响应式原理_前端培训。
数据代理
我们知道,Vue 中我们修改 data 的属性的值时候,会触发视图更新,因此很容易想到,Vue 修改了 data 的属性的行为。让用户设置 data 属性时候可以做相应地操作。
我们可以修改数据的属性的行为,当我们在访问或者修改对象的某个属性时,访问或者修改的行为实际是我们修改后的,这样我们就可以进行额外的操作或者修改返回的结果。这种让我们指定的行为代替数据的默认行为的技术叫“数据代理”。
在 Vue2.0 中,使用 Object.defineProperty()方法来进行数据代理,但是这种方法无法代理数组类型的数据属性,Vue2.0 中通过改写数组方法的方式来监听数组的改变。在 Vue3.0 时候改用 ES6 的新特性 Proxy 来进行数据代理,就可以方便地监听数组变化了。
把 data 设置为响应式
在 Vue 实例化时候,Vue 会把 data 设置为响应式,即让用户修改 data 属性时候,依赖这个属性的地方能够被通知到,从而做出响应。
其中有两个比较重要的类,Dep 和 Watcher,后面会介绍到。
下面看如何将 data 设置为响应式,实例代码会将 Vue 源码精简和简单修改,省略与本节无关的细节。
首先看 Vue 组件实例化时候对 data 的处理,Vue 会将组件的 data 的每个属性定义 get 和 set 方法,在 get 中收集依赖(即将订阅者保存),在 set 中通知订阅者_前端视频。
// 调用 walk 方法,遍历 data 中的每一个属性,监听数据的变化。
function walk(obj) {
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i]);
}
}
// 执行 defineProperty 监听数据读取和设置。
function defineReactive(obj, key, val) {
// 为每个属性创建 Dep(依赖搜集的容器,后文会讲)
const dep = new Dep();
// 绑定 get、set
Object.defineProperty(obj, key, {
get() {
const value = val;
// 如果有 target 标识,则进行依赖收集
if (Dep.target) {
dep.depend();
}
return value;
},
set(newVal) {
val = newVal;
// 修改数据时,通知页面重新渲染
dep.notify();
},
});
}
代码中的 Dep 是一个发布-订阅的实现,我们看到在 data 的属性的 get 方法中使用 dep.depend()收集依赖,在 set 方法中使用 dep.notify()通知订阅者。
下面看 Dep 的代码
class Dep {
// 根据 ts 类型提示,我们可以得出 Dep.target 是一个 Watcher 类型。
static target: ?Watcher;
// subs 存放搜集到的 Watcher 对象集合
subs: Array<Watcher>;
constructor() {
this.subs = [];
}
addSub(sub: Watcher) {
// 搜集所有使用到这个 data 的 Watcher 对象。
this.subs.push(sub);
}
depend() {
if (Dep.target) {
// 搜集依赖,最终会调用上面的 addSub 方法
Dep.target.addDep(this);
}
}
notify() {
const subs = this.subs.slice();
for (let i = 0, l = subs.length; i < l; i++) {
// 调用对应的 Watcher,更新视图
subs[i].update();
}
}
}
这里的 Watcher 是订阅者用来订阅 dep 的类,通过实例化 Watcher 并传入订阅的值和回调来订阅,dep 会在订阅的值改变后发布给订阅者。
下面看 Watcher 的代码
class Watcher {
constructor(vm: Component, expOrFn: string | Function) {
// 将 vm._render 方法赋值给 getter。
// 这里的 expOrFn 其实就是 vm._render,后文会讲到。
this.getter = expOrFn;
this.value = this.get();
}
get() {
// 给 Dep.target 赋值为当前 Watcher 对象
Dep.target = this;
// this.getter 其实就是 vm._render
// vm._render 用来生成虚拟 dom、执行 dom-diff、更新真实 dom。
const value = this.getter.call(this.vm, this.vm);
return value;
}
addDep(dep: Dep) {
// 将当前的 Watcher 添加到 Dep 收集池中
dep.addSub(this);
}
update() {
// 开启异步队列,批量更新 Watcher
queueWatcher(this);
}
run() {
// 和初始化一样,会调用 get 方法,更新视图
const value = this.get();
}
}
渲染界面时候会实例化 Watcher,从而订阅渲染用到的 data 的属性。
渲染的代码如下
const updateComponent = () => {
vm._update(vm._render());
};
// 结合上文,我们就能得出,updateComponent 就是传入 Watcher 内部的 getter 方法。
new Watcher(vm, updateComponent);
// new Watcher 会执行 Watcher.get 方法
// Watcher.get 会执行 this.getter.call(vm, vm) ,也就是执行 updateComponent 方法
// updateComponent 会执行 vm._update(vm._render())
// 调用 vm._render 生成虚拟 dom
// 调用 vm._update(vnode) 渲染虚拟 dom
渲染视图时候实例化 Watcher 并传递参数 getter 为 updateComponent。
实例化时候,调用 Watcher 的 get 方法,这个方法首先执行 Dep.target = this(注意,这是精简后的代码,还有其他与当前无关的逻辑后面会提及),将自身绑定到调用 getter,即 updateComponent。
在执行 updateComponent 的过程中,会用到 data 的某些属性,这样就会触发属性的 get 方法,在上面设置 data 响应式代码中我们看到 get 方法判断如果存在 Dep.target,就将这个依赖收集到 dep 的依赖池(subs)中。
当 data 属性改变,会触发 set 方法,从而调用 dep.notify(),在 dep.notify 方法中调用每个 watcher 的 update 方法,然后将 watcher 加入到异步队列中。
在下个 tic 清空异步队列时候(flushSchedulerQueue)会调用 watcher.run,watcher.run 调用 getter 方法,即 updateComponent,从而更新视图。
简单总结为,在组件初始化时候遍历 data 的属性,为每个属性设置 get 方法和 set 方法,在 get 方法中收集依赖,在 set 方法里通知订阅者,更新视图时候创建订阅者,更新视图时候如果依赖了 data 的某个属性,就会触发这个属性的 get 方法时候,该订阅者(更新视图的方法)就会被 data 的属性收集,在更新属性时候触发 set 方法,从而触发界面更新。
computed 原理
在 Vue 组件模板中,如果一个表达式有复杂计算,可以使用 computed(计算属性)。
computed 依赖某些 data 属性,并计算得到一个新的值。
{
name: 'myComponent',
data() {
return {
message: 'hello'
};
},
computed: {
info() {
// 字符串翻转
return this.message.split('').reverse().join('');
}
}
}
当 data 相关属性变化时候,并不会重新计算 computed 的值,只会标记数据已经发生改变,当前的是脏数据(dirty),后面如果其他地方(比如渲染)用到 computed,发现是 dirty 就会重新计算,如果不是 dirty,直接使用当前的值,不需要重新计算,这样可以避免不必要的复杂计算。
这里有两个关键逻辑
1. 在 computed 依赖的 data 属性更新后,需要对 computed 标记 dirty
2. 在访问 computed 时候,会判断是否是 dirty,dirty ? 重新计算 : 返回当前的值。
computed 的原理其实就是如何实现这两个关键逻辑。
第一个逻辑的实现思路是,订阅 data 属性的变化,在 data 属性变化时候标记 dirty。
第二个逻辑的实现思路是,设定 computed 的 get 方法,在访问时候处理相关逻辑。
下面看关键的代码,注意代码是简化的。
// 组件实例化时候初始化 computed
function initComputed(vm: Component, computed: Object) {
for (const key in computed) {
const getter = computed[key];
const watcher = new Watcher(
vm,
getter || noop,
noop,
{lazy: true}
);
Object.defineProperty(vm, key, {
get() {
if (watcher) {
if (watcher.dirty) {
watcher.evaluate();
}
// 把 watcher 绑定的所有 dep,都绑定到当前的 Dep.target 上
if (Dep.target) {
watcher.depend();
}
return watcher.value;
}
}
});
}
}
初始化 computed,对每个 computed 的 key,都实例化一个 watcher。另外每个 computed 的 key 都绑定到 vm 实例上,并设置 get 方法。
我们看下 Watcher 的关键方法,同上一节“把 data 设置为响应式”的 Watcher 代码相比,下面的 Watcher 代码突出了 computed 使用的场景。
实际的 Watcher 代码更综合更复杂,请参考 Vue 源码。
class Watcher {
constructor(vm: Component, expOrFn: string | Function, cb, options) {
this.getter = expOrFn;
this.lazy = !!options.lazy;
this.value = this.get();
}
get() {
// 实际执行了 Dep.target = this
pushTarget(this);
const value = this.getter.call(this.vm, this.vm);
return value;
}
addDep(dep: Dep) {
dep.addSub(this);
}
update() {
if (this.lazy) {
this.dirty = true;
}
else {
queueWatcher(this);
}
}
evalute() {
this.value = this.get();
this.dirty = false;
}
}
下面我们分析上面两段代码是如何实现两个关键的逻辑的。
在 initComputed 时候,对每个 computed 的 key,实例化一个 Watcher,实例的 getter 参数是 computed 的方法。
Watcher 构造函数中会调用 get 方法,先将 watcher 绑定到 Dep.target,然后调用 getter 方法(即 computed 的方法),调用 computed 方法时候会访问该 computed key 所依赖的 data 属性,从而触发 data 属性的 get 方法,我们在上一节“把 data 设置为响应式”中已经说明过,在 data 属性的 get 方法中会收集依赖,因此该 watcher 会被 data 属性所收集,即该 watcher 订阅了所依赖的 data 属性。
这样在 data 属性变化时候,会触发 dep.notify,从而调用 watcher 的 upadte 方法,我们看到 watcher 的 update 方法中会判断 this.lazy,因为实例化 watcher 时候传入的 options.lazy 为 为 true,所以这里标记 this.dirty 为 true。这样就实现了第一个逻辑。
另外我们看到初始化 computed 时候,设定了 computed 的 get 方法,当用户访问这个 computed 属性时候,首先判断如果 dirty 为 true,则执行 watcher.get()方法,并赋值给 watcher,如果 dirty 为 false 则不处理。最后返回 watcher.value,这样就实现了第二个逻辑。
总结一下,computed 的原理是:
1. 在初始化时候实例化 watcher,实例化 watcher 时候对依赖的 data 属性取一次值,从而触发 data 属性收集依赖。当改变 data 属性时,会通知订阅者 watcher,由于 watcher 设置了 lazy 选项,因此会将 watcher 置为 dirty(即数据更新),但不会重新计算。
2. 设置 computed 的 get 方法,在访问 computed 的时候,判断如果是 dirty,就重新计算,否则直接返回当前的值。
通俗地说,computed 是 data 属性的一个订阅者,它在初始化时候被 data 属性收集依赖,当 computed 依赖的 data 属性改变后,标记该 computed 为 dirty,即数据更改过,当渲染使用到 computed 时候,再计算出 computed 的值从而得到最新的正确的值。
还有一个面试中不常问的问题:Vue 是如何让 computed 和渲染都能够监听到 data 属性的变更的呢?
这个问题相当于:computed 的 watcher 和渲染的 watcher 都是如何绑定到 data 属性的依赖池中的?
computed 的 watcher 我们已经分析过,是在初始化时候就已经绑定,那么渲染时候如果用到了 computed,而不是直接访问 data 属性,那么渲染的 watcher 是如何绑定到 data 属性的 dep 上的呢?
我们知道依赖收集的关键是 watcher 先将自己挂到 Dep.target 上,然后访问 data 属性,data 属性的 get 方法就能将 Dep.target 对应的 watcher 收集了。
实际上,在 watcher.get()方法中,是通过调用 pushTarget()来设置 watcher 到 Dep.target 的。pushTarget()是将 watcher 推入 watcher 栈中,watcher 栈用来管理 Dep.target 上面挂载的 watcher,它解决了在一个订阅者的执行中遇到另一个订阅者的问题(在渲染过程中遇到 computed)。
// /vue/src/core/observer/dep.js
// The current target watcher being evaluated.
// This is globally unique because only one watcher
// can be evaluated at a time.
Dep.target = null
const targetStack = []
export function pushTarget (target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}
export function popTarget () {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}
在渲染视图时候,首先 render 会创建一个 watcher,在 watcher 中将自身推入 targetStack,然后在 updateComponent 时候遇到了 computed,触发 computed 的 getter,如果是 computed 的 watcher 是 dirty,那么执行 watcher.evalute(),evalute 方法调用 watcher.get()方法,注意 watcher.get()方法首先 pushTarget,在最后会 popTarget,这样在执行完 watcher.evalute(),当前的 Dep.target 指向 targetStack 的上一个元素,即渲染的 watcher。
然后执行 watcher.depend(),就是把 computed 的 watcher 绑定的所有 dep,都绑定到 Dep.target,即渲染的 watcher 上(这样做是因为上一个 watcher 依赖 computed,也一定依赖 computed 所依赖的 data 属性)。这样渲染的 watcher 就绑定到相应的 data 属性的 dep 上面了。
在 data 属性变化后,首先会执行 computed 的 watcher 的 update 方法,置为 dirty,然后执行渲染的 watcher,渲染过程中用到 computed 又会进行计算,从而得到更新后的界面。
watch 原理
watch 实现的功能是监听 data 属性变化,当属性变化时候触发用户定义的方法。
{
name: 'myComponent',
data() {
return {
message: 'hello'
};
},
watch: {
message(value) {
console.log('message change: ', value);
}
},
mounted() {
this.message = 'world';
}
}
下面看初始化 watch 的代码(动态 watch 的原理类似),注意代码简化过。
function initWatch (vm: Component, watch: Object) {
for (const key in watch) {
const handler = watch[key];
const watcher = new Watcher(vm, key, handler);
}
}
Vue 在初始化时候调用 initWatch 初始化,订阅相应的 key,实例化一个 watcher,watcher 实例化时候会调用 get,对监听的 key 进行取值,从而触发监听的 key 的 getter 方法,进而将 watcher 自身加入到监听的 data 属性的 dep 的依赖池中,如果监听的是 computed,则取值时候也会触发 data 属性的 getter,从而进行 watcher 绑定。
当 data 属性改变后,会触发 watcher 的 update,然后放入 update 的队列中,在清空 watcher 队列(flushSchedulerQueue)时候,会调用 watcher.run()方法,调用回调方法。
通俗地讲,在组件初始化时候,遍历所有的 watch,对每个 watch 创建订阅者,绑定依赖的 data 属性,当 data 属性改变后发布给订阅者,然后会执行相应地回调。
Watcher
watcher 是一个订阅者,它可以和相应的 dep 绑定,从而订阅 data 属性变化。
它的 getter 参数很关键,getter 参数是订阅者根据依赖的属性获取值的一个方法。在 Watcher 实例化的时候就会取一次值,在这个取值操作中会访问 watcher 依赖的属性,从而触发属性的 dep 的收集。因此在 Watcher 实例化的时候,就已经绑定了发布者了。
当 data 的属性更新后,会重新执行 watcher 的 getter,取得最新的值来做后面的处理。
渲染有一个 watcher、computed 有一个 watcher、watch 也有一个 watcher。
渲染的 watcher 的 getter 是 updateComponent,实际它不关心取值,当属性值改变后再次执行 updateComponent 即可。
computed 的 watcher 的 getter 是用户定义计算方法,computed 就是根据这个计算方法返回结果的。当属性值改变后会更新 dirty 而不会调用 getter 进行取值,然后取 computed 值时候再重新计算,这就是惰性求值。
watch 的 watcher 的 getter 是 watch 所监听的属性,属性值改变后会触发重新求值,并用新的值调用 watch 的回调。
Watcher 还有一个 cb 参数,是 callback 回调,对于渲染和 computed,它们的 watcher 的 cb 都是 noop,这是因为在 data 属性值改变后,调用 getter 重新渲染就行了,而 computed 只用标记 dirty,也不需要其他操作。
watch 的 cb 是用户定义的方法,当属性改变后,不但要用 getter 重新求值,还要用新的值调用回调。
双向绑定
你可以用 v-model 指令在表单 <input>、<textarea> 及 <select> 元素上创建双向数据绑定。它会根据控件类型自动选取正确的方法来更新元素。尽管有些神奇,但 v-model 本质上不过是语法糖。它负责监听用户的输入事件以更新数据,并对一些极端场景进行一些特殊处理。—— 表单输入绑定
简单地说,Vue 在编译模板时候会将 v-model 指令特殊处理:
1. 创建订阅者,当组件的 data 属性改变时候,修改表单元素的 value。
2. 给表单元素创建事件(change 事件或者 input 事件),事件的回调中,修改组件的数据。
文章来源于灵题库
评论