Vue框架源码:源码剖析-模板编译和组件化

100 阅读6分钟

模板编译的作用

  • Vue 2.x 使用 VNode 描述视图以及各种交互,用户自己编写 VNode 比较复杂
  • 用户只需要编写类似 HTML 的代码 - Vue.js模板,通过编译器将模板转换为返回 VNode 的 render 函数
  • .vue 文件会被 webpack 在构建的过程中转换成 render 函数

Vue2和Vue3的一些区别

在Vue2中,要去除无意义的空白内容,因为这些空白会被编译到render函数中。而Vue3自动去除了这些空白内容,所以不用手动去去除。

模板编译入口

入口是createCompileToFunctionFn这个函数

模板编译过程 baseCompile

什么是AST

在Babel中,也是会把代码转换成AST,在把AST转换成降级后的JS代码。

通过转换成AST,在Vue中,可以对节点标记static来判断是否是静态节点,从而优化性能。

模板编译的开端

模板的编译入口函数是定义在compiler/index.js文件,可分为这几步:

  • 传入的模板字符串进行parse,生成出语法树AST
  • 再进行optimize,优化AST,优化的过程其实就是在标记静态节点、静态根节点
  • 再将优化后的AST对象 generate 成字符串形式的JS代码
  • 最后再将字符串形式的JS代码,通过 new Function转换成匿名函数

这个匿名函数就是最终的render函数,模板编译就是把模板字符串转换成渲染函数

// `createCompilerCreator` allows creating compilers that use alternative
// parser/optimizer/codegen, e.g the SSR optimizing compiler.
// Here we just export a default compiler using the default parts.
// 此处又通过createCompilerCreator处理,传入了一个核心函数,再返回一个函数
export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 把模板转换成 ast 抽象语法树
  // 抽象语法树,用来以树形的方式描述代码结构
  const ast = parse(template.trim(), options)
  if (options.optimize !== false) {
    // 优化抽象语法树
    optimize(ast, options)
  }
  // 把抽象语法树生成字符串形式的 js 代码
  const code = generate(ast, options)
  return {
    ast,
    // 渲染函数
    render: code.render,
    // 静态渲染函数,生成静态 VNode 树
    staticRenderFns: code.staticRenderFns
  }
})

parse函数解析

parse函数接收两个参数:模板字符串、合并后的选项。返回的是解析好的AST对象

const ast = parse(template.trim(), options)

parse函数中做了几件事:解析options、对传入模板进行解析、返回解析好的AST对象。

该函数中核心函数是parseHTML

export function parseHTML (html, options) {
	...
  ...
}

该函数借鉴了一个开源库simplehtmlparser。该方法里,定义了很多正则表达式,作用是匹配HTML字符串模板中的内容。

// Regular Expressions for parsing tags and attributes
const attribute = /^\s*([^\s"'<>/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)[[^=]+?][^\s"'<>/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = `[a-zA-Z_][\-\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(/?)>/
const endTag = new RegExp(`^<\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
// #7298: escape - to avoid being passed as HTML comment when inlined in page
const comment = /^<!--/
const conditionalComment = /^<![/
  • attribute:匹配标签中的属性,包括vue指令

  • startTagOpen、startTagClose:匹配开始标签的

  • endTag:匹配结束标签

  • doctype:匹配文档声明

  • comment:匹配注释节点

在parseHTML函数中,通过while循环,进行如下判断,直到html全都处理完毕

// 判断是否是注释节点,如果是,则执行方法,截取剩余html内容
// Comment:
if (comment.test(html)) {
  const commentEnd = html.indexOf('-->')

  if (commentEnd >= 0) {
    if (options.shouldKeepComment) {
      options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3)
    }
    advance(commentEnd + 3)
    continue
  }
}

// 匹配是否是条件注释
// http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
if (conditionalComment.test(html)) {
  const conditionalEnd = html.indexOf(']>')

  if (conditionalEnd >= 0) {
    advance(conditionalEnd + 2)
    continue
  }
}

// 是否是文档声明
// Doctype:
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
  advance(doctypeMatch[0].length)
  continue
}

// 是否是结束标签
// End tag:
const endTagMatch = html.match(endTag)
if (endTagMatch) {
  const curIndex = index
  advance(endTagMatch[0].length)
  parseEndTag(endTagMatch[1], curIndex, index)
  continue
}

// 是否是开始标签
// Start tag:
const startTagMatch = parseStartTag()
if (startTagMatch) {
  handleStartTag(startTagMatch)
  if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
    advance(1)
  }
  continue
}

通过该函数来更新处理的最新索引,及文档剩余内容:

function advance (n) {
  index += n
  html = html.substring(n)
}

在处理开始标签的内容中,有个handleStartTag方法,在该方法中,做了很多判断处理,还会处理标签中的属性。最终调用了外界传进来的start方法,

function handleStartTag (match) {
  ...
  if (options.start) {
    // 传入标签名、属性、是否为自闭合标签、起始位置
    options.start(tagName, attrs, unary, match.start, match.end)
  }
  ...
}

这里来看传入的start方法

start (tag, attrs, unary, start, end) {
  ...
  // 调用了createASTElement方法,就是在这创建的AST对象
  let element: ASTElement = createASTElement(tag, attrs, currentParent)
  ...
}
// 抽象语法树,就是一个对象而已
  export function createASTElement (
  tag: string,
   attrs: Array<ASTAttr>,
   parent: ASTElement | void
  ): ASTElement {
    return {
      type: 1,
      tag,
      attrsList: attrs,
      attrsMap: makeAttrsMap(attrs),
      rawAttrsMap: {},
      parent,
      children: []
    }
  }

在start方法中,处理Vue指令

if (!inVPre) {
  processPre(element)
  if (element.pre) {
    inVPre = true
  }
}
if (platformIsPreTag(element.tag)) {
  inPre = true
}
if (inVPre) {
  processRawAttrs(element)
} else if (!element.processed) {
  // structural directives
  processFor(element)
  processIf(element)
  processOnce(element)
}

AST优化 - optimize

这里注释说明,优化器的目的是:遍历生成的模板AST树并检测纯静态的子树节点,即DOM中不需要更改的部分。一旦我们检测到这些子树,我们可以:

  1. 将它们提升为常量,这样我们就不再需要在每次重新渲染时为它们创建新节点;
  2. 在修补(patch)过程中完全跳过它们。

什么是静态节点:对应的DOM子树永远不会发生变化,比如纯文本内容。

/**
 * Goal of the optimizer: walk the generated template AST tree
 * and detect sub-trees that are purely static, i.e. parts of
 * the DOM that never needs to change.
 *
 * Once we detect these sub-trees, we can:
 *
 * 1. Hoist them into constants, so that we no longer need to
 *    create fresh nodes for them on each re-render;
 * 2. Completely skip them in the patching process.
 */
export function optimize (root: ?ASTElement, options: CompilerOptions) {
  // 是否传入root
  if (!root) return
  isStaticKey = genStaticKeysCached(options.staticKeys || '')
  isPlatformReservedTag = options.isReservedTag || no
  // first pass: mark all non-static nodes.
  // 标记root中所有静态节点
  markStatic(root)
  // second pass: mark static roots.
  // 标记root中所有静态根节点
  markStaticRoots(root, false)
}

标记静态节点的方法

function markStatic (node: ASTNode) {
  node.static = isStatic(node)

  // type为1,则是元素节点,则会去遍历它的子元素节点
  if (node.type === 1) {
    // do not make component slot content static. this avoids
    // 1. components not able to mutate slot nodes
    // 2. static slot content fails for hot-reloading
    // 这里判断了是否为保留标签,目的是判断是否为组件。如果是组件,则不把组件中的slot标记为静态节点
    // 如果组件中的slot被标记为静态节点,那么将来就没法改变
    if (
      !isPlatformReservedTag(node.tag) &&
      node.tag !== 'slot' &&
      node.attrsMap['inline-template'] == null
    ) {
      return
    }
    // 遍历children
    for (let i = 0, l = node.children.length; i < l; i++) {
      const child = node.children[i]
      // 标记静态
      markStatic(child)
      if (!child.static) {
        // 如果有一个 child 不是 static,那么当前 node 就不是 static
        node.static = false
      }
    }
    // 处理条件渲染中的AST对象
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        const block = node.ifConditions[i].block
        markStatic(block)
        if (!block.static) {
          node.static = false
        }
      }
    }
  }
}

判断是否为静态节点

function isStatic (node: ASTNode): boolean {
  // 类型为2,是表达式。例如插值表达式,它的内容会发生变化
  if (node.type === 2) { // expression
    return false
  }
  // 静态文本内容
  if (node.type === 3) { // text
    return true
  }
  // 如果以下条件都满足,则是一个静态节点
  return !!(node.pre || (
    !node.hasBindings && // no dynamic bindings
    !node.if && !node.for && // not v-if or v-for or v-else
    !isBuiltInTag(node.tag) && // not a built-in 不能是内置组件
    // 是平台保留的标签
    isPlatformReservedTag(node.tag) && // not a component 不能是组件
    !isDirectChildOfTemplateFor(node) && // 不能是v-for下的直接子节点
    Object.keys(node).every(isStaticKey)
  ))
}

标记静态根节点的方法

function markStaticRoots(node: ASTNode, isInFor: boolean) {
  // 判断是否为元素类型
  if (node.type === 1) {
    // 判断是否为静态的,或者只渲染一次
    if (node.static || node.once) {
      // 来标记该节点在for循环中,是否是静态的
      node.staticInFor = isInFor;
    }
    // For a node to qualify as a static root, it should have children that
    // are not just static text. Otherwise the cost of hoisting out will
    // outweigh the benefits and it's better off to just always render it fresh.
    // 如果一个元素内只有文本节点,此时这个元素不是静态的Root
    // Vue 认为这种优化会带来负面的影响
    if (
      node.static &&
      node.children.length &&
      !(node.children.length === 1 && node.children[0].type === 3)
    ) {
      node.staticRoot = true;
      return;
    } else {
      node.staticRoot = false;
    }
    // 检测当前节点的子节点中是否有静态的Root
    if (node.children) {
      for (let i = 0, l = node.children.length; i < l; i++) {
        markStaticRoots(node.children[i], isInFor || !!node.for);
      }
    }
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        markStaticRoots(node.ifConditions[i].block, isInFor);
      }
    }
  }
}

generate-生成字符串形式js代码

该函数接收优化好的AST对象,以及额外配置对象。

// 把抽象语法树生成字符串形式的 js 代码
 const code = generate(ast, options);

下面是generate源代码,最核心的是genElement这个方法,它是最终将AST对象转换为代码的方法。这里先生成一个状态对象,然后再判断有无AST来调用genElement方法。

export function generate (
ast: ASTElement | void,
 options: CompilerOptions
): CodegenResult {
  const state = new CodegenState(options)
  // fix #11483, Root level <script> tags should not be rendered.
  const code = ast ? (ast.tag === 'script' ? 'null' : genElement(ast, state)) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

上面代码中返回的render代表的是,根据AST对象生成的,定义vNode的JS代码的字符串形式,其形式如下所示,被包裹在wtih函数中。这就是由模板解析成AST后,在生成的JS代码,用于去生成对应的页面样式。

"with(this){return _c('div',
{attrs:{"id":"app"}},
[_m(0),_v(" "),_c('div',[_v(_s(msg)),_c('p',[_v("hello")])]),_v(" "),_c('div',[_v("是否显示")])]
)}"

CodegenState源代码,它作用是生成代码生成过程中所使用到的状态对象。

export class CodegenState {
  options: CompilerOptions;
  warn: Function;
  transforms: Array<TransformFunction>;
  dataGenFns: Array<DataGenFunction>;
  directives: { [key: string]: DirectiveFunction };
  maybeComponent: (el: ASTElement) => boolean;
  onceId: number;
  staticRenderFns: Array<string>;
  pre: boolean;

  constructor (options: CompilerOptions) {
    this.options = options
    this.warn = options.warn || baseWarn
    this.transforms = pluckModuleFunction(options.modules, 'transformCode')
    this.dataGenFns = pluckModuleFunction(options.modules, 'genData')
    this.directives = extend(extend({}, baseDirectives), options.directives)
    const isReservedTag = options.isReservedTag || no
    this.maybeComponent = (el: ASTElement) => !!el.component || !isReservedTag(el.tag)
    this.onceId = 0
    // 这个属性用于存储静态根节点生成的代码
    this.staticRenderFns = []
    // 这个属性记录,当前处理的节点是否使用v-pre标记的
    this.pre = false
  }
}

最后通过new Function方法,将字符串转为函数:

function createFunction(code, errors) {
  try {
    return new Function(code);
  } catch (err) {
    errors.push({ err, code });
    return noop;
  }
}

模板编译过程-总结

模板编译,先是将模板字符串解析成AST,它有些类似于VNode的结构(AST更多的是编译时生成的中间代码(包括runtime compile),而VNode则是存在于运行时的一种DOM节点及其关系的抽象)。

然后对AST优化后,在转换成JS代码,这JS代码有个重点是_c也就是createElement方法,它返回VNode。这个Vnode最终是在patch方法中会被平台的DOM操作方法为,挂载为真实DOM。

Vue组件化

  • 一个Vue组件就是一个拥有预定义选项的一个Vue实例。
  • 一个组件可以组成页面上一个功能完备的区域,组件可以包含脚本、样式、模板。

组件化机制

  • 组件化可以让我们方便的把页面拆分成多个可重用的组件
  • 组件是独立的,系统内可重用,组件之间可以嵌套
  • 有了组件可以像搭积木一样开发网页
  • 下面我们将从源码的角度来分析 Vue 组件内部如何工作
    • 组件实例的创建过程是从上而下
    • 组件实例的挂载过程是从下而上

Vue.extend源码

它的源码整体来看,就是返回一个组件的构造函数,将传给方法的options和Vue的options合并起来,并且该构造函数继承了Vue的原型,构造函数确定为了执行_init方法的一个自定义函数,而这个函数执行时就会初始化创建整个组件。

Vue.extend中将Vue实例的所有静态、原型方法都继承了下来,并且在传入的选项中定义了一个缓存属性,将该构造函数缓存了下来。

  /**
   * Class inheritance
   */
  Vue.extend = function (extendOptions: Object): Function {
    extendOptions = extendOptions || {}
    const Super = this
    const SuperId = Super.cid
    // 从缓存中加载组件的构造函数
    const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
    if (cachedCtors[SuperId]) {
      return cachedCtors[SuperId]
    }

    const name = extendOptions.name || Super.options.name
    if (process.env.NODE_ENV !== 'production' && name) {
      // 如果是开发环境验证组件的名称
      validateComponentName(name)
    }

    const Sub = function VueComponent (options) {
      this._init(options)
    }
    // 继承Vue构造函数的原型,原型继承自Vue
    Sub.prototype = Object.create(Super.prototype)
    Sub.prototype.constructor = Sub
    Sub.cid = cid++
    // 合并 options
    Sub.options = mergeOptions(
      Super.options,
      extendOptions
    )
    Sub['super'] = Super

    // For props and computed properties, we define the proxy getters on
    // the Vue instances at extension time, on the extended prototype. This
    // avoids Object.defineProperty calls for each instance created.
    // 初始化所有的基本功能和属性
    if (Sub.options.props) {
      initProps(Sub)
    }
    if (Sub.options.computed) {
      initComputed(Sub)
    }

    // allow further extension/mixin/plugin usage
    Sub.extend = Super.extend
    Sub.mixin = Super.mixin
    Sub.use = Super.use

    // create asset registers, so extended classes
    // can have their private assets too.
    ASSET_TYPES.forEach(function (type) {
      Sub[type] = Super[type]
    })
    // enable recursive self-lookup
    // 把组件构造构造函数保存到 Ctor.options.components.comp = Ctor,在当前组件选项中记录自己
    if (name) {
      Sub.options.components[name] = Sub
    }

    // keep a reference to the super options at extension time.
    // later at instantiation we can check if Super's options have
    // been updated.
    Sub.superOptions = Super.options
    Sub.extendOptions = extendOptions
    Sub.sealedOptions = extend({}, Sub.options)

    // cache constructor
    // 把组件的构造函数缓存到 options._Ctor
    cachedCtors[SuperId] = Sub
    // 返回改造后的Vue构造函数
    return Sub
  }
}

总体是基于传入的选项对象,创建了组件的构造函数,组件的构造函数继承了Vue的原型,所以组件对象拥有和Vue实例一样的构造成员。

调试组件注册过程

这里是注册过程的相关代码:

export function initAssetRegisters (Vue: GlobalAPI) {
  /**
   * Create asset registration methods.
   */
  ASSET_TYPES.forEach(type => {
    Vue[type] = function (
      id: string,
      definition: Function | Object
    ): Function | Object | void {
      if (!definition) {
        return this.options[type + 's'][id]
      } else {
        /* istanbul ignore if */
        if (process.env.NODE_ENV !== 'production' && type === 'component') {
          validateComponentName(id)
        }
        if (type === 'component' && isPlainObject(definition)) {
          definition.name = definition.name || id
          // 组件注册最终是调用了extend方法来生成组件的构造函数
          definition = this.options._base.extend(definition)
        }
        if (type === 'directive' && typeof definition === 'function') {
          definition = { bind: definition, update: definition }
        }
        // 对于component,会在此处进行全局注册
        this.options[type + 's'][id] = definition
        return definition
      }
    }
  })
}

组件的创建过程

回顾首次渲染过程:

Vue的自定义组件创建是在src/core/vdom/create-element.js文件中,通过调用createComponent方法来创建VNode。

组件真正创建的位置是在组件钩子函数的init函数中:

init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
  if (
    vnode.componentInstance &&
    !vnode.componentInstance._isDestroyed &&
    vnode.data.keepAlive
  ) {
    // kept-alive components, treat as a patch
    const mountedNode: any = vnode // work around flow
    componentVNodeHooks.prepatch(mountedNode, mountedNode)
  } else {
    const child = vnode.componentInstance = createComponentInstanceForVnode(
      vnode,
      activeInstance
    )
    child.$mount(hydrating ? vnode.elm : undefined, hydrating)
  }
}

最终调用了该方法,调用组件的构造函数,并传入了options:

export function createComponentInstanceForVnode (
  // we know it's MountedComponentVNode but flow doesn't
  vnode: any,
  // activeInstance in lifecycle state
  parent: any
): Component {
  const options: InternalComponentOptions = {
    _isComponent: true,
    _parentVnode: vnode,
    parent
  }
  // check inline-template render functions
  const inlineTemplate = vnode.data.inlineTemplate
  if (isDef(inlineTemplate)) {
    options.render = inlineTemplate.render
    options.staticRenderFns = inlineTemplate.staticRenderFns
  }
  // 创建组件实例
  return new vnode.componentOptions.Ctor(options)
}

组件的patch过程

patch函数中会调用createElm方法,内部有专门处理组件的createComponent方法,有时间重新看一遍。