一个简单示例-刷新你对Vue2响应式原理的认知

微信扫一扫,分享到朋友圈

一个简单示例-刷新你对Vue2响应式原理的认知

为什么是不一样的 Vue2 响应式原理?

事情源于前两天给同事 review 代码,发现了一个超出自己认知的 Vue2 响应式现象,一个很有意思的现象,示例大家可以参考这个沸点

Vue2 对象的响应式,大家普遍的认知是这样的, 来源于官网

  • 对于对象

    简单来说就是:对象的响应式只能针对已存在的属性, obj.newProdelete obj.oldProVue2 的响应式是拦截不到的,需要使用 this.$set 或者 Vue.set 方法才可以

  • 对于数组

    简单来说就是:数组的响应式一般是通过 7 个数组操作方法(被 Object.defineProperty 教育过的七个葫芦娃)来实现响应式,像是 arr[0] = 1arr.length = 0 这种是没办法实现响应式的

但是, 不一样的Vue2响应式原理 就会让你重新认识,发现上面的说法不是绝对的正确

接下来我们就通过一个简单的示例来从 源码 层次去找答案,去解析 Vue 从初始化到更新这两个过程都做了什么,当然,这篇文章主要是为了说明问题,源码做了一部分精简,全部拿出来的话太多了。

2 以下示例的现象和内部原理(执行过程)是什么?

大家可以先看代码,再看答案,当然也可以自己写个示例试一试,验证以下

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id = "app">
<p>{{ form }}</p>
<ul>
<li v-for = "item in arr" :key = "item">{{ item }}</li>
</ul>
</div>
<script src = "../../dist/vue.js"></script>
<script>
const ins = new Vue({
data () {
return {
form: {
name: 'lyn'
},
arr: [1]
}
},
mounted () {
setTimeout(() => {
this.form.name = 'test'
this.arr[0] = 11
}, 2000)
}
})
ins.$mount('#app')
</script>
</body>
</html>
复制代码

2.1 先上结论

相信很多同学的回答都是,页面初始完成,显示内容为:

然后两种以后执行定时函数,页面更新为入下:

如果你的回答是这个,那这篇文章你是值得一看的

当然你的回答所用的理论基础是没错的,但是答案为什么会错呢?理由很简单,对响应式整个执行过程的理解认知有那么一些瑕疵,至少前两天在看到这个现象时的我是这样的

2.1.1 现象

初始渲染结果为:

2s 钟后执行定时函数,页面更新为:

2.1.2 内部原理

示例代码被加载到浏览器以后, Vue 开始初始化,执行各种 init 操作,其中最重要的就是实例化组件 Watcher 、初始化数据、收集依赖(dep)、关联 depwatcher ,当然中间穿插着生命周期方法的执行,比如 beforeCreatecreatedbeforeMountmounted ,如果有子组件的话, beforemount 执行完了会去初始化子组件,直到自组件的 mounted 执行完成,然后回来执行 mounted ,唉,扯远了,不过不影响,问题点不在初始化,而是后面的更新。

页面渲染完 2s 后执行定时函数

首先执行 this.form.name = 'test' ,这里分了两步执行

  • 首先 this.form 触发 getter 得到 value = { name: 'lyn' }

  • 然后执行 value.name = 'test' 触发 setter ,更新数据,现在 this.form.name 的值为 test

setter 中触发 dep.notify() ,通知 watcher 执行自己的 update 方法, update 方法会将 watcher 自己 push 进一个队列(queue数组),然后调用 nextTick 方法注册一个刷新队列(其实就是执行queue数组中的每个watcher的run方法)的函数, nextTick 方法将刷新队列的函数用一个箭头函数包裹起来然后保存到一个名叫 callbacks 的数组中,接下来 nextTick 会执行 timerFunc 函数

timerFunc 函数利用浏览器的异步机制,将刷新 callbacks 数组的函数注册为一个异步任务,当所有的同步任务执行完成以后就会去刷新刚才注册的队列,由于现在同步任务还没执行完,还差一个 this.arr[0] = 11 ,所以异步任务暂时先挂起

接下来执行 this.arr[0] = 11 ,这里也是分两步执行

  • 首先 this.arr 会触发 getter 得到 value = [1]

  • 然后,就没了,因为 this.arr[0] = 11 这样的写法, Vue2 的响应式核心 Object.defineProperty 无法拦截,但是

但是很重要的一点, this.arr[0] = 11 这句代码确实是执行了,就意味着 this.arr 现在的值真的是 [11] 了,很重要,有疑问,带着疑问接着往下看

到这里所有的同步任务执行完成,开始执行刚才注册的异步任务

上面说的异步任务,就那堆回调函数,它最终做的事情很简单(纯粹),就是执行 watcher.run 方法, watcher.run 方法执行 watcher.get 方法, get 方法负责执行 updateComponent 方法,这个方法是初始化组件时,实例化 watcher 时传递给 watcher 的,执行 updateComponent 方法时会先执行 vm._render 函数生成新的 vdom ,注意,生成新的 vdom 时需要去读取 vue 实例也就是 this 上的各个属性,当然只读取模版中用到的属性,我们的示例中就是 this.formthis.arr ,读到这里是不是已经有点明白了?

虽然 this.arr[0] = 11 没办法触发 Vue2 的响应式机制,但是它却可以更改 this 的属性值,所以,就在页面上看到了之前不理解的一幕

生成新的 vdomupdateComponent 执行 vm._update 方法,调用 patch 方法,对比新旧 vdom ,找出发生变化的 dom 节点,然后更新

看到这里是不是已经有点明白了,是不是也有点不一样的想法了?比如:

我就想通过 this.arr[idx] = xxx 更新数组元素,不想用 this.splice 之类的方法,这时只需要再带一个可以触发 setter 的有效操作即可,当然,在实际中还是不要这么写,就当是一个比较有意思的 黑魔法 吧,毕竟万一你写了,给别人带来不好体验就不太好了。

看到这里不知道是直接就明白了?还是有点懵,可以接着往下看,从源码中找答案,代码经过精简,有详细的注释,看完以后,自己回想一下过程,再回来对照着这个结论看,一定会有很大的收获的。

2.2 从源码中找答案

这一节会分析整个示例代码的执行过程,当内容被加载到浏览器以后, Vue 源码的执行过程是这样的:

  • src/core/instance/index.js

    /**
    * Vue 构造函数,执行初始化操作
    */
    function Vue (options) {
    this._init(options)
    }
    复制代码
  • src/core/instance/init.js

    /**
    * 执行各种初始化操作,比如:
    * 最重要的给数据设置响应式(这部分内容就不展开了,否则太多了)然后执行实例的 $mount 方法
    */
    Vue.prototype._init = function (options?: Object) {
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')
    if (vm.$options.el) {
    vm.$mount(vm.$options.el)
    }
    }
    复制代码
  • src/platforms/web/runtime/index.js

    /**
    * $mount, 负责执行 mountComponent 方法
    */
    Vue.prototype.$mount = function (
    el?: string | Element,
    hydrating?: boolean
    ): Component {
    el = el && inBrowser ? query(el) : undefined
    return mountComponent(this, el, hydrating)
    }
    复制代码
  • src/platforms/web/entry-runtime-with-compiler.js

    /**
    * 不用管这个,和问题无关,这里其实重写了 $mount 执行了编译模版的动作,
    * 最后生成 render 函数,这部分内容被我删了
    */
    const mount = Vue.prototype.$mount
    Vue.prototype.$mount = function (
    el?: string | Element,
    hydrating?: boolean
    ): Component {
    return mount.call(this, el, hydrating)
    }
    复制代码
  • src/core/instance/lifecycle.js

    /**
    * mountComponent 方法
    * 很重要的几点
    * 1、定义组件的 updateComponent 方法
    * 2、实例化组件 watcher,并将 updateComponent 方法传递给 watcher
    *
    * watcher 后面会在自己的 run 方法中调用 get 方法,get 方法会负责执行这个 updateComponent 方法,重新生成新的 vdom,watcher 相关看下面的 watcher 部分
    */
    export function mountComponent (
    vm: Component,
    el: ?Element,
    hydrating?: boolean
    ): Component {
    callHook(vm, 'beforeMount')
    let updateComponent = () => {
    // vm._render 执行后会生成新的 vdom,vm._update 方法会调用 patch 方法,对比新旧 dom,更新视图
    vm._update(vm._render(), hydrating)
    }
    // we set this to vm._watcher inside the watcher's constructor
    // since the watcher's initial patch may call $forceUpdate (e.g. inside child
    // component's mounted hook), which relies on vm._watcher being already defined
    new Watcher(vm, updateComponent, noop, {
    before () {
    if (vm._isMounted && !vm._isDestroyed) {
    callHook(vm, 'beforeUpdate')
    }
    }
    }, true /* isRenderWatcher */)
    hydrating = false
    // 调用组件实例的 mounted 方法
    if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
    }
    return vm
    }
    /**
    *负责执行各种各样的生命周期方法,比如 mounted
    */
    export function callHook (vm: Component, hook: string) {
    // handlers = vm.$options.mounted
    const handlers = vm.$options[hook]
    const info = `${hook} hook`
    if (handlers) {
    for (let i = 0, j = handlers.length; i < j; i++) {
    invokeWithErrorHandling(handlers[i], vm, null, vm, info)
    }
    }
    }
    /**
    * 负责执行 patch 方法,分为首次渲染和再次更新
    */
    Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    const restoreActiveInstance = setActiveInstance(vm)
    vm._vnode = vnode
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
    }
    }
    复制代码
  • src/core/util/error.js

    // 执行 声明周期方法
    export function invokeWithErrorHandling (
    handler: Function,
    context: any,
    args: null | any[],
    vm: any,
    info: string
    ) {
    // 真正执行声明周期方法的地方
    return args ? handler.apply(context, args) : handler.call(context)
    }
    复制代码

走到这里,mounted 方法已经执行完成,页面也已经渲染完成,接下来就是执行 2s 后的定时函数,更新数据,通过响应式拦截触发视图更新

``javascript
/**
* mounted 方法中的定时函数
*/
setTimeout(() => {
this.form.name = 'test'
this.arr[0] = 11
}, 2000)
```
复制代码

接下来分析定时器注册的回调函数执行过程是什么

  • src/core/observer/index.js

    执行定时函数时会触发下面的 gettersetter ,比如: this.form.name = 'test' 会先执行 this.form 触发 getter 得到 value = { name: 'lyn' } ,再执行 value.name = 'test' 触发 setter 更新 name 属性,然后执行 dep.notify()

    /**
    * 这个其实就是数据响应式的核心了,拦截了示例对象上的各个属性,数据读取时执行 get,设置数据时执行 set
    */
    export function defineReactive (
    obj: Object,
    key: string,
    val: any,
    customSetter?: ?Function,
    shallow?: boolean
    ) {
    const dep = new Dep()
    let childOb = !shallow && observe(val)
    Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
    const value = getter ? getter.call(obj) : val
    if (Dep.target) {
    dep.depend()
    if (childOb) {
    childOb.dep.depend()
    if (Array.isArray(value)) {
    dependArray(value)
    }
    }
    }
    return value
    },
    set: function reactiveSetter (newVal) {
    const value = getter ? getter.call(obj) : val
    /* eslint-disable no-self-compare */
    if (newVal === value || (newVal !== newVal && value !== value)) {
    return
    }
    // #7981: for accessor properties without setter
    if (getter && !setter) return
    if (setter) {
    setter.call(obj, newVal)
    } else {
    val = newVal
    }
    childOb = !shallow && observe(newVal)
    // 通知 watcher 去执行 update 方法
    dep.notify()
    }
    })
    }
    复制代码
  • src/core/observer/dep.js

    /**
    * A dep is an observable that can have multiple
    * directives subscribing to it.
    *
    * dep 负责收集依赖,通知 watcher 更新
    * 这里只保留了 notify(通知watcher更新) 和 构造函数
    */
    export default class Dep {
    static target: ?Watcher;
    id: number;
    subs: Array<Watcher>;
    constructor () {
    this.id = uid++
    this.subs = []
    }
    // 通知通知执行 update 方法
    notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
    // 这个其实就是 watcher 的 update 方法
    subs[i].update()
    }
    }
    }
    复制代码
  • src/core/observer/watcher.js

    /**
    * A watcher parses an expression, collects dependencies,
    * and fires callback when the expression value changes.
    * This is used for both the $watch() api and directives.
    *
    * 一个组件对应一个 watcher 实例(渲染watcher),实例化过程是在 mountComponent 方法中做的,也就是执行 vm.$mount 之后
    * 在同步执行过程中最重要的就是将当前 watcher 实例 push 到一个 watcher 执行队列中,
    * 待将来执行,通过一个 Promise.resolve().then() 来执行 run 方法,从而执行 updateComponent 方法
    */
    export default class Watcher {
    constructor (
    vm: Component,
    // updateComponent
    expOrFn: string | Function,
    // noop
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
    ) {
    this.vm = vm
    // 很重要,就是租价更新方法,updateComponent
    this.getter = expOrFn
    }
    /**
    * Evaluate the getter, and re-collect dependencies.
    *
    * 由 this.run 执行,执行 updateComponent 方法,生成新的 vdom,然后执行 patch,更新视图
    */
    get () {
    // Dep.target = watcher实例,这里让 dep 和 watcher 关联
    pushTarget(this)
    let value
    // 组件实例
    const vm = this.vm
    try {
    // 这里其实执行的是这个 updateComponent 方法:
    // let updateComponent = () => { vm._update(vm._render(), hydrating) }
    value = this.getter.call(vm, vm)
    } catch (e) {
    } finally {
    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
    traverse(value)
    }
    popTarget()
    }
    return value
    }
    /**
    * 将 watcher 实例加入 watcher 队列
    */
    update () {
    queueWatcher(this)
    }
    /**
    * Scheduler job interface.
    * Will be called by the scheduler.
    *
    *
    * 这里其实通过 timerFunc 来调用,借用了浏览器的异步机制(Promise)
    * 执行 this.get 方法,让 get 执行 updateComponent
    */
    run () {
    const value = this.get()
    }
    }
    复制代码
  • src/core/observer/scheduler.js

    /**
    * 将 watcher push 进 queue 数组,然后注册一个回到函数,在将来[Promise.resolve().then()]来执行这些 watcher 的 run 方法
    */
    export function queueWatcher (watcher: Watcher) {
    queue.push(watcher)
    // 这里其实就是注册回调函数 flushSchedulerQueue
    nextTick(flushSchedulerQueue)
    }
    /**
    * 负责让队列中所有的 watcher 执行自己的 run 方法.
    */
    function flushSchedulerQueue () {
    for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    watcher.run()
    }
    }
    复制代码
  • src/core/util/next-tick.js

    如果宏任务、微任务不太理解,可以看这篇文章

    /**
    * 很重要的几个点
    *
    * 定义 nextTick 方法,将回调函数全部放到一个 callbacks 数组,然后执行 timerFunc
    * 定义 timerFunc,其实就是就是利用了浏览器的异步任务机制,这里选了 Promise 微任务,Vue首选就是Promise
    * Promise.resolve().then() 注册的回调函数就是刷新刚才存储的 queue 队列(数组),
    * 执行 watcher.run(),触发 updateComponent,这里很关键的一点是理解宏任务、微任务,
    * 当宏任务都执行结束后,比如示例中的整个setTimeout 回调,就会执行这里注册的微任务,Promise.resolve().then()
    */
    const callbacks =[]
    let pending = false
    // nextTick 就是用一个箭头函数将 flushSchedulerQueue 函数包裹然后放到 callbacks 数组
    export function nextTick (cb?: Function, ctx?: Object) {
    let _resolve
    callbacks.push(() => {
    cb.call(ctx)
    })
    if (!pending) {
    pending = true
    // 就是执行一个 异步 方法,首选 Promise
    timerFunc()
    }
    }
    // 执行一个立即就绪 Promise,Promise 回调负责执行 flushCallbacks 函数
    let timerFunc = () => {
    Promise.resolve().then(flushCallbacks)
    }
    /**
    * 执行 callbacks 数组中的 () => flushSchedulerQueue.call(ctx),而最终会放 watcher 去执行自己的 run 方法,
    * run 方法执行 get 方法,get 方法中最终会调用组件的 updateComponent 方法,然后执行 render 重新生成 vnode,然后执行
    * patch 过程,最终更新 dom
    */
    function flushCallbacks () {
    pending = false
    const copies = callbacks.slice(0)
    callbacks.length = 0
    for (let i = 0; i < copies.length; i++) {
    copies[i]()
    }
    }
    复制代码
  • vm._render 函数执行后生成的内容是什么?

    /**
    * 可以看到,生成 vdom 的时候,会去组件实例对象上读取响应的属性值,比如我们这里的,this.form,this.arr
    * 理解这里很重要,为什么我们的视图会被有效更新?是因为 vm.arr 确实被更新成了 [11]
    */
    function anonymous() {
    with(this) {
    return _c(
    'div',
    {attrs:{"id":"app"}},
    [
    // this.form
    _c('p',[_v(_s(form))]),_v(" "),
    _c(
    'ul',
    _l(
    // this.arr
    (arr),
    function(item) {
    return _c('li',{key:item},[_v(_s(item))])
    }
    ),
    )
    ]
    )
    }
    }
    复制代码

3 升华 ?

读到这里,自己在梳理一遍思路,再回头看一遍前面的结论,是不是就有种豁然开朗的感觉??

微信扫一扫,分享到朋友圈

一个简单示例-刷新你对Vue2响应式原理的认知

教练,我想从零做一个自己的ui组件库

上一篇

Js高级---彻底理解Js中this的指向

下一篇

你也可能喜欢

一个简单示例-刷新你对Vue2响应式原理的认知

长按储存图像,分享给朋友