乐趣区

从源码切入Vue双向绑定原理并实现一个demo

本文涉及源码版本为 2.6.9

准备工作

down 一份 Vue 源码,从 package.json 入手,找我们需要的代码
1、package.json 中的 scripts,"build": "node scripts/build.js"
2、scripts/build.js line26 build(builds),其中 builds 的定义为 11 行的let builds = require('./config').getAllBuilds(), 这个大概就是打包的代码内容,另一个 build 是在下面定义的函数,他的代码是这样的:

function build (builds) {
  let built = 0
  const total = builds.length
  const next = () => {buildEntry(builds[built]).then(() => {
      built++
      if (built < total) {next()
      }
    }).catch(logError)
  }

  next()}

这段代码有说法,其中的 buildEntry 是使用 rollup 进行打包的函数,定义一个 next 函数,把多个吃内存的打包操作串行,达到减小瞬间内存消耗的效果,这算是常用的一个优化方式了。
3、顺着 scripts/config.js 里的 getAllBuilds()的逻辑摸到 line28 的 const aliases = require('./alias'), 然后打开 scripts/alias.js, 看到里面的vue: resolve('src/platforms/web/entry-runtime-with-compiler') 终于有点豁然开朗,然后再根据一层层的 import 找到 src/core/instance/index.js 里的function Vue(){},准备工作到此结束。

new Vue()发生了什么

就一行,this._init(options), 这是在函数 initMixin()中定义在 Vue.prototype 上的方法

export function initMixin (Vue: Class<Component>) {Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    
    vm._self = vm
    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)
    }
  }
}

需要留意的分别是 initState(vm)vm.$mount(vm.$option.el)

  1. initState(vm)

    export function initState (vm: Component) {
     const opts = vm.$options
     if (opts.props) initProps(vm, opts.props)
     if (opts.methods) initMethods(vm, opts.methods)
     if (opts.data) {initData(vm)
     }
    }

    字面意思,初始化 props,methods,data,由于目的是看数据双向绑定,就直接进 initData()

    1.1 proxy

    在 initData()中,遍历 data 中的 keys 判断是否与 props 和 methods 重名,然后对他们设置了一层代理

    const sharedPropertyDefinition = {
     enumerable: true,
     configurable: true,
     get: noop,
     set: noop
    }
    
    export function proxy (target: Object, sourceKey: string, key: string) {sharedPropertyDefinition.get = function proxyGetter () {return this[sourceKey][key]
     }
     sharedPropertyDefinition.set = function proxySetter (val) {this[sourceKey][key] = val
     }
     Object.defineProperty(target, key, sharedPropertyDefinition)
    }

    这就是为什么我们可以直接通过 this.name 获取到 this.data.name 的值。
    关于 Object.defineProperty()可以设置的属性描述符,其中

  • configurable 控制是否可以配置,以及是否可以 delete 删除,配置就是指是否可以通过 Object.defineProperty 修改这个属性的描述,没错如果你通过 defineProperty 把某个属性的 configurable 改为 false,再想改回来是不可能的。
  • enumerable 控制是否可枚举,赋值为 false 之后,Object.keys()就看不见他了。
  • 还有 value、writable、get、set,都比较好理解就不再赘述。

    1.2、new Observe()

    遍历完 keys,就是以 data 作为参数调用 observe 了, 而 observe 内部得主要内容就是ob = new Observer(value), 再看 Observer 这个类。(有一种抽丝剥茧得感觉)

    export class Observer {
     value: any;
     dep: Dep;
     vmCount: number; // number of vms that have this object as root $data
    
     constructor (value: any) {
       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)
       }
     }
    
     walk (obj: Object) {const keys = Object.keys(obj)
       for (let i = 0; i < keys.length; i++) {defineReactive(obj, keys[i])
       }
     }
    
     observeArray (items: Array<any>) {for (let i = 0, l = items.length; i < l; i++) {observe(items[i])
       }
     }
    }

    函数 def 的作用就是在对象上定义属性。然后判断传进的 data 是对象还是数组。

    1.2.1、Array.isArray(value)

    如果 value 是数组的话,先通过 hasProto 这个自定义函数来判断当前环境中是否存在 __proto__,如果有的话就可以直接用,没有的话,手动
    实现一下,功能是一样的,那就只看protoAugment(value, arrayMethods) 干了啥就好

    function protoAugment (target, src: Object) {target.__proto__ = src}

    其中 target 自然就是我们 observe 的数组,而 src 也就是 arrayMethods 的定义如下

    const arrayProto = Array.prototype
    export const arrayMethods = Object.create(arrayProto)
    
    const methodsToPatch = [
     'push',
     'pop',
     'shift',
     'unshift',
     'splice',
     'sort',
     'reverse'
    ]
    
    methodsToPatch.forEach(function (method) {
     // cache original method
     const original = arrayProto[method]
     def(arrayMethods, method, function mutator (...args) {const result = original.apply(this, args)
       const ob = this.__ob__
       let inserted
       switch (method) {
         case 'push':
         case 'unshift':
           inserted = args
           break
         case 'splice':
           inserted = args.slice(2)
           break
       }
       if (inserted) ob.observeArray(inserted)
       // notify change
       ob.dep.notify()
       return result
     })
    })

    看着代码里的 methodsToPatch 里的几项,眼熟吗

    再看到倒数第四行的ob.dep.notify(), 配上官方注释notify change
    也就是说arrayMethods 是一个继承数组原型的对象,并对其中特定的几种方法做了处理,然后在 new Observe(value) 的时候,如果 value 是数组,就让 value 继承这个 arrayMethods,然后这个数组调用特定的方法时,会调用当前 Observe 类上的 dep 属性的notify 方法,进行后续操作。
    定义完这些,再进行递归对数组中的每一项继续调用observe

    1.2.2、walk & defineReactive

    然后对于对象而言,直接调用walk,然后遍历对象中的非继承属性,对每一项调用defineReactive

    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}
         /* eslint-enable no-self-compare */
         if (process.env.NODE_ENV !== 'production' && customSetter) {customSetter()
         }
         // #7981: for accessor properties without setter
         if (getter && !setter) return
         if (setter) {setter.call(obj, newVal)
         } else {val = newVal}
         childOb = !shallow && observe(newVal)
         dep.notify()}
     })
    }

    defineReactive的主要代码就是各种判断递归和 Object.defineProperty() 了,这也是双向绑定的关键一部分,从数据到 DOM。
    其中对 get 的定义包含了if(Dep.target){dep.depend() }, 对 set 的定义包含了dep.notify(), 接下来看 Dep 的方法。

    1.3 Dep

    Dep 的定义是这样的

    export default class Dep {
     static target: ?Watcher;
     id: number;
     subs: Array<Watcher>;
    
     constructor () {
       this.id = uid++
       this.subs = []}
    
     addSub (sub: Watcher) {this.subs.push(sub)
     }
    
     removeSub (sub: Watcher) {remove(this.subs, sub)
     }
    
     depend () {if (Dep.target) {Dep.target.addDep(this)
       }
     }
    
     notify () {
       // stabilize the subscriber list first
       const subs = this.subs.slice()
       if (process.env.NODE_ENV !== 'production' && !config.async) {
         // subs aren't sorted in scheduler if not running async
         // we need to sort them now to make sure they fire in correct
         // order
         subs.sort((a, b) => a.id - b.id)
       }
       for (let i = 0, l = subs.length; i < l; i++) {subs[i].update()}
     }
    }

    来看在 get 中调用的 dep.depend(),Dep.target 不为空的情况下,以 this 为参数,调用Dep.target.addDep,target 是 Dep 类的静态属性,类型为 Watcher,方法 addDep 定义如下

    addDep (dep: Dep) {
    const id = dep.id
     if (!this.newDepIds.has(id)) {this.newDepIds.add(id)
       this.newDeps.push(dep)
       if (!this.depIds.has(id)) {dep.addSub(this)
       }
     }
    }

    可以看到 addDep 有去重 dep 的作用,然后通过调用dep.addSub(this), 把当前的 Dep.target push 到 subs 中。
    也就是说,data 里面有个 observer, 然后 observer 里面有个 dep,dep 里面有个 watcher 数组,收集依赖一条龙。

    至于在 set 中调用的 dep.notify(), 是遍历 watcher 数组,调用每一项的 update 方法,而 update 方法,核心代码是调用 watcher 的 run 方法,run 方法的核心是this.cb.call(this.vm, value, oldValue)。问题又来了,这个 cb 是 new Watcher 时的传参,但是从initState 一步一步看下来,先 new 一个 Observe,然后定义其中每个属性的 getsetget时收集依赖,set时通知变更。但是并没有看到哪里真的触发了我们所设置的 get,而且之前说到的Dep.target 是个啥呢。

  1. vm.$mount(vm.$option.el)

    前文有提到 new Vue 时也调用了这个方法,$mount 是前面找 Vue 入口文件的过程中,在其中一个里定义在 Vue 原型上的方法

    Vue.prototype.$mount = function (
     el?: string | Element,
     hydrating?: boolean
    ): Component {el = el && inBrowser ? query(el) : undefined
     return mountComponent(this, el, hydrating)
    }

    然后再找mountComponent, 果然在这个函数的调用中,找到了

    mountComponent() {
     // 其他逻辑
     new Watcher(vm, updateComponent, noop, {before () {if (vm._isMounted && !vm._isDestroyed) {callHook(vm, 'beforeUpdate')
         }
       }
     }, true /* isRenderWatcher */)
    }

    再去看 Watcher 的构造函数,有调用自己的 get 方法,定义如下

    get () {pushTarget(this)
     let value
     const vm = this.vm
     try {value = this.getter.call(vm, vm)
     } catch (e) {if (this.user) {handleError(e, vm, `getter for watcher "${this.expression}"`)
       } else {throw e}
     } finally {
       // "touch" every property so they are all tracked as
       // dependencies for deep watching
       if (this.deep) {traverse(value)
       }
       popTarget()
       this.cleanupDeps()}
     return value
    }

    pushTarget(this) 来设置 Dep 的静态属性 target, 然后调用this.getter.call(vm, vm) 来做虚拟 DOM 相关的操作,并且触发对 data 对象上的属性设置的 getter, 最后popTarget()Dep.target置为 null。
    Dep.target的作用就是只有在初始化时才会收集依赖,要不然每次取个值收集依赖再判重,卡都卡死了。

最后

跟着源码梳理了一遍逻辑,对 Vue 的了解也更深入了一些,再去看 Vue 官网中对响应式原理的描述,也更清晰了。

本文也只是大概讲了一下右边红框中的实现逻辑,关于左边的虚拟 DOM,暂时真的没看懂。基于上面逻辑自己尝试着写了一个简版的 Vue-> 传送门,尤大不是说一开始 Vue 也只是个自己写着玩的项目,多尝试总是没有错。
文中没有说清楚的地方欢迎指正,如果你也对 Vue 实现原理感兴趣,不妨也去 down 一份源码亲自探索吧

退出移动版