前言
多数框架随着发展会有越来越多的封装,这些封装对于专门使用该框架做开发的人而言,是提效的,是优雅的,但对于第一次使用该框架的人而言,必然是黑魔法的。
我一直好奇,Vue、React之类的框架是如何基于JS/TS构建出来的,本文将使用100左右的代码使用Vue响应式效果。
实现虚拟DOM
Vue框架会通过虚拟DOM(Virtual DOM)来操作HTML元素,在实现VDOM(本文以VDOM作为虚拟DOM的简称)前,有必要理解一下,为啥各大框架都使用VDOM来操作HTML元素,而不直接操作原生DOM呢?
网上很多资料会说操作VDOM会比操作原生DOM快并举出VDOM可局部刷新从而提高性能的例子,但这其实是无解,多数情况下,VDOM会比原生DOM慢,其原因是,多数前端框架为了考虑通用性会操作更多上层API,从而影响性能,但React、Vue等框架还是不约而同的使用VDOM,其核心原因在于更好的可读性和可维护性,此外,抽象出VDOM,可以更轻松的实现跨平台的能力(更多可阅读参考[1])。
理解VDOM的优势后,便来实现它。
首先定义出创建VNode(虚拟DOM中的节点)的函数:
// 创建vnode
function h(tag, props, children) {
return {tag, props, children}
}
h函数其实就返回了一个对象,该对象便是VNode。
有了VNode后,需要定义将VNode添加到原生DOM和从原始DOM卸载VNode的方法,先看添加到原生DOM的函数:
// 将VNode挂载到指定的DOM节点上,让VNode在页面中可以正常显示
function mount(vnode, container){
const {tag, props, children} = vnode
// 创建DOM元素
vnode.el = document.createElement(tag)
// 给元素设置属性
setProps(vnode.el, props)
// 设置子元素
setChildren(vnode.el, children)
// 将新创建的元素添加到指定的DOM中
container.appendChild(vnode.el)
// 设置子元素
function setChildren(el, children) {
// 字符串,直接作为父元素的内容
if (typeof children == 'string') {
el.textContent = children
} else {
// 递归调研mount处理子元素
children.forEach(child => mount(child, el))
}
}
}
// 给元素设置属性
function setProps(ele, props){
for (const [key, value] of Object.entries(props)) {
ele.setAttribute(key, value)
}
}
上述代码中,定义了mount函数,通过document.createElement创建DOM元素,然后通过setProps函数为DOM元素设置属性,通过setChildren函数为DOM元素设置子元素。
卸载VNode的方法更加简单:
// 将Vnode从指定DOM中移除
function unmount(vnode) {
vnode.el.parentNode.removeChild(vnode.el)
}
有了添加和卸载后,还需要替换VNode的逻辑,即对比新旧VNode,找出不同的地方进行替换,这块逻辑便是前端同学常聊的diff算法,这里简单实现一下:
// 将新VNode(n2)与旧的VNode(n1)进行对比,找出不同,并进行替换
function patch(n1, n2) {
const el = n1.el
n2.el = el
if (n1.tag !== n2.tag) {
// 如果两个节点标签类型都不相同,则直接添加新节点(n2),删除旧节点(n1)
mount(n2, el.parentNode)
unmount(n1)
}else {
// 两节点标签类型一样
// 新节点的子节点是字符串,则替换内容并重新设置属性
if (typeof n2.children == 'string') {
el.textContent = n2.children
setProps(el, n2.props)
} else {
// 如果子节点不是字符串,即当前同类型的新节点有不同的子节点,需要处理
patchChildren(n1, n2)
}
}
}
上述代码中实现了patch函数来替换新旧VNode,替换时,分两种情况:
新旧节点的标签类型不同,此时直接删除旧节点,添加新节点则可,比如新节点是h1标签,而旧节点是p标签,此时直接删除替换则可
新旧节点的标签类型相同,比如都是p标签
对于标签类型相同的情况,又可以分出两种类型:
子节点是String,例子:比如都是p标签,但新旧p标签的内容不同,此时替换一下内容则可
如果子节点不是String,此时就需要比较新旧节点的子节点了
看一下替换新旧VNode子节点的逻辑:
function patchChildren(n1, n2) {
const c1 = n1.children
const c2 = n2.children
// 计算出两节点公共长度
const commonLen = Math.min(
typeof c1 == 'string' ? 0: c1.length,
c2.length,
)
// 通过patch方法替换相同长度的节点
for (let i = 0; i < commonLen; i++) {
patch(c1[i], c2[i])
}
// 如果就节点长,则通过unmount删除多出的子节点
if (c1.length > commonLen) {
for (let i = commonLen; i < c1.length; i++) {
unmount(c1[i])
}
}
// 如果新节点长,则通过mount添加多出的子节点
if(c2.length > commonLen) {
for (let i = commonLen; i < c2.length; i++) {
mount(c2[i], n2.el)
}
}
}
patchChildren函数对3种情况做了处理:
两个子节点相同长度的部分,递归调用patch函数去处理
旧节点比新节点长,那么调用unmount函数卸载多出的旧VNode
新节点比旧节点长,那么调用mount函数添加新VNode
至此,一个简单的VDOM系统便实现好了,创建index.html文件,引入上述JS,然后测试一下,完整代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
</body>
<!-- 虚拟DOM逻辑 -->
<script src="./vdom.js"></script>
<script>
const app = $('#app')
// 创建span元素
const span = h('span', {}, 'hello world!')
// 创建h1元素,span是其子元素
const h1 = h('h1', {id: 'title'}, [span])
// 添加h1元素到app元素中(挂载虚拟节点)
mount(h1, app)
</script>
</html>
实现响应式状态管理
所谓状态管理(state reactivity)是指变量状态发生变化时,自动执行指定的操作,这也是Vue中的常见功能,如点击按钮(Button),改变h1标签中显示的值,其功能核心是:某个变量改变时,知道其发生了改变并调用关联的函数执行相应的逻辑。
要知道某个变量改变了,可以使用Object.defineProperty函数,在其中定义出get与set方法,如果变量被访问时,会调用get方法,如果变量被赋值时,会调用set方法。
为了监控变量全部属性,可以定义出如下函数:
// 监听obj对象所有属性
function observe(obj) {
Object.keys(obj).forEach(key => {
let internalValue = obj[key]
const dep = new Dep()
// get 与 set 钩子,监听变量读取与赋值操作
Object.defineProperty(obj, key, {
get() {
dep.depend()
return internalValue
},
set(newVal) {
internalValue = newVal
// 变量的值变化时,通过notify发出通知
dep.notify()
}
})
})
}
上述代码中,在get方法处,调用depend函数添加关联函数,该函数在变量改变时被调用,在set方法处,调用notify函数,调用关联的函数,相关代码如下:
let activeFunc = null
class Dep{
constructor() {
// 存放当前变量的依赖函数(变量改变时,会调用这些函数)
this.subscribers = new Set()
}
depend() {
// 添加关联函数
activeFunc && this.subscribers.add(activeFunc)
}
notify() {
// 调用绑定的每个方法
this.subscribers.forEach(func => func())
}
}
// 绑定关联函数
function autorun(func) {
activeFunc = func
func()
activeFunc = null
}
上述代码中,定义了全局变量activeFunc来暂存关联函数,为了比较好的理解代码,举个具体的例子:
<!-- index.html -->
<script>
const state = {
count: 1
}
// 监听state所有属性
observe(state)
function log() {
console.log(`update state.count to ${state.count}`)
}
// 关联上log函数
autorun(log)
setTimeout(() => {
state.count += 1
}, 2000)
</script>
上述代码定义了state对象并调用observe函数监控该对象,然后定义了log函数打印state.count的值并通过autorun将其与state.count变量关联起来,当state.count发生改变时,便会打印相关日志。
为啥autorun能将state.count变量与log函数关联起来?看到autorun函数:
// 绑定关联函数
function autorun(func) {
activeFunc = func
func()
activeFunc = null
}
autorun函数会先将绑定函数对象赋值给activeFunc,然后调用该函数,即log函数,在log函数里,使用了state.count,这会触发get方法,而get方法中调用了depend函数,在depend函数中,会将不为空的activeFunc添加到this.subscribers变量中,由此完成state.count变量与log函数的绑定。
当state.count改变时:
setTimeout(() => {
state.count += 1
}, 2000)
set方法被调用,notify函数被调用,从而再次执行log函数。
实现ToyVue
将前面的内容组合在一起使用,便可以实现一个简单的Vue了:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<button id="inc">add</button>
</body>
<script src="./vdom.js"></script>
<script src="./state-reactivity.js"></script>
<script>
const btn = $('#inc')
const app = $('#app')
const state = {
count: 1
}
// 监听state所有属性
observe(state)
// 添加点击事件
btn.addEventListener('click', () => {
state.count += 6
})
let node = null
// 为state.count关联匿名方法
autorun(() => {
if (node) {
// 创建虚拟节点
const newNode = h('h1', {}, String(state.count))
// 替换虚拟节点
patch(node, newNode)
node = newNode
} else {
// 创建虚拟节点
node = h('h1', {}, String(state.count))
// 添加虚拟江门
mount(node, app)
}
})
</script>
</html>
autorun函数关联了一个匿名函数,其逻辑是,如果VNode不存在,则通过h函数创建一个VNode并通过mount函数将其添加到原生DOM中,如果VNode存在,则构建出新的VNode并通过patch函数实现替换。
这题的效果便是,点击button,state.count会+7,页面中h1标签的内容也会同步发生改变。
这里也引出了ToyVue的一个问题,如果autorun函数关联的函数没有使用被关联的变量,如下例子:
<script>
const state = {
count: 1
}
// 监听state所有属性
observe(state)
function log() {
// console.log(`update state.count to ${state.count}`)
// 监听方法里,必须使用被监听对象
console.log('haha')
}
// 关联上log函数
autorun(log)
setTimeout(() => {
state.count += 1
}, 2000)
</script>
上述代码中的log函数不会随着state.count的改变而被反复调用,因为log函数中没有使用state.count,即没有触发get方法,从而没有通过depend函数实现绑定。
参考
1.尤雨溪:原生DOM与虚拟DOM的比较(https://www.zhihu.com/question/31809713)
2.MDN:Object.defineProperty(https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty)
3.从零实现一个MiniVue(https://juejin.cn/post/6873381226615570445)