(四)小菜鸡的Vue源码阅读——Vue是如何编译解析模板的并挂载组件

0.前情提要

(一)小菜鸡的Vue源码阅读——new Vue()干了件啥,我们说入口文件是platforms/web/entry-runtime.js,但这其实是运行时的vue,其实vue整个还包含了compiler(编译部分),因为vue允许我们以模板字符串的形式来声明组件,但最终渲染还是得需要一个render渲染函数,vue编译器主要就是做了一个把模板字符串转换成render函数的功能。

在初始化过程中的调用$mount挂载组件实例

Vue.prototype._init = function (options) {
  ···
  // 初始化一堆东西
  // 挂载组件实例
  if (vm.$options.el) {
    vm.$mount(vm.$options.el);
  }
}
复制代码

流程图

1. vue编译器的入口文件

platforms/web/entry-runtime-with-compiler.js

重写vue.prototype.$mount,template有三种编写方式,针对这三种方式需要做一个统一的转换成模板字符串

  1. 字符串模板
var vm = new Vue({
  el: '#app',
  template: '<div>模板字符串</div>'
})
复制代码
  1. 选择符匹配元素的 innerHTML模板
<div id="app">
  <div>test1</div>
  <script type="x-template" id="test">
    <p>test</p>
  </script>
</div>
var vm = new Vue({
  el: '#app',
  template: '#test'
})
Copy
复制代码
  1. dom元素匹配元素的innerHTML模板
<div id="app">
  <div>test1</div>
  <span id="test"><div class="test2">test2</div></span>
</div>
var vm = new Vue({
  el: '#app',
  template: document.querySelector('#test')
})
复制代码

总结一下

  • 统一转换成模板字符串
  • 生成renderstaticRenderFns函数
  • 和运行时逻辑一样,调用原来的$mount函数挂载
// platforms/web/runtime/index.js
Vue.prototype.$mount = function (el?: string | Element,hydrating?: boolean): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

// platforms/web/entry-runtime-with-compiler.js
// 这里的$mount 其实在 runtime/index已经声明了,那是不需要编译情况的函数,先把他用变量保存下来
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (el?: string | Element,hydrating?: boolean): Component {
  el = el && query(el)

  // ...
  const options = this.$options
  
  if (!options.render) {
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        // 选择符匹配
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
          
          // ...
        }
      // DOM元素匹配
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        // ...
        return this
      }
    // 如果没传template就以el为根节点做模板
    } else if (el) {
      template = getOuterHTML(el)
    }
    if (template) {
      // ...
      
      // 生成render函数
      const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns
      // ...
    }
  }
  // 调用原来的逻辑
  return mount.call(this, el, hydrating)
}
Vue.compile = compileToFunctions
复制代码

2. render函数的生成

流程图

compileToFunctions

将模板字符串转换render函数

该函数有三个参数,分别是模板字符串,编译配置,vm实例

Vue.prototype.$mount = function () {
  ···
  if(!options.render) {
    var template = options.template;
    if (template) {
      var ref = compileToFunctions(template, {
          outputSourceRange: "development" !== 'production',
          shouldDecodeNewlines: shouldDecodeNewlines,
          shouldDecodeNewlinesForHref: shouldDecodeNewlinesForHref,
          // 纯文本插入分隔符
          delimiters: options.delimiters,
          // 是否保留模板中的注释
          comments: options.comments
        }, this);
        var render = ref.render;
    }
    ...
  }
}
复制代码

接下来我们要去找compileToFunctions这个函数是怎么来的

createCompiler

生成编译器

// platforms/web/compiler/index.js
const { compile, compileToFunctions } = createCompiler(baseOptions)

// 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)
  }
  // 修改过的ast再重新生成回代码
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})
复制代码

createCompilerCreator

生成编译器的创建器

createCompilerCreator函数只有一个作用,利用偏函数的思想将baseCompile这一基础的编译方法缓存,并返回一个编程器生成器.

// compiler/create-compiler.js
export function createCompilerCreator (baseCompile: Function): Function {
  return function createCompiler (baseOptions: CompilerOptions) {
    function compile (template: string,options?: CompilerOptions): CompiledResult {
      const finalOptions = Object.create(baseOptions)
      const errors = []
      const tips = []

      let warn = (msg, range, tip) => {
        (tip ? tips : errors).push(msg)
      }

      if (options) {
        // ...
        // merge custom modules
        if (options.modules) {
          finalOptions.modules =(baseOptions.modules || []).concat(options.modules)
        }
        // merge custom directives
        if (options.directives) {
          finalOptions.directives = extend(
            Object.create(baseOptions.directives || null),
            options.directives
          )
        }
        // 复制其他的属性
        for (const key in options) {
          if (key !== 'modules' && key !== 'directives') {
            finalOptions[key] = options[key]
          }
        }
      }

      finalOptions.warn = warn

      // 这里实际上是执行了真正的编译函数
      const compiled = baseCompile(template.trim(), finalOptions)
      // ...
      compiled.errors = errors
      compiled.tips = tips
      return compiled
    }

    return {
      compile,
      compileToFunctions: createCompileToFunctionFn(compile)
    }
  }
}
复制代码

createCompileToFunctionFn

createCompileToFunctionFn利用了闭包的概念,将编译过的模板进行缓存,cache会将之前编译过的结果保留下来,利用缓存可以避免重复编译引起的浪费性能。createCompileToFunctionFn最终会将compileToFunctions方法返回。

// compiler/to-function.js
 function createCompileToFunctionFn (compile) {
    var cache = Object.create(null);

    return function compileToFunctions (template,options,vm) {
      options = extend({}, options);
      ···
      // 缓存的作用:避免重复编译同个模板造成性能的浪费
      const key = options.delimiters
      ? String(options.delimiters) + template
      : template
      if (cache[key]) {
        return cache[key]
      }
      // 执行编译方法
      var compiled = compile(template, options);
      ···
      // turn code into functions
      var res = {};
      var fnGenErrors = [];
      // 编译出的函数体字符串作为参数传递给createFunction,返回最终的render函数
      res.render = createFunction(compiled.render, fnGenErrors);
      res.staticRenderFns = compiled.staticRenderFns.map(function (code) {
        return createFunction(code, fnGenErrors)
      });
      ···
      return (cache[key] = res)
    }
  }
  
 function createFunction (code, errors) {
      try {
        return new Function(code)
      } catch (err) {
        errors.push({ err, code })
        return noop
      }
}
复制代码

总结

createCompilerCreator(baseCompiler)(baseOptions).compileToFunctions(template,options,vm)
复制代码

这里的编译逻辑理解非常难受,笔者主要参考借鉴了实例挂载流程和模板编译 · 深入剖析Vue源码 (penblog.cn),这里偏函数的思想指的学习,但是编译最主要的部分还是createCompilerCreator函数中的parsegenerate函数,但是这里过于复杂,笔者这里就不继续深究了,浅尝辄止。

3. 从render函数到VDOM

无论是用template还是手写render函数,最终都已经经过上面的步骤变成了render渲染函数,现在我们要做的工作是将渲染函数转换成VDOM,在将VDOM转换成真实DOM并挂载到页面上,回到最开始的$mount

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

const mount = Vue.prototype.$mount 
Vue.prototype.$mount = function (el?: string | Element,hydrating?: boolean): Component {
    // ...
    return mount.call(this, el, hydrating)
}
复制代码

mountComponent

  • beforeMountmounted钩子函数的调用
  • 创建一个watcher实例,实例中更新的回调函数是updateComponent,后面继续看updateComponent这个函数的声明与实现
// core/instance/lifecycle.js
export function mountComponent (vm: Component,el: ?Element,hydrating?: boolean): Component {
  vm.$el = el
  // ...
  
  // 调用beforeMount钩子
  callHook(vm, 'beforeMount')

  updateComponent = () => {
      vm._update(vm._render(), hydrating)
  }
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  if (vm.$vnode == null) {
    vm._isMounted = true
    // 挂载完毕,调用mounted钩子
    callHook(vm, 'mounted')
  }
  return vm
}
复制代码

vm._render

回顾(一)小菜鸡的Vue源码阅读——new Vue()干了件啥renderMixin阶段声明了原型方法_render,该函数主要工作是将render函数转化为Virtual DOM.

export function renderMixin (Vue: Class<Component>) {
  // ...

  Vue.prototype._render = function (): VNode {
    // ...
    try {
      currentRenderingInstance = vm
      // 生成虚拟DOM
      vnode = render.call(vm._renderProxy, vm.$createElement)
    } catch (e) {
      // ...
      vnode = vm._vnode
    } finally {
      currentRenderingInstance = null
    }
    // ...
    return vnode
  }
}
复制代码

可以看出上面函数中的关键语句是vnode = render.call(vm._renderProxy, vm.$createElement),关于这个函数调用的两个参数做解释

vm.renderProxy

这个变量实际是在初始化实例的_init中添加的

Vue.prototype._init = function (options?: Object) {
 // ...
 if (process.env.NODE_ENV !== 'production') {
     // 开发环境下数据过滤检测
     initProxy(vm)
   } else {
     // 生产就是vue实例本身
     vm._renderProxy = vm
   }
 // ...
}
 
initProxy = function initProxy (vm) {
   if (hasProxy) {
     // determine which proxy handler to use
     const options = vm.$options
     const handlers = options.render && options.render._withStripped
       ? getHandler
       : hasHandler
     vm._renderProxy = new Proxy(vm, handlers)
   } else {
     vm._renderProxy = vm
   }
 }
复制代码

vm.$createElement

这个其实也是在初始化实例中_initinitRender中添加的$c$createElement只有最后一个参数的区别

  • $c: 内部调用,在通过模板字符串转换的render函数
  • $createElement: 自己手写render函数的时候作为参数传入
function initRender(vm) {
    // ...
   vm._c = function(a, b, c, d) { return createElement(vm, a, b, c, d, false); }
   vm.$createElement = function (a, b, c, d) { return createElement(vm, a, b, c, d, true); };
   // ...
}

// core/vdom/create-element.js
function createElement (
   context, // vm 实例
   tag, // 标签
   data, // 节点相关数据,属性
   children, // 子节点
   normalizationType,
   alwaysNormalize // 区分内部编译生成的render还是手写render
 ) {
   // 对传入参数做处理,如果没有data,则将第三个参数作为第四个参数使用,往上类推。
   if (Array.isArray(data) || isPrimitive(data)) {
     normalizationType = children;
     children = data;
     data = undefined;
   }
   // 根据是alwaysNormalize 区分是内部编译使用的,还是用户手写render使用的
   if (isTrue(alwaysNormalize)) {
     normalizationType = ALWAYS_NORMALIZE;
   }
   // 真正生成Vnode的方法
   return _createElement(context, tag, data, children, normalizationType) 
 }

复制代码

_createELement

对传入的数据做校验,这些校验保证后续VDOM的生成

  • 数据对象data不能是响应式数据
  • tagis做动态的时候需要做特殊处理
  • key值必须是原始数据类型
  • 原生DOM上不应该用native修饰符
  • ...还有一些不做全部解读,意义好像也不是很大

根据render是由用户输入还是由系统生成分成两个函数来转换children

  • normalizeChildren: 用户自己输入,对文字节点的合并,递归调用
  • simpleNormalizeChildren: 由系统生成,主要是做一个数组的一级扁平化
export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  // 1. 数据对象不能是定义在Vue data属性中的响应式数据。
  if (isDef(data) && isDef((data: any).__ob__)) {
    process.env.NODE_ENV !== 'production' && warn(
      `Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n` +
      'Always create fresh vnode data objects in each render!',
      context
    )
    return createEmptyVNode()
  }
  // 2.tag用is做动态的时候需要做特殊处理
  if (isDef(data) && isDef(data.is)) {
    tag = data.is
  }
  if (!tag) {
    return createEmptyVNode()
  }
  // 3.key 值只能是原始数据类型
  if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.key) && !isPrimitive(data.key)
  ) {
    if (!__WEEX__ || !('@binding' in data.key)) {
      warn(
        'Avoid using non-primitive value as key, ' +
        'use string/number value instead.',
        context
      )
    }
  }
  // children的第一个元素是函数类型是当做默认插槽
  if (Array.isArray(children) &&
    typeof children[0] === 'function'
  ) {
    data = data || {}
    data.scopedSlots = { default: children[0] }
    children.length = 0
  }
  
  // 这个地方就是之前说的,自己写的render和框架生成的render不一样的地方
  // 自己手写的数据结构可能会不一致,出现children是简单数据类型的情况,需要生成文本虚拟DOM
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }
  // ...
}

// core/vdom/helpers/normalize-children.js
// 处理编译生成的render 函数
export function simpleNormalizeChildren (children: any) {
  for (let i = 0; i < children.length; i++) {
    // 子节点为数组时,进行开平操作,压成一维数组。
    if (Array.isArray(children[i])) {
      return Array.prototype.concat.apply([], children)
    }
  }
  return children
}

// 处理用户定义的render函数
export function normalizeChildren (children: any): ?Array<VNode> {
  return isPrimitive(children)
    // 生成文字节点
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}

function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> {
  const res = []
  let i, c, lastIndex, last
  
  // 遍历子节点
  for (i = 0; i < children.length; i++) {
    c = children[i]
    if (isUndef(c) || typeof c === 'boolean') continue
    lastIndex = res.length - 1
    last = res[lastIndex]
   
    // 当前子节点是数组
    if (Array.isArray(c)) {
      if (c.length > 0) {
        // 对children递归
        c = normalizeArrayChildren(c, `${nestedIndex || ''}_${i}`)
        // 当前遍历的子节点的第一个子节点如果是文字节点,而且当前最后一个字节点也是文字节点的,就将他们两个合并,并且剔除子节点的第一个字节点
        if (isTextNode(c[0]) && isTextNode(last)) {
          res[lastIndex] = createTextVNode(last.text + (c[0]: any).text)
          c.shift()
        }
        res.push.apply(res, c)
      }
    // 当前子节点是简单数据类型
    } else if (isPrimitive(c)) {
      // 继续合并
      if (isTextNode(last)) {
        // 最后一个是文字节点就去和最后一个合并
        res[lastIndex] = createTextVNode(last.text + c)
      } else if (c !== '') {
        // 不是文字节点,那就把当前的文字创建一个文字节点,插到最后面去
        res.push(createTextVNode(c))
      }
    } else {
      // 当前遍历的就是一个文字节点,且最后也是文字节点,那么合并
      if (isTextNode(c) && isTextNode(last)) {
        res[lastIndex] = createTextVNode(last.text + c.text)
      } else {
        // key值不存在的时候,且是列表的时候加key值
        if (isTrue(children._isVList) &&
          isDef(c.tag) &&
          isUndef(c.key) &&
          isDef(nestedIndex)) {
          c.key = `__vlist${nestedIndex}_${i}__`
        }
        res.push(c)
      }
    }
  }
  return res
}
复制代码

4. 从VDOM映射到真实DOM

updateComponent = function () {
    // render生成虚拟DOM,update渲染真实DOM
    vm._update(vm._render(), hydrating);
};
复制代码

vm._update

改方法在lifecycleMixin()的时候被添加

function lifecycleMixin() {
    Vue.prototype._update = function (vnode, hydrating) {
        var vm = this;
        var prevEl = vm.$el;
        // prevVnode为旧vnode节点
        var prevVnode = vm._vnode; 
        // 通过是否有旧节点判断是初次渲染还是数据更新
        // 初次渲染
        if (!prevVnode) {
            vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false)
        } else {
            // 数据更新
            vm.$el = vm.__patch__(prevVnode, vnode);
        }
}
复制代码

__patch__

  • reatePatchFunction方法传递一个对象作为参数,对象拥有两个属性,nodeOpsmodulesnodeOps封装了一系列操作原生DOM对象的方法。而modules定义了模块的钩子函
  • createPatchFunction函数有一千多行这里就不列出来了,说一下大概干了件什么事它的内部首先定义了一系列辅助的方法,而核心是通过调用createElm方法进行dom操作,创建节点,插入子节点,递归创建一个完整的DOM树并插入到Body中。并且在产生真实阶段阶段,会有diff算法来判断前后Vnode的差异,以求最小化改变真实阶段。后面会有一个章节的内容去讲解diff算法。createPatchFunction的过程只需要先记住一些结论,函数内部会调用封装好的DOM api,根据Virtual DOM的结果去生成真实的节点。其中如果遇到组件Vnode时,会递归调用子组件的挂载过程

数。

// platforms/web/runtime/index.js
Vue.prototype.__patch__ = inBrowser ? patch : noop

// platforms/web/runtime/patch.js
export const patch: Function = createPatchFunction({ nodeOps, modules })

// 将操作dom对象的方法合集做冻结操作
 var nodeOps = /*#__PURE__*/Object.freeze({
    createElement: createElement$1,
    createElementNS: createElementNS,
    createTextNode: createTextNode,
    createComment: createComment,
    insertBefore: insertBefore,
    removeChild: removeChild,
    appendChild: appendChild,
    parentNode: parentNode,
    nextSibling: nextSibling,
    tagName: tagName,
    setTextContent: setTextContent,
    setStyleScope: setStyleScope
  });

// 定义了模块的钩子函数
  var platformModules = [
    attrs,
    klass,
    events,
    domProps,
    style,
    transition
  ];

var modules = platformModules.concat(baseModules);
复制代码
复制代码

Guess you like

Origin juejin.im/post/7034853167347007496