导读
一个功能丰富、可用性高的框架,其源码一定包含了很多边界情况的处理,其核心逻辑占比反而很少。初学者如果直接读源码,很容易迷失在繁琐的特殊情况处理中,很难真正掌握框架的核心功能实现原理。
这里我将 vue 最核心的功能拆分出来,一步步拆解其实现方式,实现一个从模板编译到数据响应式的简易框架,探讨 vue 的核心原理 ~
点击进入 🔗 jsbin.com/niyapom , 先体验一下简易 vue 的功能。
简易框架与 vue 保持一致的语法。下述代码引入 myVue 脚本,创建 vue 实例,支持template,data,methods 等选项,主要功能有模板编译、数据响应式、事件绑定等。
const vm = new myVue({
data() {
return {
num1: 1,
num2: 2,
}
},
template: `<div class="container" style="margin: 10px;">
<div class="btns">
<span>num1: {{num1}}</span>
<button @click="addNum1">add</button>
<button @click="reduceNum1">reduce</button>
</div>
<div class="btns">
<span>num2: {{num2}}</span>
<button @click="addNum2">add</button>
<button @click="reduceNum2">reduce</button>
</div>
<div>num1 x num2 = {{num1*num2}}</div>
<div>num1 + num2 = {{num1+num2}}</div>
</div>`,
el: '#app',
methods: {
addNum1() {
this.num1 += 1
},
reduceNum1() {
this.num1 -= 1
},
addNum2() {
this.num2 += 1
},
reduceNum2() {
this.num2 -= 1
}
}
})
流程概述
了解了实现的功能范围后,我们大致看一下基本思路。其工作流程概述如下:
1、通过 template 选项获取模板字符串(形如: <ul><li>列表1</li><li>列表2</li></ul> ,即一个 String 形式的 HTML)
2、将模板字符串解析为 AST 树,建立虚拟 DOM
- 虚拟 DOM 的结构简化了,只保留 tagName 、children 和 attributes 属性,tagName 表示 DOM 的标签名,children 为子节点列表, attributes 表示 html 上属性。
- vue 源码流程为:html字符串 -> ast 树 -> render 方法 -> vnode(虚拟dom) -> 真实 dom。而这里只保留三个环节:html字符串 -> vnode -> 真实 dom。
3、根据虚拟 DOM 创建真实 DOM 树,挂载到指定的 HTML 节点上。
- 通过 document.createElement 、document.createTextNode 等 API 创建真实的 DOM 树
- 创建过程中,将 class、style 等属性添加到 DOM 节点中,并注册 @ 关键字对应的事件
4、将 data 渲染到模板中
- 识别 HTML 元素中的 mustache 语法
(即模板中的 {{xxx}}) - 将 mustache 语法替换为对应 data 的值:支持 mustache 中对象属性获取、表达式执行等;获取 data 中的值时,添加对应 key 的依赖
5、实现 data 响应式更新
-
在数据更新时,通知所有依赖方,更新对应的虚拟 DOM 节点的内容
下面重点解析五个步骤的实现方式~
Step 1:获取模板字符串
首先获取 模板字符串。模板字符串这里提供两种形式,一种是 type="x-template" 的 script 标签,一种是直接输入 字符串模式。这个环节比较简单,就是得到一个字符串形式的模板
<!-- 方式一:script 标签模式 -->
<!-- 在 html 中: -->
<script type="text/x-template" id="template">
<div class="container" style="margin: 10px;">
<div class="btns">
<span>num1: {{num1}}</span>
<button @click="addNum1">add</button>
<button @click="reduceNum1">reduce</button>
</div>
<div class="btns">
<span>num2: {{num2}}</span>
<button @click="addNum2">add</button>
<button @click="reduceNum2">reduce</button>
</div>
<div>num1 x num2 = {{num1*num2}}</div>
<div>num1 + num2 = {{num1+num2}}</div>
</div>
</script>
<script>
<!-- 获取 script 元素,获取其中文本内容 -->
const tmpl = document.querySelector(options.template).innerHTML
</script>
<!-- 方式二:纯字符串形式 -->
<script>
<!-- template 就是一个字符串,直接获取 -->
const tmpl = options.template
</script>
如下图所示,通过这一步,我们得到一个模板字符串。注意,这并不是 HTML,只是一些换了行的字符串。
Step 2:从模板字符串 到 虚拟 DOM
分析一波~
先从输入和输出的角度分析。先举一个简单的例子:
// 输入为
const tmpl = `<ul class="list"><li>列表1</li><li>列表2</li></ul>`
// 希望得到的输出为
const vnode = {
"tagName": "ul",
"children": [
{
"tagName": "li",
"children": [
"列表1"
],
"attributes": {}
},
{
"tagName": "li",
"children": [
"列表2"
],
"attributes": {}
}
],
"attributes": {
"class": "\"list\""
}
}
看起来从输入到输出还有一定的距离,那就先从简单的入手。
2.1 拆解模板结构
模板有三种结构:开始标签、结束标签、文本内容。
第一步要做的,就是将这三种结构拆分出来。
变量介绍:这里使用 word 这个全局变量,记录当前遍历到的不完整的结构。使用 stack 变量来记录所有目前得到的标签。
思路介绍:
- 在一个格式正确的模板中,当遇到一个 '<' 符号,那意味着一个开始标签或结束标签出现了,换句话说,文本内容结束了,这时应该将已有内容压入栈中,word 清空并初始化为最新的字符 '<'。[9-16行]
- 遇到一个 '>' 符号,说明一个标签结束了,那么将 word += '>' 后,推入栈中,并清空 word。[3-8行]
- 此外其他符号,都正常叠加在 word 上即可。
for(let i = 0; i < tmpl.length; i += 1) {
const char = tmpl[i]
if (char === '>') {
word += char
word = word.trim()
stack.push(word)
word = ''
// 那就结束一个标签啦
} else if (char === '<') {
// 忽略空内容
if(word.trim()) {
stack.push(word.trim())
}
// 把之前的内容压入栈中
word = char
} else {
word += char
}
}
做完这一步,就得到了开始标签、内容、结束标签的列表。
2.2 解决父子关系
下一步,就是思考:在遍历的过程中,是否能解决父子关系? 这里的解法是,每出现一个结束标签,只要往前找,找到名字相同的标签,那它们就可以构成一个节点。
*注意,这里只考虑有开始标签和闭合标签的情况,不考虑单标签(如 )。
思路如下:
- 解析得到标签名 tagName。
- tagName 如果以 '/' 开头,那是一个结束标签,否则是一个开始标签
- 对于开始标签,正常 push 进入 stack 即可
- 对于结束标签,只需要在 stack 中,挨个 pop 出来,比较名字是否相同。
- 如果不同,说明是该 node 的「子节点」,只需要将其推入 children 属性中即可
- 如果相同,说明是对应的「开始标签」,那么这一个 node 的构建就完成了。完成一个 node 后,需要推回栈中,因为它需要作为 children 被挂载在外层节点。
const tagName = word.slice(1, -1).split(' ')[0]
if(tagName[0] === '/') {
// 如果是个结束标签
const endTag = tagName.slice(1)
const node = {
tagName: endTag,
children: [],
}
while(stack.length) {
const tag = stack.pop()
if(typeof tag === 'string' && tag.slice(1, -1).split(' ')[0].trim() === endTag.trim()) {
// 遇到开始标签,完成
break
} else {
// 注意标签的顺序
node.children.unshift(tag)
}
}
stack.push(node)
} else {
stack.push(word)
}
经过这一步之后,就得到了具有父子关系的 vnode 结构。如下:
2.3 解析开始标签中的属性
既然 attributes 存在于开始标签中,那就在遇到开始标签时进行解析操作,即上述代码 [12行] 之前插入解析函数,返回解析结果。
解析函数传入的参数为 node 节点和 开始标签原始文本 rawTag。
例如:根据 rawTag 内容进行属性解析,结果存放在 node.attributes 中。以 rawTag = '<button @click="reduceNum2">' 为例,得到的结果应为 attributes: {@click: '"addNum1"'}。
思路如下:
- 由于 attributes 的值可能存在空格,比如 v-for="item in items",所以不能使用空格进行切割
- 遍历整个字符串,记录双引号出现的状态。若出现空格,但引号处于未闭合状态,忽略这个空格;若引号闭合,则为一个 独立的属性。
function parseAttributes(node, rawTag) {
const tag = rawTag.slice(1, -1)
// 不能使用空格作为分割符,因为需要忽略双引号内的空格 quetos
// 错误写法:const attributes = tag.slice(1, -1).split(' ').slice(1).filter((attr) => attr.trim())
let inQuotes = false // 是否出现一个未闭合的引号
let attr = ''
const attributes = []
for(const char of tag) {
if(char === ' ') {
// 出现空白
if(inQuotes) {
// 刚刚出现一个没匹配的引号,那不管这个空格
attr += char
} else {
// 过滤掉空的 attr
attr.trim() && attributes.push(attr.trim())
attr = '' // 清空
}
} else {
attr += char
if(char === '"') { // 遇到 " 符号,切换 inQuotes 状态
inQuotes = !inQuotes
}
}
}
// 存入最后一个结果
attr.trim() && attributes.push(attr.trim())
// 将 attributes 列表存入 node.attributes 属性中
for(const attr of attributes) {
if(attr.indexOf('=') === -1) continue
const key = attr.split('=')[0]
const val = attr.split('=')[1]
node.attributes[key] = val
}
return node
}
attributes 处理结果如下:
2.4 小结
总结下模板字符串解析为虚拟 DOM 的三个步骤:
- 先拆分标签和文本内容。
- 再细化标签,区分是开始标签还是结束标签。
- 开始标签直接推入栈中
- 结束标签就往前找,一直找到同名的开始标签,完成了节点的构建推回栈中
- 处理开始标签中的属性,添加到虚拟 DOM 的 attributes 属性中
完整代码如下:
function genVnode(tmpl) {
const stack = []
let word = ''
for(let i = 0; i < tmpl.length; i += 1) {
const char = tmpl[i]
if (char === '>') {
word += char
word = word.trim()
if(word) {
// 如果 trim 后为空白,直接跳过
const tagName = word.slice(1, -1).split(' ')[0] // 还需要考虑有attr的情况
if(tagName[0] === '/') {
// 如果是个结束标签
const endTag = tagName.slice(1)
let node = {
tagName: endTag,
children: [],
attributes: {}
}
while(stack.length) {
const tag = stack.pop()
if(typeof tag === 'string' && tag.slice(1, -1).split(' ')[0].trim() === endTag.trim()) {
// 找到了对应的开始标签,解析 attributes
node = parseAttributes(node, tag)
// 完成
break
} else {
// 注意标签的顺序
node.children.unshift(tag)
}
}
if(getType(node) === 'Array') {
stack.push(...node)
} else {
stack.push(node)
}
} else {
stack.push(word)
}
}
word = ''
// 那就结束一个标签啦
} else if (char === '<') {
if(word.trim()) {
stack.push(word.trim())
}
// 把之前的内容压入栈中
word = char
} else {
word += char
}
}
return stack[0] // 因为返回的是一个列表。根据 vue 模板只有一个根节点的要求,返回第一个节点即可
}
function parseAttributes(node, rawTag) {
const tag = rawTag.slice(1, -1)
// 不能使用空格作为分割符,因为需要忽略双引号内的空格 quetos
// 错误写法:const attributes = tag.slice(1, -1).split(' ').slice(1).filter((attr) => attr.trim())
let inQuotes = false // 是否出现一个未闭合的引号
let attr = ''
const attributes = []
for(const char of tag) {
if(char === ' ') {
// 出现空白
if(inQuotes) {
// 刚刚出现一个没匹配的引号,那不管这个空格
attr += char
} else {
// 过滤掉空的 attr
attr.trim() && attributes.push(attr.trim())
attr = '' // 清空
}
} else {
attr += char
if(char === '"') { // 遇到 " 符号,切换 inQuotes 状态
inQuotes = !inQuotes
}
}
}
// 存入最后一个结果
attr.trim() && attributes.push(attr.trim())
// 将 attributes 列表存入 node.attributes 属性中
for(const attr of attributes) {
if(attr.indexOf('=') === -1) continue
const key = attr.split('=')[0]
const val = attr.split('=')[1]
node.attributes[key] = val
}
return node
}
Step 3:从虚拟 DOM 到真实 DOM
虚拟 DOM 树,其实就是多叉树。熟悉二叉树的朋友,应该很容易想到递归。我们只要解决一个层级的问题,剩下的交给递归来做就好啦。
由于创建节点还比较复杂,使用函数包装一下~
- 如果是标签节点,需要处理属性,这里处理了 class,style 和 @开头的事件监听,如下所示。
- 如果是文本节点,需要关注是否存在 mustache 语法,并进行替换和追踪。这里先不展开,将在 Step4-5中介绍。
function createOneNode (vnode) {
// 1、非文本节点
const tag = vnode.tagName
if(tag) {
const node = document.createElement(tag)
// 将 class 加上
if(vnode.attributes && vnode.attributes.class) {
node.classList.add(vnode.attributes.class.slice(1, -1))
}
// 将 style 加上
if(vnode.attributes && vnode.attributes.style) {
node.style.cssText += vnode.attributes.style.slice(1, -1)
}
// 将 事件监听 加上
if(vnode.attributes) {
for(const key in vnode.attributes) {
const val = vnode.attributes[key];
if(key[0] === '@') {
const handlerName = val.slice(1, -1)
node.addEventListener(key.slice(1), function() {
methods[handlerName].call(data)
})
}
}
}
return node
}
// 2、文本节点
const node = document.createTextNode(vnode)
return node
}
还有比较准确的,获取对象类型的函数:
function getType(target) {
return Object.prototype.toString.call(target).slice(8, -1)
}
下面进入正题。因为要使用递归,那么传进来的参数,可能是数组,也可能是对象、文本,需要区分。
- 传进来的 vnode 是数组,那么对数组的每一项,使用 createRealDom 创建节点,并推到 realDom 列表中
- 传进来的 vnode 是对象或文本,直接创建一个新节点。
- 如果 vnode 有 children,对 children 递归使用 createRealDom,将返回的结果,使用 DOM API : appendChild 挂载到刚刚创建的新节点上。
- 返回 realDom 结果
function createRealDom(vnode) {
// 可能是对象,也可能是数组
let realDom = undefined
if(getType(vnode) === 'Array') {
// 数组类型
realDom = []
for(const item of vnode) {
realDom.push(createRealDom(item))
}
} else {
// 对象或者文本类型
realDom = createOneNode(vnode)
if(vnode.children) {
// children 返回的是一个列表
const childlist = createRealDom(vnode.children)
// 返回孩子列表,并挂载到真实 dom 上
for(const child of childlist) {
realDom.appendChild(child)
}
}
}
return realDom
}
这一步,由「虚拟 DOM」得到「真实 DOM」的结果,如下:
Step 4:将 data 渲染到模板中
第 4-5 步,和前面的函数有一些关联,在其关键步骤上(createOneNode函数)加入了 mustache 语法的识别和替换,在获取数据时动态更新相关联的 DOM。
假设模板中存在 mustache 语法,比如 {{xxx}} ,按照 vue 的语法,就是将 xxx 替换为 data.xxx 的值。这里先实现,一段 html 模板字符串(不带标签的,都是 textnode 类型)传入后,识别 {{}} 双括号中的变量,并替换为 data 中相应的值。
- 正则匹配所有的 {{}} 类型,通过匹配结果得到数据表达式
- 通过 new Function(
with(data) { return ${key}})() 的执行结果,获取表达式执行结果。函数文本中的 with(data) 将数据锁定在 data 中,因此能获取对应的值。 - 通过字符串 replace 方法,将 匹配结果替换为数据对应 key 的 value
- 返回新结果
function matchMustache(w, realDom) {
// 匹配每一对 {{}},并将其中的字符作为 key,匹配并替换 data 中对应 key 的值
// /\{\{(.*)\}\}/g 这个不行!
let word = w
const exp = /\{\{(.+?)\}\}/g
const matchs = word.match(exp) ?? []
// 然后还是用正则的替换
for(const match of matchs) {
const key = match.slice(2,-2).trim()
// 使 mustache 支持表达式,点操作
function getVal(){
// 为了简化写法,这里的 data 是全局变量
let res = new Function(`with(data) { return ${key}}`)()
if(typeof res === 'object'){
res = JSON.stringify(res)
}
return res
}
const value = getVal()
word = word.replace(match, value)
}
return word
}
那么,mustache 语法需要在哪个环节进行转换呢? 这里插在了 createOneNode 方法中:因为在简易版本中,mustache 语法只会出现在纯文本节点中。
function createOneNode (vnode, collect = true) {
// 如果是文本节点,可能是空的
const tag = vnode.tagName
if(tag) return document.createElement(tag)
const node = document.createTextNode(vnode)
// 文本节点,可能涉及到 mustache 转换了
// 如果在这里转化 mustache 语法,会不会更好收集依赖
const replaced = matchMustache(vnode, data, node, collect)
node.nodeValue = replaced
return node
}
Step 5:实现 data 响应式更新
在 vue 中,需要响应式的场景,包括模板渲染、computed选项、watch选项、属性指令等等。为简化,这里先实现模板渲染的响应式更新。 要解决的问题是:
- 哪个数据被使用
- 谁使用了数据
- 怎么通知使用方更新
5.1 哪个数据被使用?
先解决 「哪个数据被使用」 的问题。这里使用 proxy 实现,语法很简单,跟 Object.defineProperty 类似,设置属性对应的 get 和 set 动作就可以了。在 get 和 set 函数中,可知当前访问的 property,也就解决了刚刚的问题。在 get 时收集依赖,set 时通知依赖,是第 2、3步需要解决的问题。
const handler = {
get(obj, property) {
track(property)
return obj[property]
},
set(obj, property, value) {
// 通知之前收集的所有依赖:更新一下相关的值啦!
obj[property] = value
notify(property)
return true
}
}
const data = new Proxy(rawData, handler)
由于 proxy 只支持第一层属性的代理,所以遇到属性值为对象时,需要递归的将数据转化为 proxy。
function reactiveData(rawData) {
// 递归地将数据转化为响应式
function deepProxy(data) {
const newData = new Proxy(data, handler)
for(const key in newData) {
const val = newData[key]
if(typeof val === 'object') {
newData[key] = deepProxy(newData[key])
}
}
return newData
}
return deepProxy(rawData)
}
5.2 谁使用了数据?
接着解决 「谁使用了数据」 的问题。这里只考虑模板编译的情况,在 mustache 匹配时会访问 data,而且此时也已知真实 DOM,所以这里将真实 DOM 作为数据使用方。 那么怎么将这个 DOM 节点同步给 proxy 中的 get 函数呢?由于 JS 是单线程的,所以可以断定,在 matchMustache 函数运行时,只在处理某一个 DOM 节点。没法给 get 函数传参,只能将这个 DOM 节点设置为全局变量,就可以在 get 中使用了。 流程如下:
- 将当前处理节点设置为全局变量 realDomTarget,和对应的原始内容 vnode 也设置为全局变量 wTarget [13-14行]
- 运行 matchMustache 函数,当文本节点存在 mustache 语法时,会访问 data 中对应的变量, proxy 中对应 property 的 get 函数被触发
- Get 函数通过 realDomTarget 获得当前处理的 DOM 节点,并将 realDomTarget 添加到 当前 property 的依赖中
- 全局变量 realDomTarget 、wTarget 恢复为初始值,否则会在其他访问数据的情境下被误用为依赖 [20-21行]
function createOneNode (vnode) {
// 如果是文本节点,可能没有 tag
const tag = vnode.tagName
if(tag) {
const node = document.createElement(tag)
return node
}
const node = document.createTextNode(vnode)
// 将当前处理节点设置为全局变量 realDomTarget
realDomTarget = node
wTarget = vnode
const replaced = matchMustache(vnode)
node.nodeValue = replaced
// 全局变量归位
realDomTarget = null
wTarget = ''
return node
}
按上述分析,数据中的一个 property 可能对应 多个 realDom,而一个 realDom 也可能使用了 多个 property,是多对多的关系。这里使用 Map 存下每个 property 对应的 realDom 列表,维护这样的关系。
// Dep 结构如下
{
property1: [ realDom1, realDom2],
property2: [realDom3, realDom1],
}
在 get 时运行 track 函数,为对应的 property 添加依赖。
function track(property) {
if(typeof property === 'string' && realDomTarget) {
if(!Dep[property]) {
Dep[property] = []
}
// 使用 map 减少重复操作
let target = domTargetMap.get(realDomTarget)
if(!target) {
target = { w: wTarget,realDom: realDomTarget }
domTargetMap.set(realDomTarget, target)
}
// 而且是没存储过的依赖
if(Dep[property].indexOf(target) === -1) {
Dep[property].push(target)
}
}
}
上述代码运行后,收集的依赖结果如下。可以看到鼠标移至 realDom 属性时,页面上对应的 DOM 节点也高亮了;使用 num1 数据的 DOM 节点和原始模板值,都被收集在以 num1 对应的数组中, num2 同理。
5.3 怎么通知使用方更新?
到这里为止,我们已经解决了前两个问题,还剩下 「怎么通知使用方更新」 这个问题了。当数据发生改动时,proxy 中的 set 函数被调用,更新 Dep 中对应 property 的真实 DOM 列表即可。
function notify(property) {
if(Dep[property]) {
const deps = Dep[property].slice()
// Dep[property] = []
// dom 和 data 是多对多的关系,更新一个,必须更新其他
for(const dep of deps) {
const realDom = dep.realDom
// 重新调用函数计算新的 DOM 节点值
const replaced = matchMustache(dep.w)
// 更新nodevalue
realDom.nodeValue = replaced
}
}
}
修改数据前:
修改数据后,数据响应式更新:
Step 6:Vue 实例初始化
最后,将前面几步串起来,就可以啦~
let data = undefined
let methods = undefined
const Dep = {} // 用于存放依赖的对象
const domTargetMap = new Map()
function myVue(options) {
// 转成全局变量只是为了便于获取,减少代码量
data = reactiveData(options.data())
methods = options.methods
this.data = data
// 获取模板,编译为虚拟 dom
const tmpl = options.template[0] === '#' ? document.querySelector(options.template).innerHTML : options.template
// 把html字符串转化虚拟dom
const vnode = genVnode(tmpl)
// 使用虚拟dom创建真实dom树
const fragment = document.createDocumentFragment() // fragment用来接收所有的结果,之后统一挂载到应用根节点上
const realDom = createRealDom(vnode)
fragment.appendChild(realDom)
// 将 fragment 挂载到 app 节点上
const app = document.querySelector(options.el || '#app')
app.appendChild(fragment)
}
后记
这部分代码书写,并没有完全参照源码,一是源码太复杂,没看完,没看懂 QAQ;二是本次主要想探索下前端框架原理,重在「可用」和「简洁」,所以实现了功能就没太纠结于 vue 源码了。后续会继续研读源码,继续改进 ~
🔗 代码链接:github.com/dandanDQ/bl…
🔗 使用示例: jsbin.com/niyapom