编译

63 阅读1分钟

实例化包含子组件的实例化和直接使用Vue构造函数两种方式创建实例,子组件的实例化过程中使用了父节点传递过来的值,这一节就是了解在编译过程中,如何解析这些值,又存储在什么地方。

目标

如何解析标签上的属性,存储在什么地方。以及如何维持父子关系。

获取编译器

src/platforms/web/entry-runtime-with-compiler.js中重写了$mounted方法,其中通过compileToFunctions函数获取render函数。

comoleToFunctionscreateCompiler方法的返回值,而createCoplier又是createCompilerCreator方法的返回值。

由于代码比较多,部分使用注释的方式

// src/platforms/web/compiler/index.js
const { compile, compileToFunctions } = createCompiler(baseOptions)
​
// 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)
  }
// 生成render函数
  const code = generate(ast, options)
  return {
      // 抽象语法树
    ast,
      // render方法,字符串形式
    render: code.render,
      // 静态render方法,是一个数组
    staticRenderFns: code.staticRenderFns
  }
})
​
// src/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)
      // 如果有options传入,合并baseOptions到finalOptions
      ...
      // 调用baseCompile,并将返回值保存到compiled
      ...
      const compiled = baseCompile(template.trim(), finalOptions)
      // 记录编译中的错误信息到compiled中
      ...
      return compiled
    }
    return {
      compile,
      compileToFunctions: createCompileToFunctionFn(compile)
    }
  }
}

这里有点绕,其实就三个需要关注的点,baseCompileoptionscreateCompiler中的compilecompile接受一个template模板和options,使用createCompilerCreator传入的baseCompile生成渲染函数。

options

options的来源其实有两个,一个是创建编译器时传入的默认配置createCompiler(baseOptions),一个是使用时,根据不同平台传入的optionsweb环境传入的值

      const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
          // 是否处理标签属性值中的换行符
        shouldDecodeNewlines,
          // 是否处理a标签href中的换行符
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)

最后在compile两个配置合并后,提供给baseCompile使用

baseCompile

执行baseCompile返回一个对象,包含抽象语法树astrender函数(字符串形式,最后通过compileToFunctions转化成真正的方法)和静态节点的渲染函数staticRenderFns

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

小结

到这里,相信已经晕头转向了。其实核心就三个baseCompile,options,compile,因为Vue支持的平台不仅仅是web还有serverweex。通过分成多个方法,利用闭包来固定参数,通过不同的编译器baseCompile和配置options来创建适应不同平台的解析器compile。而options多是用来处理不同平台的方法,如shouldDecodeNewlinesForHref就是用来处理web平台a标签中href的换行符,而其他平台可能就没有这个问题。

解析

解析过程中。需要关注ast如何生成的,生成的是什么东西。以及render函数是如何生成的。注意我们的目标,所以这里并不详细的介绍各个方法和函数。

但是可以告诉大家,解析的过程就是创建ast对象,通过不同的正则表达式来解析不同的内容,如解析了一个<div>标签

parse-result.png

通过不同的属性来记录一个标签的名字,属性,父节点,子节点。tag记录标签名,attrs记录属性,parent记录父节点...

parse

先来看解析模板生成抽象语法树。在baseCompile中就是调用parse方法生成抽象语法树ast,下面的代码保留了重要的代码。

export function parse (
  template: string,
  options: CompilerOptions
): ASTElement | void {
  ...
  const stack = []
  const preserveWhitespace = options.preserveWhitespace !== false
  const whitespaceOption = options.whitespace
  let root
  let currentParent
  let inVPre = false
  let inPre = false
  let warned = false
  function closeElement (element) {}
// 解析模板
  parseHTML(template, {
    warn,
    expectHTML: options.expectHTML,
    isUnaryTag: options.isUnaryTag,
    canBeLeftOpenTag: options.canBeLeftOpenTag,
    shouldDecodeNewlines: options.shouldDecodeNewlines,
    shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
    shouldKeepComment: options.comments,
    outputSourceRange: options.outputSourceRange,
      // 解析开始标签
    start (tag, attrs, unary, start, end) {},
    /**
     * 普通标签调用
     * 保证正确的层级和stack
     */
    end (tag, start, end) {},
      // 解析文本节点
    chars (text: string, start: number, end: number) {},
      // 解析注释节点
    comment (text: string, start, end) {}
  })
  return root
}

parse的核心是调用parseHTML,通过不同的钩子函数处理不同的类容,存储在root中。这里我们只关注startend这两个钩子函数和stack数组。

parseHTML

虽然不打算过于详细的解析编译这块代码。但还是想提供一个大概的流程。

// src/compiler/parser/html-parser.js
export function parseHTML(html, options) {
  const stack = []
  const expectHTML = options.expectHTML
  // 是否一元标签
  const isUnaryTag = options.isUnaryTag || no
  // 是否可省略闭合标签
  const canBeLeftOpenTag = options.canBeLeftOpenTag || no
  let index = 0
  let last, lastTag
  while (html) {
    last = html
    if (!lastTag || !isPlainTextElement(lastTag)) {
      let textEnd = html.indexOf('<')
      if (textEnd === 0) {
        // Comment:
        if (comment.test(html)) {}
        if (conditionalComment.test(html)) {}
        // Doctype:
        const doctypeMatch = html.match(doctype)
        if (doctypeMatch) {}
        // End tag:
        const endTagMatch = html.match(endTag)
        if (endTagMatch) {}
        // Start tag:
        /**
         * 如果匹配到开始标签则返回
         * {
         *   tagName,
         *   attrs,
         *   start,
         *   end?, 结束位置
         *   unarySlash?, 是否为一元标签
         * }
         * @type {{start: number, tagName: *, attrs: []}}
         */
        const startTagMatch = parseStartTag()
        if (startTagMatch) {}
      }
      let text, rest, next
      if (textEnd >= 0) {}
      // 如果不包含'<'作为纯字符串处理
      if (textEnd < 0) {}
      if (text) {}
      if (options.chars && text) {}
    } else {
      // 如果在纯文本标签内的字符串,script,style,textarea
    }
    // 如果html===last则说明,没有处理html
    // 则所有都没有匹配上,说明这是一段纯字符
    if (html === last) {}
  }
  // Clean up any remaining tags
    // 清空stacks
  parseEndTag()
  // 将html截取到n个字符后,并更新index
  function advance(n) {}
  // 解析开始标签
  function parseStartTag() {}
    // 处理开始标签
  function handleStartTag(match) {}
    // 解析结束标签
  function parseEndTag(tagName, start, end) {}
}

整体流程

  1. 如果存在lastTag并且是纯文本标签如scriptstyletextarea,则当作纯文本处理
  2. 否则lastTag是非文本标签或者刚开始解析
  3. 如果以上都没有处理模板字符串last === html,则说明是没有标签的纯文本

标签解析

  1. textEnd === 0,可能是一个标签,到底是不是还需要继续判断

    a. 注释。如果是并且配置中需要保存注释,则调用comment钩子函数。
    
    b. 条件注释。调用advance,截取html获取剩余字符串
    
    c. 文档类型标签Doctype,同上
    
    d. 结束标签,先截取html,再调用parseEndTag(调用end)关闭标签
    
    e. 开始标签,调用parseStartTag解析开始标签和属性。再调用handleStartTag(调用start)生成ast节点
    
  2. textEnd >= 0,则说明存在<,在<之前一定是文本,之后的还有没有标签还需要判断

    a. 使用html.slice(textEnd)获取剩余字符串。

    b. 判断剩余字符串是不是,找到下一个startTagendTagcommentconditionCommont,

    那么在这之前,都是文本节点,调用chars转化为文本ast

  3. 如果不存在<,则说明整段都是文本节点

父子关系

在解析开始和结束标签,有一段逻辑是用来处理父子关系的。在处理开始标签的handleStartTag函数中,如果不是一元标签就会被保存到stack的栈顶。parseHtml中的stack和parse中的stack作用类似,但保存的是ast节点。

// src/compiler/parser/html-parser.js  
function handleStartTag(match) {
    ...
    // 如果不是一元标签,推入stack,并更新lastTag
    if (!unary) {
      stack.push({tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end})
      lastTag = tagName
    }
    ...
  }

在处理结束标签时,会先找到stack对应的标签,正常结束的标签,栈顶一定是对应的标签,也就是pos===stack.length,如果不是那么在对应的位置pos那么在pos + 1stack.length都不是正常关闭的标签,这个时候就会调用end钩子函数关闭所有非正常结束的标签和pos位置上的标签。

//src/compiler/parser/html-parser.js 
function parseEndTag(tagName, start, end) {
    let pos, lowerCasedTagName
    if (start == null) start = index
    if (end == null) end = index
    // Find the closest opened tag of the same type
    if (tagName) {
      lowerCasedTagName = tagName.toLowerCase()
      // 将转为小写的tagName与stack中的tag对比,找到对应的标签
      for (pos = stack.length - 1; pos >= 0; pos--) {
        if (stack[pos].lowerCasedTag === lowerCasedTagName) {
          break
        }
      }
    } else {
      // If no tag name is provided, clean shop
      pos = 0
    }
    if (pos >= 0) {
      // Close all the open elements, up the stack
      // 如果是正确闭合的标签,那么stack最顶上的标签一定等于结束标签
      // 如果不等于,则到pos位置的,不含pos的标签都没有结束标签
      for (let i = stack.length - 1; i >= pos; i--) {
        if (options.end) {
          options.end(stack[i].tag, start, end)
        }
      }
      // Remove the open elements from the stack
      // 如果不正确,删除不正确的标签
      // 如果正确删除栈顶标签
      stack.length = pos
      lastTag = pos && stack[pos - 1].tag
    }
     ...
  }

另外,如果不传入tagName那么这个函数的作用就是清空stack。

start钩子函数在解析开始标签时,把对应的ast推到栈顶,在end钩子函数中更新父节点,并将子节点保存在父节点的children属性中。在parseHtml也提到过,正常结束的标签栈顶的元素一定是父节点。

    start (tag, attrs, unary, start, end) {
      let element: ASTElement = createASTElement(tag, attrs, currentParent)
      if (!unary) {
        currentParent = element
        stack.push(element)
      }
    },
    /**
     * 普通标签调用
     * 保证正确的层级和stack
     * @param tag
     * @param start
     * @param end
     */
    end (tag, start, end) {
      currentParent = stack[stack.length - 1]
        // closeElement中的代码
      if (currentParent && !element.forbidden) {
          if (element.elseif || element.else) }
          else {
            currentParent.children.push(element)
            element.parent = currentParent
          }
      }
    }

属性解析

属性的解析,因为没有对正则表达式的解析,这里通过在浏览器直接打印结果来说明。这里写一个div标签,再添加一些属性。

<div name="parent">
    <div v-for="item in list" :key="item.key" @click="click">123</div>
</div>

结果:

attrs.png

可以看到所有的属性都被解析成两部分,等号之前的作为name,等号之后的作为value。另外需要注意,所有的属性最开始都是放到attrsList中的,在创建ast节点时,会转化为key-value形式的attrsMap。可以发现,attrsList并不全,只用@click属性,而attrsMap则保留了所有的属性。

原因就是在startend钩子函数时,执行了一系列的process*函数,有些属性在执行后,会被删除。以下是我整理会从attrsList删除属性的process*函数和属性

  1. processPre 处理 v-pre
  2. processFor 处理v-for
  3. processIf 处理v-if
  4. processOnce 处理 v-once
  5. processSlotContent 处理v-slot指令

key,slot标签的name,is,ref,也会从attrsList移除

processAttrs中会把attrsList中的属性添加到astattrsprops属性中,绝大部分都是添加到attrs,只有部分原生html标签必须的标签,才会添加到props中,所以这里的props只有原生标签的属性。使用@或者v-on添加到eventsnativeEvents属性中,使用.native修饰符的绑定的方法会被放到nativeEvents

比如video标签的muted就被添加到props中,如下

export const mustUseProp = (tag: string, type: ?string, attr: string): boolean => {
  return (
    (attr === 'value' && acceptValue(tag)) && type !== 'button' ||
    (attr === 'selected' && tag === 'option') ||
    (attr === 'checked' && tag === 'input') ||
    (attr === 'muted' && tag === 'video')
  )
}

生成render函数

使用过render函数的朋友应该熟悉如下代码,就是将上面的例子用render函数方式。

render: function (createElement) {
  return createElement('div', {
      attrs: {
          name: 'parent'
      },
  },
  this.list.map(item => {
      return createElement('div', {
          key: item.key,
          events: {
              click: this.click
          }
      })
  })
  )
}

生成render函数的过程就是将ast节点转化为类似上述这种的形式的字符串,最后通过new Function的方式转化为真正的方法。我们这里不分析具体怎么生成,只关心对属性和方法做了什么处理。

export function genData (el: ASTElement, state: CodegenState): string {
  let data = '{'
  // attributes
  if (el.attrs) {
    data += `attrs:${genProps(el.attrs)},`
  }
  // DOM props
  if (el.props) {
    data += `domProps:${genProps(el.props)},`
  }
  // event handlers
  if (el.events) {
    data += `${genHandlers(el.events, false)},`
  }
  if (el.nativeEvents) {
    data += `${genHandlers(el.nativeEvents, true)},`
  }
  return data
}

genData的方法就是处理所有的属性,不论是方法还是属性,通过不同的方法将这些属性转化为createElement(编译生成的render使用的_c,)的第二个参数。