Skip to content

模板编译

数据观测完后, 数据变成响应式了,接下来就需将数据渲染到页面, 因此现在的步骤到了模板编译,大体步骤为template -> ast -> code -> vdom -> dom

$mount

挂载优先级

options.render函数 > options.template > options.el.outerHTML

js
import { initState } from "./state";
import { compileTemplate } from "../../compile/index";

export function initMixin(Vue) {
  // 初始化
  Vue.prototype._init = function (option) {
    const vm = this;
    // TODO: 选项合并
    vm.$options = option || {};

    // 数据观测(初始化状态)
    initState(vm);

    // 挂载
    if (vm.$options.el) {
      vm.$mount(vm.$options.el);
    }
  };

  // 挂载
  Vue.prototype.$mount = function (el) {
    const vm = this;
    const opts = vm.$options;
    // 取到el元素
    el = typeof el === "string" ? document.querySelector(el) : el;

    // 挂载优先级 render函数 > temlate  > el
    if (!opts.render) {
      // 取到Html模板
      if (!opts.template && el) {
        opts.template = el.outerHTML;
      }
      // 开始编译模板得到render函数opts.render
      opts.render = compileTemplate(opts.template);
    }
  };
}

dom.outerHTML取到的是包含描述元素(本身)及其后代序列化 HTML 片段, 有一定兼容性, 也可以创建一个div, 然后将el丢进去作为children, 用innerHTML来取到

compileTemplate

注意: 在$mount中需要的是一个render函数, 因此compileTemplate内返回的应该是一个函数

js
import {parseHtml} from './parseHtml'

// 模板编译
export function compileTemplate(template) {
  // 解析Html字符串后, 得到ast语法树
  const root = parseHtml(template); // root就是生成的语法树
  console.log(root);

  // 编译ast语法树后得到render函数, 内部其实就是字符串拼接(模板引擎的原理)

  // 将js代码转换为render函数返回
  return function render() {};
}

parseHtml

js
/* 下列正则用于解析模板 */
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 标签开头的正则 捕获的内容是标签名 <div
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配标签结尾的 </div >
const attribute =
  /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性的
const startTagClose = /^\s*(\/?)>/; // 匹配标签结束的 >
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // 匹配 {{}} 部分

// 处理开始标签
function start(tagName, attrs) {
  console.log('开始标签是:', tagName, "属性是", attrs)
}

// 处理文本
function char(text) {
  console.log('文本内容是:', text)
}

// <div><span></span></div>
function end(tagName) {
	console.log('结束标签是:', tagName)
}

export function parseHtml(html) {
  while (html) {
    const startTagIndex = html.indexOf("<");
    // 遇到 < 开头: 以 < 开头 可能是开始标签也可能是结束标签, 也可能是HTML注释(注释此处没有考虑, 仅考虑核心部分)
    if (startTagIndex === 0) {
      // 先处理开始标签
      const startTagMatch = parseStartTag();
      if (startTagMatch) {
        start(startTagMatch.tag, startTagMatch.attrs);
        continue; // 开始标签 + 属性 + > 匹配完后
      }

      // 处理结束标签 </div>
      let endTagMatch = html.match(endTag);
      if (endTagMatch) {
        end(endTagMatch[1]);
        advance(endTagMatch[0].length);
        continue;
      }
    }

    // 遇到文本 dsdsd < 或者 \n*<
    if (startTagIndex >= 0) {
      let text = html.substring(0, startTagIndex);
      if (text) {
        char(text);
        advance(text.length);
      }
    }

    function parseStartTag() {
      const startTagMatch = html.match(startTagOpen);
      // 遇到开始标签
      if (startTagMatch) {
        advance(startTagMatch[0].length); // 先前进一个开始标签长度 <div
        let startTagAst = {
          tag: startTagMatch[1],
          attrs: [],
        };

        // 接着解析属性, 由于属性只能一个一个解析, 可能存在多个属性, 因此用while循环来匹配
        let endMatch, attrsMatch;
        while (
          !(endMatch = html.match(startTagClose)) && // 不以 \s*> 开头
          (attrsMatch = html.match(attribute)) // 有属性 例如: id="app"
        ) {
          advance(attrsMatch[0].length); // html前进一个属性的位置
          startTagAst.attrs.push({
            name: attrsMatch[1],
            value: attrsMatch[3] || attrsMatch[4] || attrsMatch[5] || "", // 匹配属性的正则attribute有三种情况 id="app" id='app' id=app
          });
        }
        // 以 \s*> 开头,直接前进
        if (endMatch) {
          advance(endMatch[0].length);
        }
        // 以 \s* /> 结尾, 表示为自闭合标签
        return startTagAst; // 开始标签 + 属性 + > 匹配完后
      }
    }
  }

  // 让html前进n字符(通过丢弃前面不要的部分)
  function advance(n) {
    html = html.substring(n);
  }

  return root;
}

提示

正则中 ?:表示用字符创的 match 方法匹配时, 能匹配上, 但是不捕获这部分内容,例如:

js
let str = "hello-word"
let reg = /(\w+)(\-)(\w+)/; // 有3个分组, 且都会匹配和捕获
str.match(reg) //  ['hello-word', 'hello', '-', 'word', index: 0, input: 'hello-word', groups: undefined]

// 若是:
let reg = /(\w+)(?:\-)(\w+)/; // 中间的 - 仅会匹配, 不会捕获, 也就不会出现在match结果中
str.match(reg) //  ['hello-word', 'hello', 'word', index: 0, input: 'hello-word', groups: undefined]

构建AST层级树

上一步已经解析了html, 且在解析过程中会不断调用start,end,char三个函数,这三个函数内部就能得到解析后的结果, Html是tree结构, 解析过程中也会是一层层的触发这三个函数,要构建tree, 可以借助栈结构

js
// ...regexp......
const ELEMENT_TYPE = 1; // 元素类型
const TEXT_TYPE = 3; // 文本类型
const singleTag = ["img", "hr", "br", "input"]; // Html中的单标签

// 创建AST元素节点
function createAstElement(tagName, attrs) {
  return {
	tag: tagName,
	type: ELEMENT_TYPE,
	attrs,
	parent: null,
	children: [],
  };
}

// 创建AST文本节点
function createAstText(text) {
  return {
	text,
	type: TEXT_TYPE,
  };
}

// 解析HTML模板
export function parseHtml(template){

	let root = null; // ast根节点
	let currentParent; // 当前父节点
	const stack = []; // 栈


	// 处理开始标签
	function start(tagName, attrs) {
	  const element = createAstElement(tagName, attrs);

	  if (root === null) {
		root = element;
	  }
		// 若当前元素是单标签, 不用入栈, 直接作为当前父级的children即可
	  if (singleTag.includes(tagName)) {
		if (currentParent) {
		  currentParent.children.push(element);
		}
	  } else {
		currentParent = element;
		stack.push(element);
	  }
	}

	// 处理文本
	function char(text) {
	  text = text ? text.trim() : "";
	  if (text) {
		const textNode = createAstText(text);
		currentParent.children.push(textNode);
	  }
	}

	// 处理结束标签 <div><span></span></div>
	function end(tagName) {
	  const closeElement = stack.pop();
	  if (tagName === closeElement.tag) {
		currentParent = stack[stack.length - 1];
		if (currentParent) {
		  closeElement.parent = currentParent;
		  currentParent.children.push(closeElement);
		}
	  } else {
		// TODO: 可以判断当前标签是否正常关闭
		console.error("标签没有正常关闭");
	  }
	}
	// while......

	return root; // 最终得到ast tree
}

generate code

上一步有了ast语法树后, 需要利用ast语法树生成渲染函数

js
import { parseHtml } from "./parseHtml";
import { generate } from "./generate";

export function compileTemplate(template) {
  // 解析Html字符串后, 得到ast语法树
  const root = parseHtml(template); // root就是生成的语法树

  /**
   * 编译ast语法树后得到js代码字符串
   *
   *  vue中render函数中有几个函数(这里不全. 仅用了几个主要的), 用于生成虚拟DOM
   *  _c(): 用于创建元素
   *  _v(): 用于创建文本
   *  _s(): 用于序列化得到字符串
   *
   *  若html如下:
   *  <div id="app" style="color: red;background: #ccc;">
   *      <a href="http://baidu.com"> 百度</a>
   *      <img src="" alt="" />
   *      <span>hello{{name}} ---{{age}} 岁了</span>
   *      <p>{{age}}</p>
   *  </div>
   *  得到的code为:
   *  _c("div",{"id":"app","style":{"color":" red","background":" #ccc"}},_c("a",{"href":"http://baidu.com"},_v('百度')),_c("img",{"src":"","alt":""},""),_c("span",undefined,_v('hello'+_s(name)+' ---'+_s(age)+' 岁了')),_c("p",undefined,_v(_s(age))))
   */
  const code = generate(root);

  /**
   * 将js代码转换为render函数返回
   * 通过new Function 将原始的code字符串转化为函数, 中间需要注意作用域问题, _c _v _s 调用时候, this指向应该为vm,
   * 包括code中 "{{}}" 之间的变量(例如: name)取值的时候, this指向应该为vm, 因此这里用到with, renderFn最终是vm在调用,
   * 所以with(this)里面的this会指向vm, 下面的逻辑就顺理成章了
   */
  const render = `with(this){return ${code}}`; // 改变code中的this
  const renderFn = new Function(render);
  return renderFn;
}

生成代码的过程

js
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // 匹配 {{}} 部分

function geneChildrens(el) {
  const children = el.children;
  if (children && children.length) {
    return children.map((child) => gen(child)).join(",");
  } else {
    return "";
  }
}

function genText(text) {
  let strAry = [];
  let index = (defaultTagRE.lastIndex = 0);
  let match;
  while ((match = defaultTagRE.exec(text))) {
    const txt = text.substring(index, match.index);
    const val = match[1];
    if (txt) {
      strAry.push(`'${txt}'`);
    }
    strAry.push(`_s(${val})`);
    text = text.substring(defaultTagRE.lastIndex);
    index = defaultTagRE.lastIndex = 0;
  }
  if (text) {
    strAry.push(`'${text}'`);
  }

  return strAry.join("+");
}

function gen(node) {
  if (node.type === 1) {
    return generate(node);
  } else {
    return `_v(${genText(node.text)})`;
  }
}

// 生成属性
function genProps(el) {
  const attrs = el.attrs;
  if (attrs && attrs.length) {
    //attrs ==>  [{name: 'style', value: 'color: red;background: #ccc;'},{name: 'id', value:'app'}]
    // 需要变成 ==> {style: {color: 'red', background: '#ccc'}, id: 'app'}
    function formatAttrs(attrs) {
      return attrs.reduce((pre, cur) => {
        const { name, value } = cur;
        if (name === "style") {
          const valueAry = value.split(";").map((item) => {
            const [name, value] = item.split(":");
            return { name, value };
          });
          pre[name] = formatAttrs(valueAry);
        } else {
          name && (pre[name] = value);
        }
        return pre;
      }, {});
    }
    return JSON.stringify(formatAttrs(attrs));
  }
  return "undefined";
}

// 生成Code
export function generate(el) {
  return `_c("${el.tag}",${genProps(el)},${geneChildrens(el) || '""'})`;
}

这里有几个注意点:

  1. 在生成属性的时候, 仅做了常规属性以及style的转换, 且style里面类似font-size中横线连接的属性,没有做转小驼峰命名处理
  2. 在用正则匹配hello ---- 岁这种差值表达式语法时候,用的是 exec 方法:
js
let reg = 'hello {{name}}----{{age}}'
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // 匹配 {{}} 部分
defaultTagRE.exec(reg); //  ['{{name}}', 'name', index: 6, input: 'hello {{name}}----{{age}}', groups: undefined]

一次只能匹配一个若需要将一段文本内的匹配完, 需要通过循环匹配, 正则循环匹配会存在一个lastIndex的问题, 因此需要在每次匹配的时候处理偏移量, 这里采用的方法为, 匹配一段, 丢弃一段, 再将lastIndex重置为0

code生成renderFn

如上所示, code为字符串, 要转为render函数, 需要通过new Function()来实现, 这里需要考虑作用域的问题,code在后期执行的时候, this都会是vm,所以用with(this){return code}code中的this指向vm

js
  const code = generate(root);

  /**
   * 将js代码转换为render函数返回
   * 通过new Function 将原始的code字符串转化为函数, 中间需要注意作用域问题, _c _v _s 调用时候, this指向应该为vm,
   * 包括code中 "{{}}" 之间的变量(例如: name)取值的时候, this指向应该为vm, 因此这里用到with, renderFn最终是vm在调用,
   * 所以with(this)里面的this会指向vm, 下面的逻辑就顺理成章了
   */
  const render = `with(this){return ${code}}`; // 改变code中的this
  const renderFn = new Function(render);

最终得到的new Function(...)就是编译模板后得到的render函数

小结

  1. 数据初始化完成后,需要初渲染,渲染就需要用到render函数
  2. vue中渲染优先级最高的就是render函数, 其次是template,最后是el
  3. 没有render函数的时候回通过template编译得到render函数, 在没有template时候, 会用el元素及其子元素作为模板编译得到render函数
  4. 模板编译的过程为解析Html得到ast语法树, 再由ast语法树转为code字符串, code中包含_c _s _v等等一些原型上的函数(这一块后面会讲到), 作用都是为了创建虚拟DOM
  5. 为了解决render函数中的this问题, 使用with(this){return code}保证code中的this指向vm, 最后通过new Function将字符串类型的代码转为真正的render函数