【前端源码解析】AST 抽象语法树

参考:Vue 源码解析系列课程

系列笔记:

大纲:

  • 相关算法储备
  • AST 形成算法
  • 手写 AST 编译器
  • 手写文本解析功能
  • AST 优化
  • 将 AST 生成 h() 函数

本章源码:https://gitee.com/szluyu99/vue-source-learn/tree/master/AST_Study

抽象语法树介绍

抽象语法树是什么?

  • 模板语法 直接编译到 HTML 语法 十分困难
  • 通过 抽象语法树 进行过度会让这个工作变得简单

抽象语法树本质上就是一个 JS 对象

  • 理解为 HTML 语法通过一系列规则对应到的 JS 对象

抽象语法树虚拟节点 的关系:

相关算法储备

相关代码:https://gitee.com/szluyu99/vue-source-learn/tree/master/AST_Study/pre

指针

题目:试寻找字符串中,连续重复次数最多的字符。

function findMaxChar(str) {
    
    
    let i = 0, j = 1
    let maxChar = str[i], maxRepeat = -1

    while (i < str.length) {
    
    
        if (str[i] != str[j]) {
    
    
            if (j - i > maxRepeat) {
    
    
                maxRepeat = j - i
                maxChar = str[i]
            }
            i = j
        }
        j++
    }
    console.log(`最多的字母是 ${
      
      maxChar},重复了 ${
      
      maxRepeat}`);
}

let str = 'aaaabbbbbcccccccccccccdddddd'
findMaxChar(str)

递归

题目1:试输出斐波那契数列的前10项,即1、1、2、3、5、8、13、21、34、55。然后请思考,代码是否有大量重复的计算?应该如何解决重复计算的问题

无缓存的递归:

function fib(n) {
    
    
    return n <= 1 ? n : fib(n - 1) + fib(n - 2)
}

有缓存的递归:

let cache = {
    
    }
function fib(n) {
    
    
    if (n in cache) return cache[n]
    return cache[n] = (n <= 1 ? n : fib(n - 1) + fib(n - 2))
}

可以在函数中添加 console.count() 来对该函数的调用次数进行计数

题目2:试将高维数组 [1, 2, [3, [4, 5], 6], 7, [8], 9] 变为下方所示的对象

{
    
    
  children: [
    {
    
     value: 1 },
    {
    
     value: 2 },
    {
    
    
      children: [
        {
    
     value: 3 },
        {
    
     children: [{
    
     value: 4 }, {
    
     value: 5 }] },
        {
    
     value: 6 }
      ]
    },
    {
    
     value: 7 },
    {
    
     children: [{
    
    value: 8}] },
    {
    
     value: 9 }
  ]
};
// 转换函数1
function convert(arr) {
    
    
    return {
    
    children: help(arr)}
}

function help(arr) {
    
    
  let res = [];
  for (let i = 0; i < arr.length; i++) {
    
    
    if (typeof arr[i] === "number") {
    
    
      res.push({
    
     value: arr[i] });
    } else if (Array.isArray(arr[i])) {
    
    
      res.push({
    
     children: help(arr[i]) });
    }
  }
  return res;
}
// 转换函数2
function convert(item) {
    
    
  if (typeof item == 'number')
    return {
    
     value: item }
  if (Array.isArray(item))
    return {
    
     children: item.map(_item => convert(_item)) }
}

JavaScript 中,栈可以用数组模拟,使用 push()pop()

题目:试编写 “智能重复” smartRepeat 函数,实现:

  • 3[abc] 变为 abcabcabc
  • 3[2[a]2[b]] 变为 aabbaabbaabb
  • 2[1[a]3[b]2[3[c]4[d]]] 变为 abbbcccddddcccddddabbbcccddddcccdddd

不用考虑输入字符串是非法的情况,比如:

  • 2[a3[b]] 是错误的,应该补一个 1,即 2[1[a]3[b]]
  • [abc] 是错误的,应该补一个 1,即 1[abc]

一般遇到括号相关,需要做词法分析的时候,经常会使用到栈

思路:遍历每一个字符

  • 如果这个字符是 数字,将数字压入栈A,空字符压入 栈B
  • 如果这个字符是 字母,将 栈B 的栈顶改为该字母
  • 如果这个字符是 ],将 栈A 和 栈B 分别弹栈,进行组合,将组合结果拼接到 栈B 最顶层的字符串后面
function smartRepeact(templateStr) {
    
    
    let stackA = [] // 存放数字
    let stackB = [] // 存放临时字符串
    let rest = templateStr // 剩余字符串

    let idx = 0 // 指针
    while (idx < templateStr.length - 1) {
    
    
        rest = templateStr.substring(idx) // 更新剩余字符串
        if (/^\d+\[/.test(rest)) {
    
     // 判断是否以数字和 [ 开头
            // 取出开头的数字
            let times = Number(rest.match(/^(\d+)\[/)[1])
            stackA.push(times)
            stackB.push('')
            idx += times.toString().length + 1
        } else if(/^\w+\]/.test(rest)) {
    
     // 判断是否以字母和 ] 开头
            // 如果这个字符是字母,那么就把B栈顶这项改为这个字母
            // 取出开头的字母
            let word = rest.match(/^(\w+)\]/)[1]
            stackB[stackB.length - 1] = word
            idx += word.length
        } else if (rest[0] === ']') {
    
    
            // 如果这个字符是 ],组合两个栈的结果并放入栈B
            let times = stackA.pop() // 取出栈顶的数字
            let word = stackB.pop() // 取出栈顶的字符串
            stackB[stackB.length - 1] += word.repeat(times)
            idx++
        }
    }
    return stackB[0].repeat(stackA[0])
}

正则表达式

补充一点 JS 中常用的正则表达式的知识:

// replace() 方法用于在字符串中用一些字符替换另一些字符,或替换一个与正则表达式匹配的子串
'abc666def123'.replace(/\d/g, '') // abcdef

// search() 方法用于检索字符串中指定的子字符串,或检索与正则表达式相匹配的子字符串
// 找不到则返回 -1
'abc666def123'.search(/\d/g)  // 3

// match() 方法可在字符串内检索指定的值,或找到一个或多个正则表达式的匹配
// 找不到则返回 null
'abc666def123'.match(/\d/g) // ['6', '6', '6', '1', '2', '3']

// test() 方法用于检测一个字符串是否匹配某个模式
/^\d/.test('5abc') // true
/^\d/.test('abc') // false
'555[abc]'.match(/^\d+\[/)
// ['555[', index: 0, input: '555[abc]', groups: undefined]

// () 表示捕获
'555[abc]'.match(/(^\d+)\[/)
// ['555[', '555', index: 0, input: '555[abc]', groups: undefined]

手写 AST 抽象语法树

本章源码:https://gitee.com/szluyu99/vue-source-learn/tree/master/AST_Study

识别开始结束标签

相关知识参考上面的 “算法储备 - 栈” 内容

parse.js

export default function parse(templateStr) {
    
    
    let rest = ''

    // 开始标签的正则
    const startRegExp = /^\<([a-z]+[1-6]?)\>/
    // 结束标签的正则
    const endRegExp = /^\<\/([a-z]+[1-6]?)\>/
    // 结束标签前文字的正则(注意开头不含 <)
    const wordRegExp = /^([^\<]+)\<\/[a-z]+[1-6]?\>/

    let stackA = [], stackB = []

    let index = 0
    while (index < templateStr.length - 1) {
    
    
        rest = templateStr.substring(index)
        if (startRegExp.test(rest)) {
    
     // 识别遍历到的字符,是 开始标签
            let tag = rest.match(startRegExp)[1];
            // console.log(`检测到开始标记:<${tag}>`);
            stackA.push(tag) // 将开始标记推入 栈A
            stackB.push([]) // 将空数组推入 栈B
            // 移动开始标签的长度,由于 <> 是两个字符,所以需要 + 2
            index += tag.length + 2
        } else if (endRegExp.test(rest)) {
    
     // 识别遍历到的字符,是 结束标签
            let tag = rest.match(endRegExp)[1];
            // console.log(`检测到结束标记:</${tag}>`);
            // 此时,tag 一定和 栈A 顶部是相同的
            if (tag === stackA[stackA.length - 1]) {
    
    
                stackA.pop()
            } else {
    
    
                throw new Error(`${
      
      stackA[stackA.length - 1]} 标签没有封闭`)
            }
            // 移动结束标签的长度,由于 </.> 是两个字符,所以需要 + 3
            index += tag.length + 3
        } else if (wordRegExp.test(rest)) {
    
     // 识别到遍历的字符,是 文字(并且不能为全空)
            let word = rest.match(wordRegExp)[1];
            if (!/^\s+$/.test(word)) {
    
    
                console.log(`检测到文字:${
      
      word}`);
            }
            // 指针移动到文字的末尾
            index += word.length
        } else {
    
    
            index++
        }

        // 未考虑文字在标签后面的情况,如:
        // <p>123</p> hello
    }
}

使用栈形成 AST

达到效果:

let htmlStr = `<div>
    <h1>Hello</h1>
    <ul>
        <li>111</li>
        <li>222</li>
        <li>333</li>
    </ul>
</div>`;

const ast = parse(htmlStr)
console.log(ast);

parse.js

export default function parse(templateStr) {
    
    
  let rest = "";

  // 开始标签的正则
  const startRegExp = /^\<([a-z]+[1-6]?)\>/;
  // 结束标签的正则
  const endRegExp = /^\<\/([a-z]+[1-6]?)\>/;
  // 结束标签前文字的正则(注意开头不含 <)
  const wordRegExp = /^([^\<]+)\<\/[a-z]+[1-6]?\>/;

  let stackA = [];
  let stackB = [{
    
     children: [] }];

  let index = 0;
  while (index < templateStr.length - 1) {
    
    
    rest = templateStr.substring(index);
    if (startRegExp.test(rest)) {
    
     // 识别遍历到的字符,是 开始标签
      let tag = rest.match(startRegExp)[1];
      console.log(`检测到开始标记:<${
      
      tag}>`);
      stackA.push(tag); // 将开始标记推入 栈A
      stackB.push({
    
     tag: tag, children: [] }); // 将空数组推入 栈B
      // 移动开始标签的长度,由于 <> 是两个字符,所以需要 + 2
      index += tag.length + 2;

      // console.log(stackA, stackB);
    } else if (endRegExp.test(rest)) {
    
     // 识别遍历到的字符,是 结束标签
      let tag = rest.match(endRegExp)[1];
      console.log(`检测到结束标记:</${
      
      tag}>`);
      let pop_tag = stackA.pop();
      // 此时,tag 一定和 栈A 顶部是相同的
      if (tag === pop_tag) {
    
    
        let pop_arr = stackB.pop();
        if (stackB.length > 0) {
    
    
          stackB[stackB.length - 1].children.push(pop_arr);
        }
      } else {
    
    
        throw new Error(`${
      
      stackA[stackA.length - 1]} 标签没有封闭`);
      }
      // 移动结束标签的长度,由于 </.> 是两个字符,所以需要 + 3
      index += tag.length + 3;

      // console.log(stackA, stackB);
    } else if (wordRegExp.test(rest)) {
    
     // 识别到遍历的字符不是 文字(并且不能为全空)
      let word = rest.match(wordRegExp)[1];
      // 文字不能全是空
      if (!/^\s+$/.test(word)) {
    
    
        console.log(`检测到文字:${
      
      word}`);
        // 改变此时 stackB 中的栈顶元素
        stackB[stackB.length - 1].children.push({
    
     text: word, type: 3 });
        console.log(stackB);
      }
      // 指针移动到文字的末尾
      index += word.length;
    } else {
    
    
      index++;
    }

    // 未考虑文字在标签后面的情况,如:
    // <p>123</p> hello
  }
  console.log(stackB);
  return stackB[0].children[0]
}

识别 Attrs

parseAttrsString.js:把 attrsString 解析成 attrs 对象数组

  • 解析前的字符串:class="box red" id="mybox"
  • 解析后的对象数组:
[
	{
    
     
	 name: 'class', 
	 value: 'box red'
	},
	{
    
     
	 name: 'id', 
	 value: 'mybox' 
	}
]

parseAttrsString 算法核心思路:

  • 遍历 attrsStr,如果遇到了空格,且不在引号中,则将前一个断点到当前为止的字符串加入 result
/**
 * 把 attrsString 解析成 attrs 对象数组
 */
export default function parseAttrsString(attrsStr) {
    
    
    if (!attrsStr) return []

    let inFlag = false // 当前是否处于引号内
    let point = 0 // 断点处

    let result = []
    // 遍历 attrsStr,不能直接用 split(),有如下情况 class="aa bb cc" id="gg"
    for (let i = 0; i < attrsStr.length; i++) {
    
    
        let c = attrsStr[i]
        if (c === '"') inFlag = !inFlag // 遇到 双引号,切换 inFlag 状态
        else if (c === ' ' && !inFlag) {
    
     // 遇见 空格,且不在引号中
            if (!/^\s*$/.test(attrsStr.substring(point, i))) {
    
    
                // 不全为空格
                result.push(attrsStr.substring(point, i).trim())
                point = i
            }
        }
    }
    // 循环结束后,还剩一个属性
    result.push(attrsStr.substring(point).trim())
    // 将 ["k1=v1", "k2=v2"] 变为 [{name: k1, value: v1}, {name: k2, value: v2}]
    result = result.map(item => {
    
    
        const o = item.match(/^(.+)="(.+)"$/)
        return {
    
     name: o[1], value: o[2] }
    })
    return result
}

parse.js

export default function parse(templateStr) {
    
    
  let rest = "";

  // 开始标签的正则
  const startRegExp = /^\<([a-z]+[1-6]?)(\s[^\<]+)?\>/;
  // 结束标签的正则
  const endRegExp = /^\<\/([a-z]+[1-6]?)\>/;
  // 结束标签前文字的正则(注意开头不含 <)
  const wordRegExp = /^([^\<]+)\<\/[a-z]+[1-6]?\>/;

  let stackA = [];
  let stackB = [{
    
     children: [] }];

  let index = 0;
  while (index < templateStr.length - 1) {
    
    
    rest = templateStr.substring(index); // 更新剩余字符串
    if (startRegExp.test(rest)) {
    
     // 识别遍历到的字符,是 开始标签
      let tag = rest.match(startRegExp)[1]; // 标签内容
      let attrsString = rest.match(startRegExp)[2] // attr 内容
    //   console.log(`检测到开始标记:<${tag}>`);
      stackA.push(tag); // 将开始标记推入 栈A
      stackB.push({
    
     
        tag: tag, 
        children: [], 
        attrs: parseAttrsString(attrsString) // 解析属性字符串
      }); // 将空数组推入 栈B
      // 移动开始标签的长度,由于 <> 是两个字符,所以需要 + 2,还需加上 attrs 的长度
      index += tag.length + 2 + (attrsString?.length || 0);
      // console.log(stackA, stackB);
    } else if (endRegExp.test(rest)) {
    
     // 识别遍历到的字符,是 结束标签
      let tag = rest.match(endRegExp)[1]; // 标签内容
    //   console.log(`检测到结束标记:</${tag}>`);
      let pop_tag = stackA.pop(); // 栈A 顶部元素
      // 此时,tag 一定和 栈A 顶部是相同的
      if (tag === pop_tag) {
    
    
        let pop_arr = stackB.pop();
        if (stackB.length > 0) {
    
    
          stackB[stackB.length - 1].children.push(pop_arr);
        }
      } else {
    
    
        throw new Error(`${
      
      pop_tag} 标签没有封闭`);
      }
      // 移动结束标签的长度,由于 </> 是两个字符,所以需要 + 3
      index += tag.length + 3;
      // console.log(stackA, stackB);
    } else if (wordRegExp.test(rest)) {
    
     // 识别到遍历的字符,是 文字(并且不能为全空)
      let word = rest.match(wordRegExp)[1];
      // 文字不能全是空
      if (!/^\s+$/.test(word)) {
    
    
        // console.log(`检测到文字:${word}`);
        // 改变此时 stackB 中的栈顶元素
        stackB[stackB.length - 1].children.push({
    
     text: word, type: 3 });
        // console.log(stackB);
      }
      // 指针移动到文字的末尾
      index += word.length;
    } else {
    
    
      index++;
    }

    // 未考虑文字在标签后面的情况,如:
    // <p>123</p> hello
  }
  return stackB[0].children[0]
}

猜你喜欢

转载自blog.csdn.net/weixin_43734095/article/details/125517711