Vue 源码解析之AST抽象语法树

4,185 阅读10分钟

Vue源码解析之AST抽象语法树.png 在面试的过程中,AST抽象语法树 也是面试的高频知识,本文章我们会亲自手写实现抽象语法树,让你真正的理解 AST抽象语法树。开始手写 AST抽象语法树 之前,我们先要了解3个常见的算法思想:指针思想递归缓存和栈。然后利用这些算法思想,实现 AST抽象语法树。读完这篇文章,相信你的算法水平会有质的飞跃!!说不多说,直接进入正题!!

抽象语法树是什么

在模板语法中(如v-for循环中)如果我们直接将模板语法编译成正常的HTLM语法是非常困难的。

image.png 所以,我们需通过AST抽象语法树,将模板语法转换成正常的HTML语法,如下图所示

image.png 那么AST抽象语法树到底是什么呢?其实AST抽象语法树本质上是一个JS对象

image.png 上面图中,用JS结构来表示HTML结构实际上就是AST抽象语法树。抽象语法树是服务于模板编译的,从一种语法翻译成另外一种语法,比如 ES6 转 ES5

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

  • 虚拟DOM 其实就是一个普通的JS对象,是为了提高页面渲染的性能而存在的。虚拟DOM 是通过 h函数 生产一个虚拟状态的DOM,然后根据虚拟节点进行渲染,首次渲染的就会直接渲染,但是二次往后的话就是进行 虚拟DOM的对比,只更新不同的地方。
  • 换成AST的是Vue模板,Vue需要根据模版去处理各种插值、指令;生成虚拟DOM的是最终要展示在页面上的内容的对象描述,Vue 每次需要通过 diff算法 对比 新旧虚拟DOM 的差异;
  • 固定模版生成的AST是不变的,虚拟DOM是不断变化、需要进行差异对比的(数据等会变)。
  • 渲染函数(h函数),它既是AST的产物,也是vnode(虚拟节点)的起源。h函数里面是不含指令的。
  • 抽象语法树不会进行 diff算法 的并且 抽象语法树 不会直接生成 虚拟节点,抽象语法树最终生成的是 渲染函数

模板语法先转换成抽象语法树,然后抽象语法树最后会直接变成 渲染函数(h函数)。而 渲染函数 的执行会生成 虚拟节点虚拟节点 经过 diff等算法,将 虚拟DOM 变成 真实DOM 从而在页面进行展示。

image.png

相关算法储备

指针思想

试找出字符串中,连续重复次数最多的字符
'aaaabbbbcccccccdddd'

解题思路:
看到这个题目,我们只需要用生活中的常识就可以解决问题,一只手指指向第一个字符,另外一个手指从第二个开始,然会逐一往后与第一个进行比较,如果相同则,第一个手指不移动,如果不相同,第一个手指移动到的第二个手指指向的位置,然后继续上面的操作即可。 这里可以使用双指针,i和j分别代表着两只手指,i=0,j=1

  • 如果 i 和 j 指向的字一样,那么 i 不移动,j 后移
  • 如果 i 和 j 指向的字不一样,此时说明它们之间的字都是连续相同的,让 i 追上 j,j 后移 有了上面的解题思路,我们就看编写代码了:
<script>
        const str = 'aaaaaabbbbbccccccccccccccccccddddd'
        let i = 0
        let j = 1
        let max = 0  // 记录出现最多的次数
        let strChar = '' // 记录出现最多次数的字符
        while(i <= str.length - 1) {
            if (str[i] != str[j]) {
                if(j - i > max) {
                    max = j - i
                    strChar = str[i]
                }
                i = j
            }
            // 无论是否相同 j都要后移
            j++
        }
    </script>

总结:其实,指针的问题都是源于我们生活中的常识,快速的找到匹配的结果,一般都可以用指针解题思路来解决问题。

递归

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

解题思路: 通过观察我们可以发现,第一项和第二项均为1,第三项等于前两项相加,后一项等于前两项相加,代码如下:

<script>
        // 斐波那契数列 输出前十项
        function fib (n) {
            return (n == 0 || n==1) ? 1: fib(n-1) + fib(n-2)
        }
        for(let i = 0; i <= 9; i++) {
            console.log(fib(i));
        }
    </script>

减少代码重复计算:通过观察发现,当我们计算fib(8) = fib(7) + fib(6),此时在计算fib(7)的时候,也会重新计算fib(6),这就造成了代码重复计算的问题,所以我们要将计算过的结果存储起来,现实一个缓存的效果。代码如下:

<script>
        // 斐波那契数列 输出前十项
        let cache = {}
        function fib (n) {
            if(cache.hasOwnProperty(n)) {
                // 如果存在这个值直接返回
                return cache[n]
            }
            // 获取该值
            let v = (n == 0 || n==1) ? 1: fib(n-1) + fib(n-2) 
            // 写入缓存
            cache[n] = v
            return v
        }
        for(let i = 0; i <= 9; i++) {
            console.log(fib(i));
        }
        // 实现缓存 减少递归次数 提高效率
    </script>
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}
    ]
}

解题思路: 通过上面观察我们发现,当遇到数字的时候,直接添加value属性,值为当前数字,当遇到一个数组的时候,就会在当前项中添加一个children属性,其中里面又重复上面分析的操作,以此类推,直到结束。 代码如下:

// 方法一
var array = [1, 2, [3, [4, 5], 6], 7, [8], 9]
    // 以数组为参数
    function converObj(array) {
        var res= []
        for(let i = 0; i < array.length; i++) {
            if(typeof array[i] === 'number') {
                res.push({
                    value: array[i]
                })
            } else if (Array.isArray(array[i])) {
                res.push({
                    children: converObj(array[i])
                })
            }
        }
        return {children: res}
    }
    console.log(converObj(array));
// 方法二
var array = [1, 2, [3, [4, 5], 6], 7, [8], 9]
    // item为参数
    function converObj(item) {
        if(typeof item === 'number') {
            return {value: item}
        }else if(Array.isArray(item)) {
            return {
                children:item.map(_item => converObj(_item))
            }
        }
    }
    console.log(converObj(array));

这里使用到了一个ES6数组中的映射方法,不会改变原数组,会在当前数组的基础上进行操作,然后返回一个新的数组。

概念

栈又名为堆栈,它是一种运算受限的线性表,仅在表尾能进行插入和删除操作。这一端被称为栈顶,相对地,把另一端称为栈底。向一个栈插入新元素又称为进栈、入栈或压栈;从一个栈删除元素又称作出栈或退栈

在JavaScript中,栈可以用数组进行模拟。需要限制只能使用push() 和pop(),不能使用unshift() 和 shift()。即数组尾是栈顶

特点: 先进后出(LIFO):栈中的元素,最先进栈的必定是最后出栈,后进栈的一定会先出栈。

试编写“智能重复”,smartRepeat函数,实现:
将'3[abc]'变成abcabcabc
将'3[2[a]2[b]]'变成aabbaabbaabb
将'2[1[a]3[b]2[3[c]4[d]]]'变成abbbcccddddcccdddabbbcccddddcccddd

看到题目,一些小伙伴们就可能就会想到用递归的方法进行编写,但是你会发现这个是一个字符串,而且这个[不知道是和哪一个 ] 进行拼接的,所以使用的递归的方法比较麻烦,不是说不可以解题,但是会很复杂。这里我们就需要使用栈的思想来解决问题。
在进行编写代码之前,我们需要了解正则表达式中的四个常用的方法分别是replace()方法match()方法test()方法search()方法

replace()方法

正则表达式中的replace方法表示,将字符串中的指定的内容替换为replace方法中的指定内容。
第一个参数:替换的正则
第二个参数:字符串或函数,函数三参数分别是目标字符串目标字符串下标位置匹配的字符串,当正则有捕获的时候,四个参数为:匹配到的正则部分、目标字符串,目标字符串下标、匹配的字符串

// 第二个参数是字符串
let str = 'accsnhsbcskdas12345sad4'.replace(/\d/g,'')
console.log(str)     // "accsnhsbcskdassad"

// 第二个参数是函数
let str = 'ansbsda112fasf578'
        str.replace(/\d/g, (...args) => {
            console.log(...args);
        })

image.png

match()方法

找出第一个符号正则条件的下标位置,并且返回一个数组,在全局的情况下会返回全部符合条件的下标位置并组成一个数组返回。

let str = 'ansbsda112fasf578'
let num = str.match(/\d/)
console.log(num);

image.png

let str = 'ansbsda112fasf578'
let num = str.match(/\d/g)
console.log(num);

image.png

test()方法

test 检测字符串是否符合当前正则表达式

image.png

search()方法

找出第一个符号正则条件的下标位置,返回下标,在全局的情况下也只会返回第一个符合条件的下标位置。

let str = 'ansbsda112fasf578'
// let num =  str.search(/\d/) // 7
let num =  str.search(/\d/g) // 7
console.log(num); // 7

了解了正则表达式中常用的方法之后,我们正式来讲解一下题目的解题思路:
上面分析我们知道,不能使用递归的方式来解决问题,我们需要通过栈的思想来进行解答,首先需要两个栈stack1和stack2,一个用来存储数字,一个用来存储字符串。

  • 遍历每一个字符串
  • 如果这个字符是数字,那么就将数字压入 stack1栈,把空字符串压入stack2栈
  • 如果这个字符是字母,那么此时就把stack2栈顶 这一项改为这个字母
  • 如果这个字符是],那么就将数字从 stack1 栈中弹出,把 stack2的栈顶元素重复 stack1中 弹栈的数字的次数,然后将其从stack2中弹出,拼接到stack2的新栈顶上

1.gif 分析了思路之后,相关代码如下:


        function smartRepeat(templateStr) {
            // 指针
            var index = 0
            // 栈1
            var stack1 = []
            // 栈2
            var stack2 = []
            // 尾部
            var rest = templateStr
            while(index < templateStr.length -1) {
                // 改变尾部 剩余部分
                rest = templateStr.substring(index)
                // 检测是否以数字和[开头
                if (/^\d+\[/.test(rest)) {
                    // 将数字入栈1
                    // 如果这个字符是数字,那么就将数字压入 stack1栈,把`空字符串压入stack2栈,
                    let times = Number(rest.match(/^(\d+)\[/)[1])
                    stack1.push(times)
                    // 将空字符串入栈2
                    stack2.push('')
                    // 让指针后移 times这个数字多少就后移多少位 +1 
                    // +1 是[括号
                    index += times.toString().length + 1
                } else if (/^\w+\]/.test(rest)) {
                    // 如果这个字符是字母,那么此时就把stack2栈顶这一项改为这个字母,
                    let word = rest.match(/^(\w+)\]/)[1]
                    // 字母和]开头
                    stack2[stack2.length-1] = word
                     // 让指针后移 word这个数字多少就后移多少位 
                    index += word.length
                } else if(rest[0] == ']'){
                    // 如果这个字符是],那么就将数字从 stack1 栈中弹出,把 stack2的栈顶元素重复 stack1中 弹栈的数字的次数`,
                    // 然后将其从stack2中弹出,拼接到stack2的新栈顶上。
                    let times = stack1.pop()
                    let word =  stack2.pop()
                    // repeat是h5中新增的方法 比如: 'a'.repeat(2) = 'aa'
                    stack2[stack2.length -1 ] += word.repeat(times)
                    index++
                }
                console.log(index, stack1,stack2);
            }
            // while遍历结束 stack1和stack2各剩一项
            return stack2[0].repeat(stack1[0])
        }
        console.log(smartRepeat('3[2[a]2[b]]')); 

总结:词法分析的时候经常会用到栈的思路去解决问题。

手写 AST 抽象语法树

import parse from "./parse";
var templateStr = `<div>
    <h3>你好</h3>
    <ul>
        <li>A</li>
        <li>B</li>
        <li>C</li>
    </ul>
    </div>
`
let ast = parse(templateStr)
console.log(ast);

image.png 我们知道AST抽象语法树如上图所示,所以我们需要就 模板字符串 转化为 AST抽象语法树,通过观察我们发现,其实这 AST抽象语法树 的转换和上面栈的题目几乎差不多,都是要使用两个栈来进行运算,当遇到 <>标签进栈,遇到 </>标签 出栈。所以我们可以借助上面的题目 栈的思路 来完成 AST抽象语法树 的转换。如果你真的理解上面的题目,这道题你理解起来很非常轻松!

parse()方法

parse()方法 的作用 通过栈思想来生成AST语法树

// index.js
import parse from "./parse";
var templateStr = `<div>
    <h3>你好</h3>
    <ul>
        <li>A</li>
        <li>B</li>
        <li>C</li>
    </ul>
</div>
`
let ast = parse(templateStr)
console.log(ast);
/**
 * 作用:用来生成AST抽象语法树
 * @param {*} templateStr 传入的模板字符串 
 */
export default function parse(templateStr) {
    // 指针
    var index = 0
    // 栈1 存储标签
    var stack1 = []
    // 栈2 存储文字内容 默认留一项 不用判断stack2结束
    var stack2 = [{'children':[]}]
    // 尾巴
    var rest = templateStr
    // 开始正则
    var startRegExp = /^\<([a-z]+[1-6]?)\>/
    // 结束正则
    var endRegExp = /^\<\/([a-z]+[1-6]?)\>/
    // 文字正则 
    var wordErgExp = /^([^\<]+)\<\/([a-z]+[1-6]?)\>/
    while (index < templateStr.length - 1) {
        rest = templateStr.substring(index)
        // 识别遍历到这个字符的时候,是不是一个开始标签
        if (startRegExp.test(rest)) {
            let tag = rest.match(startRegExp)[1]
            // 入栈
            stack1.push(tag)
            stack2.push({'tag': tag, 'children': []})
            // 指针跳过标签  并且<> 所以要+2
            index += tag.length + 2
        }else if (endRegExp.test(rest)){
            // 识别这个字符是不是结束标签
            let tag = rest.match(endRegExp)[1]
            //此时tag一定和栈1的栈顶相同
            let pop_tag =  stack1.pop()
            if(tag === pop_tag ) {
                // 出栈
                let pop_arr =  stack2.pop()
                // 检测stack2是否有children属性
                if(stack2.length > 0) {
                    stack2[stack2.length - 1].children.push(pop_arr)
                } 
            } else {
                new Error(stack1[stack1.length - 1] + '标签没有闭合')
            }
            
            // 指针跳过标签  并包含</> 所以要+3
            index += tag.length + 3
        } else if(wordErgExp.test(rest)) {
            // 检测到文字
            let word = rest.match(wordErgExp)[1]
            // 文字不能是全空 去除空格
            if(!/^\s+$/.test(word)) {
                // 改变此时stack2栈顶元素
                stack2[stack2.length - 1].children.push({'text': word, 'type': 3})
            }
            index += word.length
        } 
        else {
            index++
        }
    }
    // console.log(stack2);
    return stack2[0].children[0]
}

上面代码的过程,跟栈的算法过程一模一样,如果看不懂的小伙伴,可以再细读栈的例题,再来看这道题,保证你可以很很好的掌握和理解 AST抽象语法树 的生成。当我们往标签中添加类名的时候,其实会报如下图的错误。

import parse from "./parse";
var templateStr = `<div>
    <h3 class="box box1" id="h3">你好</h3>
    <ul>
        <li>A</li>
        <li>B</li>
        <li>C</li>
    </ul>
</div>
`
let ast = parse(templateStr)
console.log(ast);

image.png 其实原因很简单,就是在解析标签的时候,会默认将class="box box1" id="h3"也当成标签来处理,所以就会报错,即我们还需要将开始标签的正则进行完善

//修改开始正则,如果存在以空格除<之外的任意字符  +表示一个或多个 ?表示没有类名或id 属性名
    var startRegExp = /^\<([a-z]+[1-6]?)(\s[^\<]+)?\>/
 ...
 if (startRegExp.test(rest)) {
            // 获取标签
            let tag = rest.match(startRegExp)[1]
            // 获取属性
            let attrsString = rest.match(startRegExp)[2]
            console.log('开始标记', tag);
            // 入栈
            stack1.push(tag)
            stack2.push({'tag': tag, 'children': [],attrs: attrsString})
            // 指针跳过标签  并且<> 所以要+2
            // 判断是否存在attrsString 因为有些标签是没有attrs属性的
            const attrsStringLength = attrsString != null ? attrsString.length : 0
            index += tag.length + 2 + attrsStringLength
 ...

image.png 通过打印我们可以发现有了attrs属性,但是attrs属性是一个数组,里面包含name和value的值,所以我们需要对attrs进行改造。

attrs:[{name:class,value:box},{name:id,value:h3}]

parseAttrsString()方法

将attrs改造成数组包含对象name和value属性。

1.gif 通过上面的动图思路,我们就可以通过这个思路去书写代码:

export default function parseAttrsString(attrsString) {
    if (attrsString == undefined) return []
    // 遇到引号 标记 去除不在引号里面的空格 false 不在 true在
    var isClass = false
    // 断点 使用指针的思想
    var point = 0
    // 结果数组
    var result = []
    for (let i = 0; i < attrsString.length; i++) {
        let char = attrsString[i]
        if (char == '"') {
            // 遇到引号 改标记
            isClass = !isClass
        } else if (char == ' ' && !isClass) {
            // 遇到空格并且不在引号内
            if (!/^\s*$/.test(attrsString.substring(point, i).trim())) {
                //去掉空格
                result.push(attrsString.substring(point, i).trim())
                point = i
            }
        }
    }
    // 循环结束之后还剩一项
    result.push(attrsString.substring(point).trim())
    // 映射 将["k = v", "k = v"] 变成[{name: 'k', value:'v'},{name: 'k', value: 'v'}]形式
    result = result.map(item => {
        // 根据等号拆分
        let obj = item.match(/^(.+)="(.+)"$/)
        return {
            name: obj[1],   // 第一个()的捕获
            value: obj[2]   // 第二个()捕获
        }
    })
    return result
}

最后一个完美的 AST抽象语法树 就完成了,测试结果如下图所示: image.png 本文章用到了大量的正则表达式,如果之前正则表达式不是很好的小伙伴,通过这篇文章,应该可以掌握了正则的一些基本用法,其实,正则表达式用的好是真的无敌。

好了,以上就是 VueAST抽象语法树 的主干部分,最精彩的部分莫过于是 双指针的解决问题的思路,希望读完这篇文章,你对栈和指针的算法会有一个新的认识,体会到栈的魅力所在。文章的源码已在gitee上发布,需要的小伙伴可以 戳我获取源码!!!!!,希望各位大佬能给个赞!!!