V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
answershuto
V2EX  ›  前端开发

从 template 到 DOM(Vue.js 源码角度看内部运行机制)

  •  
  •   answershuto ·
    answershuto · 2017-10-15 21:08:13 +08:00 · 2020 次点击
    这是一个创建于 2592 天前的主题,其中的信息可能已经有所发展或是发生改变。

    写在前面

    这篇文章算是对最近写的一系列 Vue.js 源码的文章(https://github.com/answershuto/learnVue)的总结吧,在阅读源码的过程中也确实受益匪浅,希望自己的这些产出也会对同样想要学习 Vue.js 源码的小伙伴有所帮助。

    因为对 Vue.js 很感兴趣,而且平时工作的技术栈也是 Vue.js ,这几个月花了些时间研究学习了一下 Vue.js 源码,并做了总结与输出。

    文章的原地址:https://github.com/answershuto/learnVue

    在学习过程中,为 Vue 加上了中文的注释https://github.com/answershuto/learnVue/tree/master/vue-src,希望可以对其他想学习 Vue 源码的小伙伴有所帮助。

    可能会有理解存在偏差的地方,欢迎提 issue 指出,共同学习,共同进步。

    从 new 一个 Vue 对象开始

    let vm = new Vue({
        el: '#app',
        /*some options*/
    });
    

    很多同学好奇,在 new 一个 Vue 对象的时候,内部究竟发生了什么?

    究竟 Vue.js 是如何将 data 中的数据渲染到真实的宿主环境环境中的?

    又是如何通过“响应式”修改数据的?

    template 是如何被编译成真实环境中可用的 HTML 的?

    Vue 指令又是执行的?

    带着这些疑问,我们从 Vue 的构造类开始看起。

    Vue 构造类

    function Vue (options) {
      if (process.env.NODE_ENV !== 'production' &&
        !(this instanceof Vue)) {
        warn('Vue is a constructor and should be called with the `new` keyword')
      }
      /*初始化*/
      this._init(options)
    }
    

    Vue 的构造类只做了一件事情,就是调用_init 函数进行

    来看一下 init 的代码

    Vue.prototype._init = function (options?: Object) {
        const vm: Component = this
        // a uid
        vm._uid = uid++
    
        let startTag, endTag
        /* istanbul ignore if */
        if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
          startTag = `vue-perf-init:${vm._uid}`
          endTag = `vue-perf-end:${vm._uid}`
          mark(startTag)
        }
    
        // a flag to avoid this being observed
        /*一个防止 vm 实例自身被观察的标志位*/
        vm._isVue = true
        // merge options
        if (options && options._isComponent) {
          // optimize internal component instantiation
          // since dynamic options merging is pretty slow, and none of the
          // internal component options needs special treatment.
          initInternalComponent(vm, options)
        } else {
          vm.$options = mergeOptions(
            resolveConstructorOptions(vm.constructor),
            options || {},
            vm
          )
        }
        /* istanbul ignore else */
        if (process.env.NODE_ENV !== 'production') {
          initProxy(vm)
        } else {
          vm._renderProxy = vm
        }
        // expose real self
        vm._self = vm
        /*初始化生命周期*/
        initLifecycle(vm)
        /*初始化事件*/
        initEvents(vm)
        /*初始化 render*/
        initRender(vm)
        /*调用 beforeCreate 钩子函数并且触发 beforeCreate 钩子事件*/
        callHook(vm, 'beforeCreate')
        initInjections(vm) // resolve injections before data/props
        /*初始化 props、methods、data、computed 与 watch*/
        initState(vm)
        initProvide(vm) // resolve provide after data/props
        /*调用 created 钩子函数并且触发 created 钩子事件*/
        callHook(vm, 'created')
    
        /* istanbul ignore if */
        if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
          /*格式化组件名*/
          vm._name = formatComponentName(vm, false)
          mark(endTag)
          measure(`${vm._name} init`, startTag, endTag)
        }
    
        if (vm.$options.el) {
          /*挂载组件*/
          vm.$mount(vm.$options.el)
        }
      }
    

    _init 主要做了这两件事:

    1.初始化(包括生命周期、事件、render 函数、state 等)。

    2.$mount 组件。

    在生命钩子 beforeCreate 与 created 之间会初始化 state,在此过程中,会依次初始化 props、methods、data、computed 与 watch,这也就是 Vue.js 对 options 中的数据进行“响应式化”(即双向绑定)的过程。对于 Vue.js 响应式原理不了解的同学可以先看一下笔者的另一片文章《 Vue.js 响应式原理》

    /*初始化 props、methods、data、computed 与 watch*/
    export function initState (vm: Component) {
      vm._watchers = []
      const opts = vm.$options
      /*初始化 props*/
      if (opts.props) initProps(vm, opts.props)
      /*初始化方法*/
      if (opts.methods) initMethods(vm, opts.methods)
      /*初始化 data*/
      if (opts.data) {
        initData(vm)
      } else {
        /*该组件没有 data 的时候绑定一个空对象*/
        observe(vm._data = {}, true /* asRootData */)
      }
      /*初始化 computed*/
      if (opts.computed) initComputed(vm, opts.computed)
      /*初始化 watchers*/
      if (opts.watch) initWatch(vm, opts.watch)
    }
    
    

    双向绑定

    以 initData 为例,对 option 的 data 的数据进行双向绑定 Oberver,其他 option 参数双向绑定的核心原理是一致的。

    function initData (vm: Component) {
    
      /*得到 data 数据*/
      let data = vm.$options.data
      data = vm._data = typeof data === 'function'
        ? getData(data, 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
      /*遍历 data 对象*/
      const keys = Object.keys(data)
      const props = vm.$options.props
      let i = keys.length
    
      //遍历 data 中的数据
      while (i--) {
        /*保证 data 中的 key 不与 props 中的 key 重复,props 优先,如果有冲突会产生 warning*/
        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 if (!isReserved(keys[i])) {
          /*判断是否是保留字段*/
    
          /*这里是我们前面讲过的代理,将 data 上面的属性代理到了 vm 实例上*/
          proxy(vm, `_data`, keys[i])
        }
      }
      /*Github:https://github.com/answershuto*/
      // observe data
      /*从这里开始我们要 observe 了,开始对数据进行绑定,这里有尤大大的注释 asRootData,这步作为根数据,下面会进行递归 observe 进行对深层对象的绑定。*/
      observe(data, true /* asRootData */)
    }
    

    observe 会通过 defineReactive 对 data 中的对象进行双向绑定,最终通过 Object.defineProperty 对对象设置 setter 以及 getter 的方法。getter 的方法主要用来进行依赖收集,对于依赖收集不了解的同学可以参考笔者的另一篇文章《依赖收集》。setter 方法会在对象被修改的时候触发(不存在添加属性的情况,添加属性请用 Vue.set ),这时候 setter 会通知闭包中的 Dep,Dep 中有一些订阅了这个对象改变的 Watcher 观察者对象,Dep 会通知 Watcher 对象更新视图。

    如果是修改一个数组的成员,该成员是一个对象,那只需要递归对数组的成员进行双向绑定即可。但这时候出现了一个问题,?如果我们进行 pop、push 等操作的时候,push 进去的对象根本没有进行过双向绑定,更别说 pop 了,那么我们如何监听数组的这些变化呢? Vue.js 提供的方法是重写 push、pop、shift、unshift、splice、sort、reverse 这七个数组方法。修改数组原型方法的代码可以参考observer/array.js以及observer/index.js

    export class Observer {
      value: any;
      dep: Dep;
      vmCount: number; // number of vms that has this object as root $data
    
      constructor (value: any) {
        //.......
    
        if (Array.isArray(value)) {
          /*
              如果是数组,将修改后可以截获响应的数组方法替换掉该数组的原型中的原生方法,达到监听数组数据变化响应的效果。
              这里如果当前浏览器支持__proto__属性,则直接覆盖当前数组对象原型上的原生数组方法,如果不支持该属性,则直接覆盖数组对象的原型。
          */
          const augment = hasProto
            ? protoAugment  /*直接覆盖原型的方法来修改目标对象*/
            : copyAugment   /*定义(覆盖)目标对象或数组的某一个方法*/
          augment(value, arrayMethods, arrayKeys)
    
          /*如果是数组则需要遍历数组的每一个成员进行 observe*/
          this.observeArray(value)
        } else {
          /*如果是对象则直接 walk 进行绑定*/
          this.walk(value)
        }
      }
    }
    
    /**
     * Augment an target Object or Array by intercepting
     * the prototype chain using __proto__
     */
     /*直接覆盖原型的方法来修改目标对象或数组*/
    function protoAugment (target, src: Object) {
      /* eslint-disable no-proto */
      target.__proto__ = src
      /* eslint-enable no-proto */
    }
    
    /**
     * Augment an target Object or Array by defining
     * hidden properties.
     */
    /* istanbul ignore next */
    /*定义(覆盖)目标对象或数组的某一个方法*/
    function copyAugment (target: Object, src: Object, keys: Array<string>) {
      for (let i = 0, l = keys.length; i < l; i++) {
        const key = keys[i]
        def(target, key, src[key])
      }
    }
    
    /*
     * not type checking this file because flow doesn't play well with
     * dynamically accessing methods on Array prototype
     */
    
    import { def } from '../util/index'
    
    /*取得原生数组的原型*/
    const arrayProto = Array.prototype
    /*创建一个新的数组对象,修改该对象上的数组的七个方法,防止污染原生数组方法*/
    export const arrayMethods = Object.create(arrayProto)
    
    /**
     * Intercept mutating methods and emit events
     */
     /*这里重写了数组的这些方法,在保证不污染原生数组原型的情况下重写数组的这些方法,截获数组的成员发生的变化,执行原生数组操作的同时 dep 通知关联的所有观察者进行响应式处理*/
    ;[
      'push',
      'pop',
      'shift',
      'unshift',
      'splice',
      'sort',
      'reverse'
    ]
    .forEach(function (method) {
      // cache original method
      /*将数组的原生方法缓存起来,后面要调用*/
      const original = arrayProto[method]
      def(arrayMethods, method, function mutator () {
        // avoid leaking arguments:
        // http://jsperf.com/closure-with-arguments
        let i = arguments.length
        const args = new Array(i)
        while (i--) {
          args[i] = arguments[i]
        }
        /*调用原生的数组方法*/
        const result = original.apply(this, args)
    
        /*数组新插入的元素需要重新进行 observe 才能响应式*/
        const ob = this.__ob__
        let inserted
        switch (method) {
          case 'push':
            inserted = args
            break
          case 'unshift':
            inserted = args
            break
          case 'splice':
            inserted = args.slice(2)
            break
        }
        if (inserted) ob.observeArray(inserted)
          
        // notify change
        /*dep 通知所有注册的观察者进行响应式处理*/
        ob.dep.notify()
        return result
      })
    })
    
    

    从数组的原型新建一个 Object.create(arrayProto)对象,通过修改此原型可以保证原生数组方法不被污染。如果当前浏览器支持__proto__这个属性的话就可以直接覆盖该属性则使数组对象具有了重写后的数组方法。如果没有该属性的浏览器,则必须通过遍历 def 所有需要重写的数组方法,这种方法效率较低,所以优先使用第一种。

    在保证不污染不覆盖数组原生方法添加监听,主要做了两个操作,第一是通知所有注册的观察者进行响应式处理,第二是如果是添加成员的操作,需要对新成员进行 observe。

    但是修改了数组的原生方法以后我们还是没法像原生数组一样直接通过数组的下标或者设置 length 来修改数组,Vue.js 提供了$set()及$remove()方法

    对于更具体的讲解数据双向绑定以及 Dep、Watcher 的实现可以参考笔者的文章《从源码角度再看数据绑定》

    template 编译

    在$mount 过程中,如果是独立构建构建,则会在此过程中将 template 编译成 render function。当然,你也可以采用运行时构建。具体参考运行时-编译器-vs-只包含运行时

    template 是如何被编译成 render function 的呢?

    function baseCompile (
      template: string,
      options: CompilerOptions
    ): CompiledResult {
      /*parse 解析得到 ast 树*/
      const ast = parse(template.trim(), options)
      /*
        将 AST 树进行优化
        优化的目标:生成模板 AST 树,检测不需要进行 DOM 改变的静态子树。
        一旦检测到这些静态树,我们就能做以下这些事情:
        1.把它们变成常数,这样我们就再也不需要每次重新渲染时创建新的节点了。
        2.在 patch 的过程中直接跳过。
     */
      optimize(ast, options)
      /*根据 ast 树生成所需的 code (内部包含 render 与 staticRenderFns )*/
      const code = generate(ast, options)
      return {
        ast,
        render: code.render,
        staticRenderFns: code.staticRenderFns
      }
    }
    

    baseCompile 首先会将模板 template 进行 parse 得到一个 AST 语法树,再通过 optimize 做一些优化,最后通过 generate 得到 render 以及 staticRenderFns。

    parse

    parse 的源码可以参见https://github.com/answershuto/learnVue/blob/master/vue-src/compiler/parser/index.js#L53

    parse 会用正则等方式解析 template 模板中的指令、class、style 等数据,形成 AST 语法树。

    optimize

    optimize 的主要作用是标记 static 静态节点,这是 Vue 在编译过程中的一处优化,后面当 update 更新界面时,会有一个 patch 的过程,diff 算法会直接跳过静态节点,从而减少了比较的过程,优化了 patch 的性能。

    generate

    generate 是将 AST 语法树转化成 render funtion 字符串的过程,得到结果是 render 的字符串以及 staticRenderFns 字符串。

    具体的 template 编译实现请参考《聊聊 Vue.js 的 template 编译》

    Watcher 到视图

    Watcher 对象会通过调用 updateComponent 方法来达到更新视图的目的。这里提一下,其实 Watcher 并不是实时更新视图的,Vue.js 默认会将 Watcher 对象存在一个队列中,在下一个 tick 时更新异步更新视图,完成了性能优化。关于 nextTick 感兴趣的小伙伴可以参考《 Vue.js 异步更新 DOM 策略及 nextTick 》

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

    updateComponent 就执行一句话,_render 函数会返回一个新的 Vnode 节点,传入_update 中与旧的 VNode 对象进行对比,经过一个 patch 的过程得到两个 VNode 节点的差异,最后将这些差异渲染到真实环境形成视图。

    什么是 VNode ?

    VNode

    在刀耕火种的年代,我们需要在各个事件方法中直接操作 DOM 来达到修改视图的目的。但是当应用一大就会变得难以维护。

    那我们是不是可以把真实 DOM 树抽象成一棵以 JavaScript 对象构成的抽象树,在修改抽象树数据后将抽象树转化成真实 DOM 重绘到页面上呢?于是虚拟 DOM 出现了,它是真实 DOM 的一层抽象,用属性描述真实 DOM 的各个特性。当它发生变化的时候,就会去修改视图。

    但是这样的 JavaScript 操作 DOM 进行重绘整个视图层是相当消耗性能的,我们是不是可以每次只更新它的修改呢?所以 Vue.js 将 DOM 抽象成一个以 JavaScript 对象为节点的虚拟 DOM 树,以 VNode 节点模拟真实 DOM,可以对这颗抽象树进行创建节点、删除节点以及修改节点等操作,在这过程中都不需要操作真实 DOM,只需要操作 JavaScript 对象,大大提升了性能。修改以后经过 diff 算法得出一些需要修改的最小单位,再将这些小单位的视图进行更新。这样做减少了很多不需要的 DOM 操作,大大提高了性能。

    Vue 就使用了这样的抽象节点 VNode,它是对真实 DOM 的一层抽象,而不依赖某个平台,它可以是浏览器平台,也可以是 weex,甚至是 node 平台也可以对这样一棵抽象 DOM 树进行创建删除修改等操作,这也为前后端同构提供了可能。

    先来看一下 Vue.js 源码中对 VNode 类的定义。

    export default class VNode {
      tag: string | void;
      data: VNodeData | void;
      children: ?Array<VNode>;
      text: string | void;
      elm: Node | void;
      ns: string | void;
      context: Component | void; // rendered in this component's scope
      functionalContext: Component | void; // only for functional component root nodes
      key: string | number | void;
      componentOptions: VNodeComponentOptions | void;
      componentInstance: Component | void; // component instance
      parent: VNode | void; // component placeholder node
      raw: boolean; // contains raw HTML? (server only)
      isStatic: boolean; // hoisted static node
      isRootInsert: boolean; // necessary for enter transition check
      isComment: boolean; // empty comment placeholder?
      isCloned: boolean; // is a cloned node?
      isOnce: boolean; // is a v-once node?
    
      constructor (
        tag?: string,
        data?: VNodeData,
        children?: ?Array<VNode>,
        text?: string,
        elm?: Node,
        context?: Component,
        componentOptions?: VNodeComponentOptions
      ) {
        /*当前节点的标签名*/
        this.tag = tag
        /*当前节点对应的对象,包含了具体的一些数据信息,是一个 VNodeData 类型,可以参考 VNodeData 类型中的数据信息*/
        this.data = data
        /*当前节点的子节点,是一个数组*/
        this.children = children
        /*当前节点的文本*/
        this.text = text
        /*当前虚拟节点对应的真实 dom 节点*/
        this.elm = elm
        /*当前节点的名字空间*/
        this.ns = undefined
        /*编译作用域*/
        this.context = context
        /*函数化组件作用域*/
        this.functionalContext = undefined
        /*节点的 key 属性,被当作节点的标志,用以优化*/
        this.key = data && data.key
        /*组件的 option 选项*/
        this.componentOptions = componentOptions
        /*当前节点对应的组件的实例*/
        this.componentInstance = undefined
        /*当前节点的父节点*/
        this.parent = undefined
        /*简而言之就是是否为原生 HTML 或只是普通文本,innerHTML 的时候为 true,textContent 的时候为 false*/
        this.raw = false
        /*静态节点标志*/
        this.isStatic = false
        /*是否作为跟节点插入*/
        this.isRootInsert = true
        /*是否为注释节点*/
        this.isComment = false
        /*是否为克隆节点*/
        this.isCloned = false
        /*是否有 v-once 指令*/
        this.isOnce = false
      }
    
      // DEPRECATED: alias for componentInstance for backwards compat.
      /* istanbul ignore next */
      get child (): Component | void {
        return this.componentInstance
      }
    }
    

    这是一个最基础的 VNode 节点,作为其他派生 VNode 类的基类,里面定义了下面这些数据。

    tag: 当前节点的标签名

    data: 当前节点对应的对象,包含了具体的一些数据信息,是一个 VNodeData 类型,可以参考 VNodeData 类型中的数据信息

    children: 当前节点的子节点,是一个数组

    text: 当前节点的文本

    elm: 当前虚拟节点对应的真实 dom 节点

    ns: 当前节点的名字空间

    context: 当前节点的编译作用域

    functionalContext: 函数化组件作用域

    key: 节点的 key 属性,被当作节点的标志,用以优化

    componentOptions: 组件的 option 选项

    componentInstance: 当前节点对应的组件的实例

    parent: 当前节点的父节点

    raw: 简而言之就是是否为原生 HTML 或只是普通文本,innerHTML 的时候为 true,textContent 的时候为 false

    isStatic: 是否为静态节点

    isRootInsert: 是否作为跟节点插入

    isComment: 是否为注释节点

    isCloned: 是否为克隆节点

    isOnce: 是否有 v-once 指令


    打个比方,比如说我现在有这么一个 VNode 树

    {
        tag: 'div'
        data: {
            class: 'test'
        },
        children: [
            {
                tag: 'span',
                data: {
                    class: 'demo'
                }
                text: 'hello,VNode'
            }
        ]
    }
    

    渲染之后的结果就是这样的

    <div class="test">
        <span class="demo">hello,VNode</span>
    </div>
    

    更多操作 VNode 的方法,请参考《 VNode 节点》

    字数超过了限制,剩余内容请看《从 template 到 DOM(Vue.js 源码角度看内部运行机制)》

    关于

    作者:染陌

    Email: [email protected] or [email protected]

    Github: https://github.com/answershuto

    Blog:http://answershuto.github.io/

    知乎主页:https://www.zhihu.com/people/cao-yang-49/activities

    知乎专栏:https://zhuanlan.zhihu.com/ranmo

    掘金: https://juejin.im/user/58f87ae844d9040069ca7507

    osChina:https://my.oschina.net/u/3161824/blog

    转载请注明出处,谢谢。

    欢迎关注我的公众号

    1 条回复    2017-10-15 21:21:00 +08:00
    SourceMan
        1
    SourceMan  
       2017-10-15 21:21:00 +08:00 via iPhone
    这么多人喜欢这里做外链,做宣传。
    一点都不尊重站长的运营工作
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3212 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 34ms · UTC 13:32 · PVG 21:32 · LAX 05:32 · JFK 08:32
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.