【Vue2.x 源码学习】第十三篇 - 生成 ast 语法树 - 正则说明

1,102 阅读2分钟

一,前言

上篇,主要介绍了 vue 数据渲染核心流程,涉及以下几个点:

  • Vue 核心渲染流程回顾
  • 三种模板写法及优先级
  • 两种数据挂载方式
  • Vue 的原型方法 $mount
  • compileToFunction -> parserHTM流程说明

本篇,生成 ast 语法树-正则说明


二,模板解析

1,模板解析的说明

上文说到,compileToFunction方法是Vue模板编译的入口:

export function compileToFunction(template) {
  // 1,将模板变成 AST 语法树
  let ast = parserHTML(template);
  // 2,根据 AST 语法树生成 render 函数
  let code = generate(ast);
}

在上面的compileToFunction方法中,主要做了以下两件事:

  1. 将模板变成AST语法树;
  2. 根据AST语法树生成render函数;

html模板编译为ast语法树,就是用js对象的树形结构来描述HTML语法;

这里,需要对html模板进行解析,而解析的方式就是使用正则不断地进行匹配和处理;

构建 ast 语法树有很多可用插件,例如:html 的解析器 htmlparser2

Vue中,由于存在自定义指令等框架特性功能,所以Vueast解析逻辑是定制实现的;

一个AST在线解析网站:astexplorer.net/

image.png

从示例中可以看出,ast抽象语法树就是使用js来描述html语法;

3,模板解析的方式

使用正则对html模板进行顺序解析和处理,每处理完一段,就将这部分截取掉,就这样不停的进行解析和截取,直至将整个模板全部解析完毕;

html模板示例:

<!-- start:从头开始,使用正则不断进行匹配和截取 -->

<!-- 1,解析开始标签,并截取删除 -->
<div>abcdefg<span></span></div>		开始标签:<div>
<!-- 2,解析文本内容,并截取删除 -->
abcdefg<span></span></div>		文本内容:abcdefg
<!-- 3,解析开始标签,并截取删除 -->
<span></span></div>			开始标签:<span>
<!-- 4,解析结束标签,并截取删除 -->
</span></div>				结束标签:</span>
<!-- 5,解析结束标签,并截取删除 -->
</div>					结束标签:</div>

<!-- end:全部匹配完成 -->

根据示例的逻辑地点,在parserHTML方法中,可以使用while循环对模板不停执行截取操作,直至全部解析完毕后,即可构建出ast语法树:

function parserHTML(html) {
  while(html){
    // todo: 解析 html 并构建 ast 语法树
  }
}

还有一个问题,那就是:如何判断html的内容是“标签”还是“文本”?

方法:判断内容开头的第一个字符是否为尖角号<

  • 如果是尖角号,说明是标签;
  • 如果不是尖角号,说明是文本

三,正则表达式说明

1,Vue2 正则相关说明

Vue2源码中,使用正则对html标签和内容进行匹配并解析出结果,涉及相关正则如下:

// 标签名 a-aaa
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;  
// 命名空间标签 aa:aa-xxx
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
// 开始标签-捕获标签名
const startTagOpen = new RegExp(`^<${qnameCapture}`); 
// 结束标签-匹配标签结尾的 </div>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`);
// 匹配属性
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; 
// 匹配标签结束的 >
const startTagClose = /^\s*(\/?)>/;
// 匹配 {{ }} 表达式
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;

在正则解析开始之前,还需要了解一些前置知识点:

  • 在字符串中,存在转译行为,\具有转译含义;(以上正则都是字符串)
  • 字符串需要通过new RegExp(xxx)才能转换为正则表达式对象;
  • 在正则表达式中,同样也存在转译行为,\也具有转译含义;
  • 正则表达式的开始和结尾都被/包裹;

js 正则可视化工具:jex.im/regulex

2,匹配标签名

标签说明

// 匹配标签名 aa-xxx
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;  

正则解析

1,字符串转译:

  • 第一个\:用来转译第二个\,所以第一组\\相当于\
  • 第三个\:用来转译第四个\,所以第二组\\也相当于\

2,正则表达式转译:

  • 字符串\\-转译后,成为正则中的\-,正则中表示-
  • 字符串\\.转译后,成为正则中的\.,正则中表示.

todo:vue标签可以是 xx-.xxx 吗?

测试匹配结果

const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;  
let reg = new RegExp(ncname);

console.log(reg) // 输出转译后的正则: /[a-zA-Z_][\-\.0-9_a-zA-Z]*/

console.log(reg.test('a-aaa')) // 匹配结果 true

1,对比ncname字符串和正则表达式,字符串中的/起到了转译作用;

2,将字符串转为正则表达式对象,new RegExp(ncname)结果为/[a-zA-Z_][\-\.0-9_a-zA-Z]*/

3,根据正则匹配结果,正则表达式中的/也起到了转译作用;

4,正则的开始和结尾都被/包裹,如上转译后的正则表达式 /[a-zA-Z_][\-\.0-9_a-zA-Z]*/

3,匹配命名空间标签

标签说明

// 命名空间标签:aa:aa-xxx
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;

ncname标签名正则的基础上,实现了对命名空间标签的匹配;

正则解析

分为两部分来看

1,(?:${ncname}\\:)?

  • ?:: 表示匹配,但不捕获;
  • ${ncname}: 标签名,参考ncname正则;
  • \\:: 正则中的\:,表示后面有一个冒号;
  • ?: 最后的问号,表示前面的内容可有可无;比如:aa:可以没有

2,${ncname} 标签名,参考ncname正则;

  • 用于匹配aa:aa-xxx,这种标签叫做“命名空间标签”,很少会使用到;

测试匹配结果

const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;  
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
let reg = new RegExp(qnameCapture);

//转译后:/((?:[a-zA-Z_][\-\.0-9_a-zA-Z]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Z]*)/
console.log(reg)
console.log(reg.test('aa:aa-xxx')) // 匹配结果 true

4,匹配开始标签-开始部分

标签说明

// 标签开头的正则 捕获的内容是标签名
const startTagOpen = new RegExp(`^<${qnameCapture}`);

//转译后:/^<((?:[a-zA-Z_][\-\.0-9_a-zA-Z]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Z]*)/
console.log(startTagOpen)  

匹配标签名->匹配命名空间标签名->匹配开始标签

正则解析

在命名空间标签的正则基础之上,添加了标签开头的匹配;

  • ^<:表示以<开头;

测试匹配结果

const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;  
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
const startTagOpen = new RegExp(`^<${qnameCapture}`); 

//转译后:/^<((?:[a-zA-Z_][\-\.0-9_a-zA-Z]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Z]*)/
console.log(startTagOpen)  
console.log(startTagOpen.test('<aa:aa-xxx')) // 匹配结果 true

console.log('<aa:aa-xxx'.match(startTagOpen))
[
  '<aa:aa-xxx',
  'aa:aa-xxx',   // 开始标签的标签名,数组索引 1
  index: 0,
  input: '<aa:aa-xxx',
  groups: undefined
]

结果:匹配到开始标签,获得开始标签中的标签名;

match的匹配结果是一个数组,数组的第一项为匹配结果;

5,匹配结束标签

标签说明

// 匹配结束标签,如: </div>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); 

// /^<\/((?:[a-zA-Z_][\-\.0-9_a-zA-Z]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Z]*)[^>]*>/
console.log(endTag) 

正则解析

此正则分为 4 部分:

1,^<\\/<符号开头,后面跟一个/,即以</开头

2,${qnameCapture}:参考命名空间标签;

3,[^>]*:中间可以是任意数量不为>的字符;

4,>:最后一位必须是>

测试匹配结果

console.log('</aa:aa-xxxdsadsa>'.match(endTag))
[
  '</aa:aa-xxxdsadsa>',
  'aa:aa-xxxdsadsa',    // 结束标签的标签名,数组索引 1
  index: 0,
  input: '</aa:aa-xxxdsadsa>',
  groups: undefined
]

结果:匹配到结束标签,获得结束标签中的标签名;

6,匹配属性

标签说明

// 匹配属性(索引 1 为属性 key、索引 3、4、5 其中一直为属性值):aaa="xxx"、aaa='xxx'、aaa=xxx
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; 

正则解析

按以下几个部分进行拆解:

  • ^\s*
    • 开头为 n 个空格(0个或n个空格)
  • [^\s"'<>\/=]+: 匹配属性名;
    • 不是空格、引号、尖脚号、等号的n个字符;
  • ?:\s*(=)\s*
    • 空格和空格之间可以夹着一个等号=
  • ?:"([^"])"+|'([^'])'+|([^\s"'=<>`]+) :匹配属性值
    • "([^"]*)": 可能是双引号;
    • '([^']*)': 可能是单引号;
    • [^\s"'=<>`]+: 不是空格、引号、尖脚号、等号的 n 个字符;

属性的 3 种情况与取值

  • 情况 1:双引号的情况,如:aaa="xxx";
  • 情况 2:单引号的情况,如:aaa='xxx';
  • 情况 3:没引号的情况,如:aaa=xxx;
// 情况 1:双引号的情况,aaa="xxx"
console.log('aaa="xxx"'.match(attribute))
[
  'aaa="xxx"',
  'aaa',
  '=',
  'xxx',
  undefined,
  undefined,
  index: 0,
  input: 'aaa="xxx"',
  groups: undefined
]
// 此时,索引3是有值的(xxx),4、5是undefined

// 情况 2:单引号的情况,aaa='xxx',会匹配到下一个位置
console.log(`aaa='xxx'`.match(attribute))
[
  "aaa='xxx'",
  'aaa',
  '=',
  undefined,
  'xxx',
  undefined,
  index: 0,
  input: "aaa='xxx'",
  groups: undefined
]
// 此时,会匹配到索引 4,即第二个位置

// 情况 3:没有引号的情况,aaa=xxx,第三个位置就是不带单引号的
console.log('aaa=xxx'.match(attribute))
[
  'aaa=xxx',
  'aaa',
  '=',
  undefined,
  undefined,
  'xxx',
  index: 0,
  input: 'aaa=xxx',
  groups: undefined
]
// 索引 3、4 是 undefined,5 是有值的(xxx),表示匹配到了最后一位

// 属性名:[1]
// 属性值:[3] || [4] || [5]

综上 3 种情况:

  • 属性的key:取索引 1;
  • 属性的value:取索引 3(双引号)、4(单引号)、5(无引号) 中,有值的那一个;

7,匹配开始标签-闭合部分

标签说明

// 匹配结束标签:>
const startTagClose = /^\s*(\/?)>/;

正则解析

  • ^\s*: 空格 n 个
  • (\/?)>: 尖角号有以下两种情况 -/>: 自闭合 ->: 没有/的闭合

8,匹配 {{ }} 插值表达式

标签说明

// 匹配 {{ xxx }} ,匹配到 xxx
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g

在 Vue3 中是靠状态机来实现的,不会像 Vue2 有如此多正则表达式;


四,结尾

本篇,主要介绍了生成 ast 语法树-正则说明部分,涉及以下几个点:

  • 介绍了html模板的解析原理;
  • 详解源码中模板解析相关的7类正则;
    • 匹配标签名
    • 匹配命名空间标签名
    • 匹配开始标签-开始部分
    • 匹配结束标签
    • 匹配属性
    • 匹配开始标签-闭合部分
    • 匹配插值表达式

下一篇,生成 ast 语法树-代码实现


维护日志:

  • 20230119:添加内容中的代码高亮,微调目录结构和部分内容描述;
  • 20230120:对每种正则优化了“正则解析”部分的描述,对每种正则添加了“测试匹配结果”部分;
  • 20230124:添加正则前置知识点,重写“正则解析含义理解”部分,补充“正则匹配结果”的详细分析,添加必要注释说明;