百行代码实现ToyVue

f68fc728baa5f0e2e7d8ef7b5cef2ab1.png

前言

多数框架随着发展会有越来越多的封装,这些封装对于专门使用该框架做开发的人而言,是提效的,是优雅的,但对于第一次使用该框架的人而言,必然是黑魔法的。

我一直好奇,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)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

懒编程-二两

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值