阅读 4712

Vue.js源码角度:剖析模版和数据渲染成最终的DOM的过程

文章首发于:https://github.com/USTB-musion/fee-skills

写在前面

因为最近做的项目采取的技术栈是vue.js,加上自己对vue.js的底层运行机制很感兴趣,所以最近每天花点时间,大概一两个月左右把vue.js源码捋了一遍,在这里针对模版和数据渲染成最终的DOM的过程这一部分做一下总结。

在看源码的过程当中,可能当中有自己理解出偏差或者大家有理解不一样的地方,欢迎大家评论或私信我,共同学习进步。

Vue.js运行机制全局概览图

这是我在网上找的一张Vue.js运行机制全局概览图。可能有一些人在初次看到这张图的时候有点模糊。希望模糊的同学在看完下文的分析之后再回头看这幅图能有豁然开朗的感觉。

Vue.js源码目录的设计

我们在看vue.js源码的时候,了解源码的目录设计是非常必要的。这是截取vue.js的源码目录,它们的大体功能如下:

文件夹功能
compiler编译相关(将模版解析成ast语法树,ast语法树优化,代码生成)
core核心功能相关(vue实例化,全局api封装,虚拟DOM,侦测变化等)
platforms不同平台支持相关(包含web和weex)
server服务端渲染相关(服务端渲染相关的逻辑)
sfc解析.vue文件相关(将.vue文件内容解析成一个javascript对象)
shared不同平台共享代码(定义一些全局共享的工具方法)
从源码目录设计来看,作者们把源码拆分成一个个独立的模块,相关的逻辑放在专门的目录下去维护,这样一来代码的可读性和可维护性就变得非常清晰。
## 从Vue的入口开始

真正初始化的地方在src/core/index.js中:

import Vue from './instance/index'
import { initGlobalAPI } from './global-api/index'
import { isServerRendering } from 'core/util/env'
import { FunctionalRenderContext } from 'core/vdom/create-functional-component'

initGlobalAPI(Vue)

Object.defineProperty(Vue.prototype, '$isServer', {
  get: isServerRendering
})

Object.defineProperty(Vue.prototype, '$ssrContext', {
  get () {
    /* istanbul ignore next */
    return this.$vnode && this.$vnode.ssrContext
  }
})

// expose FunctionalRenderContext for ssr runtime helper installation
Object.defineProperty(Vue, 'FunctionalRenderContext', {
  value: FunctionalRenderContext
})

Vue.version = '__VERSION__'

export default Vue

复制代码

这里有两处非常关键的代码,import Vue from './instance/index.js'和initGlobalAPI(Vue),Vue的定义和将Vue作为参数传入initGlobalAPI,初始化全局API。

先来看Vue的定义,看一下这个src/core/instance/index.js文件:

import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'

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)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

复制代码

可以看出来,Vue实际上是一个用Function实现的Class,这也是为什么我们要通过new Vue()来实例化它。

initGlobalAPI()定义在src/core/global-api/index.js中,它的作用是在Vue对象本身扩展一些全局的方法,这些全局API都可以在Vue官网中找到。

从new Vue()开始

new Vue({
    el: '#app',
    data() {
        return {
            message: '11'
        }
    },
    mounted() {
        console.log(this.message)  
    },
    methods: {}
});
复制代码

可能很多人在写vue写代码的时候,或多或少都有这样的疑问?

1.new Vue背后发生了哪些事情?
2.为什么在mounted过程中能通过this.message打印出data中定义的message?
3.模版和数据是如何渲染成最终的DOM的?

带着这些疑问,我们来分析一下,在new Vue内部究竟发生来哪些事情。

Vue实际上是一个类,定义在src/core/instance/index.js中:

 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)
}
复制代码

我们可以看见,通过new关键字来初始化Vue的时候,会调用_init方法。该方法定义在src/core/instance/init.js中:

   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-start:${vm._uid}`
      endTag = `vue-perf-end:${vm._uid}`
      mark(startTag)
    }

    // a flag to avoid this being observed
    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)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')

    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      vm._name = formatComponentName(vm, false)
      mark(endTag)
      measure(`vue ${vm._name} init`, startTag, endTag)
    }

    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
复制代码
可以看见,_init方法主要做了两件事情:

1.合并配置,初始化生命周期,初始化事件,初始化render,初始化data,computed,methods,wacther等等。

2.在初始化的最后,如果检测到有el属性,则调用vm.$mount方法挂载vm,mount组件。

在生命周期beforeCreate和created的时候会调用initState来初始化state,initState()方法定义在src/core/instance/state.js中:

 export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}
复制代码

在此过程中,会依次初始化props、methods、data、computed与watch,这也就是Vue.js对options中的数据进行“响应式化”(即双向绑定)的过程。在initData方法中:

 function initData (vm: Component) {
  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
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }
  // observe data
  observe(data, true /* asRootData */)
}
复制代码

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

分析proxy(vm, _data, key)这行代码,将data上的属性挂载到vm上,再来看proxy方法的定义:

 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)
}
复制代码

proxy通过defineProperty实现了代理,把target[sourceKey][key]的读写变成了对target[key]的读写。这就能解释刚才提出第二个的问题:为什么在mounted过程中能通过this.message打印出data中定义的message?

再回过头来看_init方法过程中mount组件的实现。先来看Runtime+compiler版本的$mount的实现,在src/platforms/web/entry-runtime-with-compiler.js文件中:

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el)

  /* istanbul ignore if */
  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
  // resolve template/el and convert to render function
  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) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }

      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns

      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  return mount.call(this, el, hydrating)
}
复制代码

这段代码首先缓存了原型上的$mount方法。首先,对el做了限制,不能把Vue挂载在html或body这样的跟节点上,然后如果没有定义render方法,则会把el或者template字符串转换成render方法,因为在Vue2.x版本中,所有的Vue组件的渲染最终都需要render方法,在代码的最后,有这么一行代码Vue.compile = compileToFunctions,compileToFunctions函数的作用,是把模版template编译成render函数。

template是如何编译成render function的?

Vue提供了两个版本,一个是Runtime+Compiler版本的,一个是Runtime only版本的。Runtime+Compiler是包含编译代码的,可以把编译过程放在运行时来做。而Runtime only是不包含编译代码的,所以需要借助webpack的vue-loader来把模版编译成render函数。

在实际开发当中,我们通常在组件中采用的是编写template模版。那template是如何编译的呢?来看一下编译的入口,定义在src/compiler/index.js中:

 export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  const ast = parse(template.trim(), options)
  if (options.optimize !== false) {
    optimize(ast, options)
  }
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})

复制代码

编译主要有三个过程:

1.解析模版字符串生成AST

  • AST(在计算机科学中,抽象语法树(abstract syntax tree或者缩写为AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。)
 const ast = parse(template.trim(), options)
复制代码

parse 会用正则等方式解析 template模板中的指令、class、style等数据,形成AST树。AST是一种用Javascript对象的形式来描述整个模版,整个parse的过程就是利用正则表达式来顺序地解析模版,当解析到开始标签,闭合标签,文本的时候会分别对应执行响应的回调函数,从而达到构造AST树的目的。

举个例子:

<div :class="c" class="demo" v-if="isShow">
    <span v-for="item in sz">{{item}}</span>
</div>
复制代码

经过一系列的正则解析,会得到的AST如下:

 {
    /* 标签属性的map,记录了标签上属性 */
    'attrsMap': {
        ':class': 'c',
        'class': 'demo',
        'v-if': 'isShow'
    },
    /* 解析得到的:class */
    'classBinding': 'c',
    /* 标签属性v-if */
    'if': 'isShow',
    /* v-if的条件 */
    'ifConditions': [
        {
            'exp': 'isShow'
        }
    ],
    /* 标签属性class */
    'staticClass': 'demo',
    /* 标签的tag */
    'tag': 'div',
    /* 子标签数组 */
    'children': [
        {
            'attrsMap': {
                'v-for': "item in sz"
            },
            /* for循环的参数 */
            'alias': "item",
            /* for循环的对象 */
            'for': 'sz',
            /* for循环是否已经被处理的标记位 */
            'forProcessed': true,
            'tag': 'span',
            'children': [
                {
                    /* 表达式,_s是一个转字符串的函数 */
                    'expression': '_s(item)',
                    'text': '{{item}}'
                }
            ]
        }
    ]
}
复制代码

当构造完AST之后,下面就是优化这颗AST树。

2.optimize:优化AST语法树

 optimize(ast, options)
复制代码

为什么此处会有优化过程?我们知道Vue是数据驱动,是响应式的,但是template模版中并不是所有的数据都是响应式的,也有许多数据是初始化渲染之后就不会有变化的,那么这部分数据对应的DOM也不会发生变化。后面有一个 update 更新界面的过程,在这当中会有一个 patch 的过程, diff 算法会直接跳过静态节点,从而减少了比较的过程,优化了 patch 的性能。

来看下optimize这部分代码的定义,在src/compiler/optimize.js中:

 export function optimize (root: ?ASTElement, options: CompilerOptions) {
  if (!root) return
  isStaticKey = genStaticKeysCached(options.staticKeys || '')
  isPlatformReservedTag = options.isReservedTag || no
  // first pass: mark all non-static nodes.
  markStatic(root)
  // second pass: mark static roots.
  markStaticRoots(root, false)
}
复制代码

我们可以看到,optimize实际上就做了2件事情,一个是调用markStatic()来标记静态节点,另一个是调用markStaticRoots()来标记静态根节点。

3.codegen:将优化后的AST树转换成可执行的代码。

 const code = generate(ast, options)
复制代码

template模版经历过parse->optimize->codegen三个过程之后,就可以d得到render function函数了。

最后

再看上面这张图,是不是有一个大概的脉络了呢。本文是我写的第一篇Vue.js源码学习的文章,可能会有许多的缺陷,希望在以后的学习探索慢慢改进了吧。

你可以关注我的公众号「慕晨同学」,鹅厂码农,平常记录一些鸡毛蒜皮的点滴,技术,生活,感悟,一起成长。