Article 三月 15, 2021

简单实现Vue中的双向绑定、生命周期以及计算属性!!!

Words count 13k Reading time 11 mins. Read count 0

效果图

如何实现一个双向绑定已经是一个老生常谈的话题了,最近也写了一个 双向绑定 demo,最终呈现如下(demo丑了点勿怪):

在这里插入图片描述

点击 demo预览 可以在线预览

前言

最近整理收藏夹发现了 自己手动实现简单的双向数据绑定mvvm 这篇博客,它以非常简单易懂的例子讲解了 Vue 响应式的核心——双向绑定的实现。看完之后,我也写了一个 双向绑定 并新增了几个简单功能:

  • 生命周期
  • 方法和事件
  • 计算属性

下面我便来简述一下这些功能的实现。

什么是MVVM

谈及 双向绑定 就不得不提 MVVM模式,它是是Model-View-ViewModel的简写。

在前端页面中,把Model用纯JavaScript对象表示,View负责显示,两者做到了最大限度的分离。

把Model和View关联起来的就是ViewModel。ViewModel负责把Model的数据同步到View显示出来,还负责把View的修改同步回Model。

在这里插入图片描述

在 Vue 应用中我们可以将 <div id="app"></div> 内的模板看作是 View ,将 data 函数的返回值看作为 Model ,而 ViewModel 就是双向绑定功能,它可以感知数据的变化,用来连接 ViewModel

Vue 中的 ViewModel 做了什么,举个最简单的例子:

<div id="app">
    <h1>{{ msg }}</h1>
    <input type="text" v-model="msg" />
</div>
<script>
    new Vue({
        el: '#app',
        data () {
            return { msg: 'Hello world' }
        }
    })
</script>

Hello world 赋给 h1 和 input 标签,这里是初始化模板,Compile 做的事。 而通过修改 msg 的值,随之 h1 和 input 值被改变,在 input 内键入其他值从而改变 h1 和 msg 的值,实现了这样的功能便就是实现了一个 双向绑定了。实现一个简单的 Vue双向绑定可以拆分为以下功能:

  • Compile 解析模板初始化页面
  • Dep 依赖收集、通知
  • Watcher 提供依赖,更新视图
  • Observer 侦听数据变化,通知视图更新

在这里插入图片描述

上图是我画的双向绑定的流程图,应该比文字要直观很多,下面直接上代码。

options

可以先看下最终的调用代码,我们尝试模拟一个已存在的框架的时候,可以通过现有的结果去推断我们的代码如何设计。

new Vue({
    el: '#app',
    data() {
        return {
            foo: 'hello',
            bar: 'world'
        }
    },
    computed: {
        fooBar() {
            return this.foo + ' ' + this.bar
        },
        barFoo() {
            return this.bar + ' ' + this.foo
        }
    },
    mounted() {
        console.log(document.querySelector('h1'));
        console.log(this.foo);
    },
    methods: {
        clickHandler() {
            this.foo = 'hello'
            this.bar = 'world'
            console.log('实现一个简单事件!')
            console.log(this)
        }
    }
})

Vue 类

上述代码 1:1 还原 Vue 的调用写法,很明显自然而然就写一个 Vue 类,传一个 options 对象作为参数。

class Vue {
    constructor(options) {
        this.$options = options
        this.$el = document.querySelector(options.el)
        // 缓存data中的key,用来数据劫持
        // (ps: Vue 将 options.data 中的属性挂在 Vue实例 上, Object.defineProperty 劫持的其实是 Vue实例 上的属性, options.data 里的数据初始化之后应该用处不大)
        this.$depKeys = Object.keys({...options.data(), ...options.computed})
        // 计算属性的依赖数据
        this._computedDep = {}
        this._addProperty(options)
        this._getComputedDep(options.computed)
        this._init()
    }

    _init() {
        observer(this)
        new Compile(this)
    }

    // 获取计算属性依赖
    _getComputedDep(computed) {
        Object.keys(computed).forEach(key => {
            const computedFn = computed[key]
            const computedDeps = this._getDep(computedFn.toString())

            computedDeps.forEach(dep => {
                if (!this._computedDep[dep]) {
                    this._computedDep[dep] = {
                        [key]: computed[key]
                    }
                } else {
                    Object.assign(this._computedDep[dep], {
                        [key]: computed[key]
                    })
                }
            })
        })
    }

    _getDep(fnStr) {
        const NOT_REQUIRED = ['(', ')', '{', '}', '+', '*', '/', '\'']
        return fnStr.replace(/[\r\n ]/g, '')
            .split('')
            .filter(item => !NOT_REQUIRED.includes(item))
            .join('')
            .split('return')[1]
            .split('this.')
            .filter(Boolean)
    }

    // 将 data 和 methods 中的值注入Vue实例中(实现在方法或生命周期等能直接用 this[key] 来取值)
    _addProperty(options) {
        const {computed, data, methods} = options
        const _computed = {}
        Object.keys(computed).forEach(key => {
            _computed[key] = computed[key].call(data())
        })

        const allData = {...data(), ...methods, ..._computed}
        Object.keys(allData).forEach(key => {
            this[key] = allData[key]
        })
    }
}

Vue 类中调用了 observerCompile 来进行初始化操作,收集一些必要参数挂在 Vue 实例上方便后续操作。在这里我额外将 data 、 computed 以及 methods 挂在了 Vue 实例,这里为何这样做后面会提到。

Compile

// 编译模板
class Compile {
    constructor(vm) {
        this.vm = vm
        this._init()
    }

    _init() {
        // 搬来 Vue 中匹配 {{ 插值 }} 的正则
        const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/;
        // 获取主容器
        const container = this.vm.$el
        // 创建虚拟节点
        const fragment = document.createDocumentFragment()
        // 只取元素节点
        let firstChild = container.firstElementChild
        while (firstChild) {
            // 这里 append 一个,container 就会少一个 子元素,若没有子元素则会返回 null
            fragment.appendChild(firstChild)
            firstChild = container.firstElementChild
        }
        fragment.childNodes.forEach(node => {
            // 将属性节点(ArrayLike)转换为数组
            [...node.attributes].forEach(attr => {
                // 匹配 v-model 指令
                if (attr.name === 'v-model') {
                    const key = attr.value
                    node.value = this.vm[key]

                    new Watcher(this.vm, key, val => {
                        node.value = val
                    })

                    node.addEventListener('input', e => {
                        // input 事件触发 set 方法,并通知 Watcher 实例操作变更dom
                        this.vm[key] = e.target.value
                    })
                }
                // 匹配 @click 绑定点击事件
                if (attr.name === '@click') {
                    // 使用 bind 将此函数内部 this 改为 Vue实例
                    node.addEventListener('click', this.vm[attr.value].bind(this.vm))
                }
            })

            // 匹配双花括号插值(textContent取赋值 比 innerText 好一点)
            if (node.textContent && defaultTagRE.test(node.textContent)) {
                console.log(node.textContent);
                const key = RegExp.$1.trim()
                // 替换 {{}} 后的文本,用于初始化页面
                const replaceTextContent = node.textContent.replace(defaultTagRE, this.vm[key])
                // 移除 {{}} 后的文本,用于响应性更新
                const removeMustache = node.textContent.replace(defaultTagRE, '')
                node.textContent = replaceTextContent

                new Watcher(this.vm, key, val => {
                    node.textContent = removeMustache + val
                })
            }
        })

        // 将 虚拟节点 添加到主容器中(这里可以将虚拟节点理解为 Vue 中的 template 标签,只起到一个包裹作用不会存在真实标签)
        this.vm.$el.appendChild(fragment)
        // 此处定义 mounted 生命周期
        typeof this.vm.$options.mounted === 'function' && this.vm.$options.mounted.call(this.vm)
    }
}

如果你想第一时间看到成果的话,先写 Compile 准没错。我这里贴上最终的完整代码,所以代码会比较多,细细拆分的话有以下几个功能:

  • 解析 #app 内容,将元素中的 {{}}v-model 转为实际值赋上,并追加到一个虚拟节点中。关于为何使用 createDocumentFragment 方法创建一个虚拟节点,第一个好处就是方便,第二个好处就是减少性能开销了。在浏览器中,每一次的添加和删除元素都会引起页面的回流,它需要重新计算其他元素的位置。如果有几百个元素依次加入到dom中就会引起几百次重绘,而将所有元素添加到 虚拟节点 中则只会引起一次回流重绘。
  • 生成 Watcher 实例,它缓存了依赖key,并加入了更新dom数据的方法,为的就是在依赖值被更改的时候去更新dom。

Observer

function observer(vm) {
    const dep = new Dep()
    const {_computedDep} = vm
    vm.$depKeys.forEach(key => {
        let value = vm[key]
        Object.defineProperty(vm, key, {
            enumerable: true,
            configurable: true,
            get() {
                if (Dep.target) {
                    // 添加订阅者-Watcher实例
                    dep.add(Dep.target)
                }
                return value
            },
            set(newVal) {
                value = newVal
                // (ps:可以在此处根据 key 值通知对应的 Watcher 进行更新)
                dep.notify()

                Object.keys(_computedDep).forEach(computedDep => {
                    // 在 set 中匹配对应的依赖项更新对应的计算属性
                    if (key === computedDep) {
                        for (let getter in _computedDep[key]) {
                            vm[getter] = _computedDep[key][getter].call(vm)
                        }
                    }
                })
            }
        })
    })
}

observer 其实是一个侦听器,这里用来监测 data 和 computed 的改动并通知dom更新,那么这里的 Object.defineProperty 方法就是 Vue 得以实现双向绑定的基石了。关于 computed 计算属性的自动收集依赖真的有点难,Vue源码中奇奇怪怪的转调实在看不下去,只好写了个简单粗暴的方式实现了。计算属性本质上是一个方法的返回值,所有我这里的实现原理就是:一个依赖key对应多个computed方法,检测依赖key的更新,同时触发computed方法。

DepWatcher 代码都比较简单没什么好讲的,就不贴了,文章后面会贴上源代码。

如何实现生命周期?

要是在以前问我Vue中的生命周期是如何实现的?我还真的不知道,我很少看面试题。最初接触Vue时我猜测它的生命周期可能是我不知道的某些js api实现的。其实在某些js代码执行之前或执行之后,来调用一个生命周期函数就形成了生命周期。在 Compile 中,在模板解析完毕填入到dom后,调用 mounted 函数便实现了 mounted 生命周期。

如何方便的调取 data 值和 计算属性?

在mothods、生命周期以及computed中都是需要获取data和计算属性,在 Vue 中直接随心所欲的用 this 就可以调取所有值。在上面我写了一句话 通过现有的结果去推断我们的代码如何设计 ,最简单粗暴的方式将这些数据挂在 Vue实例 上就迎刃而解了,Vue 中其实就是这样做的。

总结

确实我的代码相较于 网上例子 可能比较长,因为加了一些额外的功能,它只是我对于 Vue 其他功能实现的一个探索。

在 2020 年末和 2021 年初给我的感觉就一个字——“忙“,忙的没时间写博客,没时间做其他事,所以这篇博客没有时间由浅入深的写的比较通俗易懂。细小功能点一点点拆分写,篇幅长10倍不止,紧赶慢赶也算写出来了,算是2021年1月的的唯一一篇产出吧。

源码

点进去看index.html

参考

0%