Vue2源码学习笔记(二)——编译模板原理

前言

Vue 2的模板编译原理具体可以分为以下几步:

  1. 模板解析:Vue的编译器会将模板字符串解析成抽象语法树(AST),这个AST表示了模板的结构和内容。

  2. 静态分析:编译器会对AST进行静态分析,检测模板中的指令、表达式和属性等,并生成相应的代码。

  3. 优化处理:编译器会对模板进行优化处理,包括静态节点的标记、静态属性的提取和静态文本的优化等。这些优化可以减少运行时的开销,提高渲染性能。

  4. 代码生成:根据AST生成可执行的渲染函数。渲染函数是一个JavaScript函数,它接收数据作为参数,并返回一个虚拟DOM节点。

  5. 渲染函数执行:在组件实例化或数据更新时,会调用渲染函数来生成虚拟DOM节点。渲染函数会根据数据的变化,生成新的虚拟DOM节点,并与旧的虚拟DOM节点进行比较,找出差异并更新实际的DOM。

通过模板编译,Vue将模板转换为可执行的渲染函数,这样在组件实例化和数据更新时,可以快速生成和更新虚拟DOM节点,从而实现高效的视图更新。编译过程中的优化处理可以减少不必要的计算和操作,提高渲染性能。同时,模板编译也提供了更简洁、易读和可维护的模板语法,使开发者能够更方便地编写和维护Vue组件的模板。

简单的例子看看图一乐吧:

js 复制代码
// 假设有一个名为parseTemplate的函数,用于将模板解析成AST
function parseTemplate(template) {
  // 解析模板,生成AST
  // 省略具体实现
  return ast;
}

// 假设有一个名为optimize的函数,用于优化AST
function optimize(ast) {
  // 优化AST
  // 省略具体实现
  return optimizedAst;
}

// 假设有一个名为generateCode的函数,用于根据AST生成代码
function generateCode(ast) {
  // 根据AST生成代码
  // 省略具体实现
  return code;
}

// 假设有一个名为compileTemplate的函数,用于编译模板
function compileTemplate(template) {
  // 解析模板,生成AST
  const ast = parseTemplate(template);

  // 优化AST
  const optimizedAst = optimize(ast);

  // 根据AST生成代码
  const code = generateCode(optimizedAst);

  // 返回生成的代码
  return code;
}

// 示例使用
const template = `
  <div>
    <h1>{{ title }}</h1>
    <p v-if="showMessage">{{ message }}</p>
    <button @click="handleClick">Click me</button>
  </div>
`;

const compiledCode = compileTemplate(template);
console.log(compiledCode);

接下来我们看看具体室怎么实现的吧。

1.模板编译入口

上篇响应式原理中就说过中initMixin不仅仅会对初始化状态,还会在这个时候调用$mount方法进行挂载,vue也是在这个地方对模版进行编译的。

js 复制代码
// src/init.js

import { initState } from "./state";
import { compileToFunctions } from "./compiler/index";
export function initMixin(Vue) {
  Vue.prototype._init = function (options) {
    const vm = this;
    // 这里的this代表调用_init方法的对象(实例对象)
    //  this.$options就是用户new Vue的时候传入的属性
    vm.$options = options;
    // 初始化状态
    initState(vm);

    // 如果有el属性 进行模板渲染
    if (vm.$options.el) {
      vm.$mount(vm.$options.el);
    }
  };

  // 这块代码在源码里面的位置其实是放在entry-runtime-with-compiler.js里面
  // 代表的是Vue源码里面包含了compile编译功能 这个和runtime-only版本需要区分开
  Vue.prototype.$mount = function (el) {
    const vm = this;
    const options = vm.$options;
    el = document.querySelector(el);

    // 如果不存在render属性
    if (!options.render) {
      // 如果存在template属性
      let template = options.template;

      if (!template && el) {
        // 如果不存在render和template 但是存在el属性 直接将模板赋值到el所在的外层html结构(就是el本身 并不是父元素)
        template = el.outerHTML;
      }

      // 最终需要把tempalte模板转化成render函数
      if (template) {
        const render = compileToFunctions(template);
        options.render = render;
      }
    }
  };
}

首先需要对模板进行转化,其中核心方法就是compileToFunctions。

js 复制代码
// src/compiler/index.js

import { parse } from "./parse";
import { generate } from "./codegen";
export function compileToFunctions(template) {
  // 我们需要把html字符串变成render函数
  // 1.把html代码转成ast语法树  ast用来描述代码本身形成树结构 不仅可以描述html 也能描述css以及js语法
  // 很多库都运用到了ast 比如 webpack babel eslint等等
  let ast = parse(template);
  // 2.优化静态节点
     if (options.optimize !== false) {
       optimize(ast, options);
     }

  // 3.通过ast 重新生成代码
  // 我们最后生成的代码需要和render函数一样
  // 类似_c('div',{id:"app"},_c('div',undefined,_v("hello"+_s(name)),_c('span',undefined,_v("world"))))
  // _c代表创建元素 _v代表创建文本 _s代表文Json.stringify--把对象解析成文本
  let code = generate(ast);
  //   使用with语法改变作用域为this  之后调用render函数可以使用call改变this 方便code里面的变量取值
  let renderFn = new Function(`with(this){return ${code}}`);
  return renderFn;
}

2.模板解析

解析Html并生成ast

  • 首先需要各种规则匹配的正则表达式(开始标签,结束标签,花括号等)

  • createASTElement:将某一节点转为AST对象的函数

  • handleStartTag: 处理开始标签的函数

  • handleEndTag:处理结尾标签的函数

  • handleChars:处理文本节点的函数

  • parse:转AST的入口函数

js 复制代码
// src/compiler/parse.js

// 以下为源码的正则  对正则表达式不清楚的同学可以参考小编之前写的文章(前端进阶高薪必看 - 正则篇);
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`; //匹配标签名 形如 abc-123
const qnameCapture = `((?:${ncname}\\:)?${ncname})`; //匹配特殊标签 形如 abc:234 前面的abc:可有可无
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 匹配标签开始 形如 <abc-123 捕获里面的标签名
const startTagClose = /^\s*(\/?)>/; // 匹配标签结束  >
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配标签结尾 如 </abc-123> 捕获里面的标签名
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性  形如 id="app"

let root, currentParent; //代表根节点 和当前父节点
// 栈结构 来表示开始和结束标签
let stack = [];
// 标识元素和文本type
const ELEMENT_TYPE = 1;
const TEXT_TYPE = 3;
// 生成ast方法
function createASTElement(tagName, attrs) {
  return {
    tag: tagName,
    type: ELEMENT_TYPE,
    children: [],
    attrs,
    parent: null,
  };
}

// 对开始标签进行处理
function handleStartTag({ tagName, attrs }) {
  let element = createASTElement(tagName, attrs);
  if (!root) {
    root = element;
  }
  currentParent = element;
  stack.push(element);
}

// 对结束标签进行处理
function handleEndTag(tagName) {
  // 栈结构 []
  // 比如 <div><span></span></div> 当遇到第一个结束标签</span>时 会匹配到栈顶<span>元素对应的ast 并取出来
  let element = stack.pop();
  // 当前父元素就是栈顶的上一个元素 在这里就类似div
  currentParent = stack[stack.length - 1];
  // 建立parent和children关系
  if (currentParent) {
    element.parent = currentParent;
    currentParent.children.push(element);
  }
}

// 对文本进行处理
function handleChars(text) {
  // 去掉空格
  text = text.replace(/\s/g, "");
  if (text) {
    currentParent.children.push({
      type: TEXT_TYPE,
      text,
    });
  }
}

// 解析标签生成ast核心
export function parse(html) {
  while (html) {
    // 查找<
    let textEnd = html.indexOf("<");
    // 如果<在第一个 那么证明接下来就是一个标签 不管是开始还是结束标签
    if (textEnd === 0) {
      // 如果开始标签解析有结果
      const startTagMatch = parseStartTag();
      if (startTagMatch) {
        // 把解析好的标签名和属性解析生成ast
        handleStartTag(startTagMatch);
        continue;
      }

      // 匹配结束标签</
      const endTagMatch = html.match(endTag);
      if (endTagMatch) {
        advance(endTagMatch[0].length);
        handleEndTag(endTagMatch[1]);
        continue;
      }
    }

    let text;
    // 形如 hello<div></div>
    if (textEnd >= 0) {
      // 获取文本
      text = html.substring(0, textEnd);
    }
    if (text) {
      advance(text.length);
      handleChars(text);
    }
  }

  // 匹配开始标签
  function parseStartTag() {
    const start = html.match(startTagOpen);

    if (start) {
      const match = {
        tagName: start[1],
        attrs: [],
      };
      //匹配到了开始标签 就截取掉
      advance(start[0].length);

      // 开始匹配属性
      // end代表结束符号>  如果不是匹配到了结束标签
      // attr 表示匹配的属性
      let end, attr;
      while (
        !(end = html.match(startTagClose)) &&
        (attr = html.match(attribute))
      ) {
        advance(attr[0].length);
        attr = {
          name: attr[1],
          value: attr[3] || attr[4] || attr[5], //这里是因为正则捕获支持双引号 单引号 和无引号的属性值
        };
        match.attrs.push(attr);
      }
      if (end) {
        //   代表一个标签匹配到结束的>了 代表开始标签解析完毕
        advance(1);
        return match;
      }
    }
  }
  //截取html字符串 每次匹配到了就往前继续匹配
  function advance(n) {
    html = html.substring(n);
  }
  //   返回生成的ast
  return root;
}

3.静态分析和优化处理

js 复制代码
 function optimize(ast,option) {
  // 遍历AST的所有节点
  traverse(ast, {
    // 对于每个元素节点
    ElementNode(node) {
      // 检查节点是否有静态属性
      if (hasStaticAttributes(node)) {
        // 标记节点为静态节点
        node.static = true;
      }
    },
    // 对于每个文本节点
    TextNode(node) {
      // 检查节点是否为静态文本
      if (isStaticText(node)) {
        // 标记节点为静态节点
        node.static = true;
      }
    }
  });

  return ast;
}

// 辅助函数:检查节点是否有静态属性
function hasStaticAttributes(node) {
  // 检查节点的属性是否都是静态的
  return node.attributes.every(attr => attr.static);
}

// 辅助函数:检查节点是否为静态文本
function isStaticText(node) {
  // 检查文本节点的内容是否是静态的
  return !/\{\{.*\}\}/.test(node.content);
}

// 辅助函数:遍历AST的所有节点
function traverse(ast, visitor) {
  function traverseNode(node) {
    // 调用对应节点类型的处理函数
    const handler = visitor[node.type];
    if (handler) {
      handler(node);
    }

    // 递归遍历子节点
    if (node.children) {
      node.children.forEach(traverseNode);
    }
  }

  traverseNode(ast);
}

// 示例使用
const ast = {
  type: 'ElementNode',
  tag: 'div',
  attributes: [
    { name: 'class', value: 'container', static: true },
    { name: 'id', value: 'app', static: true }
  ],
  children: [
    {
      type: 'TextNode',
      content: 'Hello, world!',
      static: true
    }
  ]
};

const optimizedAst = optimize(ast);
console.log(optimizedAst);

4.代码生成(将AST转换成render函数格式的数据)

js 复制代码
// src/compiler/gen.js

const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; //匹配花括号 {{  }} 捕获花括号里面的内容

function gen(node) {
    if (node.type === 1) {
        // 元素节点处理
        return generate(node)
    } else {
        // 文本节点处理
        const text = node.text

        // 检测是否有花括号{{}}
        if (!defaultTagRE.test(text)) {
            // 没有的话直接返回 _v,创建文本节点
            return `_v(${JSON.stringify(text)})`
        }


        // 每次赋值完要重置defaultTagRE.lastIndex
        // 因为正则规则加上全局g的话,lastIndex会逐步递增,具体可以百度查一查正则的全局g情况下的test方法执行后的lastIndex
        let lastIndex = (defaultTagRE.lastIndex = 0);
        const tokens = []
        let match, index

        while ((match = defaultTagRE.exec(text))) {
            // 文本里只要还存在{{}}就会一直正则匹配
            index = match.index
            if (index > lastIndex) {
                // 截取{{xxx}}中的文本xxx
                tokens.push(JSON.stringify(text.slice(lastIndex, index)))
            }

            tokens.push(`_s(${match[1].trim()})`)


            // 推进lastIndex
            lastIndex = index + match[0].length

        }

        // 匹配完{{}}了,但是还有剩余的文本,那就还是push进去
        if (lastIndex < text.length) {
            tokens.push(JSON.stringify(text.slice(lastIndex)))
        }

        // return _v函数创建文本节点
        return `_v(${tokens.join('+')})`
    }

}


// 生成render函数格式的code的函数
function generate(el) {
    const children = getChildren(el)
    const code = `_c('${el.tag}',${el.attrs.length ? `${genProps(el.attrs)}` : "undefined"
        }${children ? `,${children}` : ""})`;;
    return code
}

// 处理attrs的函数
function genProps(attrs) {
    let str = ''
    for (let i = 0; i < attrs.length; i++) {
        const attr = attrs[i]

        if (attr.name === 'style') {
            const obj = {}

            attr.value.split(';').forEach(item => {
                const [key, value] = item.split(':')
                obj[key] = value
            })
            attr.value = obj
        }
        str += `${attr.name}:${JSON.stringify(attr.value)},`
    }
    return `{${str.slice(0, str.length)}}`
}

// 获取子节点,进行gen的递归
function getChildren(el) {
    const children = el.children
    if (children && children.length) {
        return `${children.map(c => gen(c)).join(',')}`
    }
}

module.exports = {
    generate
}

5.思维导图

参考文章

手写Vue2.0源码(二)-模板编译原理|技术点评 - 掘金 (juejin.cn)

相关推荐
小远yyds13 分钟前
前端Web用户 token 持久化
开发语言·前端·javascript·vue.js
程序媛小果31 分钟前
基于java+SpringBoot+Vue的宠物咖啡馆平台设计与实现
java·vue.js·spring boot
小光学长36 分钟前
基于vue框架的的流浪宠物救助系统25128(程序+源码+数据库+调试部署+开发环境)系统界面在最后面。
数据库·vue.js·宠物
吕彬-前端1 小时前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱1 小时前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
guai_guai_guai1 小时前
uniapp
前端·javascript·vue.js·uni-app
bysking2 小时前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
王哲晓3 小时前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js
fg_4113 小时前
无网络安装ionic和运行
前端·npm
理想不理想v3 小时前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试