# Vue 模板编译

# 总体概述

  • 获取 template:
    • 首先要拿到 Vue 构造函数中 $options 参数中的 el,判断有没有 el,如果有再进行下一步操作
    • 然后在 Vue 的原型上添加 $mount 方法用于渲染,在这个方法中,需要做如下操作:
      • 获取 $options,并在 Vue 上挂载 $el
      • 判断 $options 中有无 render 函数(目前不考虑有 render 函数的情况),有的话就使用 render 函数;然后判断有无 template,有的话就使用 template;最后判断在 body 标签中有无 el 所对应的元素节点,如果有就把该元素的 outerHTML 赋值给 template
  • template 转 AST(Abstract Syntax Tree,抽象语法树) 树:
    • 一边匹配一边删除,全部处理完就成为空字符串
    • 用 parseStartTag 处理开始标签获取属性,处理完后用 start 函数
    • 遇到文本节点用 chars 函数处理
    • 遇到结束标签用 end 函数处理
  • AST 树转 render 函数(_c、_v、_s):
    • 获取元素的子元素并将其全部转化为字符串【递归处理】
    • 将属性转为字符串
    • 最后将元素节点进行字符串拼接
  • render 函数转虚拟节点:
    • 使用with函数将this指向vm,然后调用函数产生虚拟节点(vnode)
    • 其中需要在原型上添加上 _c、_v、_s 方法
  • 设置 PATCH,打补丁到真实 DOM
    • 利用递归将虚拟DOM转化为真实DOM,然后替换 el
    • 转化的时候需要将各自的属性添加至节点上

# 1. 模板转 AST 树

函数名:parseHTMLToAST(html)

# 1.1 实现思路

  • 正则匹配 template 模板中的标签、属性和文本节点,正则匹配的所有功能如下,详情可查看 Vue 源码 (opens new window)
// 匹配id="app"/id='app'/id=app
const attribute =
  /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
// 匹配开始标签的起始部分,如:<div
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 匹配开始标签的结束部分,如:>、/>
const startTagClose = /^\s*(\/?)>/
// 匹配结束标签,如:</div>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
返回顶部
  • 将 template 中匹配一部分删除一部分(advance 函数),匹配开始标签的流程如下:

    • 函数名:parseStartTag()
    • 首先 template 字符串中判断 < 的下标是为为 0,如果为 0 则代表即将匹配标签的开始标签(大于 0 的情况下面会说)或者为结束标签,则匹配结束标签(endTag),放入 end 函数中处理
    • 然后匹配开始标签中的标签名(startTagOpen)、属性名和属性值(attribute),在匹配属性的时候要一直判读是否匹配到开始标签的结束部分,如果没有匹配到那就一直匹配属性
    • 最后如果匹配到开始标签的结束项 > 或者 /> (startTagClose),这样匹配结束,返回 match 对象,结构如下:
    match = {
      tagName: 'xxx',
      attrs: [
        {
          name: 'aaa',
          value: '......'
        }
      ]
      // attrs 中的 value 是字符串类型,不存在对象类型,如果匹配到像 style 	属性,最后转成 AST 树后 attrs 中的 value 是对象类型,处理在 render 		函数中将字符串转为对象形式
    }
    
    • 将返回的对象放入 start 函数中处理
  • 如果大于 0,则代表匹配文本内容,那么从开始到 < 前到内容都为文本,将其截取后放入 chars 函数中处理

  • 最后生成 AST 树,大致结构如下:

{
  tag: 'div',
  type: 1, // 代表元素节点
  attrs: [
    {
      name: 'id',
      value: 'app'
    },
    {
      name: 'style',
      value: {
        color: 'red',
        fontSize: '20px'
      }
    }
  ],
  children: [
    // 子元素
    ......
  ]
}

# 1.2 start 函数

  • 利用 createASTElement 函数创建 AST 元素,即返回如下对象:
{
  tag: tagName,
  type: 1,
  children: [],
  attrs 
}
  • 利用栈存储节点,每次匹配到开始标签的标签名,就将标签名入栈
  • 然后当前的父节点就是此元素
function start(tagName, attrs) {
  console.log('---------开始---------');
  console.log(tagName, attrs);
  const element = createElement(tagName, 1, attrs);
  !root && (root = element);
  currentParent = element;
  stack.push(element);
}

# 1.3 end 函数

  • 遇到结束标签,将栈顶元素弹出,设为 A,并且此时栈顶的元素 B (如果有)就是元素 A 的父元素,元素 A 就是元素 B 的子元素
function end(tagName) {
  console.log('---------结束---------');
  console.log(tagName);
  const element = stack.pop(),
    parent = stack[stack.length - 1];
  // 遇到结束标签那么需要找该元素的父元素,如果stack没有说明其为根元素
  if (parent) {
    parent.children.push(element);
  }
}

# 1.4 chars 函数

  • 将获取的文本进行去除前后空格,如果去除后长度不为 0,则代表当前父元素下有文本节点,那么将父元素中的 children 数组中加入文本节点对象
function chars(text) {
  text = text.trim();
  // 如果文本节点不为空则加入,因为有时候为换行符等空白字符
  if (text) {
    console.log('---------文本---------');
    console.log(text);
    currentParent.children.push({
      type: 3,
      value: text.trim()
    })
  }
}

# 1.5 代码实现(部分)

function parseHTMLToAST(html) {
  while (html) {
    let startTagIndex = html.indexOf('<');
    if (startTagIndex === 0) {
      let match;
      // 如果为开始标签
      if (match = html.match(startTagOpen)) {
        advance(match[0]);
        let attrs = parseStartTag(); // 获取属性值
        start(match[1], attrs);
      }

      // 如果为结束标签
      if (match = html.match(endTag)) {
        end(match[1]);
        advance(match[0]);
      }
    }
    else {
      // 为文本
      let text = html.substring(0, startTagIndex);
      chars(text);
      advance(text);
    }
  }
  return root;
}

提示

目前不能匹配单标签,也不支持属性里绑定变量

# 2. AST 树转 render 函数

函数名:generate(ast)

_c() => createElement()
_v() => createTextNode()
_s() => {{ name }} => _s(name)

render 函数是将 AST 对象转换后的一个字符串形式,举个例子:

  • HTML 代码:
<div id="app" style="color: red; font-size: 20px;">
  hello {{ name }}
  <span style="color: green;">{{ age }}</span>
</div>
  • 用 render 函数将 HTML 转换后的字符串:
`
	_c(
		"div",
		{
			"id": "app",
			"style": {
				"color": "red",
				"font-size": "20px"
			}
		},
		_v("hello" + _s(name)),
		_c(
			"span",
			{
				"class": "text",
				"style": {
					"color": "green"
				}
			},
			_v(_s(age))
		)
	)
`

# 2.1实现思路

  • 【递归】首先判断当前元素中是否含有 children 属性,如果没有则进行下一步,如果有那么需要产生 children 字符串,因为 children 存放的是一个数组,数组中含有元素节点对象信息,那么需要遍历每一个节点,使用 genChildren 函数对其进行字符串化,过程如下:

    • 遍历的时候该节点有可能是元素节点或者文本节点

    • 如果是元素节点,那么用 generate 函数进行处理

    • 如果为文本节点那么又分为两种可能:

      • 如果为纯文本,则直接返回即可

      • 如果为混合文本(变量+文本),则需要对大括号进行正则匹配,然后进行截取和拼接操作,将纯文本直接放入临时数组中,如果匹配到则将变量拼接后添加进临时数组,直到匹配结束,结束后如果此时的 lastIndex 小于文本的长度则表示还有文本未被截取,则需将最后一部分文本也添加进临时数组,最后用 + 字符连接即可(正则匹配可查看 Vue 源码 (opens new window)

        const defautTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;
        
  • 在 generate 函数中拼接元素节点的标签名

  • 元素节点中如果有属性,则传入 genAtrrs 中进行处理

    • 如果属性值不为 style 那么可以直接拼接为 key: value 形式
    • 否则将 style 中的每一项元素转化为 key: value 形式存储在对象中,并重新赋值给 value
    • 最后获取到字符串,但是要返回到对象,所以要在字符串外加上 {},并且删除最后一个 ,,这样属性就处理完了
  • 然后添加 children 字符串,如果没有则为 ''

# 2.2 代码实现(部分)

  • 产生文本节点
function genTextNode(text) {
  console.log("text: ", text);
  // 如果为纯文本则直接返回
  if (!defautTagRE.test(text)) return `_v(${JSON.stringify(text)})`;
  let match, index = 0, lastIndex = defautTagRE.lastIndex = 0;
  const tokens = [];
  // 如果为混合文本,先将每一部分存入tokens然后用+连接
  while (match = defautTagRE.exec(text)) {
    index = match.index;
    if (index > lastIndex) {
      tokens.push(JSON.stringify(text.substring(lastIndex, index)));
    }
    tokens.push(`_s(${match[1].trim()})`);
    lastIndex = index + match[0].length;
  }
  if (lastIndex < text.length) {
    tokens.push(JSON.stringify(text.substring(lastIndex)));
  }
  return `_v(${tokens.join("+")})`;
}
  • 子元素字符串化
function genChildren(children) {
  // 防止产生多余的逗号,那么需要把子元素都收集起来再用逗号连接即可
  let str = [];
  children.forEach(item => {
    if (item.type === 1) {
      // 如果为元素节点就用generate函数产生一次
      str.push(generate(item));
    } else if (item.type === 3) {
      // 如果为文本节点
      str.push(genTextNode(item.value));
    }
  });
  return str.join(',');
}
  • 主函数
function generate(obj) {
  const children = genChildren(obj.children);
  return `_c(${obj.tag},${obj.attrs.length ? genAtrrs(obj.attrs) : 'undefined'}${children ? `,${children}` : ''})`;
}

# 3. 将 render 函数转化为虚拟 DOM

函数名:_render

# 3.1 实现思路

  • 实现比较简单,首先需要在原型上添加 _c、_v、_s 三种方法

    • _c 代码如下:
    // 元素节点
    Vue.prototype._c = function() {
      return createElement(...arguments);
    }
    
    • _v 代码如下:
    // 文本节点
    Vue.prototype._v = function(text) {
      return createTextVode(text);
    }
    
    • 添加变量:
    // 变量
    Vue.prototype._s = function (value) {
      if (value === null) return;
      return typeof value === 'object' ? JSON.stringify(value) : value;
    }
    
  • 最后需要获取实例上的 render 函数,然后调用即可

# 3.2 代码实现

Vue.prototype._render = function () {
  const vm = this,
    render = vm.$options.render,
    vnode = render.call(vm);
  console.log("vnode: ", vnode, "\n--------------------");
  return vnode;
}

# 4.虚拟 DOM 转化为真实 DOM

函数名:patch

# 4.1 实现思路

  • 到目前为止我们已经获得了 render 函数运行后产生的虚拟 DOM,那么需要在 _update 函数里把传入的虚拟 DOM 转化为真实 DOM,然后替换 options 中的 el 元素
  • 虚拟 DOM 的结构如下:
{
    "tag": "div",
    "attrs": {
        "id": "app",
        "class": "box",
        "style": {
            "color": "red",
            "font-size": "20px"
        }
    },
    "children": [
        {
            "text": "hello xxx, welcome to Karl's mini Vue.",
            "el": null
        },
        {
            "tag": "span",
            "attrs": {
                "style": {
                    "color": "green"
                }
            },
            "children": [
                {
                    "tag": "span",
                    "children": [
                        {
                            "text": "Hello World!",
                            "el": null
                        }
                    ],
                    "el": null
                },
                {
                    "tag": "strong",
                    "attrs": {
                        "class": "test"
                    },
                    "children": [
                        {
                            "text": "Test",
                            "el": null
                        }
                    ],
                    "el": null
                }
            ],
            "el": null
        },
        {
            "tag": "p",
            "children": [
                {
                    "text": "Today is a nice day.",
                    "el": null
                }
            ],
            "el": null
        }
    ],
    "el": null
}
  • 要产生真实的 DOM 需要递归处理,处理过程如下:
    • 函数名:createElement
    • 首先判断是否有 tag 的值,因为如果有就是元素节点,否则就是文本节点
    • 然后分别用 createElement 方法和 createTextNode 方法产生相应的节点
    • 其次如果有 children,那么就遍历 children,把每一项继续用 createElement 产生子元素节点,并将子元素节点添加到父节点下即可
    • 最后返回该节点,这样就完成了 DOM 的产生,但是还需要将各自的属性添加到 DOM 节点上
    • 就需要遍历 attrs 数组:
      • 属性如果为 class,那么设置节点的 className 值
      • 属性如果为 style,那么先将属性值拼接为字符串后,设置节点的 style.cssText 值
      • 属性如果为其他值,那么直接 setAttribute 即可
  • 产生真实的 DOM 后获取 oldNode 的父元素,然后将新产生的 DOM 添加在父节点的最后,并删除 oldNode 节点

# 4.2 代码实现

function createElement(vnode) {
  const { tag, attrs, children, text } = vnode;
  let dom = vnode.el = null;
  if (tag) {
    // 元素节点
    dom = document.createElement(tag);
    addAttrs(dom, attrs);
    children.forEach(item =>
      dom.appendChild(createElement(item)));
  } else {
    // 文本节点
    dom = document.createTextNode(text)
  }
  return dom;
}

function patch(oldNode, vnode) {
  const el = createElement(vnode),
    parent = oldNode.parentNode;
  console.log('真实DOM: ', el, '\n--------------------');
  parent.insertAdjacentElement('beforeend', el);
  parent.removeChild(oldNode);
}

# 5. 写在最后