vue2: 源码学习-template解析

记录一下小白摸索学习的过程,不对之处,欢迎各位大佬指正

前言

vue 源码项目 package.json scripts/config.js 中,会根据执行命令传入的 TAGET 参数读取相关的配置,最后打包出不同的产物

按照官网的说明,在html中可以按照以下方式引入vue

html 复制代码
<!-- 开发环境版本,包含了有帮助的命令行警告 -->  
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>

使用这种方式时,使用的是打包后的 vue.js 包,在scripts/config.js 中可以找到,vue.js包的入口文件是 web/entry-runtime-with-compiler.js,这个文件就是阅读的起点了

vue 通过 $mount 将实例挂载到 DOM 上。$mount 其实执行的是 src/core/instance/lifecycle.js 中的 mountComponent 方法,在 mountComponent 中,当 vue 实例的数据发生变化时,会触发更新组件的方法--开始执行 vue 实例的 _update_render 方法, render 生成 VNode, update比较新旧节点差异,最后生成真实的 DOM,完成组件更新

而模板解析就是将 template 转成 AST, 然后将 AST 转成 render 函数的过程。

其中将template 转成 AST 的方法在 src/compiler/index.js 中的 parse

AST 转成 render 函数的方法在 src/compiler/index.js 中的 generate

通过 parse 生成 AST 之后,还执行了一个 optimize 方法,其作用是遍历 AST,找出其中的静态子树(指那些一旦被渲染,就永远不会改变的部分,比如纯文本节点)并标记它们,在组件更新时,可以跳过这些静态内容,提高渲染性能

AST

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <script src="../dist/vue.js"></script>
  </head>

  <body>
    <div id="app"></div>

    <script type="text/javascript" charset="utf-8">
      const component = {
        template: '<div>component text</div>'
      };
      const app = new Vue({
        el: '#app',
        data: {
          message: 'Hello Vue!'
        },
        template: `<div style="color: red;">
          <p>这是一段文本内容</p>
          {{ message }}
          <!-- 这是一段注释 -->
          <my-component text="propText"></my-component>
          <textarea></textarea>
          <button @click="clickButton">按钮</button>
        </div>`,
        components: {
          'my-component': component
        },
        methods: {
          clickButton() {
            console.log('click button');
          }
        }
      })
    </script>
  </body>
</html>

使用上述示例代码经过 parse 方法解析出来的 AST 结构长这样

来看看 prase 中的核心方法 parseHTML 是怎么把 template 转换成上述 AST

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
    // lastTag:string,上一个未闭合的标签name
    // isPlainTextElement(lastTag): lastTag 是否 <script>、<style>、<textarea> 元素
    if (!lastTag || !isPlainTextElement(lastTag)) {
      let textEnd = html.indexOf('<')
      if (textEnd === 0) {
        // 检测是否是一段 html 注释
        if (comment.test(html)) {
          const commentEnd = html.indexOf('-->')
          
          // 找到了注释结束下标位置
          if (commentEnd >= 0) {
              // shouldKeepComment:是否保留注释
            if (options.shouldKeepComment) {
              options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3)
            }
            // 注释结束下标位置开始截取 html: 
            // function advance (n) {
            //  index += n
            //  html = html.substring(n)
            // }
            advance(commentEnd + 3)
            continue
          }
        }

        // 处理条件注释(只在IE中生效),如:<!--[if condition]> HTML <![endif]-->
        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
        }

        // 处理结束标签
        const endTagMatch = html.match(endTag)
        // 如果通过正则表达式匹配到结束标签
        if (endTagMatch) {
          const curIndex = index
          advance(endTagMatch[0].length)
          // 1. stack 中维护未闭合的标签
          // 2. 找到stack中与当前 tag 相等的标签,记录 pos=stack中匹配标签的下标
          //    2.1 如果在stack中找到匹配的标签, 即pos>=0时;
          //        2.1.1 如果pos值小于等于stack.length - 1,说明存在未闭合的标签,打印提示语
          //        2.1.2 否则stack 最后一个标签出栈,lastTag = stack[pos-1]
          //    2.2 否则判断是否br标签,如果是,使用 options.start() 处理br标签
          //    2.3 否则判断是否p标签,如果是,使用 options.start(),options.end() 处理p标签
          parseEndTag(endTagMatch[1], curIndex, index)
          continue
        }

        // parseStartTag:匹配开始标签,返回包含了标签的名称、属性、开始位置和结束位置的对象
        // startTagMatch = {"tagName":"div","attrs":[],"start":0,"unarySlash":"","end":5}
        // unarySlash: 自闭合的标签(可以省略结束标签,如 <th />)的斜线
        const startTagMatch = parseStartTag()
        if (startTagMatch) {
          // handleStartTag: 
          // 1. 先处理 p 标签的特殊情况:p标签不能包含块级元素,如果上一个未闭合的标签是<p>,而当前开始的标签是块级元素,调用 parseEndTag 闭合 <p> 标签
          // 2. 如果当前开始标签是可以自闭合的标签,调用 parseEndTag 闭合标签
          // 3. 处理属性信息为:[{"name":"style","value":"color: red;","start":5,"end":24}]
          // 3. 是否是一元标签(<img>、<br>等),如果不是, stack 中入栈当前开始标签;lastTag=curTagName
          // 4. 使用 options.start() 处理当前开始标签
          handleStartTag(startTagMatch)
          if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
            advance(1)
          }
          continue
        }
      }

      let text, rest, next
      // 如果当前 html 不以 '<' 开始
      if (textEnd >= 0) {
        // 从 '<' 下标开始截取 html
        rest = html.slice(textEnd)
        // 检查 rest 是否不包含结束标签、开始标签、注释和条件注释
        // 直到找到以'<'开始,且包含结束标签、开始标签、注释和条件注释的文本,跳出循环,记录这个'<'的下标 textEnd
        // 把html.substring(0, textEnd)处理为纯文本
        while (
          !endTag.test(rest) &&
          !startTagOpen.test(rest) &&
          !comment.test(rest) &&
          !conditionalComment.test(rest)
        ) {
          // < in plain text, be forgiving and treat it as text
          next = rest.indexOf('<', 1)
          if (next < 0) break
          textEnd += next
          rest = html.slice(textEnd)
        }
        text = html.substring(0, textEnd)
      }

      if (textEnd < 0) {
        text = html
      }

      if (text) {
        advance(text.length)
      }
      
      // 调用 options.char() 处理文本内容部分
      if (options.chars && text) {
        options.chars(text, index - text.length, index)
      }
    } else {
      // 处理isPlainTextElement(lastTag)结束标签:</script>、</style>、</textarea>
      let endTagLength = 0
      const stackedTag = lastTag.toLowerCase()
      const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i'))
      const rest = html.replace(reStackedTag, function (all, text, endTag) {
        endTagLength = endTag.length
        if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {
          text = text
            .replace(/<!\--([\s\S]*?)-->/g, '$1') // #7298
            .replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1')
        }
        if (shouldIgnoreFirstNewline(stackedTag, text)) {
          text = text.slice(1)
        }
        if (options.chars) {
          options.chars(text)
        }
        return ''
      })
      index += html.length - rest.length
      html = rest
      parseEndTag(stackedTag, index - endTagLength, index)
    }

    if (html === last) {
      options.chars && options.chars(html)
      if (process.env.NODE_ENV !== 'production' && !stack.length && options.warn) {
        options.warn(`Mal-formatted tag at end of template: "${html}"`, { start: index + html.length })
      }
      break
    }
  }
  
  // 省略...
}

parseHTML

总结一下 parseHTML 的处理过程:

  1. 传入待解析的html, while(html) 开始循环
  2. 如果无未闭合的标签或者标签非 script,style,textarea
    • 如果当前 html 以 '<' 开始
      • 解析之前,先处理 htmL注释、条件注释、Doctype声明,处理完成之后截取html,continue
      • 处理完成之后,开始处理截取后后的html
      • 如果通过正则表达式匹配到结束标签,通过 parseEndTag 处理结束标签
      • 通过 parseStartTag 匹配开始标签
      • 如果匹配到开始标签,通过 handleStartTag 处理开始标签
    • 如果当前 html 不以 '<' 开始
      • 从 '<' 下标开始截取 html,rest = html.slice(textEnd)
      • 检查 rest 是否不包含结束标签、开始标签、注释和条件注释
      • 直到找到以'<'开始,且包含结束标签、开始标签、注释和条件注释的文本,跳出循环,记录这个'<'的下标 textEnd
      • html.substring(0, textEnd)处理为纯文本
  3. 否则如果是这些结束标签:</script>、</style>、</textarea>,开始进行相关处理

parseEndTag

parseEndTag 方法处理结束标签的逻辑如下:

  1. stack 中维护未闭合的标签
  2. 找到stack中与当前 tag 相等的标签,记录 pos=stack中匹配标签的下标
    • 如果在stack中找到匹配的标签, 即pos>=0时;
      • 如果pos值小于等于stack.length - 1,说明存在未闭合的标签,打印提示语
      • 否则stack 最后一个标签出栈,lastTag = stack[pos-1]
    • 否则判断是否br标签,如果是,使用 options.start() 处理br标签
    • 否则判断是否p标签,如果是,使用 options.start(),options.end() 处理p标签

parseStartTag

parseStartTag 方法用于匹配开始标签,返回包含了标签的名称、属性、开始位置和结束位置的对象 {"tagName":"div","attrs":[],"start":0,"unarySlash":"","end":5}

其中 unarySlash为自闭合的标签(可以省略结束标签,如 )的斜线,如果有的话

handleStartTag

handleStartTag 方法处理开始标签的逻辑如下:

  1. 先处理 p 标签的特殊情况:p标签不能包含块级元素,如果上一个未闭合的标签是 <p>,而当前开始的标签是块级元素,调用 parseEndTag 闭合 <p> 标签
  2. 如果当前开始标签是可以自闭合的标签,调用 parseEndTag 闭合标签
  3. 处理属性信息为:[{"name":"style","value":"color: red;","start":5,"end":24}]
  4. 判断是否是一元标签(<img>、<br>等),如果不是, stack 中入栈当前开始标签;lastTag=curTagName
  5. 使用 options.start() 处理当前开始标签

options.start()、options.end()、options.chars()

在处理开始标签、结束标签、文本内容时,用到了options.start(), options.end(), options.chars()方法,接下来看一看这些方法干了些什么事

  1. options.start():用于将开始标签转成 AST 元素

    • let element: ASTElement = createASTElement(tag, attrs, currentParent): 根据传入的的标签名称、属性、和父节点,创建一个 AST 元素,其结构为

      js 复制代码
      ASTElement = {
        type: 1,
        tag,
        attrsList: attrs,
        attrsMap: makeAttrsMap(attrs),
        rawAttrsMap: {},
        parent,
        children: []
      }
    • 处理 v-pre 指令,当一个元素有 v-pre 指令时,vue 会跳过这个元素和它的子元素的编译过程,直接将它们作为静态内容渲染

    • 没有 v-pre 指令时,处理 v-if, v-for, v-once 指令

      • processFor(element): 获取列表的每个元素和它的索引,然后将这些信息保存到 element 元素的 foraliasiterator1iterator2 属性中
      • processIf(element): 处理用于条件渲染的指令: v-ifv-else-ifv-else 指令。这个方法会解析这些指令的值,将信息保存到 element 元素的 ififConditionselse 属性中。
      • processOnce(element): 处理 v-once 指令。这个方法将 element 元素的 once 属性设置为 true,标识该组件已经被渲染过一次了
    • 判断是否是自闭合标签

      • 如果非自闭合标签,stack 中增加一条数据, stack 维护的是未闭合标签的 AST 元素集合
      • 如果是自闭合标签,进行相关处理
  2. options.end()stack 中的栈顶元素出栈,将结束标签与开始标签匹配。此时栈顶元素为父节点,继续开始遍历下一个标签,直至父节点的标签也闭合。

  3. options.chars():创建一个表示文本节点的对象,其结构为

    js 复制代码
    child = {
      type: 2,
      expression: res.expression,
      tokens: res.tokens,
      text: text
    };

    AST 对象中 children 属性的数组元素

经过上述解析,最后生成了一个结构如下图所示的 AST 对象

render

生成 AST 对象后,会检测其中的静态内容(如纯文本节点或者只包含静态属性的元素节点),将静态节点的 static 属性标记为 true,便于在后续的更新过程中跳过静态内容,提高渲染性能

然后,需要通过 generate 方法,将 AST 转换为渲染函数 可以看到,generate 方法最后返回的 render 是一个函数字符串。

genElement

转换成 render 字符串的核心方法就是 genElement,来看看代码

js 复制代码
export function genElement (el: ASTElement, state: CodegenState): string {
  if (el.parent) {
    el.pre = el.pre || el.parent.pre
  }
  
  // 处理静态根元素 渲染函数为 _m
  if (el.staticRoot && !el.staticProcessed) {
    return genStatic(el, state)
  } else if (el.once && !el.onceProcessed) {
    // 处理有 v-once 指令的元素,渲染函数方法为 _o
    return genOnce(el, state)
  } else if (el.for && !el.forProcessed) {
    // 处理有 v-for 指令的元素,渲染函数为 _l
    return genFor(el, state)
  } else if (el.if && !el.ifProcessed) {
    // 处理有 v-if,v-else-if,v-else 条件指令的元素
    return genIf(el, state)
  } else if (el.tag === 'template' && !el.slotTarget && !state.pre) {
    // 处理非插槽的 template 元素
    return genChildren(el, state) || 'void 0'
  } else if (el.tag === 'slot') {
    // 处理 插槽 元素
    return genSlot(el, state)
  } else {
    // 处理组件元素,渲染函数为 _c
    let code
    if (el.component) {
      code = genComponent(el.component, el, state)
    } else {
      let data
      if (!el.plain || (el.pre && state.maybeComponent(el))) {
        data = genData(el, state)
      }

      const children = el.inlineTemplate ? null : genChildren(el, state, true)
      code = `_c('${el.tag}'${
        data ? `,${data}` : '' // data
      }${
        children ? `,${children}` : '' // children
      })`
    }
    // module transforms
    for (let i = 0; i < state.transforms.length; i++) {
      code = state.transforms[i](el, code)
    }
    return code
  }
}

渲染函数

生成的 render 字符串中,每一个元素会转成一个如 _c(), _v() 等的渲染函数,其说明如下

渲染函数 说明
_c = createElement 创建一个虚拟节点(VNode),接受三个参数:标签名、数据对象和子节点数组
_o = markOnce 标记一个节点只渲染一次
_n = toNumber 尝试将一个值转换为数字
_s = toString 将一个值转换为字符串
_l = renderList 渲染一个列表
_t = renderSlot 渲染一个插槽
_q = looseEqual 检查两个值是否松散相等
_i = looseIndexOf 在一个数组中查找一个值的索引,使用松散相等
_m = renderStatic 渲染一个静态节点
_f = resolveFilter 解析一个过滤器
_k = checkKeyCodes 检查键盘事件的键码
_b = bindObjectProps 绑定一个对象的属性到一个元素上
_v = createTextVNode 创建一个文本虚拟节点
_e = createEmptyVNode 创建一个空的虚拟节点
_u = resolveScopedSlots 解析作用域插槽
_g = bindObjectListeners 绑定一个对象的事件监听器
_d = bindDynamicKeys 绑定动态的键
_p = prependModifier 添加修饰符

结合渲染函数的说明,就能很容易阅读上述示例代码中返回的 render 字符串了

js 复制代码
// 格式化成便于阅读的形式
with (this) { 
  return _c(
    'div', 
    { staticStyle: { "color": "red" } }, 
    [
      _c(
        'p', 
        [_v("这是一段文本内容")]
      ),
      _v("\n          " + _s(message) + "\n          "), 
      _v(" "), 
      _c(
        'my-component', 
        { attrs: { "text": "propText" } }
      ), 
      _v(" "), 
      _c('textarea'), 
      _v(" "), 
      _c(
        'button', 
        { on: { "click": clickButton } },
        [_v("按钮")]
      )
    ],
    1
  ) 
}

执行 render 函数生成虚拟 DOM,最后生成真实的 DOM,也就是用户所看到的页面了,模板解析渲染完成。

总结

vue 的模板解析过程主要包括以下步骤:

  1. 解析模板 :将模板解析为抽象语法树(AST)。
  2. 优化静态内容 :遍历 AST,检测其中的静态内容(如纯文本节点或者只包含静态属性的元素节点),并标记它们。这样在后续的更新过程中,vue 可以跳过这些静态内容,提高渲染性能。
  3. 生成渲染函数 :将 AST 转换为渲染函数,渲染函数返回一个表示虚拟 DOM 的对象。
  4. 编译完成 :至此,模板编译的过程完成。当 vue 组件的状态(数据)发生变化时,vue 会重新执行渲染函数,生成一个新的虚拟 DOM,并与上一个虚拟 DOM 进行对比(update()),找出两者的差异,然后将这些差异应用到真实 DOM 上,完成界面的更新
相关推荐
嚣张农民1 小时前
推荐3个实用的760°全景框架
前端·vue.js·程序员
落魄小二1 小时前
el-table 表格索引不展示问题
javascript·vue.js·elementui
neter.asia2 小时前
vue中如何关闭eslint检测?
前端·javascript·vue.js
十一吖i2 小时前
前端将后端返回的文件下载到本地
vue.js·elementplus
光影少年2 小时前
vue2与vue3的全局通信插件,如何实现自定义的插件
前端·javascript·vue.js
熊的猫3 小时前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js
mosen8684 小时前
Uniapp去除顶部导航栏-小程序、H5、APP适用
vue.js·微信小程序·小程序·uni-app·uniapp
别拿曾经看以后~5 小时前
【el-form】记一例好用的el-input输入框回车调接口和el-button按钮防重点击
javascript·vue.js·elementui
Gavin_9156 小时前
【JavaScript】模块化开发
前端·javascript·vue.js
Devil枫11 小时前
Vue 3 单元测试与E2E测试
前端·vue.js·单元测试