vue 源码分析:模板编译

380 阅读3分钟

前言

本文内容是在上一章——数据劫持的基础之上编写,建议小伙们先去查看。本文主要参考 vue2.0 源码和小野森森,因只围绕核心原理进行编写,所以更易理解(不严谨之处,还请谅解)。最后,奉上案例源码,以便大家学习理解。

目录结构

下方图片中画红色圆圈的是新增的目录和文件

目录.jpg

代码解析

vue/index.js 入口

同上一篇数据劫持相比,这里主要新增了两个函数:lifecycleMixin 和 renderMixin。关于它们的具体作用,稍后会做详细介绍。接下来我们先看看如何编译模板。

import { initMixin } from './init';
import { lifecycleMixin } from './lifecycle';
import { renderMixin } from './vdom/index';

function Vue(options) {
  // 通过关键字 new 创建 Vue实例时,便会调用 Vue 原型方法 _init 初始化数据
  this._init(options);
}

// 初始化相关操作,主要是在 Vue.prototype 上挂载 _init() 和 $mount() 方法
initMixin(Vue);
// 生命周期相关操作,主要是在 Vue.prototype 上挂载 _update 方法
lifecycleMixin(Vue);
// 渲染相关操作,主要是在 Vue.prototype 上挂载 _render()、 _c()、 _v() 和  _s()函数
renderMixin(Vue);

export default Vue;

vue/init.js 初始化

上一篇文章相比,在初始化的过程中,我们不在仅是调用 initState(vm) 对 data 数据进行处理。而是在处理完数据之后,又调用 vm.mount(vm.mount(vm.options.el) 挂载并编译模板,从而生成 AST 抽象语法树和 render 渲染函数 。

import { initState } from './state';
import { compileToFunctions } from './compiler';
import { mountComponent } from './lifecycle';

function initMixin(Vue) {
  Vue.prototype._init = function (options) {
    const vm = this; // 存储 this( Vue实例 )
    vm.$options = options; // 将 options 挂载到 vm 上,以便后续使用

    // Vue 实例中的 data、 props、methods、computed 和 watch,都会在 initState 函数中
    // 进行初始化。由于我们主要解说:Vue 数据劫持,所以只对 data 进行处理。
    initState(vm);

    if (vm.$options.el) {
      // Vue.prototype.$mount --> 挂载函数
      vm.$mount(vm.$options.el);
    }
  }

  Vue.prototype.$mount = function (el) {
    const vm = this;
    const options = vm.$options;

    // Vue 选项中的 render 函数若存在,则 Vue 构造函数不会从 
    // template 选项或通过 el 选项指定的挂载元素中提取出的 HTML 模板编译渲染函数。

    // 处理模板(优先级): render  >  template   >  html模板

    // 若是 render 函数不存在,就生成 render
    if (!options.render) {

      let template = options.template; // 获取模板

      // el存在,且 template 不存在
      if (el && !template) {
        // 挂载 el( HTML 模板),以便在实例的 _update 方法中使用
        vm.$el = document.querySelector(el);
        template = vm.$el.outerHTML;
      }

      // 编译模板,生成 AST 抽象语法树并将其生成渲染函数 render
      const render = compileToFunctions(template);
      options.render = render; // 挂载 render
    }
    
    mountComponent(vm); // 挂载组件
  }
}

export {
  initMixin
}

vue/compiler/index.js 编译模板生成 AST 和 render 函数

import { parseHtml } from './parser';
import { generate } from './generate';

//编译:HTML字符串( template ) => AST => render
function compileToFunctions(html) {
  // 解析 HTML字符串,将其转换成 AST 抽象语法树
  const ast = parseHtml(html);
  // 将 AST 转换成字符串函数
  const code = generate(ast);
  // 生成 render 渲染函数(with语句,是理解此段代码的关键)
  const render = new Function(`with(this){ return ${code} }`);

  return render;
}

export {
  compileToFunctions
}

vue/compiler/parser.js 生成 AST 抽象语法树

// 匹配属性: id="app"、id='app' 或 id=app
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;
// 匹配标签:<my-header></my-header>
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;
// 匹配标签:<my:header></my:header>
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
// 匹配开始标签:<div
const startTagOpen = new RegExp(`^<${qnameCapture}`);
// 匹配闭合标签: > 或 />
const startTagClose = /^\s*(\/?)>/;
// 匹配结束标签: </div>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`);

/*
  假设模板样例:
   <div id="app" style="color: #f66;font-size: 20px;">
    函数字符串,{{ tip }}
    <span class="cla">{{ studentNum }}</span>
  </div>
*/

// 解析模版字符串,生成 AST 语法树
function parseHtml(html) {
  const stack = []; // 所有开始标签的初始 AST 对象
  let root; // 最终返回的 AST 对象
  let text; // 纯文本
  let currentParent; // 当前元素的父级

  // vue2.0 源码中对以下几种情况分别进行了处理:注释标签、条件注释标签、Doctype、
  // 开始标签、结束标签。而每当处理完一种情况时,都会阻断代码继续往下执行且开启新
  // 的一轮循环(注:使用 continue 实现 ),并且会重置 html 字符串,也就是删掉匹配
  // 到的 html 字符串,保留未匹配的 ,以便在下一次循环处理。

  // 提示:在解读以上几种情况的源码时,配合模板样例来理解会让你更容易明了。
  
  while (html) {
    // textEnd 为 0,则说明是一个开始标签。
    let textEnd = html.indexOf('<');

    if (textEnd === 0) {
      // 解析开始标签及其属性并将其存放在一个对象中返回,例如:
      // { tagName: 'div', attrs: [{ name: 'id', value: 'app' }] }
      const startTagMatch = parseStartTag();
      // console.log('解析——开始标签——结果', startTagMatch);

      // 处理开始标签
      if (startTagMatch) {
        start(startTagMatch.tagName, startTagMatch.attrs);
        continue; // 执行到 continue,将开始新的一轮循环,后续代码不会执行
      }

      const endTagMatch = html.match(endTag); // 匹配结束标签

      // 处理结束标签
      if (endTagMatch) {
        advance(endTagMatch[0].length);
        end(endTagMatch[1]);
        continue;
      }
    }
    // 截取 HTML 模版字符串中的文本
    if (textEnd > 0) {
      text = html.substring(0, textEnd);
    }
    // 处理文本内容
    if (text) {
      advance(text.length);
      chars(text);
    }
  }

  // 解析开始标签及其属性,例如:<div id="app">
  function parseStartTag() {
    // 如果没有找到任何匹配的文本, match() 将返回 null。否则,它返回一个数组,
    // 其中存放了与它找到的匹配文本有关的信息。
    const start = html.match(startTagOpen); // 匹配开始标签
    let end, attr;
    if (start) {
      // 存放开始标签名和属性
      const match = {
        tagName: start[1], // 开始标签的名,例如:div
        attrs: [] // 开始标签的属性,例如:{ name: 'id', value: 'app' }
      }

      // 删除已匹配到的 HTML 字符串,保留未匹配到的。
      // 例如:匹配到 <div id="app"></div> 中的 <div,调用 advance() 方法后,
      // 原 HTML 字符窜就是这样:id="app"></div>
      advance(start[0].length);

      // 当匹配到属性( 形如:id='app'),但未匹配到开始标签的闭合( 形如:> )时,进入循环
      while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
        match.attrs.push({
          name: attr[1], // 属性名: id
          // 若是你在通过 new 关键字创建 vue 实例时,提供了 template 选项
          // 且在它的字符串中,有的标签的属性使用的是单引号或者没有带引号,
          // 例如:<div id='app'></div> 或 <div id=app></div> 这种形式,那么在匹配
          // 标签的属性时,其返回的数组中这个属性的值,可能在此数组的 下标4 或 下标5
          value: attr[3] || attr[4] || attr[5] // 属性值: app
        });

        advance(attr[0].length);
      }

      // 如果匹配到开始标签的闭合( 形如:> ),则返回 match 对象
      if (end) {
        advance(end[0].length);
        return match;
      }
    }
  }
  // 截取 HTML 字符串,将已匹配到的字符从原有字符中删除。
  function advance(n) {
    // substring() 方法用于提取字符串中介于两个指定下标之间的字符。
    html = html.substring(n);
  }

  function start(tag, attrs) {
    // 创建 AST 对象
    const element = createASTElement(tag, attrs);
    // 如果 root 根节点不存在,则说明当前节点即是整个模版的最顶层节点,也就是第一个节点
    if (!root) {
      root = element;
    }
    // 保存当前父节点(AST 对象)
    currentParent = element;
    // 将 AST 对象 push 到 stack 栈中,当解析到其相对应的结束标签时,
    // 则将这个标签对应的 AST 对象 从栈中 pop 出来。

    // 原因:解析开始标签时,是顺时针;解析结束标签时,是逆时针。结合模板样例看,
    // 解析顺序如下:<div> => <span> => ...  => </span> => </div>

    // 因此,解析开始标签生成的 AST 对象被 push 到栈中后,若想在解析到其相应的结束标签时 
    // 取出,则要使用 pop。整个操作流程,结合 start() 和 end() 方法一起看,会更易理解。

    stack.push(element);
  }

  function end(tag) {
    // pop() 方法将删除数组的最后一个元素,把数组长度减 1,并且返回它删除的元素的值。
    // 如果数组已经为空,则 pop() 不改变数组,并返回 undefined 值。

    const element = stack.pop(); // 获取当前元素标签的 AST 对象
    currentParent = stack[stack.length - 1]; // 获取当前元素标签的父级 AST 对象

    if (currentParent) {
      // 标记父子元素
      element.parent = currentParent; // 子元素存储父元素
      currentParent.children.push(element); // 父元素存入子元素
    }
  }

   function chars(text) {
    text = text.trim(); // 去掉首尾空格

    // 若文本存在,则直接放入父级的 children 中
    if (text && text !== ' ') {
      const element = {
        type: 3, // 文本元素的节点类型(nodeType):3
        text
      };
      
      currentParent.children.push(element);
    }
  }

  return root;
}

// 生成 AST 对象
function createASTElement(tagName, attrs) {
  return {
    tag: tagName, // 标签名
    type: 1, // 标签元素的节点类型(nodeType):1
    children: [], // 标签子级
    attrs, // 标签属性
    parent // 标签父级
  }
}


export {
  parseHtml
}

vue/compiler/generate.js 生成字符串函数

generate() 函数返回的变量 code,是一个字符串函数,它是生成 render 渲染函数的关键所在。

/* 
  以下三个个函数的作用:

  _c() => createElement() 创建元素节点

  _v() => createTextNode() 创建文本节点

  _s(value) => _s(tip) 解析双大括号,例如:{{tip}}
  
  
  AST => 字符串函数,最终结果:

  function generate() {
    return `_c("div",{id: "app",style:{ "color":"#f66","font-size":"20px"}}, 
    _v("字符串,"+_s(tip)),_c("span", { "class": "cla", "style": { "color": "green" } },
    _v(_s(studentNum))))`;
  }
*/

const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // 匹配双大括号 => {{tip}}

// 生成函数字符串
function generate(el) {
  const children = genChildren(el);
  const code = `_c('${el.tag}', ${el.attrs.length > 0 ? `${jointAttrs(el.attrs)}` : 'undefined'}${children ? `,${children}` : ''})`;

  return code;
}

// 将属性拼接成字符串,例如:`style:{ "color":"#f66","font-size":"20px"}`
function jointAttrs(attrs) {
  let str = '';

  for (let i = 0; i < attrs.length; i++) {
    let attr = attrs[i];
    // 处理 style 属性
    if (attr.name === 'style') {
      let attrValue = {};

      attr.value.split(';').map((itemArr) => {
        let [key, value] = itemArr.split(':');
        if (key) {
          attrValue[key] = value;
        }
      });
      attr.value = attrValue;
    }
    // 拼接属性(注意:不要忘记逗号)
    str += `${attr.name}:${JSON.stringify(attr.value)},`
  }

  // str.slice(0, -1) 是为了去掉字符串最后一个逗号
  return `{${str.slice(0, -1)}}`;
}

// 生成子节点
function genChildren(el) {
  const children = el.children;
  // 是否存在子节点
  if (children.length) {
    return children.map(c => genNode(c)).join(',');
  }
}

// 根据节点类型的不同进行相应处理
function genNode(node) {
  if (node.type === 1) { // 元素节点

    return generate(node);
  } else if (node.type === 3) { // 文本节点

    let text = node.text;

    if (defaultTagRE.test(text)) { // 处理双大括号
      let match,
      index,
      textArray = [],
      // lastIndex 下一次匹配开始的位置。每次循环时,都将其初始为 0,是为防止处理其它文本时,
      // 取到 lastIndex 是上一个循环结束后保留下的值而导致出错。
      lastIndex = defaultTagRE.lastIndex = 0;

      // 样例参考:<div>函数字符串,{{ tip }} 哈哈</div>

      // 处理双大括号和其之前的纯文本:函数字符串,{{ tip }}
      while (match = defaultTagRE.exec(text)) {

        index = match.index; // 双大括号的下标位置

        if (index > lastIndex) { // 截取双大括号前面的纯文本
          textArray.push(JSON.stringify(text.slice(lastIndex, index)));
        }

        textArray.push(`_s(${match[1].trim()})`); // 双大括号
        lastIndex = index + match[0].length; // 标记下一次匹配开始的位置
      }

      // 处理双大括号之后的存文本:哈哈
      if (lastIndex < text.length) {
        textArray.push(JSON.stringify(text.slice(lastIndex)));
      }

      return `_v(${textArray.join('+')})`; // 拼接整行文本

    } else { // 处理纯文本

      return `_v(${JSON.stringify(text)})`;
    }

  }
}

export {
  generate
}

vue/lifecycle.js 更新组件

还记得吗?在vue/index.js 入口文件,我们执行了 lifecycleMixin(Vue) 函数,它的主要作用(在 vue 源码中此函数作用可不止于此):是用来更新组件,也就是我们的模板。这也是,为何我们可以在 vue/init.js 中执行 mountComponent() 函数来更新组件的原因。

import { patch } from './vdom/patch';

function mountComponent(vm) {
  // vm._render() 返回虚拟节点 vnode
  vm._update(vm._render()); // 更新组件
}

function lifecycleMixin(Vue) {
  // 挂载 _update() 更新函数
  Vue.prototype._update = function (vnode) {
    const vm = this;
    patch(vm.$el, vnode); // 将 vnode 虚拟节点生成相应的 HTML 元素
  }
}

export {
  lifecycleMixin,
  mountComponent
}

vue/vdom/index.js 挂载 _render()、 _c()、 _v() 和 _s()函数

vue/index.js入口文件中调用的 renderMixin() 函数,就是从当前这个文件模块中导出的。它的作用,已在下方的代码注释中标明。

import { createElement, createTextNode } from './vnode';

function renderMixin(Vue) {
  // 创建虚拟元素节点对象
  Vue.prototype._c = function () {
    return createElement(...arguments);
  }
 
  // 创建虚拟文本节点对象
  Vue.prototype._v = function (text) {
    return createTextNode(text);
  }

   // 处理双大括号,例如:{{tip}}
   Vue.prototype._s = function (value) {
    if (value === null) return;
    return typeof value === 'object' ? JSON.stringify(value) : value;
  }

  // 调用 vm.$options.render 渲染函数,生成虚拟节点
  Vue.prototype._render = function () {
    const vm = this;
    const vnode = vm.$options.render.call(vm); // 生成虚拟节点对象并返回

    return vnode;
  }
}

export {
  renderMixin
}

vue/vdom/vnode.js 生成虚拟节点对象

createElement() 函数和 createTextNode() 函数,分别由于创建元素虚拟节点和文本虚拟节点

// 元素 vnode
function createElement (tag, attrs = {}, ...children) {
  return vnode(tag, attrs, children);
}
// 文本 vnode
function createTextNode (text) {
  return vnode(undefined, undefined, undefined, text);
}

// vnode(虚拟节点)对象
function vnode (tag, props, children, text) {
  return {
    tag,
    props,
    children,
    text
  }
}

export {
  createElement,
  createTextNode
}

vue/vdom/patch.js 将虚拟节点转换成 HTML 元素

/**
 * 样例展示:结合 patch函数中的 insertBefore 和 removeChild 方法看
 * <body>
 *  <div id="app"></div> 原有的
 *  <div id="app"></div> 新生成的
 *  <script></script>
 * </body>
 * 
 */

/**
 * 将 vnode 虚拟节点生成相应的 HTML 元素
 * @param { HTMLDivElement } template => html
 * @param { Object } vNode => 虚拟节点对象
 */ 
function patch(template, vNode) {
  const el = createElement(vNode);
  // template.parentNode => body
  const parentElement = template.parentNode;
  // 将新生成的元素插入到 body中。在 template 的后面,script标签的前面。
  parentElement.insertBefore(el, template.nextSibling);
  parentElement.removeChild(template); // 移除原有节点
}

// 创建节点(为求简便,逻辑上并未最求严谨,但是它能跑!)
function createElement(vnode) {
  const { tag, props, children, text } = vnode;

  if (typeof tag === 'string') {
    vnode.el = document.createElement(tag); // 创建元素
    updateProps(vnode); // 为元素设置属性
    children.map((child) => {
      // 为父级元素添加子元素
      vnode.el.appendChild(createElement(child));
    })
  } else {
    // 创建纯文本节点
    vnode.el = document.createTextNode(text);
  }

  return vnode.el;
}

// 为元素设置属性,这里主要处理了 style 和 class
function updateProps(vnode) {
  const el = vnode.el;
  const nodeAttrs = vnode.props || {};

  for (let key in nodeAttrs) {
    if (key === 'style') { // 设置 style 属性
      for (let sKey in nodeAttrs.style) {
        el.style[sKey] = nodeAttrs.style[sKey];
      }
    } else if (key === 'class') { // 设置 class 属性
      el.className = el.class;
    } else {
      // 设置自定义属性,并未做特殊处理
      el.setAttribute(key, nodeAttrs[key]);
    }
  }
}

export {
  patch
}

结束语

文章内看不明白的地方,不要一直瞪眼歪头想,可以将案例源码下载下来,边看边调试。好了,快速掌握的秘诀告诉你了,加油干吧,骚年!