题目描述
把 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>
输出如下:
题目链接
解题思路
把题干归纳为以下几点:
-
输入一段
jsx输出为类似ast的json(html转树),以<div>1</div>的 ast 为例,输入JSX输出由开合标签openingElement闭合标签closingElementchildren三部分组成 -
chilren由JSXText和JSXElement组成,他们分别代表着一种类型,如题干中的
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 春招闯关活动」, 点击查看 活动详情