从零实现一个React:Luster(一):JSX解析器

前言

这是之前在掘金发的两条沸点,懒得写了,直接复制过来作为前言了。然后这个项目可能之后还会继续写,增加一些路由或者模板引擎的指令什么的,但是再过没多久寒假就有大块时间了就可能不摸这个鱼去开其它坑了,随缘吧。所以先写JSX的解析器吧,这个部分也比较独立

掘金沸点里有一些代码截图,就不发在markdown里

算是利用期末考这段碎片时间摸一个水项目吧

项目地址:

  1. jsx-parser

  2. luster

12.21

最近心情比较低落,摸鱼也摸到恐慌,然后昨天就想着随便写点东西吧。然后就先选了用JavaScript写,就顺便想到了React。所以有了这个小破轮子,一个前端算是view层的框架吧,算是一个乞丐弱智版的React吧,只有两百多行。

然后又想着竟然都造轮子了,那干脆JSX语法的转译也不用babel了,所以今天就摸了一个jsx的解析器,也只有两百多行

算是一个学习的过程吧,虽然以后也不打算干前端,也都看看

反正也快期末考了,没大块时间了,就继续摸这个项目吧,可能会再加上state和dom diff之类的吧,再做点创新?

代码很水)不是前端)玩具而已)大佬轻喷)

12.22

继上一条,这个乞丐版React昨天又增加了setState和dom-diff算法。成功的实现了功能,然后把代码写成了一坨💩,估计还有我还没发现的bug。所以下面可能会稍微重构一下代码,然后写一下路由和模板引擎的指令?

这两天可能去找找有没有更好玩的可以写,不过这两天最大的收获就是清楚的了解了工整的代码变成💩堆的过程

Jsx到JavaScript对象

其实这个JavaScript对象就是虚拟dom,最后我们再根据这个虚拟dom进行渲染,后面的dom-diff也是根据这个数据结构来计算的。我们解析器的目标就是把下面这一段JSX转换成相应的JavaScript对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div name="{{jsx-parse}}" class="{{fuck}}" id="1">
Life is too difficult
<span name="life" like="rape">
<p>Life is like rape</p>
</span>
<div>
<span name="live" do="{{gofuck}}">
<p>Looking away, everything is sad</p>
</span>
<Counter me="excellent">
I am awesome
</Counter>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
{
"type": "div",
"props": {
"childrens": [
{
"type": "span",
"props": {
"childrens": [
{
"type": "p",
"props": {
"childrens": [],
"text": "Life is like rape"
}
}
],
"name": "life",
"like": "rape"
}
},
{
"type": "div",
"props": {
"childrens": [
{
"type": "span",
"props": {
"childrens": [
{
"type": "p",
"props": {
"childrens": [],
"text": "Looking away, everything is sad"
}
}
],
"name": "live",
"do": "{{gofuck}}"
}
},
{
"type": "Counter",
"props": {
"childrens": [],
"me": "excellent",
"text": "I am awesome"
}
}
]
}
}
],
"name": "{{jsx-parse}}",
"class": "{{fuck}}",
"id": "1",
"text": "Life is too difficult"
}
}

词法分析

其实这个解析器一共也就是240多行,就只要简单词法分析,然后直接递归下降生成了

如果简单的区分,Jsx里,我们也可以说成html吧。就是就只有两种token,开始标签、结束标签和文本,然后开始标签里面有各种属性。

1
2
3
4
5
6
let token = {
startTag: 'startTag',
endTag: 'endTag',
text: 'text',
eof: 'eof'
}

词法分析的主体逻辑就在lex()方法里,其实这个对于之前写的C语言的编译器,一对比就非常简单,没有什么情况好考虑的

只有这几种情况:

  • 如果是<开头的话,那只有两种情况,要么是开始标签,要么是结束标签,所以直接再进一步判断有没有斜杠就可以知道是开始标签还是结束标签
  • 像回车制表符这些直接跳过就可以了
  • 如果是空格的话还需要判断是不是在当前的文本里

然后就交由各个函数处理了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
lex() {
let text = ''
while (true) {
let t = this.advance()
let token = ''
switch (t) {
case '<':
if (this.lookAhead() === '/') {
token = this.handleEndTag()
} else {
token = this.handleStartTag()
}
break
case '\n':
break
case ' ':
if (text != '') {
text += t
} else {
break
}
case undefined:
if (this.pos >= this.string.length) {
token = [this.token['eof'], 'eof', []]
}
break
default:
text += t
token = this.handleTextTag(text)
break
}
this.string = this.string.slice(this.pos)
this.pos = 0
if (token != '') {
return token
}
}
}

处理开始标签

处理开始标签也非常简单,比较复杂的是需要处理开始标签里的属性

  • 首先是先处理标签名
  • 然后是处理开始标签里的属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
handleStartTag() {
let idx = this.string.indexOf('>')
if (idx == -1) {
throw new Error('parse err! miss match '>'')
}
let str = this.string.slice(this.pos, idx)
let s = ''
if (str.includes(' ')) {
s = this.string.split(' ').filter((str) => {
return str != ''
})[0]
} else {
s = this.string.split('>')[0]
}
let type = s.slice(1)
this.pos += type.length
let props = this.handlePropTag()
this.advance()
return [token.startTag, type, props]
}

处理开始标签的属性

处理属性也很简单,每一个属性的键值对都是用空格分隔的,所以直接用split获取每个键值对,最后返回一个键值对数组

这里上面注意token返回的格式,开始标签token的返回是一个数组,第一个元素是token类型,第二个元素是这个标签的类型,第三个元素就是这个开始标签的属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
handlePropTag() {
let idx = this.string.indexOf('>')
if (idx == -1) {
throw new Error('parse err! miss match '>'')
}
let string = this.string.slice(this.pos, idx)
let pm = []
if (string != ' ') {
let props = string.split(' ')
pm = props.filter((props) => {
return props != ''
}).map((prop) => {
let kv = prop.split('=')
let o = {}
o[kv[0]] = this.trimQuotes(kv[1])
return o
})
this.pos += string.length
}

return pm
}

处理结束标签

结束标签非常简单,直接进行字符串的切割就完事了

1
2
3
4
5
6
7
8
9
10
handleEndTag() {
this.advance()
let idx = this.string.indexOf('>')
let type = this.string.slice(this.pos, idx)
this.pos += type.length
if (this.advance() != '>') {
throw new Error('parse err! miss match '>'')
}
return [token.endTag, type, []]
}

处理文本节点

文本节点需要稍微处理一下,需要判断后面的是不是<来判断文本是不是结束了

1
2
3
4
5
6
7
8
handleTextTag(text) {
let t = text.trim()
if (this.lookAhead() == '<') {
return [this.token['text'], t, []]
} else {
return ''
}
}

语法分析生成JavaScript对象

这个过程其实就是一个递归下降的过程,如果碰到语法不正确的时间抛出异常就结束了

先定义一下这个JavaScript对象的结构,其实就和上面的json对象是一致的

1
2
3
4
5
6
class Jsx {
constructor(type, props) {
this.type = type
this.props = props
}
}

入口函数

  • 首先就是先拿到词法分析传过来的token的三个属性
  • 然后就是根据不同的token类型调用不同的处理函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
parse() {
this.currentToken = this.lexer.lex()
let type = this.currentToken[0]
let tag = this.currentToken[1]
let props = this.mergeObj(this.currentToken[2])
let func = this.parseMap[type]
if (func != undefined) {
func(tag, props)
} else {
this.parseMap['error']()
}

if (this.tags.length > 0) {
throw new Error('parse error! Mismatched start and end tags')
}

return this.jsx
}

处理开始标签

  • 首先开始先要判断这个tags的长度,因为我们可以注意到我们转换的JavaScript对象其实是一个嵌套结构,但是内部的结构并不是很一致,所以就需要一些特殊处理。(这里这样写不太好)
  • 最后把这个标签名放到一个栈里,这里需要注意,因为jsx的标签是可以无限嵌套的,所以需要维护一个栈来判断开始结束标签是否匹配。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
parseStart(tag, props) {
let len = this.tags.length
let jsx = this.jsx
if (len >= 1) {
for (let i = 0; i < len; i++) {
if (len >= 2 && i >= 1) {
jsx = jsx[jsx.length - 1]['props']['childrens']
} else {
jsx = jsx.props['childrens']
}
}
this.currentJsx = new Jsx(tag, {
'childrens': []
})
Object.assign(this.currentJsx['props'], props)
jsx.push(this.currentJsx)
} else {
this.currentJsx = jsx = new Jsx(tag, {
'childrens': []
})
Object.assign(jsx['props'], props)
this.jsx = jsx
}
this.tags.push(tag)
this.parse()
}

处理结束标签

结束标签的处理就非常简单了,只要弹出对应的前一个开始标签,用来后面判断开始结束标签是否匹配

1
2
3
4
5
6
parseEnd(tag) {
if (tag == this.tags[this.tags.length - 1]) {
this.tags.pop()
}
this.parse()
}

处理文本节点

处理文本节点就只要简单的把对应的文本内容放到对象的childrens属性中就可以了

1
2
3
4
parseText(tag) {
this.currentJsx['props']['text'] = tag
this.parse()
}

小结

又水了一篇博客:)

这个系列的下一篇啥时候写呢?我也不知道,先去摸会鱼。看是不是去稍微重构一下这个项目的代码,因为从一开始简单的只有渲染功能,再到后面加入类组件、setState、dom-diff后代码就变成了XXX了,虽然写的时候知道这样不好,但是还是想偷懒,所以现在就看看能不能改一改了