easycodesniper

blog by chen qiyi

useEffect

Effect在React中被专门定义为:由渲染引起的副作用。它允许你指定由渲染本身,而不是特定事件引起的副作用

useEffect被设计用于将组件和外部系统同步,例如数据获取、服务器的链接、设置订阅等不受React控制的系统

作为一个React Hook,它只能在组件的顶层调用,如果你需要在循环或判断的逻辑中调用,可以将这部分逻辑抽离出去成为一个组件。

语法

useEffect(setup, dependencies?)

  • setup: 处理Effect的函数,可以选择性的返回一个清理(cleanup)函数。当 组件被添加到DOM时 和 每次依赖项发生变化重新渲染后,React会首先利用旧值运行cleanup函数(如果你设置了该函数),然后使用新值运行setup函数。在组件从DOM中移除后,React将最后一次运行cleanup函数
  • dependencies(可选): setup函数中引用的所有响应式值的列表。
    • React会使用Object.is来比较每个依赖项和它先前的值,只要有依赖项和先前的值不同时,将会重新运行setup函数。
    • 如果忽略此参数,则在每次重新渲染组件之后,将重新执行setup函数
    • 如果此参数为空数组[],则只在初次渲染组件的时候执行setup函数

useEffect的运行过程:

  1. 将组件挂载到页面时,将运行setup函数
  2. 重新渲染依赖项变更的组件后:
  • 首先,使用旧值运行cleanup函数
  • 然后,使用新值运行setup函数
  1. 当组件从页面卸载后,cleanup函数将运行最后一次

开发环境下,React在运行setup之前会额外运行一次setupcleanup,这是一种压力测试,来验证Effect逻辑是否正确实现

使用示例:模拟一个连接服务器的组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useEffect, useState } from "react";
function App() {
const [serverUrl, setServerUrl] = useState('easycodesniper.top')

useEffect(() => {
const connection = createConnection(serverUrl) //模拟连接服务器
connection.connect()
return () => { //在销毁组件时断开连接
connection.disconnect()
}
}, [serverUrl]) //依赖项,当服务器路径改变时重新执行setup

return (
<>
<h1>欢迎来到{serverUrl}</h1>
</>
)
}
export default App;

useEffect执行时机

每当你的组件渲染时,React将更新视图,然后运行useEffect中的代码。换句话说,**useEffect会把这段代码放到视图更新渲染之后执行**

实践导向

在Effect中根据先前的state更新state

当想在Effect中根据先前的state更新state时,会遇到问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useEffect, useState } from "react";
function App() {
const [cnt, setCnt] = useState(0)

useEffect(() => {
const timer = setInterval(() => {
setCnt(cnt + 1) //每秒将cnt加1
}, 1000)
return () => clearInterval(timer)
}, [cnt]) //将cnt作为依赖项

return (
<>
<spsn>{cnt}</span>
</>
)
}
export default App;

因为cnt是响应式数据,所以必须在依赖项列表中指定它,这就会导致Effect在每次cnt变化之后都要执行cleanupsetup

解决方法:在setCnt中不是直接传入修改的值,而是传入c => c + 1状态更新器,这样做的目的是:将cnt从依赖项中移除

1
2
3
4
5
6
7
8
const [cnt, setCnt] = useState(0)

useEffect(() => {
const timer = setInterval(() => {
setCnt(c => c + 1) //每秒将cnt加1
}, 1000)
return () => clearInterval(timer)
}, [])

Effect依赖于对象或函数

如果你的Effect依赖于渲染期间创建的对象或函数,则它可能会频繁运行

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useEffect, useState } from "react";
function App() {
function createOptions(serverUrl) {
return {
serverUrl,
// ... others
}
}
useEffect(() => {
const options = createOptions('easycodesniper.top') //在Effect中被使用
const connection = createConnection(options)
connection.connect()
return () => {
connection.disconnect()
}
}, [createOptions])

return (
<>
<h1>欢迎来到{serverUrl}</h1>
</>
)
}
export default App;

createOptions函数本身的封装并没有问题。

首先,对象或函数都是引用类型的值,判断他们是否相同是通过是否指向同一块内存地址

然后,每次组件重新渲染,都会从头创建一个createOptions函数,那这个函数的地址和之前的地址肯定不同,也就意味着这两者不是相同的(即依赖项发生了改变),会导致Effect在每次重新渲染之后再次重新执行

所以,避免使用在渲染期间创建的函数作为依赖项请在Effect内部声明它

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
import { useEffect, useState } from "react";
function App() {

const [serverUrl, setServerUrl] = useState('easycodesniper.top')

useEffect(() => {

// 在Effect内部创建它
function createOptions(serverUrl) {
return {
serverUrl,
// ... others
}
}

const options = createOptions(serverUrl) //在Effect中被使用
const connection = createConnection(options)
connection.connect()
return () => {
connection.disconnect()
}
}, [serverUrl])

return (
<>
<h1>欢迎来到{serverUrl}</h1>
</>
)
}
export default App;

通过在Effect内部定义createOption函数,这样Effect只依赖于serverUrl字符串,字符串作为基础类型值,除非你将它设置为其他值,否则它不会改变

你不需要Effect

Effect是React范式中的一种脱围机制。它让你可以使组件和一些外部的系统同步。如果没有涉及到外部系统(例如只是像根据props或state的变化更新一个组件的state),你就不应该使用Effect

常见的情况:

  1. 不必使用Effect来转换渲染所需的数据。例如,想在展示一个列表之前先做筛选,你可能会写一个当列表变化时更新state的Effect。然而,这是低效的。
  • 当你更新state,React首先会调用组件来渲染视图
  • 然后React会执行你的Effect,如果你的Effect也立即更新了这个state,将会重新执行整个组件

所以,你应该在组件的顶层转换数据

  1. 根据props或state来更新state。例如,你又一个包含了两个state变量的组件:firstNamelastName。你想通过他们计算出fullName。你可能会写一个当firstName或者lastName变化时更新fullNameEffect
1
2
3
4
5
6
7
8
9
10
function App() {
const [firstName, setFirstName] = useState('easycode');
const [lastName, setLastName] = useState('sniper');

const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}

这样做会导致:

  • 首先,React会调用组件,使用fullName的旧值执行整个渲染流程
  • 然后,React会执行你的Effect,用更新后的值又重新渲染了一遍

所以,你应该直接计算这个值const fullName = firstName + ' ' + lastName

初始化应用

有些逻辑只需要在应用加载时执行一次,你可能会将它放到一顶层组件的Effect

1
2
3
4
5
function App() {
useEffect(() => {
loadFunction()
}, [])
}

这会遇到一些问题:在开发环境它会被执行两次,这可能会导致潜在的问题

解决方法:

添加一个顶层变量来记录它是否已经执行过了

1
2
3
4
5
6
7
8
9
10
11
12
13
let didInit = false;

function App() {
useEffect(() => {

if(!didInit) {
didInit = true
// 只在每次应用加载时执行一次
loadFunction()
}

}, [])
}

获取数据

很多场景都需要使用Effect来发起数据请求,例如:

1
2
3
4
5
6
7
8
9
10
11
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);

useEffect(() => {
// 模拟发送数据请求
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
}

然而上面的代码有一个问题,假如你快速的输入hello,那么query会从h变成he,hel,hell最后是hello,这会触发一连串不同的数据请求,但无法保证返回顺序。例如,hell的响应可能在hello的响应之后返回,这将会显示错误的搜索结果

为了修复这个问题,需要添加一个清理函数来忽略较早的返回结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);
}

这可以确保在Effect中获取数据时,除了最后一次请求的所有返回结果都将被忽略

源码分析

Vue2虚拟DOM和diff算法

学习路径:

  1. snabbdom简介
  2. snabbdom的h函数
  3. diff算法原理
  4. 手写diff算法

snabbdom简介

snabbdom是著名的虚拟DOM库,是diff算法的鼻祖,Vue源码借鉴了snabbdom

安装:

1
npm i -S snabbdom

虚拟DOM和h函数

虚拟DOM

  1. 用JavaScript对象来描述dom的结构层次,dom中的一切属性都在虚拟dom中有对应的属性
  1. 新虚拟dom和老虚拟dom进行diff(精细化比较),算出应该如何最小量更新,最后反映到真实dom上

h函数

h函数用来创建虚拟节点(vnode)

比如这样调用h函数:

1
h('a', {props: {href: 'www.kyriecqy.github.io'}}, 'cqy')

将得到这样的虚拟节点:

1
{'sel':'a', 'data': {props: {href: 'www.kyriecqy.github.io'}}, 'text': 'cqy'}

它表示的真正的dom节点:

1
<a href='www.kyriecqy.github.io'>cqy</a>

虚拟节点拥有的属性:

  1. children:子元素
  2. data:属性、样式等
  3. elm:这个节点对应的真正的dom,是一个纯dom对象
  4. key:节点的唯一标识
  5. sel:这个节点的css选择器
  6. text:文本内容

使用h函数创建虚拟dom演示:

1
2
3
4
//创建虚拟节点
var vnode = h('a', {props: {href: 'https://www.kyriecqy.github.io'}}, 'cqy')

console.log(vnode);

h函数还可以嵌套使用,从而得到虚拟dom树:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//创建虚拟节点
var vnode = h('ul', [
h('li', '欧文'),
h('li', '杜兰特'),
h('li', '哈登')
])

//得到的虚拟dom树
{
"sel": "ul",
"data": '',
"children": [
{"sel": "li", "data": '', "text": "欧文", "elm": 'li'},
{"sel": "li", "data": '', "text": "杜兰特", "elm": 'li'},
{"sel": "li", "data": '', "text": "哈登", "elm": 'li'}
],
"elm": 'ul'
}

使用snabbdom创建真实dom演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//snabbdom包中的东西
import {init} from 'snabbdom/init'
import {classModule} from 'snabbdom/modules/class'
import {propsModule} from 'snabbdom/modules/props'
import {styleModule} from 'snabbdom/modules/style'
import {eventListenersModule} from 'snabbdom/modules/eventlisteners'
import {h} from 'snabbdom/h'

//创建patch函数
const patch = init([classModule,propsModule,styleModule,eventListenersModule])

//创建虚拟节点
var vnode = h('a', {props: {href: 'https://www.kyriecqy.github.io'}}, 'cqy')

console.log(vnode);

//让虚拟dom上树
const container = document.getElementById('container')
patch(container,vnode)

了解完h函数的基本用途和使用方法,接下来就是手写h函数

手写h函数

官方的h函数重载功能较强,即可以实现不同数量参数的传入

1
2
3
4
5
6
7
8
9
10
//h函数默认三个参数,sel,data,children

//但也支持这样的写法,可以传入不同数量的参数
h('div')
h('div', '一些文字')
h('div', [])
h('div', h())
h('div', {}, '一些文字')
h('div', {}, [ 在里面嵌套h函数 ])
h('div', {}, h())

我们在之后手写的h函数必须传入三个参数,相当于一个低配版的官方h函数
所以只重载以下类型的参数:

1
2
3
h('div', {}, '一些文字')
h('div', {}, [ 在里面嵌套h函数 ])
h('div', {}, h())

下面是vnode函数和h函数的代码

1
2
3
4
5
6
//函数的功能:将收到的参数整合成一个对象返回
export default function vnode(sel, data, children, text, elm) {
return {
sel, data, children, text, elm
}
}
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
import vnode from './vnode'

//编写一个低配版本的h函数,必须接收3个参数
//相当于重载功能较弱,只支持以下形式
//情况一:h('div', {}, '一些文字')
//情况二:h('div', {}, [ 在里面嵌套h函数 ])
//情况三:h('div', {}, h())
export default function h(sel, data, c) {
//检查参数个数
if(arguments.length != 3) {
throw new Error('这个h函数必须传入三个函数')
}

//检查参数c的类型
if(typeof c == 'string' || typeof c == 'number') {
//说明h函数的参数类型是情况一
//将参数依次传给vnode函数,
//vnode的第三个参数是children,情况一显然没有children,所以传入undefined
//vnode的第五个参数是elm,只有在上树(即生成了真实dom时)才给她加上值,所以传入undefined

//直接交给vnode函数整合成一个对象
return vnode(sel, data, undefined, c, undefined)

} else if(Array.isArray(c)) {
//说明h函数的参数类型是情况二
var children = []
//遍历c,c的每一项也必须是h函数
for(let i=0; i<c.length; i++) {
if(!(typeof c[i] == 'object' && c[i].hasOwnProperty('sel'))) {
throw new Error('传入的数组中有项不是h函数')
}
//c[i]是h函数,h函数的返回值是编译好的虚拟节点,则将他收集起来
//在这里其实有一个h函数的嵌套
children.push(c[i])
}
//收集完毕了,可以返回虚拟节点,这个虚拟节点是有children的
return vnode(sel, data, children, undefined, undefined)
} else if(typeof c == 'object' && c.hasOwnProperty('sel')) {
//如果c的类型是一个对象,且它有sel这个属性(因为h函数必须有sel属性),那么c就是一个h函数
//说明h函数的参数类型是情况三
var children = []
children.push(c)
return vnode(sel, data, children, undefined, undefined)
} else {
throw new Error('第三个参数传入的不对')
}
}

在index.js中来测试h函数

1
2
3
4
5
6
7
8
9
10
//在这个测试案例中包含了三种情况
import h from './mysnabbdom/h'

var myVnode = h('div', {}, [
h('p', {}, '欧文'),
h('p', {}, '杜兰特'),
h('p', {}, h('div', {}, '哈登')),
])

console.log(myVnode);

控制台打印的结果:我们成功的生成了虚拟dom节点

diff算法

体验diff算法

先使用官方的方法来调用h函数等生成虚拟dom并完成真实dom的转化:
vnode1是我们第一个创建的虚拟dom节点,并将他渲染在浏览器,
接下来我们定义第二个虚拟节点vnode2,vnode2只是在vnode1的基础上在最后又加上了一项,当我们点击button按钮,就会将vnode2渲染上浏览器

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
import {init} from 'snabbdom/init'
import {classModule} from 'snabbdom/modules/class'
import {propsModule} from 'snabbdom/modules/props'
import {styleModule} from 'snabbdom/modules/style'
import {eventListenersModule} from 'snabbdom/modules/eventlisteners'
import {h} from 'snabbdom/h'

//创建patch函数
const patch = init([classModule,propsModule,styleModule,eventListenersModule])

//创建虚拟节点
var vnode1 = h('ul', {}, [
h('li', {}, '欧文'),
h('li', {}, '杜兰特'),
h('li', {}, '哈登')
])

//让虚拟dom上树
const container = document.getElementById('container')
patch(container,vnode1)

var vnode2 = h('ul', {}, [
h('li', {}, '欧文'),
h('li', {}, '杜兰特'),
h('li', {}, '哈登'),
h('li', {}, 'cqy')
])

const btn = document.getElementById('btn')

btn.onclick = function() {
patch(vnode1, vnode2)
}

情景一:
我们接下来要关注的是diff算法是如何最小量更新dom的,是把之前的dom销毁将新dom渲染上去还是用其他的方法?
注意看控制台中的变化:当点击按钮,只是在ul中的最后添加了一项,而两个虚拟虚拟dom中相同的部分没有被重新渲染

现在我们改变一下vnode2,与vnode1相比在它的头部添加一项数据

1
2
3
4
5
6
7
var vnode2 = h('ul', {}, [
//数据添加在头部
h('li', {}, 'cqy'),
h('li', {}, '欧文'),
h('li', {}, '杜兰特'),
h('li', {}, '哈登'),
])

来看看这次按下按钮之后控制台的变化:将新的数据添加在前面时,所有的li都进行了重新渲染,好像看上去diff算法也没这么厉害,其实是我们没有给他加上key,在之前我们说过,key是节点的唯一标识

再来看看加上key属性之后的变化:这个时候它就好像认识了每个节点一样,对比vnode1和vnode2,只在开头增加了一项,没有改变其他

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var vnode2 = h('ul', {}, [
h('li', {key: 'a'}, '欧文'),
h('li', {key: 'b'}, '杜兰特'),
h('li', {key: 'c'}, '哈登'),
])


var vnode2 = h('ul', {}, [
//数据添加在头部
h('li', {key: 'd'}, 'cqy'),
h('li', {key: 'a'}, '欧文'),
h('li', {key: 'b'}, '杜兰特'),
h('li', {key: 'c'}, '哈登'),
])

key十分关键,key服务于最小量更新,key可以大大提高diff算法的效率

情景二:
当我们将vnode2中的ul改成ol,来看一下变化:因为当ul变成ol是,整个虚拟dom的父节点改变了,vnode1和vnode2已经不能算是同一个虚拟节点(没有相同的选择器)

1
2
3
4
5
6
7
8
9
10
11
var vnode2 = h('ul', {}, [
h('li', {}, '欧文'),
h('li', {}, '杜兰特'),
h('li', {}, '哈登'),
])

var vnode2 = h('ol', {}, [
h('li', {}, '欧文'),
h('li', {}, '杜兰特'),
h('li', {}, '哈登'),
])

只有是同一个虚拟节点(选择器相同且key相同),才进行精细化比较,如果不是同一个虚拟节点,直接暴力销毁重新渲染

情景三:
然后看一下下面的代码,观察一下变化:我们给vnode2又嵌套了一层div,在点击按钮之后并没有如我们预期的只套一个div,而是重新渲染了整个虚拟dom节点

因为diff算法只进行同层比较,不会进行跨层比较
在这里vnode1和vnode2的第一层相同,都是div,但vnode1的第二层是一系列的p,而vnode2的第二层是div,他的第三层才是一系列p;
vnode1和vnode2中的一系列p节点存在了跨层的问题,不会进行diff算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var vnode1 = h('div', {}, [
h('p', {key: 'a'}, '欧文'),
h('p', {key: 'b'}, '杜兰特'),
h('p', {key: 'c'}, '哈登')
]
)

// ....

var vnode2 = h('div', {}, h('div', {}, [
h('p', {key: 'a'}, '欧文'),
h('p', {key: 'b'}, '杜兰特'),
h('p', {key: 'c'}, '哈登')
])
)

这么看下来diff算法好像也不是很精细化,但其实上面场景二、三一般不会出现,属于合理的优化

手写第一次上树

第一次上树就是老的vnode是一个真实的dom节点,我们第一次将虚拟dom变成真实dom节点的过程

先用一个简单的数据(有的是文本不是子节点)来进行手写

1
2
3
4
5
6
7
8
import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'

const container = document.getElementById('container')

const vnode = h('h1', {}, '凯里欧文')

patch(container, vnode)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
函数的作用是创建真正的dom节点
*/

export default function createElement(vnode) {
console.log(vnode);
//根据vnode的标签来创建真实dom,现在还是个孤儿节点,没有上树
let domNode = document.createElement(vnode.sel)
console.log(domNode);
//判断vnode是有文本还是子节点
if(vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) {
//vnode有的是文本
domNode.innerText = vnode.text
}
//补充elm属性,elm属性是一个纯dom
//此处一定要补充好elm属性,因为在之后的新旧节点替换时,要根据旧节点的parentNode来插入新节点,没了这个elm属性就找不到他的父元素了
vnode.elm = domNode
// 返回真实dom
//console.log(vnode);
return domNode
}
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
import vnode from "./vnode";
import createElement from "./createElement";
/*
在patch函数中完成diff算法的精细化操作
*/

export default function patch(oldVnode, newVnode) {
//判断老节点是真实dom还是虚拟dom
if(oldVnode.sel == '' || oldVnode.sel == undefined) {
//说明是个真实dom,此时要将他包装成虚拟dom
//他的sel就是它的标签,data,children,text都定义为空,elm就是它本身
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
}
//console.log(oldVnode);

//判断oldVnode和newVnode是不是同一个节点
if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key) {
//是同一个节点
console.log('yes');
} else {
//不是同一个节点,插入新的虚拟dom,暴力销毁旧的dom(先插后删,借用旧dom作为一个插入位置的标识)
let newVnodeElm = createElement(newVnode)
//将新节点插入到老节点之前
if(oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}
}
}

得到的结果:

当有子节点的情况

1
2
3
4
5
6
7
8
9
10
11
12
import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'

const container = document.getElementById('container')

const vnode = h('ul', {}, [
h('li', {}, 'kyrie'),
h('li', {}, 'kd'),
h('li', {}, 'harden'),
])

patch(container, vnode)
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
/*
函数的作用是创建真正的dom节点
*/

export default function CreateElement(vnode) {
console.log(vnode);
//根据vnode的标签来创建真实dom,现在还是个孤儿节点,没有上树
let domNode = document.createElement(vnode.sel)
//console.log(domNode);
//判断vnode是有文本还是子节点
if(vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) {
//vnode有的是文本
domNode.innerText = vnode.text

} else if(Array.isArray(vnode.children) && vnode.children.length > 0) {
//vnode拥有子节点,就要递归创建节点
for(let i = 0; i < vnode.children.length; i++) {
let ch = vnode.children[i]
console.log(ch);
//将子节点变成真实dom,并追加到父节点上
let chDom = CreateElement(ch)
domNode.appendChild(chDom)
}
}
//补充elm属性,elm属性是一个纯dom
vnode.elm = domNode
// 返回真实dom
//console.log(vnode);
return domNode
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import vnode from "./vnode";
import CreateElement from "./createElement";

export default function patch(oldVnode, newVnode) {
// ...

//判断oldVnode和newVnode是不是同一个节点
if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key) {
//是同一个节点
console.log('yes');
} else {
//不是同一个节点,插入新的虚拟dom,暴力销毁旧的dom(先插后删,借用旧dom作为一个插入位置的标识)
let newVnodeElm = CreateElement(newVnode)
//将新节点插入到老节点之前
if(oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}

}
}

得到的结果:

新旧节点不是同一个节点

其实上述代码中我们也实现了新旧节点不是同一个节点的情况,因为当新旧节点不是同一个节点时,diff算法并不进行精细化比较,而是暴力拆除和重新渲染
,我们可以改变一下传入的数据来测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'

const container = document.getElementById('container')

const vnode = h('ul', {}, [
h('li', {}, 'kyrie'),
h('li', {}, 'kd'),
h('li', {}, 'harden'),
])

patch(container, vnode)

const vnode2 = h('div', {}, [
h('p', {}, '欧文'),
h('p', {}, '杜兰特'),
h('p', {}, '哈登'),
])

const btn = document.getElementById('btn')

btn.onclick = function() {
patch(vnode, vnode2)
}

新老节点是同一个节点

新节点有text属性时

在这种情况下,并不用关心老节点是text属性还是children属性,假设老节点也有text属性,新老节点的text属性相同就不进行改变,不相同就将新节点的text属性插入到老节点身上

1
2
3
4
5
6
7
const vnode = h('section', {}, [
h('h4', {}, 'kyrie'),
h('h4', {}, 'kd'),
h('h4', {}, 'harden'),
])

const vnode2 = h('section', {}, '我是新节点')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export default function patch(oldVnode, newVnode) {

// ...

if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key) {
//是同一个节点

//判断新节点有没有text属性
if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
//新节点有text属性
if(oldVnode.text != newVnode.text) {
//不销毁老节点,而是将新节点的text写到老节点身上
//不用担心老节点上的是text还是children,新插入的text都会覆盖掉
oldVnode.elm.innerText = newVnode.text
}
} else { //新节点没有text属性,那么有的是children

}
} else {
//不是同一个节点
// ....
}
}

得到的结果:这个就是流程图中的1部分判断

老节点有text属性,新节点有children时

在这种情况下,清空老节点的内容,将新节点的children创建成真实dom之后插入到老节点中

1
2
3
4
5
6
7
const vnode = h('section', {}, '凯里欧文')

const vnode2 = h('section', {}, [
h('p', {}, 'kyrie'),
h('p', {}, 'kd'),
h('p', {}, 'harden')
])
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
export default function patch(oldVnode, newVnode) {
// ...

//判断oldVnode和newVnode是不是同一个节点
if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key) {
//是同一个节点

//判断新节点有没有text属性
if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
//新节点有text属性
// ....
} else {//新节点没有text属性,有children
//判断老节点是有text还是children
if(oldVnode.children != undefined && oldVnode.children.length > 0) {
//老节点有的是children,是最复杂的情况
} else { //老节点有的是text
//清空老节点的text内容
oldVnode.elm.innerHTML = ''
for(let i=0; i<newVnode.children.length; i++) {
//将新节点的children依次传入创建真实dom,并追加在老节点里面
let dom = CreateElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
}
} else {
//不是同一个节点
// ...
}
}

得到的结果:这个就是流程图中的2部分判断

为了方便阅读,我们将patch中时是同一个节点的模块抽离出去,成为一个新的函数再引入进来

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
import CreateElement from "./createElement"

/*
对比同一个虚拟节点的内容,并更新
*/

export default function patchVnode(oldVnode, newVnode) {
//判断新节点有没有text属性
if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
//新节点有text属性
if(oldVnode.text != newVnode.text) {
//不销毁老节点,而是将新节点的text写到老节点身上
//不用担心老节点上的是text还是children,新插入的text都会覆盖掉
oldVnode.elm.innerText = newVnode.text
}
} else {//新节点没有text属性,有children
//判断老节点是有text还是children
if(oldVnode.children != undefined && oldVnode.children.length > 0) {
//老节点有的是children,是最复杂的情况
} else { //老节点有的是text
//清空老节点的text内容
oldVnode.elm.innerHTML = ''
for(let i=0; i<newVnode.children.length; i++) {
//将新节点的children依次传入创建真实dom,并追加在老节点里面
let dom = CreateElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
}
}

diff算法更新子节点策略

四种命中查找:

  1. 新前与旧前
  2. 新后与旧后
  3. 新后与旧前
  4. 新前与旧后

所谓新前就是新节点中所有没有处理的节点的最前面

四种命中方式从上到下依次判断,命中一个就不再向下判断

前指针只会下移,后指针只会上移,且保证前指针一定在后指针前(新前 <= 新后 && 旧前 <= 旧后)

新节点有新增的情况

新前新后旧前旧后初始位置如图所示,

  1. 首先判断新前(a)和旧前(a),发现完全相同,命中1号查找方式,将旧前变成真实dom,新前、旧前指针下移
  2. 判断新前(b)和旧前(b),发现完全相同,命中1号查找方式,将旧前变成真实dom,新前、旧前指针下移
  3. 判断新前(c)和旧前(c),发现完全相同,命中1号查找方式,将旧前变成真实dom,新前、旧前指针下移
  4. 此时 旧前>旧后 ,结束循环(如果旧节点先循环完毕,说明新节点中要新增节点),新前指向d,新后指向e,此时的新前新后之间的节点(d,e)就是要新增的节点

删除节点的情况

  1. 首先判断新前(a)和旧前(a),发现完全相同,命中1号查找方式,将旧前变成真实dom,新前、旧前指针下移
  2. 判断新前(b)和旧前(b),发现完全相同,命中1号查找方式,将旧前变成真实dom,新前、旧前指针下移
  3. 判断新前(c)和旧前(d),不相同;使用2号查找方式,新后(d)和旧后(e),不相同;接着使用3、4号查找方式,都无法命中
  4. 这个时候会循环旧节点,来看看能不能找到和新节点中d相同的节点,如果找到了,将它变成真实dom,插入到已处理好的节点(a、b)之后,并将它的虚拟节点标为undefined,新前下移
  5. 此时 新前>新后 ,循环结束(如果新节点先循环完毕,说明旧节点中要删除节点),旧前指向c,旧后指向e,此时要删除的就是旧前旧后之间的节点c、e(不对标为undefined的节点进行操作)

当命中4的情况

当4号(新前和旧后)命中时,此时要移动节点,移动新前指向的节点到旧前指向的节点前面,已处理的节点后面

  1. 依次判断四种命中情况,发现命中4号查找方式,将新前指向的节点(e)变成真实dom,移动到旧前的前面,并将他的虚拟节点标为undefined,新前下移,旧后上移
  2. 此时新前(c),新后(m),旧前(a),旧后(d),依次判断四种命中情况,都没有命中;
  3. 循环旧节点,看看能不能找到和新前(c)相同的节点,如果找到了,将它变成真实dom,插入到已处理好的节点(e)之后、旧前节点之前,并将它的虚拟节点标为undefined,新前下移
  4. 此时新前(m),新后(m),旧前(a),旧后(d),依次判断四种命中情况,都没有命中;
  5. 循环旧节点,看看能不能找到和新前(m)相同的节点,如果没有找到相同的节点,就将新前指向的节点插入到已处理好的节点之后,新前下移
  6. 此时 新前>新后 ,循环结束(如果新节点先循环完毕,说明旧节点中要删除节点),旧前指向a,旧后指向d,此时要删除的就是旧前旧后之间的节点a、b、d(不对标为undefined的节点进行操作)

当命中3的情况

当3号(新后和旧前)命中时,此时要移动节点,移动新后指向的节点到旧后指向的节点后面,已处理好的节点之前

  1. 依次判断四种命中情况,发现命中3号查找方式,将新后指向的节点(a)变成真实dom,移动到旧后的后面,并将他的虚拟节点标为undefined,新后上移,旧前下移
  2. 此时新前(e),新后(b),旧前(b),旧后(d),依次判断四种命中情况,发现命中3号查找方式,将新后指向的节点(b)变成真实dom,移动到旧后的后面,已处理好的节点(a)的前面,并将他的虚拟节点标为undefined,新后上移,旧前下移
  3. 重复上述操作,直到全部移动完毕
代码实现diff算法

在patch函数中判断是同一个节点,然后调用patchVnode函数来实现相同节点内容的比较和更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import vnode from "./vnode";
import CreateElement from "./createElement";
import patchVnode from "./patchVnode";

export default function patch(oldVnode, newVnode) {
//判断老节点是真实dom还是虚拟dom
// ...

//判断oldVnode和newVnode是不是同一个节点
if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key) {
//是同一个节点
patchVnode(oldVnode, newVnode)

} else {
//不是同一个节点,插入新的虚拟dom,暴力销毁旧的dom(先插后删,借用旧dom作为一个插入位置的标识)
// ...
}
}

在patchVnode函数中判断到新旧节点都有children属性,调用updateChildren来对比和更新子节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import CreateElement from "./createElement"
import updateChildren from "./updateChildren"

/*
对比同一个虚拟节点的内容,并更新
*/

export default function patchVnode(oldVnode, newVnode) {
//判断新节点有没有text属性
if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
//新节点有text属性
// ...
} else {//新节点没有text属性,有children
//判断老节点是有text还是children
if(oldVnode.children != undefined && oldVnode.children.length > 0) {
//老节点有的是children,是最复杂的情况
//传入旧的父节点,旧的子节点,新的子节点,根据新旧子节点的对比来对旧节点进行更新
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
} else { //老节点有的是text
// ...
}
}
}

先实现命中四种命中方式的代码块

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
import patchVnode from "./patchVnode";

//判断节点是否相同
function checkSameVnode(a, b) {
return (a.sel == b.sel && a.key == b.key)
}
/*
函数作用是对比新旧节点的子节点内容,并对旧节点进行更新
接收三个参数:父节点,旧的子节点,新的子节点
*/
export default function updateChildren(parentElm, oldCh, newCh) {
console.log(parentElm, oldCh, newCh);
//旧前指针
let oldStartIdx = 0
//新前指针
let newStartIdx = 0
//旧后指针
let oldEndIdx = oldCh.length - 1
//新后指针
let newEndIdx = newCh.length - 1

//旧前节点
let oldStartVnode = oldCh[oldStartIdx]
//新前节点
let newStartVnode = newCh[newStartIdx]
//旧后节点
let oldEndVnode = oldCh[oldEndIdx]
//新后节点
let newEndVnode = newCh[newEndIdx]

while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
//新前和旧前相同,命中1号查找方式
if(checkSameVnode(oldStartVnode, newStartVnode)) {
// 进一步比较新前和旧前的内容
console.log('1');
patchVnode(oldStartVnode, newStartVnode)
//新前旧前指针后移,同时改变新前节点旧前节点
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]

} else if(checkSameVnode(oldEndVnode, newEndVnode)) { //新后与旧后相同,命中2号查找方式
// 进一步比较新后和旧后的内容
console.log('2');
patchVnode(oldEndVnode, newEndVnode)
//新后旧后指针前移,同时改变新后节点旧后节点
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if(checkSameVnode(oldStartVnode, newEndVnode)) { //新后与旧前相同,命中3号查找方式
console.log('3');
patchVnode(oldStartVnode, newEndVnode)
//当3号(新后和旧前)命中时,此时要移动节点,移动新后指向的节点到旧后指向的节点后面,已处理好的节点之前
//在这里直接移动旧节点,因为在patchNode中已经更加新旧节点的异同,将新节点中的内容插入到旧节点中了
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
//旧前指针后移,新后指针前移
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if(checkSameVnode(oldEndVnode, newStartVnode)) { //新前与旧后相同,命中4号查找方式
console.log('4');
patchVnode(oldEndVnode, newStartVnode)
//当4号(新前和旧后)命中时,此时要移动节点,移动新前指向的节点到旧前指向的节点前面,已处理的节点后面
//在这里直接移动旧节点,因为在patchNode中已经更加新旧节点的异同,将新节点中的内容插入到旧节点中了
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
//四种命中方式都没有命中
}
}

if(newStartIdx <= newEndIdx) {
//console.log('new中还有剩余');
for(let i = newStartIdx; i <= newEndIdx; i++) {
//将虚拟dom转化为真实dom再进行插入
parentElm.insertBefore(CreateElement(newCh[i]), oldCh[oldStartIdx].elm)
}
} else if(oldStartIdx <= oldEndIdx) {
//console.log('old中还有剩余');
for(let i = oldStartIdx; i <= oldEndIdx; i++) {
parentElm.removeChild(oldCh[i].elm)
}
}
}

没有命中四种命中方式的情况

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
import patchVnode from "./patchVnode";
import CreateElement from "./createElement"

//判断节点是否相同
function checkSameVnode(a, b) {
return (a.sel == b.sel && a.key == b.key)
}

export default function updateChildren(parentElm, oldCh, newCh) {
//旧前指针
let oldStartIdx = 0
//新前指针
let newStartIdx = 0
//旧后指针
let oldEndIdx = oldCh.length - 1
//新后指针
let newEndIdx = newCh.length - 1

//旧前节点
let oldStartVnode = oldCh[oldStartIdx]
//新前节点
let newStartVnode = newCh[newStartIdx]
//旧后节点
let oldEndVnode = oldCh[oldEndIdx]
//新后节点
let newEndVnode = newCh[newEndIdx]

let keyMap = null

while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
//判断旧前旧后是否是已经处理过的项(可以先看下面,再回上来看这个if语句)
if(oldStartVnode == undefined) {
oldStartVnode = oldCh[++oldStartIdx]
} else if(oldEndVnode == undefined) {
oldEndVnode = oldCh[--oldEndIdx]
}
//新前和旧前相同,命中1号查找方式
if(checkSameVnode(oldStartVnode, newStartVnode)) {
// 进一步比较新前和旧前的内容
patchVnode(oldStartVnode, newStartVnode)
//新前旧前指针后移,同时改变新前节点旧前节点
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]

} else if(checkSameVnode(oldEndVnode, newEndVnode)) { //新后与旧后相同,命中2号查找方式
// 进一步比较新后和旧后的内容
patchVnode(oldEndVnode, newEndVnode)
//新后旧后指针前移,同时改变新后节点旧后节点
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if(checkSameVnode(oldStartVnode, newEndVnode)) { //新后与旧前相同,命中3号查找方式
patchVnode(oldStartVnode, newEndVnode)
//当3号(新后和旧前)命中时,此时要移动节点,移动新后指向的节点到旧后指向的节点后面,已处理好的节点之前
//在这里直接移动旧节点,因为在patchNode中已经更加新旧节点的异同,将新节点中的内容插入到旧节点中了
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
//旧前指针后移,新后指针前移
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if(checkSameVnode(oldEndVnode, newStartVnode)) { //新前与旧后相同,命中4号查找方式
patchVnode(oldEndVnode, newStartVnode)
//当4号(新前和旧后)命中时,此时要移动节点,移动新前指向的节点到旧前指向的节点前面,已处理的节点后面
//在这里直接移动旧节点,因为在patchNode中已经更加新旧节点的异同,将新节点中的内容插入到旧节点中了
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
---------------------------------------------------------
} else { //四种命中方式都没有命中

//keyMap用于存储对应key在旧节点中的位置
if(!keyMap) {
keyMap = {}
for(let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key
if(key != undefined) {
keyMap[key] = i
}
}
}
//寻找当前项(newStartIdx)在keyMap中的位置
const idxInOld = keyMap[newStartVnode.key]
//判断,如果idxInOld是undefined,那么它就是全新的项,要插入节点
if(idxInOld == undefined) {
parentElm.insertBefore(CreateElement(newStartVnode), oldStartVnode.elm)
} else { // 不是undefined,那么它不是全新的项,要移动
//取出要移动的项
const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove, newStartVnode)
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
//将处理过的项打上undefined标记,之后就会跳过他
oldCh[idxInOld] = undefined
}
//新前指针下移
newStartVnode = newCh[++newStartIdx]
}
-------------------------------------------------------
}

if(newStartIdx <= newEndIdx) {
console.log('new中还有剩余');
for(let i = newStartIdx; i <= newEndIdx; i++) {

parentElm.insertBefore(CreateElement(newCh[i]), oldCh[oldStartIdx].elm)
}
} else if(oldStartIdx <= oldEndIdx) {
console.log('old中还有剩余');
for(let i = oldStartIdx; i <= oldEndIdx; i++) {
if(oldCh[i]) {
parentElm.removeChild(oldCh[i].elm)
}
}
}
}

完整代码

调用h函数生成虚拟dom节点,已经调用patch函数进行diff算法的精细化比较

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
import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'

const container = document.getElementById('container')

const vnode = h('section', {}, [
h('p', {key: 'a'}, 'kyrie'),
h('p', {key: 'b'}, 'kd'),
h('p', {key: 'c'}, 'harden'),
h('p', {key: 'd'}, 'cqy'),
])

patch(container, vnode)

const vnode2 = h('section', {}, [
h('p', {key: 'a'}, 'kyrie'),
h('p', {key: 'b'}, 'kd'),
h('p', {key: 'h'}, 'crf'),
h('p', {key: 'c'}, 'harden'),
h('p', {key: 'd'}, 'cqy'),

])

const btn = document.getElementById('btn')

btn.onclick = function() {
patch(vnode, vnode2)
}

h函数

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
import vnode from './vnode'
/*
函数的功能是将传入的数据变成虚拟dom
*/
//编写一个低配版本的h函数,必须接收3个参数
//相当于重载功能较弱,只支持以下形式
//情况一:h('div', {}, '一些文字')
//情况二:h('div', {}, [ 在里面嵌套h函数 ])
//情况三:h('div', {}, h())
export default function h(sel, data, c) {
//检查参数个数
if(arguments.length != 3) {
throw new Error('这个h函数必须传入三个函数')
}

//检查参数c的类型
if(typeof c == 'string' || typeof c == 'number') {
//说明h函数的参数类型是情况一
//将参数依次传给vnode函数,
//vnode的第三个参数是children,情况一显然没有children,所以传入undefined
//vnode的第五个参数是elm,只有在上树(即生成了真实dom时)才给她加上数据,所以传入undefined
return vnode(sel, data, undefined, c, undefined)

} else if(Array.isArray(c)) {
//说明h函数的参数类型是情况二
var children = []
//遍历c,c的每一项也必须是h函数
for(let i=0; i<c.length; i++) {
if(!(typeof c[i] == 'object' && c[i].hasOwnProperty('sel'))) {
throw new Error('传入的数组中有项不是h函数')
}
//c[i]是h函数,则将他收集起来
//在这个时候c[i]就是h函数返回的虚拟dom
children.push(c[i])
}
//收集完毕了,可以返回虚拟节点,这个虚拟节点是有children的
return vnode(sel, data, children, undefined, undefined)
} else if(typeof c == 'object' && c.hasOwnProperty('sel')) {
//如果c的类型是一个对象,且它有sel这个属性(因为h函数必须有sel属性)
//说明h函数的参数类型是情况三
var children = []
children.push(c)
return vnode(sel, data, children, undefined, undefined)
} else {
throw new Error('第三个参数传入的不对')
}
}

vnode函数就是将参数整合成一个对象

1
2
3
4
5
6
7
//函数的功能:将收到的参数整合成一个对象返回
export default function vnode(sel, data, children, text, elm) {
const key = data.key
return {
sel, data, children, text, elm, key
}
}

patch函数用于对比新旧节点是否是同一个节点,是同一个节点就进行diff算法的精细化处理;不是同一个节点就直接插入新的暴力删除旧的

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
import vnode from "./vnode";
import CreateElement from "./createElement";
import patchVnode from "./patchVnode";

export default function patch(oldVnode, newVnode) {
//判断老节点是真实dom还是虚拟dom
if(oldVnode.sel == '' || oldVnode.sel == undefined) {
//说明是个真实dom,此时要将他包装成虚拟dom
//他的sel就是它的标签,data,children,text都定义为空,elm就是它本身
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
}
//console.log(oldVnode);

//判断oldVnode和newVnode是不是同一个节点
if(oldVnode.sel == newVnode.sel && oldVnode.key == newVnode.key) {
//是同一个节点
//调用patchVnode来实现diff算法
patchVnode(oldVnode, newVnode)

} else {
//不是同一个节点,插入新的虚拟dom,暴力销毁旧的dom(先插后删,借用旧dom作为一个插入位置的标识)
let newVnodeElm = CreateElement(newVnode)
//将新节点插入到老节点之前
if(oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}
//删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
}

由patchVnode来进行diff算法

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
import CreateElement from "./createElement"
import updateChildren from "./updateChildren"

/*
对比同一个虚拟节点的内容,并更新
*/

export default function patchVnode(oldVnode, newVnode) {
//判断新节点有没有text属性
if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
//新节点有text属性
if(oldVnode.text != newVnode.text) {
//不销毁老节点,而是将新节点的text写到老节点身上
//不用担心老节点上的是text还是children,新插入的text都会覆盖掉
oldVnode.elm.innerText = newVnode.text
}
} else {//新节点没有text属性,有children
//判断老节点是有text还是children
if(oldVnode.children != undefined && oldVnode.children.length > 0) {
//老节点有的是children,这个时候新老节点都有children,是最复杂的情况
//传入老节点,老节点的children以及新节点的children
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
} else { //老节点有的是text
//清空老节点的text内容
oldVnode.elm.innerHTML = ''
for(let i=0; i<newVnode.children.length; i++) {
//将新节点的children依次传入创建真实dom,并追加在老节点里面
let dom = CreateElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
}
}

CreateElement函数用来创建真实dom节点

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
/*
函数的作用是创建真正的dom节点
*/

export default function CreateElement(vnode) {
//根据vnode的标签来创建真实dom,现在还是个孤儿节点,没有上树
let domNode = document.createElement(vnode.sel)
//console.log(domNode);
//判断vnode是有文本还是子节点
if(vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) {
//vnode有的是文本
domNode.innerText = vnode.text

} else if(Array.isArray(vnode.children) && vnode.children.length > 0) {
//vnode拥有子节点,就要递归创建节点
for(let i = 0; i < vnode.children.length; i++) {
let ch = vnode.children[i]
//console.log(ch);
//将子节点变成真实dom,并追加到父节点上
let chDom = CreateElement(ch)
domNode.appendChild(chDom)
}
}
//补充elm属性,elm属性是一个纯dom
vnode.elm = domNode
// 返回真实dom
//console.log(vnode);
return domNode
}

updateChildren函数是diff算法精细化比较的核心,使用了四种命中查找方法的优化策略

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
import patchVnode from "./patchVnode";
import CreateElement from "./createElement"

//判断节点是否相同
function checkSameVnode(a, b) {
return (a.sel == b.sel && a.key == b.key)
}

export default function updateChildren(parentElm, oldCh, newCh) {
//旧前指针
let oldStartIdx = 0
//新前指针
let newStartIdx = 0
//旧后指针
let oldEndIdx = oldCh.length - 1
//新后指针
let newEndIdx = newCh.length - 1

//旧前节点
let oldStartVnode = oldCh[oldStartIdx]
//新前节点
let newStartVnode = newCh[newStartIdx]
//旧后节点
let oldEndVnode = oldCh[oldEndIdx]
//新后节点
let newEndVnode = newCh[newEndIdx]

let keyMap = null

while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
//判断旧前旧后是否是已经处理过的项(可以先看下面,再回上来看这个if语句)
if(oldStartVnode == undefined) {
oldStartVnode = oldCh[++oldStartIdx]
} else if(oldEndVnode == undefined) {
oldEndVnode = oldCh[--oldEndIdx]
}
//新前和旧前相同,命中1号查找方式
if(checkSameVnode(oldStartVnode, newStartVnode)) {
// 进一步比较新前和旧前的内容
patchVnode(oldStartVnode, newStartVnode)
//新前旧前指针后移,同时改变新前节点旧前节点
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]

} else if(checkSameVnode(oldEndVnode, newEndVnode)) { //新后与旧后相同,命中2号查找方式
// 进一步比较新后和旧后的内容
patchVnode(oldEndVnode, newEndVnode)
//新后旧后指针前移,同时改变新后节点旧后节点
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if(checkSameVnode(oldStartVnode, newEndVnode)) { //新后与旧前相同,命中3号查找方式
patchVnode(oldStartVnode, newEndVnode)
//当3号(新后和旧前)命中时,此时要移动节点,移动新后指向的节点到旧后指向的节点后面,已处理好的节点之前
//在这里直接移动旧节点,因为在patchNode中已经更加新旧节点的异同,将新节点中的内容插入到旧节点中了
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
//旧前指针后移,新后指针前移
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if(checkSameVnode(oldEndVnode, newStartVnode)) { //新前与旧后相同,命中4号查找方式
patchVnode(oldEndVnode, newStartVnode)
//当4号(新前和旧后)命中时,此时要移动节点,移动新前指向的节点到旧前指向的节点前面,已处理的节点后面
//在这里直接移动旧节点,因为在patchNode中已经更加新旧节点的异同,将新节点中的内容插入到旧节点中了
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else { //四种命中方式都没有命中

//keyMap用于存储对应key在旧节点中的位置
if(!keyMap) {
keyMap = {}
for(let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key
if(key != undefined) {
keyMap[key] = i
}
}
}
//寻找当前项(newStartIdx)在keyMap中的位置
const idxInOld = keyMap[newStartVnode.key]
//判断,如果idxInOld是undefined,那么它就是全新的项,要插入节点
if(idxInOld == undefined) {
parentElm.insertBefore(CreateElement(newStartVnode), oldStartVnode.elm)
} else { // 不是undefined,那么它不是全新的项,要移动
//取出要移动的项
const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove, newStartVnode)
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
//将处理过的项打上undefined标记,之后就会跳过他
oldCh[idxInOld] = undefined
}
//新前指针下移
newStartVnode = newCh[++newStartIdx]
}
}

if(newStartIdx <= newEndIdx) {
console.log('new中还有剩余');
for(let i = newStartIdx; i <= newEndIdx; i++) {

parentElm.insertBefore(CreateElement(newCh[i]), oldCh[oldStartIdx].elm)
}
} else if(oldStartIdx <= oldEndIdx) {
console.log('old中还有剩余');
for(let i = oldStartIdx; i <= oldEndIdx; i++) {
if(oldCh[i]) {
parentElm.removeChild(oldCh[i].elm)
}
}
}
}

Vue2数据响应式原理

Object.defineProperty()方法

Object.defineProperty()的作用是直接在一个对象上定义一个新属性,或者修改一个已经存在的属性

1
Object.defineProperty(obj, prop, desc)

obj:需要操作的当前对象
prop:当前需要操作的属性名
desc:属性描述符

属性描述符
通过Object.defineProperty()为对象定义属性,有两种形式,且不能混合使用,分别为数据描述符,存取描述符

  1. 数据描述符(value, writabl)
    value就是属性的值,
    writable用来设置属性的值是否可以被改变(默认为false),
    enumerable用来设置属性是否可以被枚举(默认为false)

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let obj = {}

Object.defineProperty(obj, 'a', {
value: 123
})

Object.defineProperty(obj, 'b', {
value: 456,
writable: true
})

console.log(obj);
obj.a ++
obj.b ++
console.log(obj);

举第二个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let obj = {}

Object.defineProperty(obj, 'a', {
value: 123,
enumerable: false
})

Object.defineProperty(obj, 'b', {
value: 456,
writable: true,
enumerable: true
})

Object.defineProperty(obj, 'c', {
value: 789,
enumerable: true
})

console.log(obj);

for(let k in obj) {
console.log(k);
}
  1. 存取描述符(get,set)
    get:该方法的返回值被用作属性值,默认值为undefined
    set:该方法接收唯一参数,并将该唯一参数赋值为属性的新值,默认值为undefined

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let obj = {}
let num = 123
Object.defineProperty(obj, 'a', {
get() {
console.log('你试图访问a属性')
return num
},
set(val) {
console.log('你试图改变a属性')
num = 456
}
})
console.log(obj.a);
obj.a = 456
console.log(obj.a);

数据描述符和存取描述均具有以下描述符
enumerable用来设置属性是否可以被枚举(默认为false)
configrable 描述属性是否配置,以及可否删除

defineReactive函数

在Object.defineProperty()的存取描述符中,你需要定义一个变量来周转属性值,如果没有定义这个变量,你通过set设置的新值在get中无法读取

1
2
3
4
5
6
7
8
9
10
11
12
13
let obj = {}
Object.defineProperty(obj, 'a', {
get() {
console.log('你试图访问a属性')
return 123
},
set(val) {
console.log('你试图改变a属性',val)
}
})
console.log(obj.a);
obj.a = 456
console.log(obj.a);

这样写会导致代码不是那么优雅,所以我们用一个函数将Object.defineProperty()包裹起来,形成一个闭包,下面代码中第三个参数val就是这个闭包的核心,它很好的解决了前面的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let obj = {}

function defineReactive(data, key, val) {
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
console.log('你试图访问a属性')
return val
},
set(newvalue) {
console.log('你试图改变a属性',newvalue)
val = newvalue
}
})
}
defineReactive(obj, 'a', 10)

console.log(obj.a);
//我们改变a的值,会调用set方法进行赋值
obj.a = 100
console.log(obj.a);

现在的defineReactive无法为每一层的数据都添加get和set方法

举个例子:

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
//obj对象本身就包含了a属性,且有好几层
let obj = {
a: {
m: {
n: 5
}
}
}
function defineReactive(data, key, val) {
//判断,当传入的参数是两个时,val就等于对象data的本身值
if(arguments.length == 2) {
val = data[key]
}
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
console.log('你试图访问' + key +'属性')
return val
},
set(newvalue) {
console.log('你试图改变' + key + '属性',newvalue)
val = newvalue
}
})
}
//这里a是一个对象,不可以重新给a赋值,先不传第三个参数
defineReactive(obj, 'a')
console.log(obj.a.m.n);

控制台打印的结果:打印的是试图访问a属性,而不是n属性,所以对n属性的改变无法监视到

递归侦测对象全部属性

基于上面defineReactive()的问题,我们在这里创建一个Observer(中文解释:观察者,观测者)类,它的作用是将一个正常的object对象转化成每一层属性都是响应式(可以被侦测到)的object对象

我们先定义一个observe函数(函数名没有 r),给他传入一个对象,observe首先会在它身上寻找有没有__ob__属性,如果没有,调用Observer类实例化一个ob对象,将这个ob对象添加到__ob__身上(这个__ob__属性在后面会用到)

observe函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Observer from './Observer'

//创建一个observe函数,注意没有r
export const observe = function(value) {
//如果传入的value不是一个对象,那么什么也不做
if(typeof value != 'object') return

//定义ob,用于储存observer类的实例
var ob
if(typeof value.__ob__ != 'undefined') {
ob = value.__ob__
} else {
ob = new Observer(value)
}

return ob
}

Observer类
Observer类的作用是将一个对象转化成每一层都是响应式的对象
def是一个工具函数,用来给传入的对象加上一个__ob__属性,定义它主要是为了简化Observer类中的代码,使逻辑清晰

1
2
3
4
5
6
7
8
9
10
11
12
/*
工具函数
*/
export const def = function(obj, key, value, enumerable) {
Object.defineProperty(obj, key, {
value,
//设置__ob__属性不可遍历
enumerable,
writable: true,
configurable: true
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import {def} from './utils'
import defineReactive from './defineReactive';

export default class Observer {
constructor(value) {
//给实例加上__ob__属性
//一定要注意,构造函数中的this不是表示类本身,而是表示实例
//添加__ob__属性,值是这次new的实例
def(value, '__ob__', this, false)
this.walk(value)
}
//遍历
//为子元素进行侦测,变成响应式
walk(value) {
for(let k in value) {
defineReactive(value, k)
}
}
}

defineReactive()函数

在defineReactive()函数中会调用observe()函数,给它的子元素添加__ob__属性,并在observe中会调用Observer类,对它的子元素进行侦测,实现响应式,也是在此处形成三个函数的递归调用

注意:当个某个元素赋值时,对新的值也要调用observe,因为新值可能是一个对象,如果是一个对象,对它里面的属性也要侦测

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 { observe } from "./observe"

export default function defineReactive(data, key, val) {
//判断,当传入的参数是两个时,val就等于对象data的本身值
if(arguments.length == 2) {
val = data[key]
}
//子元素要进行observe,至此形成了递归,这个递归是observe, Observer, defineReactive三者之间的递归
let childOb = observe(val)

Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
console.log('你试图访问' + key +'属性')
return val
},
set(newvalue) {
console.log('你试图改变' + key + '属性',newvalue)
val = newvalue
//接着观察传入的新值,因为新值可能是一个对象,里面会有新的属性
childOb = observe(newvalue)
}
})
}

流程图:

我们用一个例子来检测一下能否侦测对象的全部属性:

至此完成了对对象全部属性的侦测,但是,现在还不能对数组进行响应式处理

数组的响应式处理

在Vue的底层,为了实现对数组的响应式处理,将数组的七个方法 (push, pop, shift, unshift, splice, sort, reverse)进行了改写

这些方法都被定义在Array.prototype中,Vue根据Array.prototype为原型创建一个arrayMethods对象,在arrayMethods中对七个方法进行改写,并且利用ES6中的Object.setPrototypeOOf()让Array实例的__proto__强制的指向这个arrayMethods对象

首先我们创建一个array.js对这7个方法进行改写。
在下面的代码中,我们先以Array的原型为原型创建一个对象。
然后遍历要改写的方法,对他们进行逐一改写。使用forEach进行遍历,先将这7个方法备份(以便在后面恢复它们的原始功能),然后调用def将arrayMethods对象上的这7个方法进行重写。
在这里我们先恢复7个方法原理的功能,然后让他打印一些东西来测试是否可用,

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
import {def} from './utils'
//获取Array的原型
const arrayPrototype = Array.prototype

//以Array.prototype为原型创建arrayMethods对象
export const arrayMethods = Object.create(arrayPrototype)

const methodsChange = [
'push',
'pop',
'shift',
'unshift',
'sort',
'splice',
'reverse'
]

methodsChange.forEach(method => {
//备份原来的方法
const original = arrayPrototype[method]
//定义新的方法
def(arrayMethods, method, function(){
//恢复原来的功能
//因为是某个数组调用方法,此处的this指向调用它的数组的上下文,
//此处的函数不能用箭头函数:1.箭头函数没有arguments 2.箭头函数的上下文不明确
//要记得返回原来方法的值,因为像pop等方法会返回删除的值
const result = original.apply(this, arguments)
console.log('哈哈哈');

return result
}, false)
})

接下来我们在Observer中也要做一些改变,使他能在要侦测的数据是数组时也可以实现响应式

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
import {def} from './utils'
import defineReactive from './defineReactive';
import { arrayMethods } from './array'

export default class Observer {
constructor(value) {
//给实例加上__ob__属性
//一定要注意,构造函数中的this不是表示类本身,而是表示实例
//添加__ob__属性,值是这次new的实例
def(value, '__ob__', this, false)

if(Array.isArray(value)) {
//如果是数组,将数组的原型强行指向arrayMethods
Object.setPrototypeOf(value, arrayMethods)
//让数组的每一项也observe一下,因为它们可能是对象或者包含对象
this.observeArr(value)
} else {
this.walk(value)
}
}
//遍历
walk(value) {
for(let k in value) {
defineReactive(value, k)
}
}

//数组遍历
observeArr(arr) {
for(let i = 0, l = arr.length; i < l; i++) {
observe(arr[i])
}
}
}

让我们来做一些阶段性的测试,看看上面的改动是否有效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import {observe} from './observe'
let obj = {
a: {
m: {
n: 100
}
},
b: [1,2,3]
}

observe(obj)

obj.b.push(4)

console.log(obj.b);

我们接下来还需要对push,unshift,splice做一些特殊的处理,因为这个三个方法都可以插入新值,我们也需要监测新值(新值可能是对象或者包含着对象)

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
import {def} from './utils'
const arrayPrototype = Array.prototype

//以Array.prototype为原型创建arrayMethods对象
export const arrayMethods = Object.create(arrayPrototype)

const methodsChange = [
'push',
'pop',
'shift',
'unshift',
'sort',
'splice',
'reverse'
]

methodsChange.forEach(method => {
//备份原来的方法
const original = arrayPrototype[method]
//定义新的方法
def(arrayMethods, method, function(){

const result = original.apply(this, arguments)
//arguments是一个类数组对象,需要将她变成数组。在之后splice方法的改写中要用到数组上的方法
const args = [...arguments]

const ob = this.__ob__
let inserted = []

switch(method) {
case 'push':
case 'unshift':
inserted = args;
break;
//splice可以传入三个参数,第三个参数才是要替换或者插入的项
case 'splice':
inserted = args.slice(2);
break;
}
//如果是有要插入的项,将要插入的项也observe一下,因为可能插入的项是或者有对象
if(inserted) {
ob.observeArr(inserted)
}

return result
}, false)
})

这样我们就实现了对数组的响应式,通过一些例子来测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import {observe} from './observe'
let obj = {
a: {
m: {
n: 100
}
},
b: [1,2,3]
}

observe(obj)

obj.b.splice(2, 0, 1234)

console.log(obj.b);

obj.b.push({
name: 'cqy'
})

console.log(obj.b);

依赖收集

需要用到数据的地方,称为依赖。
在Vue 2.x 中,用到数据的组件叫依赖
依赖要被收集起来,当数据发生变化时通过循环通知所有依赖
在用到数据的地方就会触发getter,当改变数据时会触发setter,所以就有一个结论:

在getter中收集依赖,在setter中触发依赖

把依赖收集的代码封装成一个Dep类,它专门用来管理依赖,每个Observer的实例中都有一个Dep的实例。

在这里我们先创建一个Dep类,

1
2
3
4
5
export default class Dep {
constructor() {}
//当数据发生更新时,通知组件
notify() {}
}

并在Observer实例中实例化Dep类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export default class Observer {
constructor(value) {
//每个Observr的实例上都有一个Dep实例
this.dep = new Dep()

//....
}
//遍历
walk(value) {
for(let k in value) {
defineReactive(value, k)
}
}

//数组遍历
observeArr(arr) {
for(let i = 0, l = arr.length; i < l; i++) {
observe(arr[i])
}
}
}

除此之外,我们需要在可能发生数据更新的地方都调用Dep的notify()方法来通知组件

在defineReactive中调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export default function defineReactive(data, key, val) {
const dep = new Dep()

if(arguments.length == 2) {
val = data[key]
}
let childOb = observe(val)

Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
// ...
},
set(newvalue) {
console.log('你试图改变' + key + '属性',newvalue)
val = newvalue
childOb = observe(newvalue)

//数据更改时发布订阅模式,通知dep
dep.notify()
}
})
}

在数组的响应式中调用

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
methodsChange.forEach(method => {
//备份原来的方法
const original = arrayPrototype[method]
//定义新的方法
def(arrayMethods, method, function(){

const result = original.apply(this, arguments)
const args = [...arguments]

const ob = this.__ob__
let inserted = []

switch(method) {
case 'push':
case 'unshift':
inserted = args;
break;
case 'splice':
inserted = args.slice(2);
break;
}
if(inserted) {
ob.observeArr(inserted)
}
//数据更改时发布订阅模式,通知dep
ob.dep.notify()

return result
}, false)
})

这样设置之后,当我们改变数据就会被Dep监测,然后发布订阅模式通知组件

让我们接着来看Dep类:Dep类中的depend方法用于添加依赖,当全局位置Dep.target(之后会说明)存在时,会调用addSubs方法将这个全局位置存入subs数组。当数据发生改变时,在前面我们已经知道会调用notify方法,而它又会调用Watcher类中的update方法(subs数组存放的是Watcher实例)

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
export default class Dep {
constructor() {
//创建数组来存储自己的订阅者
//这个数组中放的是Watcher的实例
this.subs = []
}
//添加订阅
addSubs(sub) {
this.subs.push(sub)
}
//添加依赖
depend() {
//Dep.target是我们指定的一个全局位置
if(Dep.target) {
this.addSubs(Dep.target)
}
}
//通知更新,当数据发生更新时通知订阅的依赖
notify() {
//浅克隆一份subs
const subs = this.subs.slice()

for(let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}

Watcher类是一个观察者对象。依赖收集以后的Watcher实例被保存在Dep的subs中,数据变动的时候Dep会通知Watcher实例,然后由Watcher实例调用回调函数进行视图更新。

1
2
3
4
5
6
7
export default class Watcher {
//构造函数传入三个参数:要监听的对象,这个对象的属性, 回调函数
constructor(target, expression, callback) {
this.obj = target
this.callback = callback
}
}

我们来模拟一下Watcher观察某个数据的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let obj = {
a: {
m: {
n: 100
}
},
b: [1,2,3]
}

observe(obj)
//传入的对象属性是字符串模式,需要在Watcher中将字符串解析为点语法
new Watcher(obj, 'a.m.n', (val) => {
console.log("@@@@", val);
})

将字符串解析为点语法的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export default class Watcher {
//构造函数传入三个参数:要监听的对象,这个对象的属性, 回调函数
constructor(target, expression, callback) {
this.obj = target
this.callback = callback
this.getter = parsePath(expression)
}
}
//这个解析函数将字符串按照"."进行拆分成数组,返回一个函数。
//在Watcher的构造函数中用变量getter接收这个函数,
//到时候我们只需要将构造函数传入的target(监听对象)传入getter函数就可以将解析好的点语法拼接到对象后面

//在这个例子中,我们调用this.getter(this.obj)就可以得到 obj.a.m.n
function parsePath(str) {
let segments = str.split('.')

return (obj) => {
for(let i=0; i<segments.length; i++) {
obj = obj[segments[i]]
}

return obj
}
}

当Watcher实例化之后就会去读取相应的数据,那么这个时候会触发数据的getter,我们需要在这个时候将全局位置存入Dep的subs数组中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export default function defineReactive(data, key, val) {
const dep = new Dep()
if(arguments.length == 2) {
val = data[key]
}
let childOb = observe(val)

Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
console.log('你试图访问' + key +'属性')
//如果处于依赖收集阶段
if(Dep.target) {
dep.depend()
}
return val
},
set(newvalue) {
//....
dep.notify()
}
})
}

我们在构造函数中定义变量value用来存储要观测的值,实例化时value会调用get()方法,在这个方法中,将Watcher实例本身设置为全局位置,并调用getter来获取要存储的值。
当这次的全局位置存入Dep中之后,记得将全局位置设置为null,为之后观测其他值做准备

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
export default class Watcher {
//构造函数传入三个参数:要监听的对象,这个对象的属性, 回调函数
constructor(target, expression, callback) {
this.obj = target
this.getter = parsePath(expression)
this.callback = callback
this.value = this.get()
}
//进入依赖收集阶段
get() {
//将Dep.target设置为Watcher本身,那么就进入依赖收集模式
Dep.target = this

var value
value = this.getter(this.obj)

//当依赖收集完成时,要将Dep.target设置为null,用于之后的依赖收集
//退出依赖收集阶段
Dep.target = null

return value
}
}

function parsePath(str) {
let segments = str.split('.')

return (obj) => {
for(let i=0; i<segments.length; i++) {
obj = obj[segments[i]]
}

return obj
}
}

如前所述,当监测的数据发生了更新,就会触发数据的setter,在setter中会调用dep的notify()方法,发布订阅模式,通知相应组件更新视图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default class Dep {
constructor() {
this.subs = []
}
//...
//通知更新,当数据发生更新时通知订阅的依赖
notify() {
//浅克隆一份subs
const subs = this.subs.slice()

for(let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}

Dep的notify方法又会调用Watcher实例的update方法,update方法又会调用getAndInvoke方法,这个方法会再调用一次get方法来得到新的值,然后通过Watcher实例化时传入的回调函数进行视图更新

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
export default class Watcher {
//构造函数传入三个参数:要监听的对象,这个对象的属性, 回调函数
constructor(target, expression, callback) {
this.obj = target
this.getter = parsePath(expression)
this.callback = callback
this.value = this.get()
}
//数据发生更新时调用
update() {
this.getAndInvoke(this.callback)
}
//进入依赖收集阶段
get() {
//将Dep.target设置为Watcher本身,那么就进入依赖收集模式
Dep.target = this
console.log("全局位置",Dep.target);

var value
value = this.getter(this.obj)

Dep.target = null

return value
}

getAndInvoke(cb) {
const value = this.get()

if(value !== this.value || typeof value == 'object') {
const oldValue = this.value
this.value = value
cb.call(this.obj, value, oldValue)
}
}
}

进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let obj = {
a: {
m: {
n: 100
}
},
b: [1,2,3]
}

observe(obj)

new Watcher(obj, 'a.m.n', (val) => {
console.log("@@@@", val);
})

obj.a.m.n = 200

console.log(obj.a.m.n);

最后

响应式的实现完整代码

在defineReactive()函数中会调用observe()函数,给它的子元素添加__ob__属性,并在observe中会调用Observer类,对它的子元素进行侦测,实现响应式

defineReactive()函数

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
import Dep from "./Dep"
import { observe } from "./observe"

export default function defineReactive(data, key, val) {
const dep = new Dep()
//判断,当传入的参数是两个时,val就等于对象data的本身值
if(arguments.length == 2) {
val = data[key]
}
//子元素要进行observe,至此形成了递归,这个递归是observe, Observer, defineReactive三者之间的递归
let childOb = observe(val)

Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
console.log('你试图访问' + key +'属性')
//如果处于依赖收集阶段
if(Dep.target) {
dep.depend()
}
return val
},
set(newvalue) {
console.log('你试图改变' + key + '属性',newvalue)
val = newvalue
//接着观察传入的新值,因为新值可能是一个对象,里面会有新的属性
childOb = observe(newvalue)
//数据更改时发布订阅模式,通知dep
dep.notify()
}
})
}

observe()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Observer from './Observer'

//创建一个observe函数,注意没有r
export const observe = function(value) {
//如果传入的value不是一个对象,那么什么也不做
if(typeof value != 'object') return

//定义ob,用于储存observer类的实例
var ob
if(typeof value.__ob__ != 'undefined') {
ob = value.__ob__
} else {
ob = new Observer(value)
}

return ob
}

Observer类

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
import {def} from './utils'
import defineReactive from './defineReactive';
import { arrayMethods } from './array'
import { observe } from './observe';
import Dep from './Dep';

export default class Observer {
constructor(value) {
//每个Observr的实例上都有一个Dep实例
this.dep = new Dep()
//给实例加上__ob__属性
//一定要注意,构造函数中的this不是表示类本身,而是表示实例
//添加__ob__属性,值是这次new的实例
def(value, '__ob__', this, false)

if(Array.isArray(value)) {
//如果是数组,将数组的原型强行指向arrayMethods
Object.setPrototypeOf(value, arrayMethods)
//让数组的每一项也observe一下,因为它们可能是对象或者包含对象
this.observeArr(value)
} else {
this.walk(value)
}
}
//遍历
walk(value) {
for(let k in value) {
defineReactive(value, k)
}
}

//数组遍历
observeArr(arr) {
for(let i = 0, l = arr.length; i < l; i++) {
observe(arr[i])
}
}
}

def是一个工具函数,用来给传入的对象加上一个__ob__属性,定义它主要是为了简化Observer类中的代码,使逻辑清晰

1
2
3
4
5
6
7
8
9
10
11
12
/*
工具函数
*/
export const def = function(obj, key, value, enumerable) {
Object.defineProperty(obj, key, {
value,
//设置__ob__属性不可遍历
enumerable,
writable: true,
configurable: true
})
}

arrayMethods对象用于重写数组的7个方法

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
import {def} from './utils'
const arrayPrototype = Array.prototype

//以Array.prototype为原型创建arrayMethods对象
export const arrayMethods = Object.create(arrayPrototype)

const methodsChange = [
'push',
'pop',
'shift',
'unshift',
'sort',
'splice',
'reverse'
]

methodsChange.forEach(method => {
//备份原来的方法
const original = arrayPrototype[method]
//定义新的方法
def(arrayMethods, method, function(){

//恢复原来的功能
//因为是某个数组调用方法,此处的this指向调用它的数组的上下文,
//此处的函数不能用箭头函数:1.箭头函数没有arguments 2.箭头函数的上下文不明确
//要记得返回原来方法的值,因为像pop等方法会返回删除的值
const result = original.apply(this, arguments)
//arguments是一个类数组对象,需要将她变成数组。在之后splice方法的改写中要用到数组上的方法
const args = [...arguments]

const ob = this.__ob__
let inserted = []

switch(method) {
case 'push':
case 'unshift':
inserted = args;
break;
//splice可以传入三个参数,第三个参数才是要替换或者插入的项
case 'splice':
inserted = args.slice(2);
break;
}
//如果是有要插入的项,将要插入的项也observe一下,因为可能插入的项是或者有对象
if(inserted) {
ob.observeArr(inserted)
}
//
ob.dep.notify()

return result
}, false)
})

依赖收集完整代码

Dep类

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
export default class Dep {
constructor() {
//创建数组来存储自己的订阅者
//这个数组中放的是Watcher的实例
this.subs = []
}
//添加订阅
addSubs(sub) {
this.subs.push(sub)
}
//添加依赖
depend() {
//Dep.target是我们指定的一个全局位置
if(Dep.target) {
this.addSubs(Dep.target)
}
}
//通知更新,当数据发生更新时通知订阅的依赖
notify() {
//浅克隆一份subs
const subs = this.subs.slice()

for(let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}

Watcher类

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
import Dep from "./Dep";
export default class Watcher {
//构造函数传入三个参数:要监听的对象,这个对象的属性, 回调函数
constructor(target, expression, callback) {
this.obj = target
this.getter = parsePath(expression)
this.callback = callback
this.value = this.get()
}
//数据发生更新时调用
update() {
this.getAndInvoke(this.callback)
}
//进入依赖收集阶段
get() {
//将Dep.target设置为Watcher本身,那么就进入依赖收集模式
Dep.target = this
console.log("全局位置",Dep.target);

var value
value = this.getter(this.obj)

Dep.target = null

return value
}

getAndInvoke(cb) {
const value = this.get()

if(value !== this.value || typeof value == 'object') {
const oldValue = this.value
this.value = value
cb.call(this.obj, value, oldValue)
}
}
}

function parsePath(str) {
let segments = str.split('.')

return (obj) => {
for(let i=0; i<segments.length; i++) {
obj = obj[segments[i]]
}

return obj
}
}

Vue2指令与生命周期

Vue类的创建

在开始我们要创建一个Vue类来模拟Vue整体的框架

1
2
3
export default class Vue {
constructor() {}
}

在入口文件中将Vue类导入,并设置为全局属性

1
2
3
import Vue from "./Vue";

window.Vue = Vue

在html中实例化一个Vue类,并模拟Vue的挂载方式

1
2
3
4
5
6
7
8
9
10
11
12
13
<body>
<div id="app">

</div>
<script>
new Vue({
el: '#app',
data: {
a: 10
}
})
</script>
</body>

当Vue类被实例化之后,Vue内部就会进行数据响应式处理和模板编译。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import Compile from "./Compile"

export default class Vue {
constructor(options) {
//保存整个挂载对象,以便后面的使用
this.$options = options || {}
//保存传入的data数据
this._data = options.data || undefined

//将数据变成响应式

//模板编译
new Compile(options.el, this)
}
}

数据响应式部分

在这里我们需要导入之前已经完成的数据响应式模块的代码,
通过将传入的数据作为参数赋值给observe函数,来实现数据的响应式
然后调用_initData()函数将响应式的数据挂载到实例的身上

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
import Compile from "./Compile"
import { observe } from "./数据响应式/observe"

export default class Vue {
constructor(options) {
this.$options = options || {}
this._data = options.data || undefined

//将数据变成响应式
observe(this._data)
//将传入的options挂载到实例身上
this._initData()
//模板编译
new Compile(options.el, this)
}

_initData() {
var self = this
Object.keys(this._data).forEach(key => {
Object.defineProperty(self, key, {
get() {
return self._data[key]
},
set(newVal) {
self._data[key] = newVal
}
})
})
}
}

模板编译部分

我们来研究模板编译部分

我们主要通过一个Compile类来进行模板编译,它要求传入挂载的容器以及整个Vue实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export default class Compile {
constructor(el, vue) {
//保存整个vue实例,以便后续使用
this.$vue = vue
//保存挂载的容器
this.$el = document.querySelector(el)
//如果用户传入了挂载点
if(this.$el) {

//实际上用的是AST,但我们现在主要是学习指令和生命周期,所以用一个简单的函数代替
//相当于一个轻量级的AST
this.node2Fragment(this.$el)
}
}

node2Fragment(el) {}

}

node2Fragment函数

我们使用node2Fragment函数来代替AST,作为一个AST的轻量级。

在这里我们会用到一个JS方法document.createDocumentFragment()

createDocumentFragment()方法创建了一虚拟的节点对象,节点对象包含所有属性和方法。
当你想提取文档的一部分,改变,增加,或删除某些内容及插入到文档末尾可以使用createDocumentFragment() 方法。

我们在这里需要用到的就是:当向createDocumentFragment创建的虚拟节点对象插入dom树中的某一节点,那么这个节点就会从原来的dom树中删除

1
2
3
4
5
6
7
8
9
10
11
node2Fragment(el) {
var child
//创建一个虚拟节点对象
var fragment = document.createDocumentFragment()
//将挂载容器中的第一个孩子赋值给child,因为是引用类型值,将child添加到fragment对象中会使el的原dom树中的第一个孩子节点下树,
//循环一直到el中的所有子节点都被添加fragment中
while(child = el.firstChild) {
fragment.appendChild(child)
}
return fragment
}

通过node2Fragment函数得到fragment虚拟节点对象,然后传给compile函数进行编译

compile函数

在compile函数中会对传入的挂载容器的虚拟节点对象中的节点进行判断
如果是元素节点,就交给compileElement函数处理。如果是文本节点(元素节点之间的换行符也包括在内),就交给compileText函数处理。

当完成compile函数进行的模板编译之后,要将解析好的虚拟节点再重新上树

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
export default class Compile {
constructor(el, vue) {
//保存整个vue实例,以便后续使用
this.$vue = vue
//保存挂载的容器
this.$el = document.querySelector(el)
//如果用户传入了挂载点
if(this.$el) {

//实际上用的是AST,但我们现在主要是学习指令和生命周期,所以用一个简单的函数代替
//相当于一个轻量级的AST
this.$fragment = this.node2Fragment(this.$el)
//编译fragment
this.compile(this.$fragment)

//将解析好的虚拟节点上树
this.$el.appendChild(this.$fragment)
}
}

node2Fragment(el) {
// ....
}

compile(fragment) {
//获取挂载容器(生成的虚拟节点对象)的所有子节点
const childNodes = fragment.childNodes
//保存一份this,因为在下面用到的箭头函数中this不一定指向这个函数的上下文
var self = this

childNodes.forEach(node => {
//console.log('node:',node)

if(node.nodeType == 1) { //如果这个子节点是dom节点
//调用compileElement函数对这个节点进行处理
self.compileElement(node)
} else if(node.nodeType == 3){ //如果是文本节点以及一些换行符等
//调用compileText函数对文本节点进行处理
let text = node.textContent
self.compileText(text)
}
})
}

compileElement(node) {}

compileText(text) {}
}

compileElement函数

compileElement函数

compileElement函数内部会去看看有没有vue中的一些指令(例如:v-if,v-model等),如果有的话,就解析指令并进行相应操作

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
compileElement(node) {
//使用attributes可以得到dom节点上的属性,
//它的方便之处在于得到的不是字符串类型的属性,而是属性列表
let nodeAttrs = node.attributes

//类数组对象转化成数组
Array.prototype.slice.call(nodeAttrs).forEach(attr => {
//在这里对v-if等指令进行分析
//console.log('attr:',attr);
//得到属性名和属性值
let attrName = attr.name
let attrValue = attr.value

if(attrName.indexOf('v-') == 0) {
//如果是v-if等指令,那么我们需要得到'v-'之后的东西,来确定具体的指令操作
let dir = attrName.substring(2)
//console.log('dir:',dir);
if(dir == 'if') { //发现是v-if指令
// ... 对指令的具体操作
} else if(dir == 'model') { //发现是v-model指令
// ... 对指令的具体操作
}
}
})
}

在html中我们用如下代码进行测试:

1
2
3
4
5
6
7
<div id="app">
<h3 class="aaa" v-if="if" >{{a}}</h3>
<ul>
<li>A</li>
<li>B</li>
</ul>
</div>

attr中的内容:

dir的内容:

分析指令操作

我们以v-model为例来分析Vue中指令的操作
使用如下html来分析数据双向绑定v-model

1
2
3
4
<div id="app">
{{b.m.n}}
<input type="text" v-model="b.m.n">
</div>

先解决双向绑定的第一层,即input输入框能显示得到数据
具体思路就是我们先获取到v-model绑定的属性的值,然后将他赋值给input输入框

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
compileElement(node) {
//使用attributes可以得到dom节点上的属性,
//它的方便之处在于得到的不是字符串类型的属性,而是属性列表
let nodeAttrs = node.attributes
var self = this
//类数组对象转化成数组
Array.prototype.slice.call(nodeAttrs).forEach(attr => {
//在这里对v-if等指令进行分析
console.log('attr:',attr);
//得到属性名和属性值
let attrName = attr.name
let attrValue = attr.value

if(attrName.indexOf('v-') == 0) {
//如果是v-if等指令,那么我们需要得到'v-'之后的东西,来确定体的指令操作
let dir = attrName.substring(2)
if(dir == 'model') {
//通过getVueVal函数得到v-model绑定的数据的值
let v = self.getVueVal(self.$vue, attrValue)
//将input输入框的值设置为v-model绑定的数据的值
node.value = v
}
}
})
}

getVueVal(vue, exp) {
var val = vue
//这个属性可能需要连续点语法处理,例如a.m.n
//我们就需要将他拆分开再使用中括号语法组合起来,因为js无法识别obj[a.m.n]的形式
exp = exp.split('.')
exp.forEach(k => {
val = val[k]
})
return val
}

这就实现了在输入框中显示v-model绑定的数据

双向绑定的第二层,input输入框中改变数据,页面渲染的数据也相应改变

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
compileElement(node) {
//使用attributes可以得到dom节点上的属性,
//它的方便之处在于得到的不是字符串类型的属性,而是属性列表
let nodeAttrs = node.attributes
var self = this
//类数组对象转化成数组
Array.prototype.slice.call(nodeAttrs).forEach(attr => {
//在这里对v-if等指令进行分析
//得到属性名和属性值
let attrName = attr.name
let attrValue = attr.value

if(attrName.indexOf('v-') == 0) {
//如果是v-if等指令,那么我们需要得到'v-'之后的东西,来确定具体的指令操作
let dir = attrName.substring(2)
if(dir == 'model') {
let v = self.getVueVal(self.$vue, attrValue)
node.value = v
//给属性添加Watcher监听,当它的数据发生改变就会重新渲染
new Watcher(self.$vue, attrValue, val => {
node.value = val
})
//给input框绑定输入事件
node.addEventListener('input', e => {
//获取到input框中的值
var newVal = e.target.value
//将属性的值设置为取得的值
self.setVueVal(self.$vue, attrValue, newVal)
})

}
}
})
}

compileText函数

如果是一个文本节点,那么就需要看看文本中有没有双大括号语法,如果有的话,要将双大括号内的东西解析成具体数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
compileText(node, name) {
//在Vue类的实例中寻找双大括号中的元素,并将他解析好
node.textContent = this.getVueVal(this.$vue, name)
}

getVueVal(vue, exp) {
var val = vue
//这个属性可能需要连续点语法处理,例如a.m.n
//我们就需要将他拆分开再使用中括号语法组合起来,因为js无法识别obj[a.m.n]的形式
exp = exp.split('.')
exp.forEach(k => {
val = val[k]
})
return val
}

我们使用下面的例子来检测函数功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<div id="app">
{{b.m.n}}
<ul>
<li>A</li>
<li>B</li>
</ul>
</div>
<script>
let vm = new Vue({
el: '#app',
data: {
a: 10,
b: {
m: {
n: 100
}
}
}
})
</script>

我们来看看传入compileText函数的双大括号内的属性(name),以及解析好之后的具体属性值

上树之后的页面:

虽然完成了双大括号的编译,也渲染到了页面上。但是当数据发生了改变时页面不会跟着改变。
所以我们需要给这个数据加上一个Watcher来监听它的变化,然后通知组件更新

1
2
3
4
5
6
7
8
9
compileText(node, name) {
//console.log('name:',name);
//在Vue类的实例中寻找双大括号中的元素,并将他解析好
node.textContent = this.getVueVal(this.$vue, name)
//console.log('node.textContent:',node.textContent);
new Watcher(this.$vue, name, val => {
node.textContent = val
})
}

得到的结果:

最后

实例化Vue类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<body>
<div id="app">
{{b.m.n}}
<input type="text" v-model="b.m.n">
</div>
<script src="/xuni/bundle.js"></script>
<script>
let vm = new Vue({
el: '#app',
data: {
a: 10,
b: {
m: {
n: 100
}
}
}
})
</script>
</body>

Vue类

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
import Compile from "./Compile"
import { observe } from "./数据响应式/observe"
import Watcher from "./数据响应式/Watcher"

export default class Vue {
constructor(options) {
this.$options = options || {}
this._data = options.data || undefined

//将数据变成响应式
observe(this._data)
//将传入的options挂载到实例身上
this._initData()
//模板编译
new Compile(options.el, this)
}

_initData() {
var self = this
Object.keys(this._data).forEach(key => {
Object.defineProperty(self, key, {
get() {
return self._data[key]
},

set(newVal) {
self._data[key] = newVal
}
})
})
}
}

Compile类

Compile类主要用来处理模板编译的问题

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
import Watcher from "./数据响应式/Watcher"

export default class Compile {
constructor(el, vue) {
this.$vue = vue
this.$el = document.querySelector(el)
//如果用户传入了挂载点
if(this.$el) {
//实际上用的是AST,但我们现在主要是学习指令和生命周期,所以用一个简单的函数代替
//相当于一个轻量级的AST
this.$fragment = this.node2Fragment(this.$el)
//编译得到的fragment
this.compile(this.$fragment)
//将解析好的虚拟节点上树
this.$el.appendChild(this.$fragment)
}
}
node2Fragment(el) {
var child
var fragment = document.createDocumentFragment()

while(child = el.firstChild) {
fragment.appendChild(child)
}

return fragment
}

compile(fragment) {
console.log(fragment);
//获取挂载容器的所有子节点
const childNodes = fragment.childNodes
//保存一份this,因为在下面用到的箭头函数中this不一定指向这个函数的上下文
var self = this
//捕获双大括号中内容
var reg = /\{\{(.+)\}\}/
childNodes.forEach(node => {
console.log('node:',node)

if(node.nodeType == 1) {
self.compileElement(node)
} else if(node.nodeType == 3){
let text = node.textContent
if(reg.test(text)) {
console.log('匹配成功');
//双括号中的内容,即双括号语法中的数据
let name = text.match(reg)[1]
self.compileText(node, name)

}
}
})
}

compileElement(node) {
//使用attributes可以得到dom节点上的属性,
//它的方便之处在于得到的不是字符串类型的属性,而是属性列表
let nodeAttrs = node.attributes
var self = this

//类数组对象转化成数组
Array.prototype.slice.call(nodeAttrs).forEach(attr => {
//在这里对v-if等指令进行分析
//得到属性名和属性值
let attrName = attr.name
let attrValue = attr.value

if(attrName.indexOf('v-') == 0) {
//如果是v-if等指令,那么我们需要得到'v-'之后的东西,来确定具体的指令操作
let dir = attrName.substring(2)
if(dir == 'model') {
let v = self.getVueVal(self.$vue, attrValue)
node.value = v

new Watcher(self.$vue, attrValue, val => {
node.value = val
})
node.addEventListener('input', e => {
var newVal = e.target.value
self.setVueVal(self.$vue, attrValue, newVal)
})

}
}
})
}

compileText(node, name) {
//在Vue类的实例中寻找双大括号中的元素,并将他解析好
node.textContent = this.getVueVal(this.$vue, name)
new Watcher(this.$vue, name, val => {
node.textContent = val
})
}

getVueVal(vue, exp) {
var val = vue
//这个属性可能需要连续点语法处理,例如a.m.n
//我们就需要将他拆分开再使用中括号语法组合起来,因为js无法识别obj[a.m.n]的形式
exp = exp.split('.')
exp.forEach(k => {
val = val[k]
})

return val
}

setVueVal(vue, exp, value) {
var val = vue
exp = exp.split('.')

exp.forEach((k, i) => {
//在一层层找属性时,如果到了最后一层,就将它设置为新的值
//例如:b.m.n 我们要改变的是b.m.n中最后一层n的值,前面几层只需要迭代下来就可以了
if(i != exp.length - 1) {
val = val[k]
} else {
val[k] = value
}
})
}

}

Vue2模板引擎

mustache模板引擎

什么是模板引擎

先上结论:模板引擎是将数据变为视图的最优雅的方案
举一个例子,我们要将一下数据变成如下的视图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//数据
[
{name: 'cqy', sex: 'man'},
{name: 'kyrie', sex: 'man'},
]

//目标视图
<ul>
<li>
<div class="hd">cqy的基本信息</div>
<div class="bd">sex: man</div>
</li>
<li>
<div class="hd">kyrie的基本信息</div>
<div class="bd">sex: man</div>
</li>
</ul>

在历史上曾经出现的将数据变成视图的方法有:

  1. 纯DOM法
  2. 数组join()法
  3. ES6反引号法
  4. 模板引擎

纯DOM法

用纯DOM法来创建视图非常笨拙,毫无实战价值

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
<body>
<ul id="list"></ul>
<script>
var arr = [
{name: 'cqy', sex: 'man'},
{name: 'kyrie', sex: 'man'},
];

let ul = document.getElementById('list')

for(let i=0;i<arr.length;i++) {
let oli = document.createElement('li')

let hdDiv = document.createElement('div')
hdDiv.className = 'hd'
hdDiv.innerText = arr[i].name + '的基本信息'

let bdDiv = document.createElement('div')
bdDiv.className = 'hd'
bdDiv.innerText = 'sex: ' + arr[i].sex

oli.appendChild(hdDiv)
oli.appendChild(bdDiv)
ul.appendChild(oli)
}
</script>
</body>

数组join()法

数组join()法的本质是使用了字符串,以字符串的视角将html字符串添加到body中
众所周知,单引号和双引号都是不支持换行的

1
2
3
4
5
// 单引号和双引号不支持换行,这样写是不合法的
let str = '
adsfd
saf
'

这个时候数组的join()方法就发挥了作用,join()可以将数组的内容拼接在一起

1
2
3
4
5
let str = [
'a',
'b',
'c'
].join('')

可能现在还看不出有什么特别之处,当我们将内容替换成html语句时,就可以显示出html语句的层次关系,比单纯的字符串写在一行要清晰

1
2
3
4
5
6
let str = [
'<li>',
' <div class="hd"></div>',
' <div class="bd"></div>',
'</li>'
].join('')

这个时候你可能还是不知道这有什么用,这时就要用到 innerHTML() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<body>
<ul id="list"></ul>
<script>
var arr = [
{name: 'cqy', sex: 'man'},
{name: 'kyrie', sex: 'man'},
];

let ul = document.getElementById('list')

for(let i=0;i<arr.length;i++) {
//注意此处的 +=
ul.innerHTML += [
'<li>',
' <div class="hd">' + arr[i].name + '的基本信息' + '</div>',
' <div class="bd">' + 'sex: ' + arr[i].sex + '</div>',
'</li>'
].join('')
}
</script>
</body>

ES6反引号法

es6中的反引号支持换行,并且可以用${}语法糖直接插入数据,本质上和数组join()法一样,只是更加优雅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<body>
<ul id="list"></ul>
<script>
var arr = [
{name: 'cqy', sex: 'man'},
{name: 'kyrie', sex: 'man'},
];

let ul = document.getElementById('list')

for(let i=0;i<arr.length;i++) {
ul.innerHTML += `
<li>
<div class="hd">${arr[i].name}的基本信息</div>
<div class="bd">sex: ${arr[i].sex}</div>
</li>
`
}
</script>
</body>

Mustache的基本使用

为了学习mustache我们需要引入它,可以使用npm的方法在node环境下使用,也可以通过cdn的方法引入在浏览器使用
在bootcdn.com上找到mustache,为了方便后面的源码分析,我直接将他的源码复制到本地js文件

在mustache引入之后,就会有一个Mustache对象
mustache的模板语法非常的简单,用{{}}表示,
注意 {{}}中不支持写表达式的,因为mustache是逻辑很弱的模板引擎

循环对象数组
模板语法:

1
2
3
4
5
6
7
8
9
//以#开始、以/结束表示区块,它会根据当前上下文中的键值来对区块进行一次或多次渲染
{{#data}}
// ...
{{/data}}
//在区块内,使用{{}}将要展示的数据填入
{{name}}
//调用Mustache.render(templateStr, data) 将数据填入模板字符串中
//会返回被填充好的dom结构
Mustache.render(templateStr, data)

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script>
console.log(Mustache);
let data = {
arr: [
{name: 'cqy', sex: 'man'},
{name: 'kyrie', sex: 'man'},
]
}
let templateStr = `
<ul>
{{#arr}}
<li>
<div class="hd">{{name}}的基本信息</div>
<div class="bd">sex: {{sex}}</div>
</li>
{{/arr}}
</ul>
`
let domStr = Mustache.render(templateStr, data)
console.log(domStr);
let container = document.getElementById('container')
container.innerHTML = domStr
</script>

不进行循环,直接写入数据
模板语法: {{}}

1
2
3
4
5
6
7
8
9
10
11
<script>
let data = {
name: 'cqy',
age: '20'
}
//使用{{}}模板语法
let templateStr = `my name is {{name}}, age is {{age}}`
let domStr = Mustache.render(templateStr, data)
let container = document.getElementById('container')
container.innerHTML = domStr
</script>

循环简单数组
模板语法: {{.}}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script>
console.log(Mustache);
let data = {
//arr: [
// {name: 'cqy', sex: 'man'},
// {name: 'kyrie', sex: 'man'},
//]
arr: ['kyrie', 'kd','harden']
}
let templateStr = `
<ul>
{{#arr}}
<li>
<p>{{.}}</p>
</li>
{{/arr}}
</ul>
`
let domStr = Mustache.render(templateStr, data)
console.log(domStr);
let container = document.getElementById('container')
container.innerHTML = domStr
</script>

循环嵌套数组
模板语法:

1
2
3
4
5
6
{{#data1}} 
// ...
{{#data2}}
// ...
{{/data2}}
{{/data1}}

例子:

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
<script>
console.log(Mustache);
let data = {
arr: [
{name: 'cqy', sex: 'man', hobbies: ['打球','敲代码']},
{name: 'kyrie', sex: 'man', hobbies: ['打球','高尔夫']},
]
}
let templateStr = `
<ul>
{{#arr}}
<li>
<div>{{name}}的爱好</div>
//嵌套数字
{{#hobbies}}
<ol>
{{.}}
</ol>
{{/hobbies}}
</li>
{{/arr}}
</ul>
`
let domStr = Mustache.render(templateStr, data)
console.log(domStr);
let container = document.getElementById('container')
container.innerHTML = domStr
</script>

布尔值
模板语法:{{#m}} m为布尔值,m为真显示区块中的内容,为假则不显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script>
console.log(Mustache);
let data = {
show: false
}
let templateStr = `
<div>
<p>哈哈哈</p>
{{#show}}
<div>我没有被展示</div>
{{/show}}
</div>
`
let domStr = Mustache.render(templateStr, data)
console.log(domStr);
let container = document.getElementById('container')
container.innerHTML = domStr
</script>

模拟简单的数据替换

使用字符串的replace来替换捕获到的数据
replace()的第一个参数是匹配项,可以是具体值或者正则表达式,第二个参数是要替换成的内容,可以是具体内容或者一个函数
这个函数有四个参数;

  1. 完整的匹配项
  2. 捕获组的内容
  3. 匹配项的索引
  4. 整个字符串的内容
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <script>
    let data = {
    name: 'cqy',
    age: 20
    }

    let templateStr = "<h2>My name is {{name}}, Age is {{age}}</h2>"
    //模拟简单的数据替换
    function myRender(templateStr,data) {
    // /\{\{(\w+)\}\}/g 匹配整个{{}},并为双大括号内的数据设置捕获组
    return templateStr.replace(/\{\{(\w+)\}\}/g, function(findStr, $1) {
    //findStr就是匹配到的整个{{}} $1是捕获组
    console.log($1);
    return data[$1];
    })
    }
    let res = myRender(templateStr, data)
    console.log(res);
    </script>

但当情况复杂时,正则表达式的思路肯定是不可以得

Mustache的底层核心机理

tokens是一个JS嵌套数组,说白了,就是模板字符串的JS表示
tokens是抽象语法树和虚拟节点的开山鼻祖

简单数据下的tokens
模板字符串:<h1>my hobby is {{hobby}}, age is {{age}}</h1>

tokens:

1
2
3
4
5
6
7
8
9
//纯文本会被编译成text
//{{}}包裹的被编译成name
[
['text', '<h1>my name is '],
['name', 'hobby'],
['text', ', age is '],
['name', 'age'],
['text', '</h1>']
]

当模板字符串有循环的存在时,它将被编写成嵌套更深的tokens
模板字符串:

1
2
3
4
5
6
7
<ul>
{{#arr}}
<li>
{{.}}
</li>
{{/arr}}
</ul>

tokens:

1
2
3
4
5
6
7
8
9
10
11
12
[
['text', '<ul>'],

//将{{#arr}} {{/arr}}之间的东西都包含在这一个token里面
['#', 'arr', [
['text', '<li>'],
['name', '.'],
['text', '</li>'],
]],

['text', '</ul>']
]

由此可见,mustache的底层重点要做两件事

  1. 将模板字符串翻译为tokens形式
  2. 将tokens结合数据,解析为dom字符串

手写实现Mustache

手写Scanner类

要实现模板字符串转化成tokens,首先得可以实现有一个东西扫描整个模板字符串来找到双大括号的位置,从而将双大括号外面和双大括号里面的东西抽离出来,Scanner类就是来实现这个功能

例如:模板字符串 'my name is {{myName}}, age is {{age}}'要转化为tokens
主要过程就是:定义一个指针指向模板字符串的开头,然后向后面扫描,当指针指向第一个指定内容{时,返回在这之前扫描到的字符串,然后跳过{{`,接着扫描直到遇到指定内容`}`时,返回在这之前扫描到的字符串,然后跳过`}},重复以上步骤直到模板字符串末尾

定义Scanner类

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
/*
扫描器类
*/

//定义一个Scanner扫描器类,并向外暴露
export default class Scanner {
//构造函数,接收传入的模板字符串
constructor(templateStr) {
this.templateStr = templateStr
//指针
this.pos = 0
//尾巴字符串
//指针指到的当前字符的后面的字符串(包括指针)被称为尾巴字符串
//尾巴字符串的精妙之处在于,我们只要判断尾巴字符串的开头字符是不是指定内容(双大括号),
//就可以决定指针是继续往后走还是跳过
this.tail = templateStr
}
//模板字符串 'my name is {{myName}}, age is {{age}}'要转化为tokens,
//mustache内部定义了两个方法来转化 scan() 和 scanUntil()

// scan: 匹配到指定内容(双大括号),并跳过他,没有返回值
scan(tag) {
if(this.tail.indexOf(tag) == 0) {
//只要让指针向后移 tag的长度 就可以实现跳过指定内容的功能
this.pos += tag.length
//调整尾巴字符串
this.tail = this.templateStr.substring(this.pos)
}
}
//scanUntil: 指针从模板字符串的开头开始扫描,
//直到遇到指定内容(双大括号)结束,返回结束之前路过的字符串
scanUntil(stopTag) {
//记录本方法执行前pos指针的位置
const pos_start = this.pos
//当尾巴字符串的开头不是指定目标时循环进行
// &&后面的判断语句很有必要,防止因为找不到指定目标而进入死循环
while(this.tail.indexOf(stopTag) != 0 && !this.eos()) {
//指针后移
this.pos++
//更新尾巴字符串为当前指针后面(包含指针)的字符串
this.tail = this.templateStr.substring(this.pos)

}
//返回从本方法执行前位置到结束指针位置的字符串,
//substring默认前闭后开,this.pos正好指向指定内容位置,不会被包含进去
return this.templateStr.substring(pos_start, this.pos)
}

//eos:end of string 判断指针是否到模板字符串最后
//返回一个布尔值 到最后了返回true
eos() {
return this.pos >= this.templateStr.length
}
}

使用Scanner类

1
2
3
4
5
6
7
<body>
<script>
let templateStr = '我今天写了{{code}}, 好{{mood}}呀'
let data = {}
TemplateEngine.render(templateStr, data)
</script>
</body>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import Scanner from './Scanner'
//定义一个全局的TemplateEngine来模拟mustache
window.TemplateEngine = {
//实现模板字符串转化成tokens
render(templateStr,data) {
//实例化一个扫描器,构造时候提供一个参数,这个参数就是模板字符串
//也就是说这个扫描器就是针对这个模板字符串工作的
var scanner = new Scanner(templateStr)
while(scanner.pos != templateStr.length) {
var words = scanner.scanUntil('{{')
scanner.scan('{{')
//console.log(words, scanner.pos);
words = scanner.scanUntil('}}')
scanner.scan('}}')
//console.log(words, scanner.pos);
}
}
}

得到的结果:

手写将模板字符串变成tokens

简单的一维数组tokens

当我们的模板字符串没有嵌套多层时,可以很容易转化成一维数组的tokens

1
2
3
4
5
6
7
8
<body>
<script src="/xuni/bundle.js"></script>
<script>
let templateStr = '我今天写了{{code}}, 好{{mood}}呀'
let data = {}
TemplateEngine.render(templateStr, data)
</script>
</body>
1
2
3
4
5
6
7
8
9
//导入将模板字符串转化成tokens的函数
import parseTemplateToTokens from './parseTemplateToTokens'
window.TemplateEngine = {
render(templateStr,data) {
//调用parseTemplateToTokens函数,让模板字符串变成tokens数组
var tokens = parseTemplateToTokens(templateStr)
console.log(tokens);
}
}
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
import Scanner from './Scanner'
/*
将模板字符串变为tokens数组
*/
export default function parseTemplateToTokens(templateStr) {

var tokens = []

//创建一个扫描器类
var scanner = new Scanner(templateStr)
var words = ''
while(!scanner.eos()) {
// 收集开始标记出现之前的文字
words = scanner.scanUntil('{{')

if(words != '') {
//存入tokens数组
tokens.push(['text', words])
}
//跳过指定内容
scanner.scan('{{')

// 收集结束标记出现之前的文字
words = scanner.scanUntil('}}')

if(words != '') {
//存入tokens数组
tokens.push(['name', words])
}
//跳过指定内容
scanner.scan('}}')

}

return tokens
}

更进一步的tokens

当我们遇到模板字符串的嵌套时,先初步将{{#...}} {{/...}}分离出来
在上一步的基础上做一点小改变

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 收集结束标记出现之前的文字
words = scanner.scanUntil('}}')

if(words != '') {
//此处的words是{{}}中的内容,判断words的首字母
if(words[0] == '#') {
//将 # 和 words中#之后的内容 分开存储
tokens.push(['#', words.substring(1)])
} else if(words[0] == '/') {
tokens.push(['/', words.substring(1)])
} else {
tokens.push(['name', words])
}
}
//跳过指定内容
scanner.scan('}}')

将tokens嵌套起来

我们要将tokens嵌套起来,如下图的形式

在实现嵌套tokens时我们要用到

1
2
3
4
5
6
7
8
9
import Scanner from './Scanner'
/*
将模板字符串变为tokens数组
*/
export default function parseTemplateToTokens(templateStr) {
// ...
//parseTemplateToTokens整理得到的是一个没有嵌套结构的假tokens,我们在它的返回值中调用函数来将tokens嵌套起来
return nestTokens(tokens)
}

以下代码是根据我自己的思路来实现嵌套tokens
可以实现tokens的嵌套,但是逻辑结构不是十分清晰

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
/*
nestTokens函数的作用是折叠tokens,将'#' 和 '/'之间的内容整合在一起,作为数组的第三项
*/
export default function nestTokens(tokens) {
//最终的结果,折叠好的tokens
var nestedTokens = []

//栈
//将要嵌套的内容存入栈
var sections = []

for(let i=0 ; i < tokens.length; i++) {
let token = tokens[i]
//判断token的下标0的内容是 '#' 还是 '/' 或者 其他内容
switch(token[0]) {
case '#':
//为token的下标为2的位置创建一个数组,用于收集子元素
token[2] = []
//入栈
sections.push(token)
break;
case '/':
//出栈
let section = sections.pop()

//当遇到 '/' 时,整个嵌套内容section出栈
//判断栈是否为空 栈为空直接存到nestedTokens中,
if(sections.length == 0) {
nestedTokens.push(section)
} else {
//栈不为空,将嵌套内容存到上一层嵌套的下标为2的位置
sections[sections.length - 1][2].push(section)
}
break;
default :
// 如果栈内没有内容了,说明暂时没有嵌套的内容,将这个token直接存入最终结果中
if(sections.length == 0) {
nestedTokens.push(token)
} else {
//sections[sections.length - 1]表示栈顶的token,就是最新进去的包含 '#' 的token
// [2].push(token)在case中已经为这个token创建了下标为2的数组,将token存入最新进去的包含 '#' 的token的下标为2的位置
sections[sections.length - 1][2].push(token)
}
}
}
return nestedTokens
}

控制台打印的结果:

源码中很巧妙的使用了一个收集器,很大程度上简化了代码,并且逻辑更清晰

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
/*
nestTokens函数的作用是折叠tokens,将'#' 和 '/'之间的内容整合在一起,作为数组的第三项
*/
export default function nestTokens(tokens) {
//折叠好的tokens
var nestedTokens = []

//console.log(tokens);
//栈
//将要嵌套的内容存入栈
var sections = []

------------------------------------------
//收集器,天生指向nestedTokens,引用类型值,所以collector和nestedToken指向同一值
var collector = nestedTokens
------------------------------------------

for(let i=0 ; i < tokens.length; i++) {
let token = tokens[i]

switch(token[0]) {
case '#':
//在收集器中放入这个token
collector.push(token)
//入栈
sections.push(token)
//改变收集器指向,给token添加下标为2的项,让收集器指向它
//token[2] = [] 放在 collector.push(token) 之前 或 在此处定义 没有区别
token[2] = []
collector = token[2]
break;
case '/':
//出栈
let section = sections.pop()
//判断栈是否为空,若为空 收集器指向nestedTokens ,若不为空 收集器指向栈顶那项下标为2的项
collector = sections.length > 0 ? sections[sections.length - 1][2] : nestedTokens
break;
default :
collector.push(token)
}

}
return nestedTokens
}

控制台打印的结果:

手写将tokens变成dom字符串

我们已经得到了嵌套好的tokens数组,接下来需要结合数据将他们变成dom字符串

简单模板的测试

1
2
3
4
5
6
7
8
9
10
11
<body>
<script src="/xuni/bundle.js"></script>

<script>
let templateStr = '我是{{somebody}}门徒'
let data = {
somebody: '欧文'
}
TemplateEngine.render(templateStr, data)
</script>
</body>
1
2
3
4
5
6
7
8
9
10
11
12
import parseTemplateToTokens from './parseTemplateToTokens'
import renderTemplate from './renderTemplate';

window.TemplateEngine = {
render(templateStr,data) {
//调用parseTemplateToTokens函数,让模板字符串变成tokens数组
var tokens = parseTemplateToTokens(templateStr)
//调用renderTemplate函数,让tokens变成dom字符串
var domStr = renderTemplate(tokens, data)
//console.log(tokens);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*
函数的作用是将tokens变成dom字符串
*/

export default function renderTemplate(tokens, data) {
console.log(tokens, data);

//结果字符串
var resultStr = ''

for(let i=0; i<tokens.length; i++) {
let token = tokens[i]
if(token[0] == 'text') {
resultStr += token[1]
} else if(token[0] == 'name') {
//在数据中寻找对应数据,然后拼接到结果字符串
resultStr += data[token[1]]
}
}

console.log(resultStr);
}

控制台得到的结果:可以初步实现简单的模板转化

问题
上面的代码还存在一个很大的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
for(let i=0; i<tokens.length; i++) {
let token = tokens[i]
if(token[0] == 'text') {
resultStr += token[1]
} else if(token[0] == 'name') {
//这里会出现一个问题,js无法识别字符串形式的点语法
//当模板中的数据需要使用点语法时
//let templateStr = '我是{{somebody}}门徒,我得了{{a.b.c}}分'
//let data = {
// somebody: '欧文',
// a: {
// b: {
// c: 100
// }
// }
//}
//这时候的data[token[1]]就是data['a.b.c'],无法被识别
resultStr += data[token[1]]
} else if(token[0] == '#') { // '#'标记的tokens,要递归处理它的标为2的数组
}
}

这个时候我们就需要一个函数来帮助我们实现点语法的实现

lookup函数

lookup函数就是用来将字符串形式的点语法进行实现

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
/*
函数的功能是在dataObj对象中,找出用连续点符号的keyName的属性
比如:dataObj是
{
a: {
b: {
c: 100
}
}
}
那么lookup(dataObj, 'a.b.c')的结果就是100
*/
export default function lookup(dataObj, keyName) {
//判断keyName中有没有点语法,但本身不能是'.',
//因为mustache中有{{.}}语法,在后面会解析
if(keyName.indexOf('.') != -1 && keyName != '.') {
//将点符号的各个属性分开
var keys = keyName.split('.')

var temp = dataObj
for(let i=0; i<keys.length; i++) {
//依次追加属性
temp = temp[keys[i]]
}
return temp
}
//如果没有点符号,直接返回
return dataObj[keyName]
}

这样就可以将他导入到renderTemplate函数中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import lookup from './lookup'
/*
函数的作用是将tokens变成dom字符串
*/
export default function renderTemplate(tokens, data) {
console.log(tokens, data);

//结果字符串
var resultStr = ''

for(let i=0; i<tokens.length; i++) {
let token = tokens[i]
if(token[0] == 'text') {
resultStr += token[1]
} else if(token[0] == 'name') {
//resultStr += data[token[1]]
resultStr += lookup(data, token[1])
}
}

console.log(resultStr);
return resultStr
}

到现在为止,我们只是实现了text,name开头的token,还没有实现 ‘#’ 下的嵌套功能

嵌套tokens的转化,不包含{{.}}

对于嵌套tokens的转化,其实就是递归调用renderTemplate函数,一层一层转化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export default function renderTemplate(tokens, data) {
// ....
for(let i=0; i<tokens.length; i++) {
let token = tokens[i]
if(token[0] == 'text') {

resultStr += token[1]

} else if(token[0] == 'name') {

//resultStr += data[token[1]]
resultStr += lookup(data, token[1])

} else if(token[0] == '#') { // '#'标记的tokens,要递归处理它的下标为2的数组
// 在这里调用parseArray()来解析 '#' 开头的token
resultStr += parseArray(token, data)

}
}
console.log(resultStr);
return resultStr
}
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
import lookup from "./lookup";
import renderTemplate from "./renderTemplate";
/*
parseArray()处理数组,结合renderTemplate函数实现递归
注意这个函数接收的是一个token,不是tokens

这个函数要递归调用renderTemplate函数,调用的次数由data数据中这部分要使用的数组的长度
例如data的形式如下,
假设我们此次传入的token要用到的是arr中的数据,那么parseArray()就要调用renderTemplate函数2次
data = {
arr: [
{name: 'cqy'},
{name: 'kyrie'}
]
}
*/
export default function parseArray(token, data) {
console.log(token, data);
//定义这个函数的结果字符串
var resultStr = ''

//得到整体数据中这个token要用到的数据
var v = lookup(data, token[1])
//console.log(v);

//这里是一个重点,它遍历的是数据,v数组保存的就是数据,v一定是个数组,根据数据(v)的长度来觉得要循环几次,而不是遍历token
//因为 {{#...}} {{/...}}之间的代码本来的需求就是根据数据的条数来循环创建的
for(let i=0; i<v.length; i++) {
//调用renderTemplate()函数,根据每一组数据来创建dom字符串,并拼接到parseArray()的结果字符串
//token[2]是要插入数据的当前项,v是包含了整个循环要用到的数据数组,v[i]就是当前项要用到的数据
resultStr += renderTemplate(token[2], v[i])
}
//返回结果parseArray()的字符串,
//拼接到renderTemplate()的结果字符串后面
return resultStr
}

控制台打印的结果:
来分析一下图中1,2,3,4的来源
最开始是在renderTemplate函数中if-else语句中的 token[0] == '#',由此进入到parseArray()函数,
在parseArray()中的此次for循环传入token[2]和v[i]来调用renderTemplate函数,renderTemplate函数将此条token结合数据转化成dom字符串返回,就是图中的1,并将结果返回给parseArray(),parseArray()中的resultStr接收到这个数据,然后parseArray()进入下一次循环
parseArray()来到下一次循环,将token[2]和新的v[i]传入来调renderTemplate函数,renderTemplate函数将此条token结合数据转化成dom字符串返回,就是图中的2,
循环次数正好到达数据的长度,即2组数据两次循环
parseArray()的整个for循环结束,resultStr将两次循环的dom字符串拼接在一起,就是图中的3,返回给最开始调用它的renderTemplate函数
renderTemplate函数接收到parseArray()返回数据,将他拼接到renderTemplate的结果字符串中,最后返回全部转化好的dom字符串,就是图中的4

解决{{.}}的问题

先使用只有点符号的数据和模板来探究问题

1
2
3
4
5
6
7
8
9
10
11
12
let templateStr = `
<ul>
{{#name}}
<li>
{{.}}
</li>
{{/name}}
</ul>
`
let data = {
name: ['kyrie', 'cqy', 'kd']
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default function parseArray(token, data) {
console.log(token, data);

var resultStr = ''

var v = lookup(data, token[1])
console.log('我是token要用到的整体数据'+v);

for(let i=0; i<v.length; i++) {
//token[2]是要插入数据的当前项,v[i]就是当前项要用到的数据
console.log(token[2],'我是当前项要用到的数据'+v[i]);

resultStr += renderTemplate(token[2], v[i])
}
console.log(resultStr);
return resultStr
}

由控制台可以看出,v得到的是循环要用到的所用数据的一个数组,而v[i]就是循环要使用的当前数据

在每个循环中调用renderTemplate函数来将token转化成dom字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export default function renderTemplate(tokens, data) {

//结果字符串
var resultStr = ''

for(let i=0; i<tokens.length; i++) {
// ....

else if(token[0] == 'name') {

//resultStr += data[token[1]]
resultStr += lookup(data, token[1])

}

// ...
return resultStr
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default function lookup(dataObj, keyName) {
//console.log(dataObj, keyName);
//判断keyName中有没有点语法,但本身不能是'.',
if(keyName.indexOf('.') != -1 && keyName != '.') {
//将点符号的各个属性分开
var keys = keyName.split('.')
//console.log(keys);
var temp = dataObj
for(let i=0; i<keys.length; i++) {
//依次追加属性
temp = temp[keys[i]]
}
return temp
}
//如果没有点符号,直接返回
return dataObj[keyName]
}

用第一次循环来举例,renderTemplate()中来到token[0] == 'name'的函数体中,将kyrie(data),’.’(token[1])传给lookup函数,因为lookup函数无法解析自身是’.’的数据,所以返回undefined

我们可以在传值的时候,将’.’作为键名,将当前数据作为键值赋值给键名来解决问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default function parseArray(token, data) {

// ...

for(let i=0; i<v.length; i++) {
//token[2]是要插入数据的当前项,v[i]就是当前项要用到的数据
console.log(token[2],'我是当前项要用到的数据'+v[i]);
//这里要添加一个'.'的属性
resultStr += renderTemplate(token[2], {
//让 . 作为键名,将当前数据作为他的键值
//拿第一次循环举例就是 '.': 'kyire'
'.': v[i]
})
}
console.log(resultStr);
return resultStr
}

这里解决了{{.}}的问题,但是复杂数组就出问题了,因为我们原本是使用v[i]传值的,现在复杂数组就查找不到这个’.’,

所以我们可以在传入的对象中将v[i]解构之后传入,相当于就是在传入v[i]的基础上又增加一个’.’属性,只不过这个’.’属性指向的是他自己

1
2
3
4
5
6
resultStr += renderTemplate(token[2], {
//让 . 作为键名,将当前数据作为他的键值
//拿第一次循环举例就是 '.': 'kyire'
...v[i],
'.': v[i]
})

用回复杂的数据来测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let templateStr = `
<ul>
{{#arr}}
<li>
<div>{{name}}的爱好</div>
{{#hobbies}}
<ol>
{{.}}
</ol>
{{/hobbies}}
</li>
{{/arr}}
</ul>
`
let data = {
arr: [
{name: 'cqy', hobbies: ['篮球','敲代码']},
{name: 'kyrie', hobbies: ['篮球','高尔夫']}
]
}

可以得到正确的结果

将dom字符串渲染到浏览器

在上面我们已经完成了从模板字符串到tokens的解析,以及tokens到dom字符串的转化,最后就是将他渲染到浏览器中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import parseTemplateToTokens from './parseTemplateToTokens'
import renderTemplate from './renderTemplate';

window.TemplateEngine = {
render(templateStr,data) {
//调用parseTemplateToTokens函数,让模板字符串变成tokens数组
var tokens = parseTemplateToTokens(templateStr)
//调用renderTemplate函数,让tokens变成dom字符串
var domStr = renderTemplate(tokens, data)
//console.log(tokens);

// 返回dom字符串
return domStr
}
}
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
<body>
<div id="container"></div>
<script src="/xuni/bundle.js"></script>

<script>
let templateStr = `
<ul>
{{#arr}}
<li>
<div>{{name}}的爱好</div>
{{#hobbies}}
<ol>
{{.}}
</ol>
{{/hobbies}}
</li>
{{/arr}}
</ul>
`
let data = {
arr: [
{name: 'cqy', hobbies: ['篮球','敲代码']},
{name: 'kyrie', hobbies: ['篮球','高尔夫']}
]
}

var domStr = TemplateEngine.render(templateStr, data)
console.log(domStr);

let container = document.getElementById('container')
container.innerHTML = domStr
</script>
</body>

最后

实现了mustache模板引擎的核心内容,下面是各部分完整的代码

Scanner扫描器类

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
/*
扫描器类
*/

export default class Scanner {
constructor(templateStr) {
console.log(templateStr);
this.templateStr = templateStr
//指针
this.pos = 0
//尾巴字符串
//指针指到的当前字符的后面的字符串(包括指针)被称为尾巴字符串
//尾巴字符串的精妙之处在于,我们只要判断尾巴字符串的开头字符是不是指定内容(双大括号),
//就可以决定指针是继续往后走还是跳过
this.tail = templateStr
}
//模板字符串 'my name is {{myName}}, age is {{age}}'要转化为tokens,
//mustache内部定义了两个方法来转化 scan() 和 scanUntil()

// scan: 匹配到指定内容(双大括号),并跳过他,没有返回值
scan(tag) {
if(this.tail.indexOf(tag) == 0) {
//只要让指针向后移 tag的长度 就可以实现跳过指定内容的功能
this.pos += tag.length
this.tail = this.templateStr.substring(this.pos)
}
}
//scanUntil: 指针从模板字符串的开头开始扫描,
//直到遇到指定内容(双大括号)结束,返回结束之前路过的字符串
scanUntil(stopTag) {
//记录本方法执行前pos指针的位置
const pos_start = this.pos
//当尾巴字符串的开头不是指定目标时循环进行
// &&后面的判断语句很有必要,防止因为找不到指定目标而进入死循环
while(this.tail.indexOf(stopTag) != 0 && !this.eos()) {
//指针后移
this.pos++
//更新尾巴字符串为当前指针后面(包含指针)的字符串
this.tail = this.templateStr.substring(this.pos)

}
//返回从本方法执行前位置到结束指针位置的字符串,
//substring默认前闭后开,this.pos正好指向指定内容位置,不会被包含进去
return this.templateStr.substring(pos_start, this.pos)
}

//eos:end of string 判断指针是否到模板字符串最后
//返回一个布尔值 到最后了返回true
eos() {
return this.pos >= this.templateStr.length
}
}

nestTokens折叠嵌套tokens

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
/*
nestTokens函数的作用是折叠tokens,将'#' 和 '/'之间的内容整合在一起,作为数组的第三项
*/
export default function nestTokens(tokens) {
//折叠好的tokens
var nestedTokens = []

//console.log(tokens);
//栈
//将要嵌套的内容存入栈
var sections = []
//收集器,天生指向nestedTokens,引用类型值,所以collector和nestedToken指向同一值
var collector = nestedTokens

for(let i=0 ; i < tokens.length; i++) {
let token = tokens[i]

switch(token[0]) {
case '#':
//在收集器中放入这个token
collector.push(token)
//入栈
sections.push(token)
//改变收集器指向,给token添加下标为2的项,让收集器指向它
token[2] = []
collector = token[2]
break;
case '/':
//出栈
let section = sections.pop()
//判断栈是否为空,若为空 收集器指向nestedTokens ,若不为空 收集器指向栈顶那项下标为2的项
collector = sections.length > 0 ? sections[sections.length - 1][2] : nestedTokens
break;
default :
collector.push(token)
}

}
return nestedTokens
}

parseTemplateToTokens将模板字符串解析成tokens

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
import Scanner from './Scanner'
import nestTokens from './nestTokens'
/*
将模板字符串变为tokens数组
*/
export default function parseTemplateToTokens(templateStr) {

var tokens = []

//创建一个扫描器类
var scanner = new Scanner(templateStr)
var words = ''
while(!scanner.eos()) {
// 收集开始标记出现之前的文字
words = scanner.scanUntil('{{')

if(words != '') {
//存入tokens数组
tokens.push(['text', words])
}
//跳过指定内容
scanner.scan('{{')

// 收集结束标记出现之前的文字
words = scanner.scanUntil('}}')

if(words != '') {
//此处的words是{{}}中的内容,判断words的首字母
if(words[0] == '#') {
//将 # 和 words中#之后的内容 分开存储
tokens.push(['#', words.substring(1)])
} else if(words[0] == '/') {
tokens.push(['/', words.substring(1)])
} else {
tokens.push(['name', words])
}
}
//跳过指定内容
scanner.scan('}}')

}

return nestTokens(tokens)
}

renderTEmplate将tokens转化成dom字符串

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
import lookup from './lookup'
import parseArray from './parseArray';
/*
函数的作用是将tokens变成dom字符串
*/

export default function renderTemplate(tokens, data) {

//结果字符串
var resultStr = ''

for(let i=0; i<tokens.length; i++) {
let token = tokens[i]
if(token[0] == 'text') {

resultStr += token[1]

} else if(token[0] == 'name') {

//resultStr += data[token[1]]
resultStr += lookup(data, token[1])

} else if(token[0] == '#') { // '#'标记的tokens,要递归处理它的下标为2的数组

resultStr += parseArray(token, data)

}
}
return resultStr
}

lookup解决连续点语法问题

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
/*
函数的功能是在dataObj对象中,找出用连续点符号的keyName的属性
比如:dataObj是
{
a: {
b: {
c: 100
}
}
}
那么lookup(dataObj, 'a.b.c')的结果就是100
*/
export default function lookup(dataObj, keyName) {
//console.log(dataObj, keyName);
//判断keyName中有没有点语法,但本身不能是'.',
if(keyName.indexOf('.') != -1 && keyName != '.') {
//将点符号的各个属性分开
var keys = keyName.split('.')
//console.log(keys);
var temp = dataObj
for(let i=0; i<keys.length; i++) {
//依次追加属性
temp = temp[keys[i]]
}
return temp
}
//如果没有点符号,直接返回
return dataObj[keyName]
}

parseArray结合renderTemplate实现tokens转变成dom字符串

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
import lookup from "./lookup";
import renderTemplate from "./renderTemplate";
/*
处理数组,结合renderTemplate函数实现递归
注意这个函数接收的是一个token,不是tokens

这个函数要递归调用renderTemplate函数,调用的次数由data数据中这部分要使用的数组的长度
例如data的形式如下,
假设我们此次传入的token要用到的是arr中的数据,那么parseArray()就要调用renderTemplate函数2次
data = {
arr: [
{name: 'cqy'},
{name: 'kyrie'}
]
}
*/
export default function parseArray(token, data) {
//console.log(token, data);

var resultStr = ''

//得到整体数据中,这个token要用到的数据
var v = lookup(data, token[1])
//console.log('我是token要用到的整体数据'+v);

//这里是一个重点,它遍历的是数据,根据数据的长度来觉得要循环几次,而不是遍历token
//因为 {{#...}} {{/...}}之间的代码本来的需求就是根据数据的条数来循环创建的
for(let i=0; i<v.length; i++) {
//token[2]是要插入数据的当前项,v[i]就是当前项要用到的数据
//console.log(token[2],'我是当前项要用到的数据'+v[i]);
//这里要添加一个'.'的属性
resultStr += renderTemplate(token[2], {
...v[i],
'.': v[i]
})
}
//console.log(resultStr);
return resultStr
}

Vue2抽象语法树

AST抽象语法树

抽象语法树(Abstract Syntax Tree)
在之前我们就知道,将模板语法变成正常的HTML时,会使用h函数来转化为虚拟dom,然后进行diff算法。
例如一个简单的h函数调用

1
h('a', {props: {href: 'www.kyriecqy.github.io'}}, 'cqy')

那这些数据肯定不是我们手动写出来的,所以我们就需要用抽象语法树将正常的HTML模板语法转化成能传入到h函数中的对象。

抽象语法树与虚拟DOM的关系

h函数相当于是抽象语法树的产物,又是虚拟DOM的起点

AST实现原理

在Vue中,我们的模板语法都写在<template></template>标签中。其实在Vue的底层是用字符串的角度来看编写在template中的内容,而不是通过html的角度。

AST实现理论过程

将html模板的字符串传给一个函数,这个函数中主要使用两个栈来实现AST
栈1用来存储开始标签,栈2用来存储整个标签的具体数据

我们用如下的html模板字符串来模拟

1
2
3
4
5
6
<div>
<p>Hello</p>
<ul>
<li>A</li>
</ul>
</div>

有一个指针会从开头开始扫描整个字符串。当指针识别到开始标记div将它压入栈1,并将div具体数据({'tag': 'div', 'children': []})压入栈2。

指针接着向下扫描,这个时候会扫描到p的开始标签与div开始标签之间的换行和空格,但是会被忽略。(在接下来的分析中会忽略这一步)

指针接着识别的开始标记p将它压入栈1,并将p具体数据({'tag': 'p', 'children': []})压入栈2。

指针接着向下识别,一直到p标签的结束标签为止,这就收集到了文本Hello。如果遇到的是文本,那么就将文本添加到栈2处于栈顶的元素(在此处是p)的children中,如下图所示(栈2中标签的具体数据省略没写)

指针接着向下识别到结束标签p,这个时候会将栈1中栈顶元素弹栈,栈2中的栈顶元素先出栈,然后添加到栈顶元素(在此处是div)的children中 (因为当识别到结束标签,说明这个标签的整个内容都识别完毕了,就要将他添加到前一项的children中)

指针接着识别到开始标签ul,将他压入栈1,他的具体数据({'tag': 'ul', 'children': []})压入栈2。

指针接着识别到开始标签li,将他压入栈1,他的具体数据压入栈2。

指针接着向下识别,一直到li标签的结束标签为止,这就收集到了文本A。因为是文本,所以只将它添加到栈2处于栈顶元素(在此处是li)的children中

指针接着向下识别到结束标签li,这个时候会将栈1中栈顶元素弹栈,栈2中的栈顶元素先出栈,然后添加到栈顶元素(在此处是ul)的children中

指针接着识别到结束标签ul,这个时候会将栈1中栈顶元素弹栈,栈2中的栈顶元素先出栈,然后添加到栈顶元素(在此处是div)的children中

最后指针识别到结束标签div,,这个时候会将栈1中栈顶元素弹栈,这个时候栈2中只要一项(字符串全部识别完成),所以不弹栈,用于返回最后这个结果
可以发现,栈2中最后剩下的一项就形成了一个AST,这就是整个原理过程。

识别开始结束标签

为了将字符串模式的语法解析为AST,我们首先需要识别开始和结束标签
这个还是比较简单,我们只要用正则表达式识别就可以。
我们创建parse函数作为解析成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
export default function parse(templateStr) {
//定义指针
var index = 0
//剩余字符串
var rest = ''
//开始标记
var startRegExp = /^\<([a-z]+[1-9]?)\>/
//结束标记
var endRegExp = /^\<\/([a-z]+[1-9]?)\>/
//定义两个栈
var stack1 = []
var stack2 = []

//扫描传入的整个字符串
while(index < templateStr.length - 1) {
//设置rest为未被扫描的部分
rest = templateStr.substring(index)
//识别到开始标签
if(startRegExp.test(rest)) {
//获得标签名
let tag = rest.match(startRegExp)[1]
console.log('开始标记:',tag);

//把开始标记推入栈1
stack1.push(tag)
//将空数组推入栈2
stack2.push([])

//指针向后走标签名的长度加 '<', '>' 的距离
index += tag.length + 2

console.log(stack1);

} else if(endRegExp.test(rest)){ //识别到结束标签
let tag = rest.match(endRegExp)[1]
console.log('结束标记:',tag);

//检测到匹配的结束标签,将栈1中处于栈顶(最后进栈的标签)的标签出栈
if(tag == stack1[stack1.length - 1]) {

stack1.pop()
console.log(stack1);

}else {
throw new Error('标签没有闭合')
}
//指针向后走标签名的长度加 '<', '>', '\' 的距离
index += tag.length + 3

} else { //识别到其他内容,暂时不写
index++
}
}
}

接下来我们用一个例子来检验一下parse函数的功能

1
2
3
4
5
6
7
8
9
10
11
import parse from "./parse"

let templateStr = `<div>
<p>Hello</p>
<ul>
<li>A</li>
<li>B</li>
</ul>
</div>`

const ast = parse(templateStr)

识别标签之间文字

识别标签之间的文字的关键在于正则表达式的书写,我们需要识别结束标签之前的文字,但又不可以将开始标签包括进去

1
2
3
4
//文字标记
var wordRegExp = /^([^\<]+)\<\/([a-z]+[1-9]?)\>/
// \<\/([a-z]+[1-9]?)\> 这一块用于识别到结束标签
// ^([^\<]+) 识别不包含开始标签的文字

具体的代码:

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
export default function parse(templateStr) {
var index = 0
var rest = ''
//开始标记
var startRegExp = /^\<([a-z]+[1-9]?)\>/
//结束标记
var endRegExp = /^\<\/([a-z]+[1-9]?)\>/
//文字标记
var wordRegExp = /^([^\<]+)\<\/([a-z]+[1-9]?)\>/

//定义两个栈
var stack1 = []
var stack2 = []

while(index < templateStr.length - 1) {
rest = templateStr.substring(index)
if(startRegExp.test(rest)) {
// ....
} else if(endRegExp.test(rest)){
// ....
} else if(wordRegExp.test(rest)) { //识别到文字

let word = rest.match(wordRegExp)[1]
//文字不能全为空,因为html字符串中会有许多的换行空格也会被识别成文字
if(!/^\s+$/.test(word)) {
console.log('检测到文字',word);
}

index += word.length
} else {
index++
}
}
}

使用栈初步实现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
export default function parse(templateStr) {
var index = 0
var rest = ''
//开始标记
var startRegExp = /^\<([a-z]+[1-9]?)\>/
//结束标记
var endRegExp = /^\<\/([a-z]+[1-9]?)\>/
//文字标记
var wordRegExp = /^([^\<]+)\<\/([a-z]+[1-9]?)\>/

//定义两个栈
var stack1 = []
var stack2 = [{'children': []}]

while(index < templateStr.length - 1) {
rest = templateStr.substring(index)
if(startRegExp.test(rest)) {
let tag = rest.match(startRegExp)[1]
//把开始标记推入栈1
stack1.push(tag)
//将标签具体数据推入栈2
stack2.push({'tag': tag, 'children': []})

index += tag.length + 2

} else if(endRegExp.test(rest)){
let tag = rest.match(endRegExp)[1]
//检测到匹配的结束标签,将栈1中处于栈顶(最后进栈的标签)的标签出栈
if(tag == stack1[stack1.length - 1]) {

stack1.pop()

let pop_arr = stack2.pop()

if(stack2.length != 0) {
//将它添加到它栈顶.length - 1项的children中
stack2[stack2.length -1].children.push(pop_arr)
}

}else {
throw new Error('标签没有闭合')
}

index += tag.length + 3
} else if(wordRegExp.test(rest)) { //识别到文字

let word = rest.match(wordRegExp)[1]
//文字不能全为空,因为html字符串中会有许多的换行空格也会被识别成文字
if(!/^\s+$/.test(word)) {
//将文本包装一下之后添加到栈顶元素的children中
stack2[stack2.length -1].children.push({'text': word, type: 3})
}

index += word.length
} else {
index++
}
}

return stack2[0].children[0]
}

得到的结果:

识别开始标签中的属性

有些开始标签中会有一些属性,例如class,id等,我们也需要识别出来,并将他以键值对的形式存储在attrs数组中,如下图划线部分的样子

那么为了能识别到这些属性,我们需要对开始标签的正则做一些修改

1
2
3
4
5
6
7
//开始标记
var startRegExp = /^\<([a-z]+[1-9]?)(\s[^\<]+)?\>/

// (\s[^\<]+)? 这部分用于识别属性,
//每个属性都用空格与前一个属性或标签分隔开,所以 \s 来捕获空格
// [^\<]+ 用来捕获属性,
// 最后的 ? 表示整个(\s[^\<]+)可以有也可以没有,因为不是每个标签都会有属性

然后我们需要对识别开始标签的代码块做一些修改,我们会使用一个独立的函数来对识别到的属性进行处理,最后将attrs加入

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
export default function parse(templateStr) {
var index = 0
var rest = ''
//开始标记
var startRegExp = /^\<([a-z]+[1-9]?)(\s[^\<]+)?\>/
//结束标记
var endRegExp = /^\<\/([a-z]+[1-9]?)\>/
//文字标记
var wordRegExp = /^([^\<]+)\<\/([a-z]+[1-9]?)\>/

//定义两个栈
var stack1 = []
var stack2 = [{'children': []}]

while(index < templateStr.length - 1) {
rest = templateStr.substring(index)
if(startRegExp.test(rest)) {
let tag = rest.match(startRegExp)[1]
//这个时候的attrsStr不一定有内容,因为不是每个标签都有属性,
//会在parseAttrsStr函数中进行判断
let attrsStr = rest.match(startRegExp)[2]

//解析开始标签上的属性
let attrs = parseAttrsStr(attrsStr)
//console.log('attrs:',attrs);

//把开始标记推入栈1
stack1.push(tag)
//将空数组推入栈2
stack2.push({'tag': tag, 'children': [],'attrs': attrs})
console.log('开始标记:',tag);

// 如果标签有属性,那么指针后移的时候要跳过这些属性的长度
const attrsLength = attrsStr != undefined ? attrsStr.length : 0
index += tag.length + 2 + attrsLength

} else if(endRegExp.test(rest)){
// ...
} else if(wordRegExp.test(rest)) { //识别到文字
// ...
} else {
// ...
}
}

return stack2[0].children[0]
}

parseAttrsStr函数来处理识别到的属性

这里有一个点需要注意,当我们得到识别到的属性如下:我们会很轻易的想到用split()方法按照空格分割这个字符串来得到完整的class属性和id属性。

1
' class="aa bb cc" id="p"'

但其实是不可以这么干的,因为css支持在class中使用"aa bb cc"的写法,如果简单的使用split()会将类中的空格也当作要分割的,但其实"aa bb cc"作为一个整体是class的值

为了解决这个问题,我们可以用 一个变量来判断是否在属性内部 和 一个变量来保存断点 来完成
以上面的例子来理解这个算法的原理:

我们要先设置一个判断变量为false、断点为0,然后遍历整个字符串,当遇到 双引号 时这个变量取反

我们从字符串的最前面开始遍历,往下走,当遇到了一个 双引号 ,就将这个变量取反,当这个变量为true时,表示进入到了属性内部,这个时候我们要忽视空格

指针一直向下走,当又遇到一个 双引号 时,将这个变量取反,当变量为false时,表示不是在属性内部。

指针接着下移,当遇到一个空格,且这个时候判断变量为false(表示不在属性内)。那么这个时候就会收集断点到当前指针之前的所有内容,在这里就是class="aa bb cc"
然后将断点设置为当前指针的位置

然后就会以如上的方法识别属性并添加,当来到最后一项属性时,由于字符串的最后没有空格,就无法将最后一项进行收集(因为收集的前提是:判断变量是false且遇到空格)。这个时候我们需要手动收集

还有一点就是,传入的字符串的第一个字符肯定是空格,因为标签与第一个属性之间有空格,且在这个空格处判断变量正好是false,所以会将这一空格识别为一项属性,要手动忽略

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
export default function parseAttrsStr(attrsStr) {
//如果没有属性就不进行处理
if(attrsStr == undefined) return []
//要遍历attrsStr,而不是直接用split按照空格分割,
//因为css中允许 class="aa bb cc"这样的存在

//用来判断是否处于引号内
let isIn = false
//断点
let point = 0
//将attrsStr按空格分割之后的数组
let resultArr = []

//对resultArr进行处理转化成的结果数组
let attrs = []

for(let i = 0; i < attrsStr.length; i++) {
const char = attrsStr[i]
if (char == '"') {
isIn = !isIn
} else if(char == ' ' && !isIn) {
//标签与第一个属性之间的空格也会别当作一项属性,要判断一下
if(!/^\s*$/.test(attrsStr.substring(point, i))) {
//将断点到当前指针之间的内容添加到数组中
resultArr.push(attrsStr.substring(point, i).trim())
point = i
}
}
}

//手动添加最后一项属性
resultArr.push(attrsStr.substring(point).trim())

//将得到的数组转化成AST中attrs属性需要的格式,
for(let i = 0; i < resultArr.length; i++) {
let attrName = resultArr[i].split('=')[0]
let attrValue = resultArr[i].split('=')[1]
//去掉属性值带着的引号
attrValue = attrValue.replace(/^"(.+)"$/, '$1')


attrs.push({
'name': attrName,
'value': attrValue
})
}

//返回转化好的对象
return attrs
}

得到的转化好的对象结果:

当我们在主函数parse中调用此函数识别属性之后就可以得到完整的AST

使用的例子

1
2
3
4
5
6
7
8
9
10
11
let templateStr = `<div>
<p class="aa bb cc" id="p">Hello</p>
<ul>
<li>A</li>
<li>B</li>
</ul>
</div>`

const ast = parse(templateStr)

console.log(ast);

得到的AST:

最后

入口

1
2
3
4
5
6
7
8
9
10
11
12
13
import parse from "./parse"

let templateStr = `<div>
<p class="aa bb cc" id="p">Hello</p>
<ul>
<li>A</li>
<li>B</li>
</ul>
</div>`

const ast = parse(templateStr)

console.log(ast);

parse函数

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
import parseAttrsStr from "./parseAttrsStr"

export default function parse(templateStr) {
var index = 0
var rest = ''
//开始标记
var startRegExp = /^\<([a-z]+[1-9]?)(\s[^\<]+)?\>/
//结束标记
var endRegExp = /^\<\/([a-z]+[1-9]?)\>/
//文字标记
var wordRegExp = /^([^\<]+)\<\/([a-z]+[1-9]?)\>/

//定义两个栈
var stack1 = []
var stack2 = [{'children': []}]

while(index < templateStr.length - 1) {
rest = templateStr.substring(index)
if(startRegExp.test(rest)) {
let tag = rest.match(startRegExp)[1]
let attrsStr = rest.match(startRegExp)[2]
//解析开始标签上的属性
let attrs = parseAttrsStr(attrsStr)
//把开始标记推入栈1
stack1.push(tag)
//将空数组推入栈2
stack2.push({'tag': tag, 'children': [],'attrs': attrs})
console.log('开始标记:',tag);



const attrsLength = attrsStr != undefined ? attrsStr.length : 0
index += tag.length + 2 + attrsLength

//console.log('栈1:',stack1);
//console.log('栈2:',JSON.stringify(stack2) );
} else if(endRegExp.test(rest)){
let tag = rest.match(endRegExp)[1]
console.log('结束标记:',tag);
//检测到匹配的结束标签,将栈1中处于栈顶(最后进栈的标签)的标签出栈
if(tag == stack1[stack1.length - 1]) {


stack1.pop()
let pop_arr = stack2.pop()

if(stack2.length != 0) {
stack2[stack2.length -1].children.push(pop_arr)
}
//console.log('栈1:',stack1);
//console.log('栈2:',JSON.stringify(stack2));

}else {
throw new Error('标签没有闭合')
}

index += tag.length + 3
} else if(wordRegExp.test(rest)) { //识别到文字

let word = rest.match(wordRegExp)[1]
//文字不能全为空,因为html字符串中会有许多的换行空格也会被识别成文字
if(!/^\s+$/.test(word)) {
console.log('检测到文字',word);
stack2[stack2.length -1].children.push({'text': word, type: 3})
//console.log('栈1:',stack1);
//console.log('栈2:',JSON.stringify(stack2));
}

index += word.length
} else {
index++
}
}

return stack2[0].children[0]
}

parseAttrsStr函数

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
export default function parseAttrsStr(attrsStr) {
if(attrsStr == undefined) return []
console.log(attrsStr);
//要遍历attrsStr,而不是直接用split按照空格分割,因为css中允许 class="aa bb cc"这样的存在

//用来判断是否处于引号内
let isIn = false
//断点
let point = 0
//将attrsStr按空格分割之后的数组
let resultArr = []

//对resultArr进行处理转化成的结果数组
let attrs = []

for(let i = 0; i < attrsStr.length; i++) {
const char = attrsStr[i]
if (char == '"') {
isIn = !isIn
} else if(char == ' ' && !isIn) {
if(!/^\s*$/.test(attrsStr.substring(point, i))) {
resultArr.push(attrsStr.substring(point, i).trim())
point = i
}
}
}
resultArr.push(attrsStr.substring(point).trim())

for(let i = 0; i < resultArr.length; i++) {
let attrName = resultArr[i].split('=')[0]
let attrValue = resultArr[i].split('=')[1]

attrValue = attrValue.replace(/^"(.+)"$/, '$1')


attrs.push({
'name': attrName,
'value': attrValue
})
}


return attrs
}