实现简易jsx编译器 | 刷题打卡

979 阅读6分钟

题目描述

jsx 编译成 ast,名字有点唬人... 但只要求实现基础功能即可,要求将<div>abc</div>或是深层的 html 转化成 抽象语法树,我们定义 ast 的数据结构为

// 包含 开合标签 闭合标签 text类型 children
const types = {
    openingElement: 'JSXOpeningElement',
    closingElement: 'JSXClosingElement',
    element: 'JSXElement',
    textElement: 'JSXText',
    children
};

输入:
<div id="test" class="test1"><div><div>1-1</div><div>1-2</div></div><div>2</div><div>3</div></div>
输出如下: image.png 题目链接

解题思路

把题干归纳为以下几点:

  1. 输入一段 jsx 输出为类似 astjson(html转树),以 <div>1</div>ast 为例,输入 JSX 输出由 开合标签openingElement 闭合标签closingElement children 三部分组成

  2. chilrenJSXTextJSXElement 组成,他们分别代表着一种类型,如题干中的

const types = {
    openingElement: 'JSXOpeningElement',
    closingElement: 'JSXClosingElement',
    element: 'JSXElement',
    textElement: 'JSXText',
};
  • JSXText 没有开闭标签和子集,输出 {type: JSXText, value: 1}
  • JSXElement 有开闭标签或存在子集,输出 {type: JSXElement, openingElement: {type: JSXOpeningElement, tagName: div}, closingElement: {type: JSXClosingElement, tagName: div}, children: {...重复之前的过程}}

astexplorer 中可以很直观的看到输出的数据结构
解析字符串最好的方式是 状态机, 为了节省代码量,我这次没有考虑标签会换行的情况
举个例子简单分析一下:

<div>
    <div>
        <div>1-1</div>
        <div>1-2</div>
    </div>
    <div>2</div>
    <div>3</div>
</div>

从左至右的解析过程:

  • 发现 < 开始解析,while 出现 > 为止,得到起始标签 <div>
  • 又发现 < 开始,while 出现 > 为止,得到起始标签 <div>
  • 又发现 < 开始,while 出现 > 为止,得到起始标签 <div>
  • 发现不是 < 开始代表 JSXText,while 再次出现 <,得到字符串 1-1
  • ...

可以发现几个状态 < 标签开始 > 标签结束 >< 标签结束,标签开始 >1 标签结束,字符串开始 1< 字符串结束,标签开始 </ 整个element结束的开始 整个element结束的开始> 整个element结束
开始试着写一个单纯解析标签功能的函数:

希望返回的结果是
{ type: 'JSXOpeningElement', name: 'div' } * 3
{ type: 'JSXClosingElement', name: 'div' }
{ type: 'JSXOpeningElement', name: 'div' }
{ type: 'JSXClosingElement', name: 'div' } * 2
{ type: 'JSXOpeningElement', name: 'div' }
{ type: 'JSXClosingElement', name: 'div' }
{ type: 'JSXOpeningElement', name: 'div' }
{ type: 'JSXClosingElement', name: 'div' } * 2

由于状态机核心思想是解析str[index],生成新 index
所以每次进入函数,从 index 开始找标签 -> 标签结束 return
// 伪代码
function tagParser() {
    while (index < str.length) {
        startIndex = index;

        ... => index++
        if (str[index] === '>') {
            endIndex = index;
            break;
        }
    }
    name = str.slice(startIndex, endIndex);
    return { type: 'JSXOpeningElement or JSXClosingElement', name: 'tagName' };
}

上完整代码注释:

while (index < str.length) {
    if (str[index] === '<') {
        tagParser(index);
    }
}

// 在外层设置 tagParser 执行条件为:str[index] === '<'
function tagParser() {
    index++;
    let [startIndex, endIndex] = [null, null]; // 定义开始和结束区间
    let isCloseTag = false; // 开合 or 闭合标签

    // 需要对 str 每个字符都做解析,可能传进来的index 分别是 5 10 15 20,但都是在 str.length 范围之内
    while (index < str.length) {
        // 寻找开合标签起始位置 => startIndex
        if (!startIndex && str[index] !== ' ' && str[index] !== '/') {
            startIndex = index;
        }

        // 发现 '/' 代表闭合标签开始,一定要加上不是开合标签的判断条件,可能 / 会出现在之前的 JSXText
        if (!startIndex && str[index] === '/') {
            isCloseTag = true;
        }

        // 寻找闭合标签作为结束标签
        // startIndex && str[index] === ' ' => 开合标签 && 当前字符是空格???
        // 这个下面会解释,开合标签 <div> 是没有空格的,出现空格一定后面会有属性<div id="test">,或者喜欢搞事的同学写成 <div     >
        // 但无论如何 出现 > 一定是标签的结束,开合出现空格也算结束
        if ((startIndex && str[index] === ' ') || str[index] === '>') {
            endIndex = index;
            break;
        }

        // 存在 startIndex 并且找到 endIndex 之后就不会再进入任何判断,单纯走index++
        index++;
    }

    // 上面拿到 startIndex 和 endIndex
    // 下面我们要输出标签信息,以及 index 走到哪了
    const tag = {
        type: isClosing ? types.closingElement : types.openingElement,
        name: str.slice(startIndex, endIndex).trim(),
    };

    // 下面是为了兼容整个jsx不是以闭合标签为结尾,可能有空格之类的,endIndex++
    while (isClosing && endIndex < str.length && str[endIndex] !== '>') {
        endIndex++;
    }

    return tag;
}

以上我们就能得到我们想要的返回结果,我们完成了标签提取,对每一个走过标签的索引都做了解析,因此我们希望能把标签内部的属性也解析出来,例如:

{
  type: 'JSXOpeningElement',
  name: 'div',
  attributes: {'class': 'test1', 'id': 'test'} // 这里搞简单点,只兼容 div(id="test", class="test1")
}

和解析标签的思路一样,先想好触发条件:开合标签结束 有空格出现 开合标签结束,因为属性在标签内部,所以解析属性的函数放在 tagParser 内部:

// 开合标签 并且 不是这种 <div> 的情况触发属性的解析
if (!isClosing && str[endIndex] !== '>') {
    attrParser();
}
function attrParser() {
    let endIndex = null;
    while (index < str.length) {
        // 每次遍历都会检查是不是结束了 属性的结束标识为:出现 >
        if (str[index] === '>') {
            endIndex = index;
            break;
        }

        index++;
    }

    let attributes = {};

    // 正常情况一定有结束标签,代表一定会有 endIndex
    if (endIndex != null) {
        // 我们取出了attr id="test" class="test1"
        // 剩下只要把这些字符串解析成我们想要的数据结构就 ok
        const attrStr = str.slice(index, endIndex).trim();

        attributes = attrStr.split(/\s/).reduce((memo, item) => {
            const [key, value] = item.split('=');
            memo[key] = value;
            return memo;
        }, {});
    }

    return attributes;
}

除了开合闭合标签,<div><div><div>1-1</div><div>1-2</div></div><div>2</div><div>3</div></div> 这段字符串中索引走到 JSXText 的时候还没有解析,但有了解析 标签和属性 我们使用状态机来解析字符串应该是小 case 啦
重复上面的思路,分析状态点,出现 > 即结束

if (开始解析text啦,但是什么情况触发后面分析) {
    jsxTextParser();
}

function jsxTextParser() {
    const startIndex = index;
    while (index < str.length && str[index] !== '<') {
        index++;
    }

    return { type: 'JSXText', value: str.slice(startIndex, index) };
};

以上,我们完成了 解析标签 解析属性 解析JSXText,还差 children 部分,children 就是需要得把我们已知的结果拼装起来,先找规律~
目前为止我们已知的为:

{ type: '开', name: 'div' } * 3
{ type: '闭', name: 'div' }
{ type: '开', name: 'div' }
{ type: '闭', name: 'div' } * 2
{ type: '开', name: 'div' }
{ type: '闭', name: 'div' }
{ type: '开', name: 'div' }
{ type: '闭', name: 'div' } * 2

这样似乎还不太直观,A1 B1 (C1 C2) (D1 D2) B2 A2 也许这样更有利于我们发现规律
我们从左向右找,先是开A 开B 开C 闭C,目前出现了第一个闭 闭C,他需要和 开C 组合成最小 child:

{
    openingElement: {type: '开合标签', name: 'div'},
    closeingElement: {type: '闭合标签', name: 'div'},
    children: [{type: 'JSXText', value: 'C 标签的文案'}]
}

接着 开D 闭D,可以发现规律,每一个 闭合标签 都会配对一个 开合标签,把思路转化成代码,让开闭完成配对

const arr = [];
arr.push('开A');
arr.push('开B');
arr.push('开C');

const 开闭C = {close: '闭C', open: arr.pop(), type: 'JSXElement'};
arr.push(开闭C);

// 此时arr=[开A 开B 开闭C]

arr.push('开D');

const 开闭D = {close: '闭D', open: arr.pop(), type: 'JSXElement'};
arr.push(开闭D); // 此时arr=[开A 开B 开闭C 开闭D]

const children = [];
children.push(arr.pop()); // 开闭D
children.push(arr.pop()); // 开闭C
const 开闭B = {
    close:'闭B',
    open: arr.pop(),
    children,
    type: 'JSXElement'
};
arr.push(开闭B); // 此时arr=[开A 开闭B]

const children = [];
children.push(arr.pop()); // 开闭B
const 开闭A = {
    close: '闭A',
    open: arr.pop();
    children,
    type: 'JSXElement'
};
arr.push(开闭A); // 此时arr=[开闭A]

if (arr.length === 1) return arr[0]; // 开闭A

走到这一步,核心思路已经差不多了,直接上代码,我会在代码中补上核心函数 parser 的注释

AC 代码

节约篇幅,tagParser attrParser jsxTextParser 就不再贴出来了:

function parse(str) {
    // 定义 stack 相当于上面的 arr,给开合标签缓存用
    let stack = [];
    let index = 0;

    // 状态机套路:保证每一个字符都要被解析到
    while (index < str.length) {
        // 开始解析标签
        if (str.charAt[index] === '<') {
            // 这里会输出全部 tag 信息
            const tag = parseTag();

            // 上面也提到,发现闭合标签,就会找他的开合标签配对,填充文案
            if (tag.type === types.closingElement) {
                let children = [];

                // 最纯粹的就是 <div>C</div><div>D</div> C和D 的children就只有文案C D
                // 同理推理过程的 开闭D 开闭C 文本1 文本2 ... 直到发现 开B
                while (stack.length && stack[stack.length - 1].type !== types.openingElement) {
                    children.push(stack.pop());
                }

                // 这是最核心部分,每次重组之后需要 push 到 stack 以便下一次闭合标签检查的时候做配对,可以把他当做下一个的children
                stack.push({
                    openingElement: stack.pop(),
                    children,
                    closingElement: elem,
                    type: types.element,
                });
            }
            // 如果是 开合标签 push
            else {
                stack.push(elem);
            }
        }
        // 上面的 if 走完,代表标签走完了,不是标签就是文本了
        else {
            const text = parseText();
            stack.push(text);
        }
    }

    return stack[0];
}

总结

这道题对我而言,再次巩固了 状态机 的使用,在刷题打卡第2篇 手写JSON.parse 中也使用了状态机,那篇文章末尾我归纳了几个 状态机 的应用场景,包含 ast 边界值判断 等等。在刷题过程中,也常碰到用 状态机 来解字符串的题,类似 实现indexOf 解析 http request 信息,都是相对简单纯粹的 状态机 案例。本题还涉及了组合算法 和平时我觉得难度系数很高的 ast,所以还是挺有挑战的。

本文正在参与「掘金 2021 春招闯关活动」, 点击查看 活动详情