学习Vue模板编译

86 阅读3分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天

vue初次渲染过程:

  1. 先初始化数据
  2. 编译模板
  3. 生成render函数
  4. 生成虚拟dom
  5. 生成真实dom
  6. 渲染页面
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="app">
      hello{{name}}
    </div>
    <script src="./vue.js"></script>
    <script>
      const vm = new Vue({
        data() {
          return {
            name: '阿伟',
            age: 26,
          }
        },
        el: '#app',
      })
    </script>
  </body>
</html>

先判断是否有render函数,没有再去判断有没有template,再没有就拿el(必填)
获取到template模板传入compileToFunction函数编译成render函数

// init.js

import { initState } from './state'
import { compileToFunction } from './comliper'

export function initMixin(Vue) {
  // 初始化
  Vue.prototype._init = function (options) {
    // 将选项保存到实例上,方便在其他原型方法中使用
    const vm = this
    vm.$options = options

    // 初始化状态
    initState(vm)
    
    // 判断选项中是否传了el参数
    if (options.el) {
      // 实现数据的挂载
      vm.$mount(options.el)
    }
  }

  Vue.prototype.$mount = function (el) {
    const vm = this
    const ops = vm.$options
    el = document.querySelector(el)
    // 先查找有没有render函数
    if (!ops.render) {
       /**
       * <div id="app"> hello{{name}} </div>
       */
      let template
      // 没有写template但写了el的就是用el
      if (!ops.template && ops.el) {
        template = el.outerHTML
      } else {
        // 如果传了template就用template
        if (el) {
          template = ops.template
        }
      }
      // 如果模板有值就把模板编译成ast语法树
      if (template) {
        const render = compileToFunction(template)
        ops.render = render
      }
    }
    // 组件挂载
    mountComponent(vm, el)
  }
}

parseHTML函数生成ast语法树,传入codegen函数先通过genChildren函数处理子节点。在genChildren函数循环遍历将每个子节点传入gen函数
gen函数判断是文本还是元素节点,如果是元素节点就递归codegen函数,如果是文本的话还需要区分是插值语法还是普通文本。如果是文本的话直接return _v(${JSON.stringify(text)}),如果是插值语法需要通过defaultTagRE.exec(text)来检验。其中正则多次匹配时,lastIndex会增加,所以需要重置为0从头开始匹配,如果匹配到的索引index大于lastIndex说明匹配到了普通文本(hello{{name}},index为5,lastIndex为0),通过text.slice(lastIndex(0), index(5))来获取到普通文本并转成josn字符串push到tokens数组中。再把匹配到的插值语法中的的值以_s(${match[1].trim()})形式push到tokens数组中,循环最后把lastIndex的值修改为index+文本长度(match[0].length),等于hello{{name}}的长度索引。结束while之后通过lastIndex < text.length(hello{{name}}123,lastIndex还在最后},后面还有123文本还需要判断)来判读还有没有普通文本,如果有通过text.slice(lastIndex)获取到最后的普通文本,gen函数最终返回_v(${tokens.join('+')})格式
如果astattrs数组有值的话,通过genProps函数处理。genProps函数将attrs数组遍历,判断当前项是否是style,如果是就把stylevalue以;号分割,再遍历以:号分割,把分割后的key、value保存到一个新对象,再把新对象赋值给stylevalue。最外层的for循环的最后将attrs的每一个对象以${attr.name}:${JSON.stringify(attr.value)},格式保存到一个字符串中,genProps函数最后通过{${str.slice(0, -1)}}删除字符串中的最后一个,号,返回对象字符串({id:"app",style:{"color":" red"}})

// comliper/index.js

import { parseHTML } from './parse'

const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g // {{ asdsadsa }}  匹配到的内容就是我们表达式的变量

// 对模板进行解析
export function compileToFunction(template) {
   /**
   * template:<div id="app"> hello{{name}} </div>
   * 转换ast为:
   * {
   *  tag: "div",
   *  attrs:[{name: 'id', value: 'app'},{name: "style",value: {color: ' red'}}],
   *  children:[{tag: 'span', type: 1, children: Array(1), attrs: Array(0), parent: {…}],
   * }
   */
  // 将template转化成ast语法树
  let ast = parseHTML(template)

  // 将ast语法树转化成render函数字符串
  let code = codegen(ast)
  code = `with(this){return ${code}}`
  // 生成render函数 
  // ƒ anonymous() {
  //  with(this){return _c('div',{id:"app",style:{"color":"red"}},_c('span',null,_v("hello"+_s(name)+_s(age))))}
  // }
  let render = new Function(code)
  return render
}

// 拼接rander函数
function codegen(ast) {
  let children = genChildren(ast.children)
   // 如果有属性就执行genProps
   // _c处理标签,_v是处理文本,_s是处理插值语法
  let code = `_c('${ast.tag}',${
    ast.attrs.length > 0 ? genProps(ast.attrs) : 'null'
  }${ast.children.length ? `,${children}` : ''})`
  return code
}

// 拼接根标签的属性
function genProps(attrs) {
  //attrs:[{"name": "id","value": "app"},{"name": "style","value": {"color": " red"}}]
  let str = ''
  // 遍历属性,转换成字符串形式
  for (let index = 0; index < attrs.length; index++) {
    let attr = attrs[index]
    // 当遇到style属性时,需要把属性转换成对象形式
    if (attr.name === 'style') {
      let obj = {}
      attr.value.split(';').forEach((item) => {
        let [key, value] = item.split(':')
        obj[key] = value
      })
      attr.value = obj
    }
    // str:id:"app",style:{"color":" red"},
    str += `${attr.name}:${JSON.stringify(attr.value)},`
  }
  // 删除最后的逗号
  // {id:"app",style:{"color":" red"}}
  return `{${str.slice(0, -1)}}`
}

// 处理子节点1
function genChildren(children) {
  return children.map((child) => gen(child)).join(',')
}

// 处理子节点2
function gen(node) {
  // 元素节点处理
  if (node.type === 1) {
    return codegen(node)
  } else {
    // 文本处理
    let text = node.text
    // 不是匹配到{{变量}}的处理
    if (!defaultTagRE.test(text)) {
      return `_v(${JSON.stringify(text)})`
    } else {
      let tokens = []
      let match
      // 正则多次匹配时,lastIndex会增加,所以需要重置为0从头开始匹配
      defaultTagRE.lastIndex = 0
      let lastIndex = 0
      while ((match = defaultTagRE.exec(text))) {
        // match:['{{name}}', 'name', index: 5, input: 'hello{{name}}', groups: undefined]
        // 匹配到的索引
        let index = match.index
        /**
         * index为第一个{的索引
         * text.slice(lastIndex, index)截取表达式文本之间的纯文本
         */
        if (index > lastIndex) {
          tokens.push(JSON.stringify(text.slice(lastIndex, index)))
        }
        // match[1].trim():{{变量}}中的变量
        tokens.push(`_s(${match[1].trim()})`)
        // 假设子节点为hello{{name}}123
        // match[0].length为匹配到的表达式文本长度,index+文本长度等于hello{{name}}的长度
        lastIndex = index + match[0].length
      }
      // hello{{name}}123,lastIndex还在最后},后面还有123文本还需要判断
      if (lastIndex < text.length) {
        tokens.push(JSON.stringify(text.slice(lastIndex)))
      }
      return `_v(${tokens.join('+')})`
    }
  }
}

将html文本一直循环匹配正则,匹配到一个就删除一个直到全部删除
首先先用indexOf匹配html中<的索引,如果值为0就代表是开始标签就执行parseStartTag函数 parseStartTag函数中通过匹配startTagOpen正则获取到开始结束标签、属性等
parseStartTag函数返回match对象,对象包括标签名与属性数组,通过start函数将匹配到的结果放到ast语法树中,并将html删除已匹配到的开始结束标签、属性等。如果超过0就代表是结束标签,获取文本通过end函数将匹配到的结果放到ast语法树中

// comliper/parse.js

const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`) // 他匹配到的分组是一个 标签名  <xxx 匹配到的是开始 标签的名字
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) // 匹配的是</xxxx>  最终匹配到的分组就是结束标签的名字
const attribute =
  /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ // 匹配属性
// 第一个分组就是属性的key value 就是 分组3/分组4/分组五
const startTagClose = /^\s*(\/?)>/ // 匹配>

export function parseHTML(html) {
  const ELEMENT_TYPE = 1
  const TEXT_TYPE = 3
  /**<div>1111 <h></h> </div>,当匹配到div的开始标签时就push到stack栈中(["div"]),  
   *匹配到h的开始标签时就push到stack栈中(["div","h"]),  
   *匹配到h的结束标签时就把h从stack栈中删除,div是最后一个元素为h的父元素  
   */
  const stack = []
  // currentParent:当前元素的父元素,root:根节点
  let currentParent, root

  // 循环解析模板字符串,解析一段删除一段
  while (html) {
    /**
     * <div>1111</div>或者<div/>单标签,如果textEnd为0说明是一个开始或者结束标签
     * 1111</div>,如果textEnd>0说明是文本结束标签
     */
    let textEnd = html.indexOf('<')
    
    // 开始、结束标签处理
    if (textEnd === 0) {
      // startTagMatch就是匹配到的开始标签与属性
      const startTagMatch = parseStartTag()
      if (startTagMatch) {
        // 将匹配到的开始标签与属性传入start函数创建ast语法树
        start(startTagMatch.tagName, startTagMatch.attrs)
        continue
      }

      // 匹配结束标签
      let endTagMatch = html.match(endTag)
      if (endTagMatch) {
        end(endTagMatch[1])
        advance(endTagMatch[0].length)
        continue
      }
    }
    
    // 文本处理
    if (textEnd > 0) {
      // 截取文本开始到<的内容
      let text = html.substring(0, textEnd)
      if (text) {
        chars(text)
        advance(text.length)
      }
    }
  }
  
  // 创建ast语法树
  function createASTelement(tag, attrs) {
    return {
      tag, // 元素标签
      type: ELEMENT_TYPE, // 元素类型,
      children: [], // 是否有子节点,默认为空数组
      attrs, // 属性
      parent: null, // 是否有父节点
    }
  }

  // 开始标签
  function start(tag, attrs) {
    // 创建一个ast节点
    let node = createASTelement(tag, attrs)
    // 判断是否是空树,如果为空就把当前节点作为根节点
    if (!root) {
      root = node
    }
    // 当前父节点为空时就把当前节点作为父节点
    if (currentParent) {
      node.parent = currentParent
      currentParent.children.push(node)
    }
    stack.push(node)
    currentParent = node
  }

  // 获取文本
  function chars(text) {
    // 去除空格
    text = text.replace(/\s/g, '')
    // 遇到文本就直接放到当前节点的children中
    text &&
      currentParent.children.push({
        type: TEXT_TYPE,
        text,
        parent: currentParent,
      })
  }

  // 结束标签
  function end() {
    stack.pop()
    currentParent = stack[stack.length - 1]
  }

  // 根据文本长度删除html
  function advance(n) {
    html = html.substring(n)
  }

  // 解析开始标签,并返回match对象,对象包括标签名与属性数组
  function parseStartTag(params) {
    const start = html.match(startTagOpen)
    /**
     * start => ["<div", "div"]
     * <div为匹配到的开始标签,div为匹配到的开始标签名
     */
    if (start) {
      // 保存开始标签的标签名、属性
      const match = {
        // 标签名
        tagName: start[1],
        // 属性数组
        attrs: [],
      }
      // 删除<+标签名,删除后html为:id="app"> hello{{name}} </div>
      advance(start[0].length)

      // 如果不是开始标签的结束就一直匹配
      let attr, end
      // 当没有匹配到>并且匹配到属性时删除属性
      while (
        !(end = html.match(startTagClose)) &&
        (attr = html.match(attribute))
      ) {
        // 删除后html为:> hello{{name}} </div>
        advance(attr[0].length)
        match.attrs.push({
          name: attr[1],
          value: attr[3] || attr[4] || attr[5] || true,
        })
      }
      // 删除>
      if (end) {
        // 删除后html为:hello{{name}} </div>
        advance(end[0].length)
      }
      return match
    }
    // 匹配到</标签名>就返回false
    return false
  }
  return root
}

调用vm._update(vm._render())虚拟dom生成真实dom,其中在vm._render函数中执行vm.$options.render.call(vm)将with的this指向vue实例,触发_c、_v、_s函数生成虚拟dom。
vm._update函数中执行patch(el, vnode)传入旧dom、虚拟dom,首先判断旧dom是否有nodeType,如果有就代表是元素节点,将虚拟dom传入createElm函数。在createElm函数判断虚拟dom的tag是不是字符串,如果是字符串就创建元素并保存在虚拟dom的el属性、处理属性、递归添加children,否则创建文本节点添加到虚拟dom的el属性,createElm函数最终虚拟dom的el属性。
createElm(vnode)返回虚拟dom创建真实dom,接下来就是把真实dom替换旧的dom,先获取旧dom的父节点在旧dom之前插入生成的真实dom,再把旧dom删除并返回最新的真实dom

// lifecycle.js

import Watcher from './observe/watcher'
import { createElementVNode, createTextVNode } from './vdom'

export function mountComponent(vm, el) {
  vm.$el = el
  // vm._render将render函数生成vnode虚拟dom,vm._update将vnode虚拟dom生成真实dom
  vm._update(vm._render())
}

// 根据虚拟dom创建真实dom
function patch(oldVNode, vnode) {
  // 判断是否是元素节点
  const isRealElement = oldVNode.nodeType
  if (isRealElement) {
    const elm = oldVNode
    // 根据虚拟dom创建真实dom
    let newElm = createElm(vnode)
    /**
     * nextSibling:返回其父节点的 childNodes 列表中紧跟在其后面的节点
     * insertBefore:在 elm.nextSibling 之前插入一个newElm
     */
    const parentElm = elm.parentNode
    parentElm.insertBefore(newElm, elm.nextSibling)
    // 删除旧dom
    parentElm.removeChild(elm)
    // 返回最新dom
    return newElm
  }
}

// 创建元素
function createElm(vnode) {
  const { tag, data, children, text } = vnode
  // tag是字符串代表是元素节点,否则是文本节点
  if (typeof tag === 'string') {
    vnode.el = document.createElement(tag)
    // 处理属性
    patchProps(vnode.el, data)
    // 递归添加子节点
    children.forEach((child) => {
      vnode.el.appendChild(createElm(child))
    })
  } else {
    vnode.el = document.createTextNode(text)
  }
  return vnode.el
}

// 处理标签属性
function patchProps(el, props) {
  for (const key in props) {
    if (key === 'style') {
      for (const styleName in props.style) {
        el.style[styleName] = props.style[styleName]
      }
    } else {
      // 设置指定元素上的某个属性值
      el.setAttribute(key, props[key])
    }
  }
}

// 向vue原型添加_update、_c、_v、_s、_render方法
export function initLifeCycle(Vue) {
  Vue.prototype._update = function (vnode) {
    const vm = this
    /**el:
     *<div id="app" style="color: red;">
     *  <span>hello{{name}}{{age}}</span>
     *</div>
    */
    const el = vm.$el
    // 传入旧dom、虚拟dom生成真实dom更新到页面
    vm.$el = patch(el, vnode)
  }
  
  // render函数中标签处理
  Vue.prototype._c = function (params) {
    // 创建虚拟dom
    return createElementVNode(this, ...arguments)
  }
  
  // render函数中普通文本处理
  Vue.prototype._v = function (params) {
    return createTextVNode(this, ...arguments)
  }
  
  // render函数中插值语法处理
  Vue.prototype._s = function (params) { // _s(name)
    // 是对象需要转换成json字符串
    if (typeof value !== 'object') {
      return params
    }
    return JSON.stringify(params)
  }
  
  Vue.prototype._render = function (params) {
    const vm = this
    // 让with中this绑定到vm
    return vm.$options.render.call(vm)
  }
}

生成元素、文本虚拟dom

vdom/index.js

// 创建虚拟dom
export function createElementVNode(vm, tag, data = {}, ...children) {
  return vnode(vm, tag, key, data, children)
}

export function createTextVNode(vm, text) {
  return vnode(vm, undefined, undefined, undefined, undefined, text)
}

function vnode(vm, tag, key, data, children, text) {
  return {
    vm,
    tag,
    key,
    data,
    children,
    text,
  }
}