为了账号安全,请及时绑定邮箱和手机立即绑定

Vue2.1.7——源码学习(三)

作者:HcySunYang

紧接前文:
Vue2.1.7——源码学习(一)
Vue2.1.7——源码学习(二)

五、通过 initData 看Vue的数据响应系统

Vue的数据响应系统包含三个部分:Observer、Dep、Watcher。关于数据响应系统的内容真的已经被文章讲烂了,所以我就简单的说一下,力求大家能理解就ok,我们还是先看一下 initData 中的代码:

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? data.call(vm)
    : data || {}
  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object:\n' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }
  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  let i = keys.length
  while (i--) {
    if (props && hasOwn(props, keys[i])) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${keys[i]}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else {
      proxy(vm, keys[i])
    }
  }
  // observe data
  observe(data)
  data.__ob__ && data.__ob__.vmCount++
}

首先,先拿到 data 数据:let data = vm.$options.data,大家还记得此时 vm.$options.data 的值应该是通过 mergeOptions 合并处理后的 mergedInstanceDataFn 函数吗?所以在得到 data 后,它又判断了 data 的数据类型是不是 ‘function’,最终的结果是:data 还是我们传入的数据选项的 data,即:

data: {
    a: 1,
    b: [1, 2, 3]
}

然后在实例对象上定义 _data 属性,该属性与 data 是相同的引用。
然后是一个 while 循环,循环的目的是在实例对象上对数据进行代理,这样我们就能通过 this.a 来访问 data.a 了,代码的处理是在 proxy 函数中,该函数非常简单,仅仅是在实例对象上设置与 data 属性同名的访问器属性,然后使用 _data 做数据劫持,如下:

function proxy (vm: Component, key: string) {
  if (!isReserved(key)) {
    Object.defineProperty(vm, key, {
      configurable: true,
      enumerable: true,
      get: function proxyGetter () {
        return vm._data[key]
      },
      set: function proxySetter (val) {
        vm._data[key] = val
      }
    })
  }
}

做完数据的代理,就正式进入响应系统,

observe(data)

我们说过,数据响应系统主要包含三部分:Observer、Dep、Watcher,代码分别存放在:observer/index.js、observer/dep.js 以及 observer/watcher.js 文件中,这回我们换一种方式,我们先不看其源码,大家先跟着我的思路来思考,最后回头再去看代码,你会有一种:”奥,不过如此“的感觉。
假如,我们有如下代码:

var data = {
    a: 1,
    b: {
        c: 2
    }
}

observer(data)

new Watch('a', () => {
    alert(9)
})
new Watch('a', () => {
    alert(90)
})
new Watch('b.c', () => {
    alert(80)
})

这段代码目的是,首先定义一个数据对象 data,然后通过 observer 对其进行观测,之后定义了三个观察者,当数据有变化时,执行相应的方法,这个功能使用Vue的实现原来要如何去实现?其实就是在问 observer 怎么写?Watch 构造函数又怎么写?接下来我们逐一实现。
首先,observer 的作用是:将数据对象data的属性转换为访问器属性:

class Observer {
    constructor (data) {
        this.walk(data)
    }
    walk (data) {
        // 遍历 data 对象属性,调用 defineReactive 方法
        let keys = Object.keys(data)
        for(let i = 0; i < keys.length; i++){
            defineReactive(data, keys[i], data[keys[i]])
        }
    }
}

// defineReactive方法仅仅将data的属性转换为访问器属性
function defineReactive (data, key, val) {
    // 递归观测子属性
    observer(val)

    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function () {
            return val
        },
        set: function (newVal) {
            if(val === newVal){
                return
            }
            // 对新值进行观测
            observer(newVal)
        }
    })
}

// observer 方法首先判断data是不是纯JavaScript对象,如果是,调用 Observer 类进行观测
function observer (data) {
    if(Object.prototype.toString.call(data) !== '[object Object]') {
        return
    }
    new Observer(data)
}

上面的代码中,我们定义了 observer 方法,该方法检测了数据data是不是纯JavaScript对象,如果是就调用 Observer 类,并将 data 作为参数透传。在 Observer 类中,我们使用 walk 方法对数据data的属性循环调用 defineReactive 方法,defineReactive 方法很简单,仅仅是将数据data的属性转为访问器属性,并对数据进行递归观测,否则只能观测数据data的直属子属性。这样我们的第一步工作就完成了,当我们修改或者获取data属性值的时候,通过 get 和 set 即能获取到通知。
我们继续往下看,来看一下 Watch:

new Watch('a', () => {
    alert(9)
})

现在的问题是,Watch 要怎么和 observer 关联???????我们看看 Watch 它知道些什么,通过上面调用 Watch 的方式,传递给 Watch 两个参数,一个是 ‘a’ 我们可以称其为表达式,另外一个是回调函数。所以我们目前只能写出这样的代码:

class Watch {
    constructor (exp, fn) {
        this.exp = exp
        this.fn = fn
    }
}

那么要怎么关联呢,大家看下面的代码会发生什么:

class Watch {
    constructor (exp, fn) {
        this.exp = exp
        this.fn = fn
        data[exp]
    }
}

多了一句 data[exp],这句话是在干什么?是不是在获取 data 下某个属性的值,比如 exp 为 ‘a’ 的话,那么 data[exp] 就相当于在获取 data.a 的值,那这会放生什么?大家不要忘了,此时数据 data 下的属性已经是访问器属性了,所以这么做的结果会直接触发对应属性的 get 函数,这样我们就成功的和 observer 产生了关联,但这样还不够,我们还是没有达到目的,不过我们已经无限接近了,我们继续思考看一下可不可以这样:

既然在 Watch 中对表达式求值,能够触发 observer 的 get,那么可不可以在 get 中收集 Watch 中函数呢?
答案是可以的,不过这个时候我们就需要 Dep 出场了,它是一个依赖收集器。我们的思路是:data 下的每一个属性都有一个唯一的 Dep 对象,在 get 中收集仅针对该属性的依赖,然后在 set 方法中触发所有收集的依赖,这样就搞定了,看如下代码:

class Dep {
    constructor () {
        this.subs = []
    }
    addSub () {
        this.subs.push(Dep.target)
    }
    notify () {
        for(let i = 0; i < this.subs.length; i++){
            this.subs[i].fn()
        }
    }
}
Dep.target = null
function pushTarget(watch){
    Dep.target = watch
}

class Watch {
    constructor (exp, fn) {
        this.exp = exp
        this.fn = fn
        pushTarget(this)
        data[exp]
    }
}

上面的代码中,我们在 Watch 中增加了 pushTarget(this),可以发现,这句代码的作用是将 Dep.target 的值设置为该Watch对象。在 pushTarget 之后我们才对表达式进行求值,接着,我们修改 defineReactive 代码如下

function defineReactive (data, key, val) {
    observer(val)
    let dep = new Dep()        // 新增
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function () {
            dep.addSub()    // 新增
            return val
        },
        set: function (newVal) {
            if(val === newVal){
                return
            }
            observer(newVal)
            dep.notify()    // 新增
        }
    })
}

如标注,新增了三句代码,我们知道,Watch 中对表达式求值会触发 get 方法,我们在 get 方法中调用了 dep.addSub,也就执行了这句代码:this.subs.push(Dep.target),由于在这句代码执行之前,Dep.target 的值已经被设置为一个 Watch 对象了,所以最终结果就是收集了一个 Watch 对象,然后在 set 方法中我们调用了 dep.notify,所以当data属性值变化的时候,就会通过 dep.notify 循环调用所有收集的Watch对象中的回调函数:

notify () {
    for(let i = 0; i < this.subs.length; i++){
        this.subs[i].fn()
    }
}

这样 observer、Dep、Watch 三者就联系成为一个有机的整体,实现了我们最初的目标,完整的代码可以戳这里:observer-dep-watch。这里还给大家挖了个坑,因为我们没有处理对数组的观测,由于比较复杂并且这又不是我们讨论的重点,如果大家想了解可以戳我的这篇文章:JavaScript实现MVVM之我就是想监测一个普通对象的变化,另外,在 Watch 中对表达式求值的时候也只做了直接子属性的求值,所以如果 exp 的值为 ‘a.b’ 的时候,就不可以用了,Vue的做法是使用 . 分割表达式字符串为数组,然后遍历一下对其进行求值,大家可以查看其源码。如下:

/**
 * Parse simple path.
 */
const bailRE = /[^\w.$]/
export function parsePath (path: string): any {
  if (bailRE.test(path)) {
    return
  } else {
    const segments = path.split('.')
    return function (obj) {
      for (let i = 0; i < segments.length; i++) {
        if (!obj) return
        obj = obj[segments[i]]
      }
      return obj
    }
  }
}

Vue 的求值代码是在 src/core/util/lang.js 文件中 parsePath 函数中实现的。总结一下Vue的依赖收集过程应该是这样的:

Vue的依赖收集过程

实际上,Vue并没有直接在 get 中调用 addSub,而是调用的 dep.depend,目的是将当前的 dep 对象收集到 watch 对象中,如果要完整的流程,应该是这样的:(大家注意数据的每一个字段都拥有自己的 dep 对象和 get 方法。)

Vue完整的收集依赖的流程

这样 Vue 就建立了一套数据响应系统,之前我们说过,按照我们的例子那样写,初始化工作只包含两个主要内容即:initData 和 initRender。现在 initData 我们分析完了,接下来看一看 initRender

六、通过 initRender 看Vue的 render(渲染) 与 re-render(重新渲染)

在 initRender 方法中,因为我们的例子中传递了 el 选项,所以下面的代码会执行:

  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  }

这里,调用了 $mount 方法,在还原Vue构造函数的时候,我们整理过所有的方法,其中 $mount 方法在两个地方出现过:
1、在 web-runtime.js 文件中:

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

它的作用是通过 el 获取相应的DOM元素,然后调用 lifecycle.js 文件中的 _mount 方法。
2、在 web-runtime-with-compiler.js 文件中:

// 缓存了来自 web-runtime.js 的 $mount 方法
const mount = Vue.prototype.$mount
// 重写 $mount 方法
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 根据 el 获取相应的DOM元素
  el = el && query(el)
  // 不允许你将 el 挂载到 html 标签或者 body 标签
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
    )
    return this
  }

  const options = this.$options
  // 如果我们没有写 render 选项,那么就尝试将 template 或者 el 转化为 render 函数
  if (!options.render) {
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      template = getOuterHTML(el)
    }
    if (template) {
      const { render, staticRenderFns } = compileToFunctions(template, {
        warn,
        shouldDecodeNewlines,
        delimiters: options.delimiters
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns
    }
  }
  // 调用已经缓存下来的 web-runtime.js 文件中的 $mount 方法
  return mount.call(this, el, hydrating)
}

分析一下可知 web-runtime-with-compiler.js 的逻辑如下:
1、缓存来自 web-runtime.js 文件的 $mount 方法
2、判断有没有传递 render 选项,如果有直接调用来自 web-runtime.js 文件的 $mount 方法
3、如果没有传递 render 选项,那么查看有没有 template 选项,如果有就使用 compileToFunctions 函数根据其内容编译成 render 函数
4、如果没有 template 选项,那么查看有没有 el 选项,如果有就使用 compileToFunctions 函数将其内容(template = getOuterHTML(el))编译成 render 函数
5、将编译成的 render 函数挂载到 this.$options 属性下,并调用缓存下来的 web-runtime.js 文件中的 $mount 方法
简单的用一张图表示 mount 方法的调用关系,从上至下调用:

mount调用关系

不过不管怎样,我们发现这些步骤的最终目的是生成 render 函数,然后再调用 lifecycle.js 文件中的 _mount 方法,我们看看这个方法做了什么事情,查看 _mount 方法的代码,这是简化过得:

  Vue.prototype._mount = function (
    el?: Element | void,
    hydrating?: boolean
  ): Component {
    const vm: Component = this

    // 在Vue实例对象上添加 $el 属性,指向挂载点元素
    vm.$el = el

    // 触发 beforeMount 生命周期钩子
    callHook(vm, 'beforeMount')

    vm._watcher = new Watcher(vm, () => {
      vm._update(vm._render(), hydrating)
    }, noop)

    // 如果是第一次mount则触发 mounted 生命周期钩子
    if (vm.$vnode == null) {
      vm._isMounted = true
      callHook(vm, 'mounted')
    }
    return vm
  }

上面的代码很简单,该注释的都注释了,唯一需要看的就是这段代码:

vm._watcher = new Watcher(vm, () => {
  vm._update(vm._render(), hydrating)
}, noop)

看上去很眼熟有没有?我们平时使用Vue都是这样使用 watch的:

this.$watch('a', (newVal, oldVal) => {

})
// 或者
this.$watch(function(){
    return this.a + this.b
}, (newVal, oldVal) => {

})

第一个参数是 表达式或者函数,第二个参数是回调函数,第三个参数是可选的选项。原理是 Watch 内部对表达式求值或者对函数求值从而触发数据的 get 方法收集依赖。可是 _mount 方法中使用 Watcher 的时候第一个参数 vm 是什么鬼。我们不妨去看看源码中 $watch 函数是如何实现的,根据之前还原Vue构造函数中所整理的内容可知:$warch 方法是在 src/core/instance/state.js 文件中的 stateMixin 方法中定义的,源码如下:

Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: Function,
    options?: Object
  ): Function {
    const vm: Component = this
    options = options || {}
    options.user = true
    const watcher = new Watcher(vm, expOrFn, cb, options)
    if (options.immediate) {
      cb.call(vm, watcher.value)
    }
    return function unwatchFn () {
      watcher.teardown()
    }
  }

我们可以发现,$warch 其实是对 Watcher 的一个封装,内部的 Watcher 的第一个参数实际上也是 vm 即:Vue实例对象,这一点我们可以在 Watcher 的源码中得到验证,代开 observer/watcher.js 文件查看:

export default class Watcher {

  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: Object = {}
  ) {

  }
}

可以发现真正的 Watcher 第一个参数实际上就是 vm。第二个参数是表达式或者函数,然后以此类推,所以现在再来看 _mount 中的这段代码:

vm._watcher = new Watcher(vm, () => {
  vm._update(vm._render(), hydrating)
}, noop)
忽略第一个参数 vm,也就说,Watcher 内部应该对第二个参数求值,也就是运行这个函数:
() => {
  vm._update(vm._render(), hydrating)
}
所以 vm._render() 函数被第一个执行,该函数在 src/core/instance/render.js 中,该方法中的代码很多,下面是简化过的:
  Vue.prototype._render = function (): VNode {
    const vm: Component = this
    // 解构出 $options 中的 render 函数
    const {
      render,
      staticRenderFns,
      _parentVnode
    } = vm.$options
    ...

    let vnode
    try {
      // 运行 render 函数
      vnode = render.call(vm._renderProxy, vm.$createElement)
    } catch (e) {
      ...
    }

    // set parent
    vnode.parent = _parentVnode
    return vnode
  }

_render 方法首先从 vm.$options 中解构出 render 函数,大家应该记得:vm.$options.render 方法是在 web-runtime-with-compiler.js 文件中通过 compileToFunctions 方法将 template 或 el 编译而来的。解构出 render 函数后,接下来便执行了该方法:
vnode = render.call(vm._renderProxy, vm.$createElement)

其中使用 call 指定了 render 函数的作用域环境为 vm._renderProxy,这个属性在我们整理实例对象的时候知道,他是在 Vue.prototype._init 方法中被添加的,即:vm._renderProxy = vm,其实就是Vue实例对象本身,然后传递了一个参数:vm.$createElement。那么 render 函数到底是干什么的呢?让我们根据上面那句代码猜一猜,我们已经知道 render 函数是从 template 或 el 编译而来的,如果没错的话应该是返回一个虚拟DOM对象。我们不妨使用 console.log 打印一下 render 函数,当我们的模板这样编写时:

<ul id="app">
  <li>{{a}}</li>
</ul>
打印的 render 函数如下:
render函数1
我们修改模板为:
<ul id="app">
  <li v-for="i in b">{{a}}</li>
</ul>

打印出来的 render 函数如下:
render函数2
其实了解Vue2.x版本的同学都知道,Vue提供了 render 选项,作为 template 的代替方案,同时为JavaScript提供了完全编程的能力,下面两种编写模板的方式实际是等价的:

// 方案一:
new Vue({
    el: '#app',
    data: {
        a: 1
    },
    template: '<ul><li>{{a}}</li><li>{{a}}</li></ul>'
})

// 方案二:
new Vue({
    el: '#app',
    render: function (createElement) {
        createElement('ul', [
            createElement('li', this.a),
            createElement('li', this.a)
        ])
    }
})

现在我们再来看我们打印的 render 函数:

function anonymous() {
    with(this){
        return _c('ul', { 
            attrs: {"id": "app"}
        },[
            _c('li', [_v(_s(a))])
        ])
    }
}

是不是与我们自己写 render 函数很像?因为 render 函数的作用域被绑定到了Vue实例,即:render.call(vm._renderProxy, vm.$createElement),所以上面代码中 _c、_v、_s 以及变量 a相当于Vue实例下的方法和变量。大家还记得诸如 _c、_v、_s 这样的方法在哪里定义的吗?我们在整理Vue构造函数的时候知道,他们在 src/core/instance/render.js 文件中的 renderMixin 方法中定义,除了这些之外还有诸如:_l、 _m、 _o 等等。其中 _l 就在我们使用 v-for 指令的时候出现了。所以现在大家知道为什么这些方法都被定义在 render.js 文件中了吧,因为他们就是为了构造出 render 函数而存在的。

现在我们已经知道了 render 函数的长相,也知道了 render 函数的作用域是Vue实例本身即:this(或vm)。那么当我们执行 render 函数时,其中的变量如:a,就相当于:this.a,我们知道这是在求值,所以 _mount 中的这段代码:

vm._watcher = new Watcher(vm, () => {
  vm._update(vm._render(), hydrating)
}, noop)

当 vm._render 执行的时候,所依赖的变量就会被求值,并被收集为依赖。按照Vue中 watcher.js 的逻辑,当依赖的变量有变化时不仅仅回调函数被执行,实际上还要重新求值,即还要执行一遍:

() => {
  vm._update(vm._render(), hydrating)
}

这实际上就做到了 re-render,因为 vm._update 就是文章开头所说的虚拟DOM中的最后一步:patch
vm_render 方法最终返回一个 vnode 对象,即虚拟DOM,然后作为 vm_update 的第一个参数传递了过去,我们看一下 vm_update 的逻辑,在 src/core/instance/lifecycle.js 文件中有这么一段代码:

 if (!prevVnode) {
      // initial render
      vm.$el = vm.__patch__(
        vm.$el, vnode, hydrating, false /* removeOnly */,
        vm.$options._parentElm,
        vm.$options._refElm
      )
    } else {
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode)
    }

如果还没有 prevVnode 说明是首次渲染,直接创建真实DOM。如果已经有了 prevVnode 说明不是首次渲染,那么就采用 patch 算法进行必要的DOM操作。这就是Vue更新DOM的逻辑。只不过我们没有将 virtual DOM 内部的实现。
现在我们来好好理理思路,当我们写如下代码时:

new Vue({
    el: '#app',
    data: {
        a: 1,
        b: [1, 2, 3]
    }
})

Vue 所做的事:
1、构建数据响应系统,使用 Observer 将数据data转换为访问器属性;将 el 编译为 render 函数,render 函数返回值为虚拟DOM
2、在 _mount 中对 _update 求值,而 _update 又会对 render 求值,render 内部又会对依赖的变量求值,收集为被求值的变量的依赖,当变量改变时,_update 又会重新执行一遍,从而做到 re-render。
用一张详细一点的图表示就是这样的:
详细流程
到此,我们从大体流程,挑着重点的走了一遍Vue,但是还有很多细节我们没有提及,比如:
1、将模板转为 render 函数的时候,实际是先生成的抽象语法树(AST),再将抽象语法树转成的 render 函数,而且这一整套的代码我们也没有提及,因为他在复杂了,其实这部分内容就是在完正则。
2、我们也没有详细的讲 Virtual DOM 的实现原理,网上已经有文章讲了,大家可以搜一搜
3、我们的例子中仅仅传递了 el ,data 选项,大家知道 Vue 支持的选项很多,比如我们都没有讲到,但都是触类旁通的,比如你搞清楚了 data 选项再去看 computed 选项或者 props 选项就会很容易,比如你知道了 Watcher 的工作机制再去看 watch 选项就会很容易。
本篇文章作为Vue源码的启蒙文章,也许还有很多缺陷,全当抛砖引玉了。
作者:HcySunYang

原本文章的名字叫做《源码解析》,不过后来想想,还是用“源码学习”来的合适一点,在没有彻底掌握源码中的每一个字母之前,“解析”就有点标题党了。建议在看这篇文章之前,最好打开2.1.7的源码对照着看,这样可能更容易理解。另外本人水平有限,文中有错误或不妥的地方望大家多多指正共同成长。

补充:Vue 2.2 刚刚发布,作为一个系列文章的第一篇,本篇文章主要从Vue代码的组织,Vue构造函数的还原,原型的设计,以及参数选项的处理和已经被写烂了的数据绑定与如何使用 Virtual DOM 更新视图入手。从整体的大方向观察框架,这么看来 V2.1.7 对于理解 V2.2 的代码不会有太大的影响。该系列文章的后续文章,都会从最新的源码入手,并对改动的地方做相应的提示。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
Web前端工程师
手记
粉丝
13
获赞与收藏
65

关注作者,订阅最新文章

阅读免费教程

  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消