0%

构建DOM树

将HTML转换为浏览器能够理解的DOM树结构

样式计算

生成DOM树后,根据CSS样式表,计算出DOM树中所有节点的样式

布局阶段

1.构建布局树:

  • 遍历DOM树中所有可见结点,并把这些结点加到布局中
  • 不可见的结点会被布局树忽略,如head标签下大雾全部内容,以及display:none的元素

2.布局计算

计算布局树中节点的坐标位置

分层

1拥有层叠上下文属性的元素会被提升为单独的一层

2需要剪裁的地方也会被创建为图层

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<style>
div {
width: 200;
height: 200;
overflow:auto;
background: gray;
}
</style>
<body>
<div >
<p>所以元素有了层叠上下文的属性或者需要被剪裁,那么就会被提升成为单独一层,你可以参看下图:</p>
<p>从上图我们可以看到,document层上有A和B层,而B层之上又有两个图层。这些图层组织在一起也是一颗树状结构。</p>
<p>图层树是基于布局树来创建的,为了找出哪些元素需要在哪些层中,渲染引擎会遍历布局树来创建层树(Update LayerTree)。</p>
</div>
</body>

当div里面文字内容过多,文字显示区域超出200*200面积,产生了剪裁,渲染引擎会把裁剪文字内容的一部分用于显示在div区域,出现裁剪情况时,渲染引擎会为文字不烦单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层

图层绘制

图层树构建完成后,渲染引擎会对图层树中每个图层进行绘制

渲染引擎会把图层的绘制拆分成很多小的绘制指令,然后把这些指令按照顺序组成一个待绘制列表

栅格化操作

绘制列表是用来记录绘制顺序和绘制指令的列表,实际上绘制操作是由渲染引擎中的合成线程完成的,当图层的绘制列表准备好后,主线程会把绘制列表提交给合成线程,合成线程会把图层划分为图块,这些图块的大小通常为256x256或512*x512,合成线程按照视口附近的图块优先生成位图,实际生成的位图由栅格化来执行,栅格化就是将图块转为位图,渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行。使用GPU生成位图的过程叫做快速栅格化。

合成和显示

图块栅格化完成后,合成线程就会生成一个绘制图块命令”DrawQuad”,将该命令提交给浏览器进程,浏览器进程有一个viz组件,用来接收合成线程发过来的DrawQuad命令,根据该命令,将页面内容绘制到内存中,最后将内存显示在屏幕上

渲染流程总结:

  • 渲染进程将HTML内容转换为浏览器能读懂的DOM树结构
  • 渲染引擎将CSS样式表转化为浏览器能理解的styleSheets,计算出DOM结点样式
  • 创建布局树,并计算元素的布局信息
  • 对布局树进行分层,并生成分层树
  • 为每个图层生成绘制列表,并将其提交到合成线程
  • 合成线程将图层分层图块,并在光栅化线程池中将图块转为位图
  • 合成线程发送绘制图块命令DrawQuad给浏览器进程
  • 浏览器进程根据DrawQuad消息生成页面,并显示到显示器上

参考链接:

https://blog.poetries.top/browser-working-principle/guide/part1/lesson06.html#_1-%E6%9B%B4%E6%96%B0%E4%BA%86%E5%85%83%E7%B4%A0%E7%9A%84%E5%87%A0%E4%BD%95%E5%B1%9E%E6%80%A7%EF%BC%88%E9%87%8D%E6%8E%92%EF%BC%89

在TypeScript中存在两种声明空间:类型声明空间和变量声明空间

类型声明空间

类型声明空间包含用来当做类型注解的内容,例如下面的类型声明:

1
2
3
class Foo{}
interface Bar{}
type Bas = {}

你可以将Foo,Bar,Bas作为类型注解使用:

1
2
3
let foo: Foo;
let bar: Bar;
let bas: Bas;

尽管你定义了interface Bar,却不能把它作为一个变量使用,因为它没有定义在变量声明空间中。

变量声明空间

变量声明空间包含可用作变量的内容,在上文中Class Foo提供了一个类型Foo到类型声明空间,此外它同样提供了一个变量Foo到变量声明空间

1
2
3
class Foo {}
const someVar = Foo;
const someOtherVar = 123

一些用var声明的变量,也只能在变量声明空间中使用,不能用作类型注解

1
2
const foo = 123;
let bar: foo;//ERROR:"cannot find name 'foo'"

当遇到不能用分页方式来加载列表数据的业务情况,可以使用长列表,但是同时加载大量数据时性能消耗大,因此可以使用虚拟列表,只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染,从而达到较高的渲染性能

实现:

虚拟列表的实现,就是在首屏加载时,只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除

1
2
3
4
5
6
7
8
9
10
<div class="infinite-list-container">
<div class="infinite-list-phantom"></div>
<div class="infinite-list">
<!-- item-1 -->
<!-- item-2 -->
<!-- ...... -->
<!-- item-n -->
</div>
</div>

  • infinite-list-container可视区域的容器
  • infinite-list-phantom 为容器内的占位,高度为总列表高度,用于形成滚动条
  • infinite-list 为列表项的渲染区域

可以计算出:

  • 列表总高度:listHeight = listData.length*itemSize

  • 可显示列表项数:visibleCount

  • 数据起始索引:startIndex = Math.floor(scrollTop/itemSize)

  • 数据结束索引:endIndex = startIndex + visibleCount

  • 列表显示数据visibleData = listData.slice(startIndex,endIndex)

    滚动后渲染区域对于可视区域已经发生了偏移,此时我需要获取一个偏移量startOffset,通过样式将渲染区域便宜到可是区域中

  • 偏移量startOffset = this.startIndex * this.itemSize

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <template>
    <div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)">
    <div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
    <div class="infinite-list" :style="{ transform: getTransform }">
    <div ref="items"
    class="infinite-list-item"
    v-for="item in visibleData"
    :key="item.id"
    :style="{ height: itemSize + 'px',lineHeight: itemSize + 'px' }"
    >{{ item.value }}</div>
    </div>
    </div>
    </template>

    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
    59
    60
    61
    62
    63
    export default {
    name:'VirtualList',
    props: {
    //所有列表数据
    listData:{
    type:Array,
    default:()=>[]
    },
    //每项高度
    itemSize: {
    type: Number,
    default:200
    }
    },
    computed:{
    //列表总高度
    listHeight(){
    return this.listData.length * this.itemSize;
    },
    //可显示的列表项数
    visibleCount(){
    return Math.ceil(this.screenHeight / this.itemSize)
    },
    //偏移量对应的style
    getTransform(){
    return `translate3d(0,${this.startOffset}px,0)`;
    },
    //获取真实显示列表数据
    visibleData(){
    return this.listData.slice(this.start, Math.min(this.end,this.listData.length));
    }
    },
    mounted() {
    this.screenHeight = this.$el.clientHeight;
    this.start = 0;
    this.end = this.start + this.visibleCount;
    },
    data() {
    return {
    //可视区域高度
    screenHeight:0,
    //偏移量
    startOffset:0,
    //起始索引
    start:0,
    //结束索引
    end:null,
    };
    },
    methods: {
    scrollEvent() {
    //当前滚动位置
    let scrollTop = this.$refs.list.scrollTop;
    //此时的开始索引
    this.start = Math.floor(scrollTop / this.itemSize);
    //此时的结束索引
    this.end = this.start + this.visibleCount;
    //此时的偏移量
    this.startOffset = scrollTop - (scrollTop % this.itemSize);
    }
    }
    };

列表项动态高度

当列表中包含文本之类的可变内容时,会导致列表项的高度并不相同,在虚拟列表中应用动态高度的解决方案一般有三种:

1.对组件属性itemSize进行扩展,支持传递类型为数字,数组,函数

2.将列表项渲染到屏幕外,对其高度进行测量并缓存,然后再将其渲染到可视区域内

先渲染到屏幕外,再渲染到屏幕内,会导致渲染成本增加一倍,对于数百万用户在低端移动设备上使用的产品来说不切实际

3.以预估高度先行渲染,然后获取真实高度并缓存

使用第三种方法:positions用来存储列表项渲染后每一项高度以及位置信息:

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
import { number } from "prop-types"

export type PositionType = {
index: number,
height: number,
top: number,
bottom: number
}
//缓存列表的总体初始化高度
const initPositionCache = (
estimatedItemSize: number =32,
length: number = 0,
) => {
let index = 0,
positions = Array(length)
while(index < length) {
positions[index] = {
index,
height: estimatedItemSize,
top: index * estimatedItemSize,
bottom: (index++ +1) * estimatedItemSize//下一条数据的开始位置就是上一条数据的结束位置
}
}
return positions
}
export default initPositionCache

列表的总高度等于列表项实际中最后一项的地步距离列表顶部的位置

getListHeight.ts:

1
2
3
4
5
6
import { PositionType } from "./initPositionCache"
export const getListHeight = (positions: Array<PotionType>) => {
let index = positions.length -1;
return index < 0 ? 0 : positions[index].bottom
}

更新缓存:在页面更新阶段,通过遍历列表每一项,获得实际内容撑起的高度height,和预估的高度oldHeight,如果两者存在差值,则更新该列表项的bottom和height,以及更新列表项后面每一项的top和bottom

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export const updateItemSize = (
positions: Array<PositionType>,
items: HTMLCollection,
) => {
Array.from(items).forEach(item => {
let index = Number(item.getAttribute('date-index'))
//内容撑起的高度
let {height} = item.getBoundingClientRect()
let oldHeight = positions[index].height
//存在差值,减去差值,并更新该结点以后的所有结点
let dValue = oldHeight - height
if(dValue) {
positions[index].bottom = positions[index].bottom - dValue
positions[index].height = height

for(let k = index + 1; k < positions.length; k++) {
positions[k].top = positions[k-1].bottom
positions[k].bottom = positions[k].bottom - dValue
}
}
})

}

滚动后获取列表的开始索引的方法修改为通过缓存获取:

1
2
3
4
5
//获取列表起始索引
getStartIndex(scrollTop = 0){
let item = this.positions.find(i => i && i.bottom > scrollTop);
return item.index;
}

由于缓存数据是有顺序的,所以获取开始索引的方法可以考虑通过二分查找的方式降低检索次数:

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
import { PositionType } from "./initPositionCache"
export const binarySearch = (list: Array<PotionType>,value: number = 0) => {
let start: number = 0
let end: number = list.length - 1
let tempIndex = null
while(start <= end) {
let midIndex = Math.floor((start + end)/2)
let midValue = list[midIndex].bottom
//因为用bottom 所有查找到的数据索引为下一条
if(midValue === value){
return midIndex + 1
}
else if(midValue < value) {
start = midIndex+1
}
else if(midValue > value) {
//tempIndex存放最靠值为value的所有
if(tempIndex === null || tempIndex > midIndex) {
tempIndex = midIndex;
}
end = midIndex -1
}
}
return tempIndex
}

滚动过程中二分查找到可视区域的内容

1
2
3
4
5
6
7
8
9
//回调函数在浏览器下一次重绘之前执行
const onScroll = () => {
requestAnimationFrame(() => {
let { scrollTop } = getEl()
let startIndex = binarySearch(positions,scrollTop)
//startIndex变更,更新endIndex,ListHeight
updateState({myvisibleCount,startIndex})
})
}

在每一次页面重新挂载后监听滚动事件,卸载后移除回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//useLayoutEffect相当于componentDidMount,在dom更新后,页面渲染前执行,避免出现闪烁现象
useLayoutEffect(()=>{
const el = getEl()
el.addEventListener('scroll',onScroll,false)
//火狐浏览器是DOMMouseScroll
if(isFF)el.addEventListener('DOMMouseScroll',onScroll,false)
else el.addEventListener('wheel',onScroll,false)
return () => {
if(el){
el.removeEventListener('scroll',onScroll,false)
if(isFF)el.removeEventListener('DOMMouseScroll',onScroll,false)
else el.removeEventListener('wheel',onScroll,false)
}
}
},[])

滚动后将偏移量的获取方式进行变更:

1
2
3
4
5
if(this.start >= 1){
this.startOffset = this.positions[this.start - 1].bottom
}else{
this.startOffset = 0;
}

在componetDidUpdate阶段更新缓存

1
2
3
4
5
6
7
8
9
10
11
//在componentDidUpdate阶段更新缓存,useEffect渲染到页面后异步执行
useEffect(() => {
let nodes: HTMLCollection = items.current?.children
if(!nodes.length) return
//更新缓存
updateItemSize(positions,nodes)
//更新总高度
setListHeight(getListHeight(positions))
//更新总偏移量
setStartOffset(getStartOffset(startIndex,positions,aboveCount))
},[])

获取结束索引的函数:

1
2
3
4
5
export const getEndIndex = (resource:Array<string>,startIndex: number,visibleCount: number) => {
let resourceLength = resource.length
let endIndex = startIndex + visibleCount
return resourceLength > 0 ? Math.min(resourceLength,endIndex) : endIndex;
}

当滚动过快时,会出现短暂的白屏现象,为了使页面平滑滚动,我们还需要再可见区域的上方和下方渲染额外的项目,在滚动时基于一些缓冲,所以将屏幕分为三个区域:

可视区域上方:above

可视区域:screen

可视区域下方:below

定义bufferScale,用于接收缓冲区数据和可视区数据的比例:

1
2
3
4
5
6
const bufferScale = 1
const aboveCount = Math.min(startIndex,bufferScale * myvisibleCount)
const blowCount = Math.min(resources.length - endIndex,bufferScale * myvisibleCount)
//可视区域数据
let visibleData = resources.slice(startIndex - aboveCount,endIndex + blowCount)

获取总偏移量getStartOffset:

1
2
3
4
5
6
7
8
9
export const getStartOffset = (startIndex: number,positions: Array<PositionType> = [],aboveCount: number) => {
if(startIndex >=1 ) {
let size = positions[startIndex].top - (positions[startIndex-aboveCount] ? positions[startIndex-aboveCount].top : 0)
//如果上一个数据条目滚动到一半,则要获取的是上一个数据条目的bottom值即为startOffset
return positions[startIndex-1].bottom -size
}else{
return 0
}
}

完整源码:https://github.com/Coloey/july-design/tree/master/src/vitual-list

参考:

问题:当我们的应用瞬间发出很多请求,比如几十万http请求时,或者堆积了无数调用栈导致内存溢出,这个时候需要我们对http的连接数做限制

思路:

初始化一个pool数组作为并发池,然后先循环把并发池塞满,不断调用addTask,通过自定义请求函数request,每个任务task是一个Promise对象包装,执行完就pop出连接池,然后将新任务添加进并发池pool

方法一:不通过Promise.race:

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
let urls = ["1", "2", "3", "4", "5", "6", "7"];
let pool = []; //并发池
let max = 3; //最大并发数量
//自定义请求函数
function request(url) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(url);
console.log(`任务${url}完成`);
}, 1000);
}).then((res) => {
console.log("外部逻辑", res);
});
}
function addtask(url){
let task = request(url);
pool.push(task);
task.then(res=>{
//任务完成,从并发池中删除
pool.splice(pool.indexof(task),1);
cosole.log(`${url}完成,当前并发数为${pool.length}`)
//每当并发池结束一个任务,就再塞入一个任务
url=url.shift();
if(url!==undefined){
addTask(url);
}
})
}
//先把并发池塞满
while(pool.length<max){
let url=urls.shift();
addTask(url);
}

Promise.race()实现:

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
let urls = ["1", "2", "3", "4", "5", "6", "7"];
let pool = []; //并发池
let max = 3; //最大并发数量
//自定义请求函数
function request(url) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(url);
console.log(`任务${url}完成`);
}, 1000);
}).then((res) => {
console.log("外部逻辑", res);
});
}
function addTask(url) {
let task = request(url);
pool.push(task);
task.then((res) => {
//请求结束将Promise任务从pool中移除
pool.splice(pool.indexOf(task), 1);
console.log(`${url}结束,当前并发数:${pool.length}`);
});
}
function run(task) {
task.then((res) => {
let url = urls.shift();
if (url !== undefined) {
addTask(url);
run(Promise.race(pool));
}
});
}
while (pool.length < max) {
let url = urls.shift();
addTask(url);
}
let race = Promise.race(pool);
run(race);

Promise.race+async…await实现:

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
let urls = ["1", "2", "3", "4", "5", "6", "7"];
let pool = []; //并发池
let max = 3; //最大并发数量
//自定义请求函数
function request(url) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(url);
console.log(`任务${url}完成`);
}, 1000);
}).then((res) => {
console.log("外部逻辑", res);
});
}
async function fn() {
for (let i = 0; i < urls.length; i++) {
let task = request(urls[i]);
//请求结束将Promise任务从pool中移除
task.then((res) => {
pool.splice(pool.indexOf(task), 1);
console.log(`${urls[i]}结束,当前并发数:${pool.length}`);
});
pool.push(task);
//并发池塞满后需要等待一个task完成才可以继续往里面塞任务
if (pool.length === max) {
await Promise.race(pool);
}
}
}
fn();

在对象字面量方法中的this类型,将由以下决定:

如果这个方法显示指定了this参数,那么this具有该参数的类型

1
2
3
4
5
6
let bar = {
x: "hello",
f(this:{ message: string }){
this;//{message: string}
}
}

否则,如果方法由带this参数的签名进行上下文键入,那么this具有该参数的类型

1
2
3
4
5
6
let foo = {
x: "hello",
f(n: number) {
this;//{x:string,f(n: number): void}
}
}

如果–noImplicityThis选项启用,并且对象字面量中包含由ThisType键入的上下文类型,那么this的类型为T

如果–noImplicityThis选项启用,并且对象字面量中包含由ThisType键入的上下文类型,那么this的类型为该上下文类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type ObjectDescriptor<D,M> = {
data?: D;
methods?: M & ThisType<D&M>//Type of 'this' is D&M
}
function makeObject<D,M>(desc:ObjectDescriptor<D,M>:D & M) {
let data: object = desc.data || {}
let methods: object = desc.methods || {}
return {...data,...methods} as D&M;
}
let obj = makeObject({
data: {x:0,y:0},
methods: {
moveBy(dx:number,dy:number) {
this.x+=dx;//Strongly typed this
this.y+=dy;//Strongly typed this
}
}
})
obj.x=10;
obj.y=20;
obj.moveBy(5,5);

否则,如果 --noImplicitThis 选项已经启用,this 具有该对象字面量的类型。

否则,this 的类型为 any

TypeScript只能严格的单继承,因此你不能:

1
2
3
class User extends Tagged,Timestamped {
//ERROR:不能多重继承
}

从可重用组件构建类的另一种方式是通过基类构建它们,这种方式成为混合

混合是一个函数:

  • 传入一个构造函数;
  • 创建一个带有新功能,并且扩展构造函数的新类
  • 返回这个新类

example:

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
//所有mixins都需要
type Constructor<T = {}> = new (...args: any[]) => T;
//添加属性的混合例子
function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
timestamp = Date.now()
}
}
//添加属性和方法的混合例子
function Activatable<TBase extends Constructor>(Base: TBase) {
return class extends Base {
isActivated = false;
activate() {
this.isActivated = true;
}
deactivate() {
this.isActivated = false
}
}
}
class User {
name=''
}
//添加TImestamped的User
const Timestamped = Timestamped(User)
const TimestampedActivatableUser = Timestamped(Activatable(User))
const timestampUserExample = new Timestamped()
console.log(timestampUserExample.timestamp)
const timestampedActivatableUserExample = new TimestampedActivatableUser()
console.log(timestampedActivatableUserExample.timestamp)

上面这个例子中,

创建一个构造函数

混合接受一个类,并且使用新功能扩展它,因此,我们需要定义构造函数的类型:

1
type Constructor<T = {}> = new (...args:any[]) => T

扩展一个类并且返回它:

1
2
3
4
5
6
//添加属性的混合例子
function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
timestamp = Date.now()
}
}

Freshness会进行更严格的对象字面量检查,用来确保对象字面量在结构上类型兼容

结构类型兼容规则:如果两个类型的结构一样,就说它们是互相兼容的,且可以相互赋值的(即如果类型x要兼容类型y,那么类型y至少具有与类型x相同的属性)

Freshness 特性

如上所述,只要满足结构类型兼容规则的两个类型便可相互兼容。那是否有例外存在呢?让我们看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
interface Named {
name:string
}
interface Person {
id: number;
name: string;
}
let p: Named;
p = {
id:1,// 不能将类型“{ id: number; name: string; }”分配给类型“Named”。 对象文字可以只指定已知属性,并且“id”不在类型“Named”中。ts(2322)
name: 'Tom',
}

虽然为变量p赋予的字面值完全符合结构类型兼容规则,但它却抛出异常,这主要是由TypeScript中的Freshness特性导致,该特性会对对象字面量进行更为严格的类型检测:只有目标变量的类型与该对象字面量的类型完全一致,对象字面量才可能赋值给目标变量,否则抛出类型错误,我们可以通过类型断言消除异常:

1
2
3
4
5
let p: Named;
p = {
id: 1,
name: 'Tom',
} as Person;

Vue.js模板编译器用于把模板编译为渲染函数:

  • 分析模板,将其解析为AST
  • 将模板AST转换为用于描述渲染函数的JavaScript AST
  • 根据JavaScript AST生成渲染函数代码

解析Token

为Vue.js模板构造AST,AST在结构上和模板同构

首先,将模板解析为一个个token,利用有限状态机进行分词

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
//定义状态机状态
const State = {
initial: 1, //初始状态
tagOpen: 2, //标签开始状态
tagName: 3, //标签名称状态
text: 4, //文本状态
tagEnd: 5, //结束状态
tagEndName: 6, //结束标签名称状态
};
//一个辅助函数,用于判断是否是字母
function isAlpha(char) {
return (char >= "a" && char <= "z") || (char >= "A" && char <= "Z");
}
//接收模板字符串作为参数,并将模板切割为Token返回
function tokenize(str) {
//设置状态机的当前状态为初始状态
let currentState = State.initial;
//用于缓存字符
const chars = [];
//生成的token会存储到tokens中,并作为函数返回值返回
const tokens = [];
//使用while循环开启自动机,只要模板字符串没有被消费完,自动机一直运行
while (str) {
//查看第一个字符只是查看,没有消费
const char = str[0];
//switch语句匹配状态
switch (currentState) {
//状态机处于初始状态
case State.initial:
//遇到字符<
if (char === "<") {
//切换到标签开始状态
currentState = State.tagOpen;
//消费字符
str = str.slice(1);
} else if (isAlpha(str)) {
//初始状态下遇到文本,切换到文本状态
currentState = State.text;
//将当前文本存到chars数组
chars.push(char);
//消费字符
str = str.slice(1);
}
break;
//状态机处于标签开始状态
case State.tagOpen:
//遇到字母,切换到标签名称状态
if (isAlpha(char)) {
currentState = State.tagName;
//将当前字符缓存到chars数组
chars.push(char);
str = str.slice(1);
} else if (char === "/") {
//遇到"/"切换到标签结束状态
currentState = State.tagEnd;
str = str.slice(1);
}
break;
//状态机处于标签名状态
case State.tagName:
//遇到字母,仍然处于标签名状态,不需要切换状态
//但需要将字符缓存进chars数组
if (isAlpha(char)) {
chars.push(char);
str = str.slice(1);
} else if (char === ">") {
//切换到初始状态
currentState = State.initial;
//同时创建一个标签Token,并添加到tokens数组
//此时chars数组中缓存的就是标签名
tokens.push({
type: "tag",
name: chars.join(""),
});
//chars数组已经被消费,清空
chars.length = 0;
//同时消费当前字符>
str = str.slice(1);
}
break;
//状态机处于文本状态
case State.text:
if (isAlpha(char)) {
chars.push(char);
str = str.slice(1);
} else if (char === "<") {
//切换标签开始状态
currentState = State.tagOpen;
//从文本状态到标签开始状态,此时应该创建文本Token,并添加到tokens数组
//chars数组中的内容就是文本内容
tokens.push({
type: "text",
content: chars.join(""),
});
//清空数组内容
chars.length = 0;
str = str.slice(1);
}
break;
//状态机处于标签结束状态
case State.tagEnd:
///遇到字母切换到标签结束名
if (isAlpha(char)) {
currentState = State.tagEndName;
chars.push(char);
str = str.slice(1);
}
break;
//状态机处于结束标签名称状态
case State.tagEndName:
if (isAlpha(char)) {
chars.push(char);
str = str.slice(1);
} else if (char === ">") {
currentState = State.initial;
tokens.push({
type: "tagEnd",
name: chars.join(""),
});
}
chars.length = 0;
str = str.slice(1);
break;
}
}
//最后返回tokens
return tokens;
}

比如:

1
const tokens = tokenize(`<div><p>Vue</p></div>)

得到:

1
2
3
4
5
6
7
8
const tokens = [
{type:"tag",name:"div"},//div开始标签
{type:'tag',name:'p'},//p开始标签
{type:'text',context:'Vue'},//文本节点
{type:'tagEnd',name:'p'},//p结束标签
{type:'tagEnd',name:'div'}//div结束标签

]

构建AST:

接下来,扫描token列表构建AST:

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
//扫描token列表构建AST
//parse函数接收模板作为参数
function parse(str) {
//首先对模板标记化,得到tokens
const tokens = tokenize(str)
//创建Root节点
const root = {
type: 'Root',
children: []
}
//创建elementStack,起初里面只有Root根结点
const elementStack = [root]
//开启一个while循环扫描tokens,直到所有Token都被扫描完毕为止
while(tokens.length) {
//获取栈顶节点作为父结点
const parent = elementStack[elementStack.length-1]
//当前扫描的Token
const t = tokens[0]
switch(t.type){
case 'tag':
//如果当前token是开始标签,则创建Element类型的AST节点
const elementNode = {
type: 'Element',
tag: t.name,
children: []
}
//将其添加到父级节点的children
parent.children.push(elementNode)
//将当前结点压入栈
elementStack.push(elementNode)
break;
case 'text':
const textNode = {
type: 'Text',
content: t.content
}
parent.children.push(textNode)
break;
case 'tagEnd':
//遇到结束标签,将栈顶节点弹出
elementStack.pop()
break;
}
//消费已经扫描过的token
tokens.shift()

}
}

AST的转换

AST的转换,即对AST的一系列操作

transform函数完成AST的转换,使用深度遍历算法对节点进行访问

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
59
60
61
62
63
64
65
66
67

//首先需要编写一个深度优先遍历算法
//转换上下文context看做是在程序中某个范围内的全局变量,下面,所有AST转换函数都可以通过context共享数据
//将对结点的访问和操作进行解耦
function traverseNode(ast,context) {
//设置当前转换的结点信息context.currentNode
context.currentNode = ast
const transforms = context.nodeTransforms
//执行对结点的操作
for(let i = 0;i<transforms.length;i++){
transforms[i](context.currentNode,context)
}
const children = context.currentNode.children
//执行对结点的深度遍历访问
for(let i = 0;i<children.length;i++){
//递归调用traverseNode转换子节点之前,将当前结点设置为父结点
context.parent = context.currentNode
//设置位置索引
context.childIndex = i
//递归调用时,将context透传
traverseNode(children[i],context)
}
}
function transformElement(node,context){
if(node.type === 'Element' && node.tag === 'p') {
context.removeNode()
}
}
function transformText(node,context) {
if(node.type === 'Text') {
context.replaceNode({
type:'Element',
tag: 'span'
})
}
}

function transform(ast) {
const context = {
//增加currentNode用来存储当前正在转换的结点
currentNode:null,
//增加currentIndex,用来存储当前结点在父结点的children中的位置索引
childIndex: 0,
//增加parant用来存储当前转换结点的父结点
parent: null,
//用于替换结点的函数,接收新节点作为参数
replaceNode(node){
//为了替换结点,需要修改AST
//找到当前结点在父结点的children位置:context.childIndex
//使用新节点替换
context.parent.children[context.childIndex]=node
context.currentNode = node
},
//用于移除当前访问结点
removeNode(node){
//根据当前结点在父结点中的索引删除结点
context.parent.children.splice(context.childIndex,1)
context.currentNode = null
},
nodeTransforms: [
transformElement,
transformText
]
}
traverseNode(ast,context)
}

Vue.js模板编译器用于把模板编译为渲染函数:

  • 分析模板,将其解析为AST
  • 将模板AST转换为用于描述渲染函数的JavaScript AST
  • 根据JavaScript AST生成渲染函数代码

解析Token

为Vue.js模板构造AST,AST在结构上和模板同构

首先,将模板解析为一个个token,利用有限状态自动机构造一个词法分析器,词法分析的过程就是状态机在不同状态之间迁移的过程,在此过程中,状态机会产出一个个token,生成一个token列表我们使用该token列表来构造用于描述模板的AST

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
//定义状态机状态
const State = {
initial: 1, //初始状态
tagOpen: 2, //标签开始状态
tagName: 3, //标签名称状态
text: 4, //文本状态
tagEnd: 5, //结束状态
tagEndName: 6, //结束标签名称状态
};
//一个辅助函数,用于判断是否是字母
function isAlpha(char) {
return (char >= "a" && char <= "z") || (char >= "A" && char <= "Z");
}
//接收模板字符串作为参数,并将模板切割为Token返回
function tokenize(str) {
//设置状态机的当前状态为初始状态
let currentState = State.initial;
//用于缓存字符
const chars = [];
//生成的token会存储到tokens中,并作为函数返回值返回
const tokens = [];
//使用while循环开启自动机,只要模板字符串没有被消费完,自动机一直运行
while (str) {
//查看第一个字符只是查看,没有消费
const char = str[0];
//switch语句匹配状态
switch (currentState) {
//状态机处于初始状态
case State.initial:
//遇到字符<
if (char === "<") {
//切换到标签开始状态
currentState = State.tagOpen;
//消费字符
str = str.slice(1);
} else if (isAlpha(str)) {
//初始状态下遇到文本,切换到文本状态
currentState = State.text;
//将当前文本存到chars数组
chars.push(char);
//消费字符
str = str.slice(1);
}
break;
//状态机处于标签开始状态
case State.tagOpen:
//遇到字母,切换到标签名称状态
if (isAlpha(char)) {
currentState = State.tagName;
//将当前字符缓存到chars数组
chars.push(char);
str = str.slice(1);
} else if (char === "/") {
//遇到"/"切换到标签结束状态
currentState = State.tagEnd;
str = str.slice(1);
}
break;
//状态机处于标签名状态
case State.tagName:
//遇到字母,仍然处于标签名状态,不需要切换状态
//但需要将字符缓存进chars数组
if (isAlpha(char)) {
chars.push(char);
str = str.slice(1);
} else if (char === ">") {
//切换到初始状态
currentState = State.initial;
//同时创建一个标签Token,并添加到tokens数组
//此时chars数组中缓存的就是标签名
tokens.push({
type: "tag",
name: chars.join(""),
});
//chars数组已经被消费,清空
chars.length = 0;
//同时消费当前字符>
str = str.slice(1);
}
break;
//状态机处于文本状态
case State.text:
if (isAlpha(char)) {
chars.push(char);
str = str.slice(1);
} else if (char === "<") {
//切换标签开始状态
currentState = State.tagOpen;
//从文本状态到标签开始状态,此时应该创建文本Token,并添加到tokens数组
//chars数组中的内容就是文本内容
tokens.push({
type: "text",
content: chars.join(""),
});
//清空数组内容
chars.length = 0;
str = str.slice(1);
}
break;
//状态机处于标签结束状态
case State.tagEnd:
///遇到字母切换到标签结束名
if (isAlpha(char)) {
currentState = State.tagEndName;
chars.push(char);
str = str.slice(1);
}
break;
//状态机处于结束标签名称状态
case State.tagEndName:
if (isAlpha(char)) {
chars.push(char);
str = str.slice(1);
} else if (char === ">") {
currentState = State.initial;
tokens.push({
type: "tagEnd",
name: chars.join(""),
});
}
chars.length = 0;
str = str.slice(1);
break;
}
}
//最后返回tokens
return tokens;
}

比如:

1
const tokens = tokenize(`<div><p>Vue</p></div>)

得到:

1
2
3
4
5
6
7
8
const tokens = [
{type:"tag",name:"div"},//div开始标签
{type:'tag',name:'p'},//p开始标签
{type:'text',context:'Vue'},//文本节点
{type:'tagEnd',name:'p'},//p结束标签
{type:'tagEnd',name:'div'}//div结束标签

]

构建AST:

接下来,扫描token列表构建AST:

扫描Token列表维护一个标签栈,每当扫描到一个开始标签结点,将其压入栈顶,栈顶的结点始终作为下一个扫描的结点的父结点,这样,当所有token扫描完,构建一颗AST树

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
//扫描token列表构建AST
//parse函数接收模板作为参数
function parse(str) {
//首先对模板标记化,得到tokens
const tokens = tokenize(str)
//创建Root节点
const root = {
type: 'Root',
children: []
}
//创建elementStack,起初里面只有Root根结点
const elementStack = [root]
//开启一个while循环扫描tokens,直到所有Token都被扫描完毕为止
while(tokens.length) {
//获取栈顶节点作为父结点
const parent = elementStack[elementStack.length-1]
//当前扫描的Token
const t = tokens[0]
switch(t.type){
case 'tag':
//如果当前token是开始标签,则创建Element类型的AST节点
const elementNode = {
type: 'Element',
tag: t.name,
children: []
}
//将其添加到父级节点的children
parent.children.push(elementNode)
//将当前结点压入栈
elementStack.push(elementNode)
break;
case 'text':
const textNode = {
type: 'Text',
tag: t.content
}
parent.children.push(textNode)
break;
case 'tagEnd':
//遇到结束标签,将栈顶节点弹出
elementStack.pop()
break;
}
//消费已经扫描过的token
tokens.shift()

}
}

AST的转换

AST的转换,即对AST的一系列操作

transform函数完成AST的转换

为了解耦结点的访问和操作,设计了插件化架构,将结点的操作封装到独立的转换函数,这些转换函数可以通过context.nodeTransforms来注册,这里的context称为转换上下文,上下文对象维护程序的当前状态,可以实现结点的替换删除功能,但是有时候,当前结点的转换工作依赖于子节点的转换结果,所以为了优先执行子节点的转换,我们将整个转换过程分为“进入阶段“和”退出阶段“,每个转换函数分成两个阶段执行,这样可以实现更加细粒度的转换控制

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
59
60
61
62
63
64
65
66
67

//首先需要编写一个深度优先遍历算法
//转换上下文context看做是在程序中某个范围内的全局变量,下面,所有AST转换函数都可以通过context共享数据
//将对结点的访问和操作进行解耦
function traverseNode(ast,context) {
//设置当前转换的结点信息context.currentNode
context.currentNode = ast
const transforms = context.nodeTransforms
//执行对结点的操作
for(let i = 0;i<transforms.length;i++){
transforms[i](context.currentNode,context)
}
const children = context.currentNode.children
//执行对结点的深度遍历访问
for(let i = 0;i<children.length;i++){
//递归调用traverseNode转换子节点之前,将当前结点设置为父结点
context.parent = context.currentNode
//设置位置索引
context.childIndex = i
//递归调用时,将context透传
traverseNode(children[i],context)
}
}
function transformElement(node,context){
if(node.type === 'Element' && node.tag === 'p') {
context.removeNode()
}
}
function transformText(node,context) {
if(node.type === 'Text') {
context.replaceNode({
type:'Element',
tag: 'span'
})
}
}

function transform(ast) {
const context = {
//增加currentNode用来存储当前正在转换的结点
currentNode:null,
//增加currentIndex,用来存储当前结点在父结点的children中的位置索引
childIndex: 0,
//增加parant用来存储当前转换结点的父结点
parent: null,
//用于替换结点的函数,接收新节点作为参数
replaceNode(node){
//为了替换结点,需要修改AST
//找到当前结点在父结点的children位置:context.childIndex
//使用新节点替换
context.parent.children[context.childIndex]=node
context.currentNode = node
},
//用于移除当前访问结点
removeNode(node){
//根据当前结点在父结点中的索引删除结点
context.parent.children.splice(context.childIndex,1)
context.currentNode = null
},
nodeTransforms: [
transformElement,
transformText
]
}
traverseNode(ast,context)
}

增加回退功能的traverseNode:

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
function traverseNode(ast, context) {
//增加一个退出阶段回调函数数组
const exitFns = []
//设置当前转换的结点信息context.currentNode
context.currentNode = ast;
const transforms = context.nodeTransforms;
//执行对结点的操作
for (let i = 0; i < transforms.length; i++) {
const onExit = transforms[i](context.currentNode, context);
if(onExit){
//将退出阶段的回调函数添加到exitFns数组中
exitFns.push(onExit)
}
}
const children = context.currentNode.children;
//执行对结点的深度遍历访问
for (let i = 0; i < children.length; i++) {
//递归调用traverseNode转换子节点之前,将当前结点设置为父结点
context.parent = context.currentNode;
//设置位置索引
context.childIndex = i;
//递归调用时,将context透传
traverseNode(children[i], context);
}
//在节点处理的最后阶段执行缓存到exitFns中的回调函数
let i = exitFns.length
while(i--){
exitFns[i]()
}
}

将模板AST转为JavaScript AST

代码生成的过程就是字符串的拼接过程,需要为不同AST结点编写对应的代码生成函数。

这段模板:

1
2
3
4
5
6
7
8
<div>
<p>
Vue
</p>
<p>
Template
</p>
</div>

等价于下面这段渲染函数:

1
2
3
4
5
6
function render(){
return h('div',[
h('p','Vue'),
h('p','Template')
])
}

等价于下面这段JavaScript AST:

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
const FunctionDeclNode = {
type: 'FunctionDecl',
//函数名称是一个标识符,标识符本身也是一个结点
id: {
type:'identifier',
name:'render',//存储标识符名称,这里是渲染函数render
},
params:[],//参数
body: [
{
type: 'ReturnStatement',
//最外层的h函数调用
return: {
//函数调用语句
type: 'CallExpression',
//被调用函数的名称,是一个标识符
callee: {type:'identifier',name:'h'},
arguments: [
//第一个参数是字符串字面量div
{
type: 'StringLiteral',
value:'div'
},
{
//第二个参数是一个数组
type: 'ArrayExpression',
elements: [
//数组第一个元素时h函数的调用
{
type:'CallExpression',
callee: {type:'identifier',name:'h'},
arguments:[
//该h函数调用的第一个参数是一个字符串字面量
{type: 'StringLiteral',value:'p'},
{type: 'StringLiteral',value:'Vue'}
]
},
{
type: 'CallExpression',
callee: {type:'identifier',name:'h'},
arguments:[
{type: 'StringLiteral',value:'p'},
{type: 'StringLiteral',value:'Template'}

]
}
]
}
]
}

}
]

}

编写一些用来辅助创建js ast的函数

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
function createStringLiteral(value) {
return {
type:'StringLiteral',
value
}
}
//创建Identifier节点(函数声明
function createIdentifier(name) {
return {
type:'Identifier',
name
}
}
//创建ArrayExpression节点(数组表达式)
function createArrayExpression(elements){
return {
type:'ArrayExpression',
elements
}
}
//创建CallExpression节点(函数调用)
function createCallExpression(callee,arguments) {
return {
type:'CallExpression',
callee:createIdentifier(callee),
arguments
}
}

编写转换文本节点,标签结点和根结点的函数:

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
//编写转换文本节点函数
function transformText(node) {
if(node.type !== 'Text'){
return
}
node.jsNode = createStringLiteral(node.content)
}
//编写转换标签节点函数
function transformElement(node) {
//转换代码编写在退出阶段的回调函数中
//可以保证该标签结点的子节点全部被处理完毕
return () => {
if(node.type !== 'Element') {
return
}
//创建h函调用语句,第一个是标签名
const callExp = createCallExpression('h',[
createStringLiteral(node.tag)
])
//处理h函数调用的参数
node.children.length === 1
//如果当前标签结点只有一个子节点,则直接使用子节点的jsnode作为参数
? callExp.arguments.push(node.children[0].jsNode)
:callExp.arguments.push(
//数组的每个元素都是子节点的jsnode
createArrayExpression(node.children.map(c => c.jsNode))
)
//将当前标签结点对应的JavaScript AST添加到jsNode属性下
node.jsNode = callExp;
}
}
//还需要把描述render的函数声明附加到js ast中
function transformRoot(node) {
return () => {
if(node.type !== 'Root') {
return;
}
//node是根结点,第一个子节点就是模板的根结点
const vnodeJSAST = node.children[0].jsNode
//创建render函数的声明语句结点,将vnodeAST作为render函数体的返回语句
node.jsNode = {
type: 'FunctionDecl',
id: {type:'Identifier',name:'render'},
params: [],
body: [
{
type: 'ReturnStatement',
return: vnodeJSAST
}
]
}
}
}

编译代码,代码生成:

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
//编译
function compile(template) {
//模板AST
const ast = parse(template)
//将模板ast转换为JavaScript AST
transform(ast)
const code = generate(ast.jsNode)
return code
}
function generate(node) {
const context = {
code: '',
push(code) {
context.node += code
},
//当前缩进的级别,初始值为0,即没有缩进
currentIndent: 0,
//该函数用来换行,即在代码字符串后面追加\n字符,换行时保留缩进
newLine(){
context.code+= '\n'+` `.repeat(context.currentIndent)
},
//用来缩进,即让currentIndent自增后调用换行函数
indent(){
context.currentIndent++;
context.newLine()
},
//取消缩进
deindent(){
context.currentIndent--;
context.newLine()
}
}
genNode(node,context)
return context.code
}
function genNode(node,context) {
switch(node.type) {
case 'FunctionDecl':
genFunctionDecl(node,context)
break
case 'ReturnStatement':
genReturnStatement(node,context)
break
case 'CallExpression':
genCallExpression(node,context)
break
case 'StringLiteral':
genStringLiteral(node,context)
break
case 'ArrayExpression':
genArrayExpression(node,context)
break
}
}
//为函数参数生成代码
function genNodeList(nodes,context) {
const {push} = context
for(let i=0;i<nodes.length;i++){
genNode(nodes[i],context)
if(i<nodes.length-1){
push(',')
}

}
}
function genFunctionDecl(node,context) {
//从context中取出工具函数
const {push,indent,deindent} = context
//node.id.name即函数名
push(`funtion(${node.id.name})`)
push(`(`)
//为函数参数生成代码
genNodeList(node.params,context)
push(`)`)
push(`{`)
indent()
//为函数生成代码,递归调用genNode
node.body.forEach(n => genNode(n,context))
//取消缩进
deindent()
push(`}`)

}
function genArrayExpression(node,context) {
const {push} = context
push('[')
//调用genNodeList为数组元素生成代码
genNodeList(node.elements,context)
push(']')
}
function genReturnStatement(node,context) {
const {push} = context
push('return')
//genNode函数递归生成返回值代码
genNode(context.return,context)

}
function genStringLiteral(node,context) {
const {push} = context
push(`${node.value}`)
}
function genCallExpression(node,context) {
const {push} = context
//取得调用函数名称和参数列表
const {callee,arguments} = context
push(`${callee.name}(`)
//调用genNodeList生成参数列表
genNodeList(arguments,context)
push(')')
}

例子:

1
2
3
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)
const code = generate(ast.jsNode)

转换为:

1
2
3
function render(){
return history('div',[h('p','Vue'),h('p','Template')])
}

Vue.js模板编译器用于把模板编译为渲染函数:

  • 分析模板,将其解析为AST
  • 将模板AST转换为用于描述渲染函数的JavaScript AST
  • 根据JavaScript AST生成渲染函数代码

解析Token

为Vue.js模板构造AST,AST在结构上和模板同构

首先,将模板解析为一个个token,利用有限状态机进行分词

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
//定义状态机状态
const State = {
initial: 1, //初始状态
tagOpen: 2, //标签开始状态
tagName: 3, //标签名称状态
text: 4, //文本状态
tagEnd: 5, //结束状态
tagEndName: 6, //结束标签名称状态
};
//一个辅助函数,用于判断是否是字母
function isAlpha(char) {
return (char >= "a" && char <= "z") || (char >= "A" && char <= "Z");
}
//接收模板字符串作为参数,并将模板切割为Token返回
function tokenize(str) {
//设置状态机的当前状态为初始状态
let currentState = State.initial;
//用于缓存字符
const chars = [];
//生成的token会存储到tokens中,并作为函数返回值返回
const tokens = [];
//使用while循环开启自动机,只要模板字符串没有被消费完,自动机一直运行
while (str) {
//查看第一个字符只是查看,没有消费
const char = str[0];
//switch语句匹配状态
switch (currentState) {
//状态机处于初始状态
case State.initial:
//遇到字符<
if (char === "<") {
//切换到标签开始状态
currentState = State.tagOpen;
//消费字符
str = str.slice(1);
} else if (isAlpha(str)) {
//初始状态下遇到文本,切换到文本状态
currentState = State.text;
//将当前文本存到chars数组
chars.push(char);
//消费字符
str = str.slice(1);
}
break;
//状态机处于标签开始状态
case State.tagOpen:
//遇到字母,切换到标签名称状态
if (isAlpha(char)) {
currentState = State.tagName;
//将当前字符缓存到chars数组
chars.push(char);
str = str.slice(1);
} else if (char === "/") {
//遇到"/"切换到标签结束状态
currentState = State.tagEnd;
str = str.slice(1);
}
break;
//状态机处于标签名状态
case State.tagName:
//遇到字母,仍然处于标签名状态,不需要切换状态
//但需要将字符缓存进chars数组
if (isAlpha(char)) {
chars.push(char);
str = str.slice(1);
} else if (char === ">") {
//切换到初始状态
currentState = State.initial;
//同时创建一个标签Token,并添加到tokens数组
//此时chars数组中缓存的就是标签名
tokens.push({
type: "tag",
name: chars.join(""),
});
//chars数组已经被消费,清空
chars.length = 0;
//同时消费当前字符>
str = str.slice(1);
}
break;
//状态机处于文本状态
case State.text:
if (isAlpha(char)) {
chars.push(char);
str = str.slice(1);
} else if (char === "<") {
//切换标签开始状态
currentState = State.tagOpen;
//从文本状态到标签开始状态,此时应该创建文本Token,并添加到tokens数组
//chars数组中的内容就是文本内容
tokens.push({
type: "text",
content: chars.join(""),
});
//清空数组内容
chars.length = 0;
str = str.slice(1);
}
break;
//状态机处于标签结束状态
case State.tagEnd:
///遇到字母切换到标签结束名
if (isAlpha(char)) {
currentState = State.tagEndName;
chars.push(char);
str = str.slice(1);
}
break;
//状态机处于结束标签名称状态
case State.tagEndName:
if (isAlpha(char)) {
chars.push(char);
str = str.slice(1);
} else if (char === ">") {
currentState = State.initial;
tokens.push({
type: "tagEnd",
name: chars.join(""),
});
}
chars.length = 0;
str = str.slice(1);
break;
}
}
//最后返回tokens
return tokens;
}

比如:

1
const tokens = tokenize(`<div><p>Vue</p></div>)

得到:

1
2
3
4
5
6
7
8
const tokens = [
{type:"tag",name:"div"},//div开始标签
{type:'tag',name:'p'},//p开始标签
{type:'text',context:'Vue'},//文本节点
{type:'tagEnd',name:'p'},//p结束标签
{type:'tagEnd',name:'div'}//div结束标签

]

构建AST:

接下来,扫描token列表构建AST:

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
//扫描token列表构建AST
//parse函数接收模板作为参数
function parse(str) {
//首先对模板标记化,得到tokens
const tokens = tokenize(str)
//创建Root节点
const root = {
type: 'Root',
children: []
}
//创建elementStack,起初里面只有Root根结点
const elementStack = [root]
//开启一个while循环扫描tokens,直到所有Token都被扫描完毕为止
while(tokens.length) {
//获取栈顶节点作为父结点
const parent = elementStack[elementStack.length-1]
//当前扫描的Token
const t = tokens[0]
switch(t.type){
case 'tag':
//如果当前token是开始标签,则创建Element类型的AST节点
const elementNode = {
type: 'Element',
tag: t.name,
children: []
}
//将其添加到父级节点的children
parent.children.push(elementNode)
//将当前结点压入栈
elementStack.push(elementNode)
break;
case 'text':
const textNode = {
type: 'Text',
content: t.content
}
parent.children.push(textNode)
break;
case 'tagEnd':
//遇到结束标签,将栈顶节点弹出
elementStack.pop()
break;
}
//消费已经扫描过的token
tokens.shift()

}
}

AST的转换

AST的转换,即对AST的一系列操作

transform函数完成AST的转换,使用深度遍历算法对节点进行访问

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
59
60
61
62
63
64
65
66
67

//首先需要编写一个深度优先遍历算法
//转换上下文context看做是在程序中某个范围内的全局变量,下面,所有AST转换函数都可以通过context共享数据
//将对结点的访问和操作进行解耦
function traverseNode(ast,context) {
//设置当前转换的结点信息context.currentNode
context.currentNode = ast
const transforms = context.nodeTransforms
//执行对结点的操作
for(let i = 0;i<transforms.length;i++){
transforms[i](context.currentNode,context)
}
const children = context.currentNode.children
//执行对结点的深度遍历访问
for(let i = 0;i<children.length;i++){
//递归调用traverseNode转换子节点之前,将当前结点设置为父结点
context.parent = context.currentNode
//设置位置索引
context.childIndex = i
//递归调用时,将context透传
traverseNode(children[i],context)
}
}
function transformElement(node,context){
if(node.type === 'Element' && node.tag === 'p') {
context.removeNode()
}
}
function transformText(node,context) {
if(node.type === 'Text') {
context.replaceNode({
type:'Element',
tag: 'span'
})
}
}

function transform(ast) {
const context = {
//增加currentNode用来存储当前正在转换的结点
currentNode:null,
//增加currentIndex,用来存储当前结点在父结点的children中的位置索引
childIndex: 0,
//增加parant用来存储当前转换结点的父结点
parent: null,
//用于替换结点的函数,接收新节点作为参数
replaceNode(node){
//为了替换结点,需要修改AST
//找到当前结点在父结点的children位置:context.childIndex
//使用新节点替换
context.parent.children[context.childIndex]=node
context.currentNode = node
},
//用于移除当前访问结点
removeNode(node){
//根据当前结点在父结点中的索引删除结点
context.parent.children.splice(context.childIndex,1)
context.currentNode = null
},
nodeTransforms: [
transformElement,
transformText
]
}
traverseNode(ast,context)
}