目录

前言

MVVM

mini-vue实现

Compile(指令解析)

Updater(视图更新)

Proxy(代理data)

Observer(数据劫持)

Dep(调度中心)

Watcher(数据观察)

函数的连接

写在最后


前言

MVVM实际上是MVC的改进版,其立足于MVP框架。使用Vue时,我们会体会到其与React的区别,绑定表单数据时react对输入框读写需要input事件设置state,以及value绑定,而vue则只需将数据与model绑定即可,这种数据驱动视图却与视图解耦的编程方式使用起来很方便。以前面试官问vue原理,若能说出双向绑定实现和Object.defineProperty就已经够了,现在随着对vue深入的学习,面试官已经不仅仅局限于此。所以,为了深入体验mvvm模式,我实现了一个mini-vue。

MVVM

在开始前,我们先试着参照下图实现一个简单的双向绑定案例

 

DOM通过eventListener修改Model,Model通过修改data驱动视图

 在html>body中添加以下代码就可以实现

    <input id="input-box" type="text">
    <div id="show-text"></div>
    <script>
        const showText = document.querySelector('#show-text')
        const inputBox = document.querySelector('#input-box')
        class VM {
            data = {
                value: ''
            }
            constructor() {
                Object.defineProperty(this.data, 'value', {
                    set(v) {
                        showText.textContent = v
                    },
                    get() {
                        return showText.textContent
                    }
                })
            }
        }

        const vm = new VM()
        inputBox.addEventListener('input', function (e) {
            vm.data.value = e.target.value
            console.log(vm.data.value)
        })
    </script>

在上述代码中,我们可以使用Object.defineProperty将data和textContent的值绑定,从而达到数据驱动视图的效果,那么这样就够了吗?

mini-vue实现

下面是双向绑定的流程

通过上图我们可以得知:new MVVM后会进行两步操作,一是compile指令解析,将v-if,@click,{{  }}解析出来,获取data中的数据,并且都与watcher绑定,第一次初始化和watcher监听到数据变化时会执行updater,重新渲染页面,二是observer数据劫持,将data中的数据通过defineProperty添加读写监听,并将数据变化与watcher绑定在一起,那么此时watcher就是连接数据变化和视图更新的枢纽。

下面我们一步一步实现上述代码

Compile(指令解析

我们回顾一下vue是如何使用的,标签中各种v-if,v-show,v-html,以及@click等等属性,绑定着data中的属性和methods中的函数

    <div id="app">
        <span v-text='title.name'></span>
        <div v-if='isRender'>
            <span>1</span>
            <span>2</span>
            <span>3</span>
        </div>
        <ul>
            <li v-if='isRender'>{{info.name}}---{{info.age}}---{{modelData}}---{{inputVal.item.value}}</li>
            <li v-show='isShow'>{{info.age}}</li>
            <li v-if='isRender'>{{modelData}}</li>
            <li v-show='isShow'>{{inputVal.item.value}}</li>
        </ul>
        <span v-text='inputVal.item.value'></span>
        <div v-html='htmlTemp'></div>
        <div v-show='isShow'>world</div>
        <button v-on:click='handlerShow'>点击显示</button>
        <button @click='handlerRender'>点击渲染</button>
        <input v-model='modelData' type="text">
        <input v-model='inputVal.item.value' type="text">
    </div>

而实例化vue则是将数据和函数初始化到vue中

        let vm = new Vue({
            el: '#app',
            data: {
                title: {
                    name: 'hello'
                },
                info: {
                    name: '张三',
                    age: 23,
                },
                isShow: true,
                isRender: true,
                modelData: 123,
                htmlTemp: '<span style="color:red;">html</span>',
                inputVal: {
                    item: {
                        value: 'abc'
                    }
                }
            },
            methods: {
                handlerShow() {
                    this.isShow = !this.isShow
                },
                handlerRender() {
                    this.isRender = !this.isRender
                }
            },
        })

那么,我们要如何去让js识别这些指令并渲染视图呢
首先,创建标签碎片,将Dom元素获取到DocumentFragment中,以便于解析指令及根据指令对视图响应,其次,将标签属性分离,每种指令对应一种响应方式(updater)。最后绑定watcher监听到数据变化时,再次触发updater
以下是compile.js,用来解析标签内容和属性

// 指令解析器
const textRegex = /\{\{(.+?)\}\}/g //解析{{}}的正则
class Compile {
    constructor(elem, vm) {
        this.elem = isElemNode(elem) === '1' ? elem : document.querySelector(elem)
        this.vm = vm
        const fragment = this.createFragment(this.elem)
        this.getTemp(fragment, this.vm)
        this.elem.appendChild(fragment);
    }
    // 递归子元素,查找所有元素
    getTemp(fragment, vm) {
        const fragmentChild = Array.from(fragment.childNodes)
        fragmentChild.forEach(item => {
            this.filterElem(item, vm)
            item.childNodes && item.childNodes.length && this.getTemp(item, vm)
        })
    }
    // 创建标签碎片,将dom元素添加到标签碎片中
    createFragment(elem) {
        const fragment = document.createDocumentFragment();
        while (elem.firstChild) {
            fragment.append(elem.firstChild)
        }
        return fragment
    }

    // 针对不同元素节点进行分离
    filterElem(elem, vm) {
        switch (isElemNode(elem)) {
            case 1: //元素节点
                this.renderNode(elem, vm)
                break;
            case 3: //文本节点
                this.renderText(elem, vm)
                break;
        }
    }
    // 渲染文本,主要解析‘{{}}’及多个‘{{}}’
    renderText(elem, vm) {
        textRegex.test(elem.textContent) && updater(elem, vm, elem.textContent, 'text-content')
    }
    // 渲染标签
    renderNode(elem, vm) {
        //取出所有属性和值
        Array.from(elem.attributes).forEach(attr => {
            const {
                name,
                value
            } = attr;
            // 过滤‘v-’和‘@’操作,并移除标签属性
            name.startsWith('v-') ? (this.compileV_Command(elem, vm, name, value), removeAttr(elem, name)) : name.startsWith('@') ? (this.compileEventComment(elem, vm, name.split('@')[1], value), removeAttr(elem, name)) : null
        })
    }
    // v- 指令解析,指令
    compileV_Command(elem, vm, name, value) {
        const key = name.split('v-')
        const eventCommand = key[1] && key[1].split(':')[1]
        // v-model事件
        key[1] === 'model' && this.compileEventComment(elem, vm, 'input', value, e => {
            setDeepData(vm, value, e.target.value)
        })
        // 过滤指令是否为事件
        eventCommand ? this.compileEventComment(elem, vm, eventCommand, value) : updater(elem, vm, value, key[1])
    }
    // @ 指令解析,事件
    compileEventComment(elem, vm, name, value, fn) {
        !fn && elem.addEventListener(name, vm.options.methods[value].bind(vm))
        fn && elem.addEventListener(name, fn.bind(vm))
    }
}

Updater(视图更新)

指令解析完后自然需要updater.js,对当前元素进行下一步渲染,在此之前,我们的值需要从vue.data中取,这样才能将data数据绑定到标签中,lodash有两个函数一个是_.get(),另一个是_.set(),作用是获取和设置对象某一层某个值,所以我们需要在utils(工具函数)中实现一下

utils.js

//lodash中的 _.get(),获取对象多级属性
function getDeepData(object, path, defaultValue) {
    const paths = path.split('.')
    for (const i of paths) { //逐层遍历path
        object = object[i]
        if (object === undefined) { //不能用 '!object' null,0,false等等会等于false
            return defaultValue
        }
    }
    return object
}
//lodash中的 _.set(),赋值对象某级属性
function setDeepData(object, path, value) {
    const paths = path.split('.')
    const last = paths[paths.length - 1]//为何要在length - 1时赋值:因为object的引用关系使得我们可以一级一级赋值,而当最后一项是基本类型时,无法将引用的值赋给原始的object
    let _obj = object
    for (const i of paths) {
        last === i && (_obj[last] = value)
        _obj = _obj[i]
    }
}
// 移除属性值
function removeAttr(elem, key) {
    elem.removeAttribute(key)
}
// 获取标签类型
function isElemNode(elem) {
    return elem.nodeType
}

updater.js 

// 更新视图,标签中指令属性处理
function updater(elem, vm, value, type) {
    switch (type) {
        case 'text':
                elem.textContent = getDeepData(vm.data, value)
            break;
        case 'text-content':
                elem.textContent = value.replace(textRegex, (..._) => getDeepData(vm.data, _[1]))
            break;
        case 'html':
                elem.innerHTML = getDeepData(vm.data, value)
            break;
        case 'model':
                elem.value = getDeepData(vm.data, value)
            break;
        case 'if':
            const temp = document.createTextNode('')
            elem.parentNode.insertBefore(temp, elem);
                getDeepData(vm.data, value) ? temp.parentNode.insertBefore(elem, temp) : temp.parentNode.removeChild(elem)
            break;
        case 'show':
                elem.hidden = !getDeepData(vm.data, value)
            break;
    }
}

完成这一步后,我们在vue.js中调用

class VueDemo {
    constructor(options) {
        this.options = options //配置信息
        this.data = options.data;
        // 判断options.el是否存在
        (this.el = options.el) && Object.defineProperties(this, {
            compile: {
                value: new Compile(options.el, this) //指令解析器
            }
        })
    }
}

效果出来了,指令被解析出来并且在页面中显示

Proxy(代理data)

我们虽然将vue.data中的数据渲染到了页面,但是还是需要通过this.data来获取数据,而vue可以中直接通过this来拿到数据,此时我们需要新建一个proxy.js将this.data代理到this上

// data数据代理到vue
class DataProxy {
    constructor(data, vm) {
        for (const key in data) {
            Object.defineProperty(vm, key, {
                get() {
                    return data[key];
                },
                set(val) {
                    data[key] = val;
                }
            })
        }
        return data
    }
}

在vue.js中调用,并将updater.js 中的vm.data改成vm

class VueDemo {
    constructor(options) {
        this.options = options //配置信息
        this.$data = options.data;
        // 判断options.el是否存在
        (this.el = options.el) && Object.defineProperties(this, {
            proxy: {
                value: new DataProxy(options.data, this) //data代理到this
            },
            compile: {
                value: new Compile(options.el, this) //指令解析器
            }
        })
    }
}

写到这里,compile和updater已经实现了,接下来将是数据劫持的实现方式

Observer(数据劫持)

这一步的作用是将data中的数据都加上读写响应控制,给所有数据绑定可以更新视图的函数

// 发布模式
class Observer {
    constructor(data) {
        this.initObserver(data)
    }
    // 劫持所有数据
    initObserver(data) {
        if (data && typeof data === 'object') {
            for (const key in data) {
                this.defineReactive(data, key, data[key])
            }
        }
    }
    // 响应拦截器,递归监听所有层级
    defineReactive(data, key, val) {
        this.initObserver(val) //劫持子项
        Object.defineProperty(data, key, {
            enumerable: true, // 允许枚举
            configurable: false, // 不能被定义
            get: _ =>  val,//初始化获取值时对dep绑定
            set: newVal => val = newVal
        })
    }
}

Dep(调度中心)

watcher的作用是将上面的observer与视图的刷新函数updater进行连接,当observer监测到数据变化时会通过dep告诉watcher,watcher就会执行updater更新视图,于是,我们需要先实现observer与watcher之间的观察者dep,我们先假定watcher中更新视图的函数名字叫compareVal,将watcher注册到调度中心中

// 调度中心(观察者模式)
class Dep {
    observerList = [] //调度中心,存放与属性绑定的事件
    //触发所有与该属性绑定的事件
    fireEvent() {
        this.observerList.forEach(target => {
            target.compareVal()
        })
    }
    //注册事件
    subscribe(target) {
        target.compareVal && this.observerList.push(target)
    }
}

Watcher(数据观察)

watcher的作用是连接observer和compile,使数据和视图绑定
以下是watcher.js的实现

// 订阅模式(比较绑定值的变化)
class Watcher {
    constructor(vm, val, update) {
        this.vm = vm
        this.val = val;
        this.update = update
        this.oldVal = getDeepData(this.vm, this.val)
        update() //首次渲染初始化
    }
    // 对比数据,更新视图
    compareVal() {
        const newVal = getDeepData(this.vm, this.val);
        newVal !== this.oldVal && (this.update(), this.oldVal = newVal) //更新视图后将新值赋到oldVal上
    }
}

函数的连接

我们来回顾一下以上功能的实现

整个流程中的函数部分已经全部实现,只剩下如何将他们联系在一起,这时如果你对整个功能实现还有些模糊,那请认真分析一下这张流程图,并继续看下去吧

首先我们把watcher和指令解析以及updater之间的关系实现。
在updater中给予每一个指令一个watcher,将更新视图操作绑定到watcher中,由compareVal来更新视图

// 更新视图,标签中指令属性处理
function updater(elem, vm, value, type) {
    switch (type) {
        case 'text':
            new Watcher(vm, value, _ => {
                elem.textContent = getDeepData(vm, value)
            })
            break;
        case 'text-content':
            value.replace(textRegex, (..._) => { //外面的content.replace获取所有{{}}中的属性
                new Watcher(vm, _[1], _ => { //里面的content.replace获取data中绑定的值
                    elem.textContent = value.replace(textRegex, (..._) => getDeepData(vm, _[1]))
                })
            })
            break;
        case 'html':
            new Watcher(vm, value, _ => {
                elem.innerHTML = getDeepData(vm, value)
            })
            break;
        case 'model':
            new Watcher(vm, value, _ => {
                elem.value = getDeepData(vm, value)
            })
            break;
        case 'if':
            const temp = document.createTextNode('')
            elem.parentNode.insertBefore(temp, elem);
            new Watcher(vm, value, _ => {
                getDeepData(vm, value) ? temp.parentNode.insertBefore(elem, temp) : temp.parentNode.removeChild(elem)
            })
            break;
        case 'show':
            new Watcher(vm, value, _ => {
                elem.hidden = !getDeepData(vm, value)
            })
            break;
    }
}

那么如何告诉watcher数据发生了改变呢?
在watcher中我们获取oldvalue时采用this.oldVal = getDeepData(this.vm, this.val)
这个操作会使observer中data属性的get被触发,此时如果我们将watcher注册到dep中即可对所有数据变化进行监听,然鹅,在实现的时候,发现了一些问题,由于defineReactive将data所有属性都监听了,导致取属性时使用{{info.name}}时,data.info和data.info.name都会被劫持,而我们只需要info.name,所以,当dep注册watcher时需要设置一个开关,并且在observer中根据开关添加监听,修改的watcher和observer如下:
watcher.js

// 订阅模式(比较绑定值的变化)
class Watcher {
    constructor(vm, val, update) {
        this.vm = vm
        this.val = val;
        this.update = update
        this.oldVal = this.getOldVal() //获取初始值,触发observer中属性的get
        update() //首次渲染初始化
    }
    getOldVal() {
        Dep.target = this //将watcher暂存到Dep上,在Observer中通过dep.subscribe将watcher传到dep的observerList(调度中心)中,后续当值发送修改时通过fireEvent触发watcher.compareVal来更新视图
        const oldVal = getDeepData(this.vm, this.val) //触发Observer中的getter,将watcher注册到dep中
        Dep.target = null
        return oldVal
    }
    // 对比数据,更新视图
    compareVal() {
        const newVal = getDeepData(this.vm, this.val);
        newVal !== this.oldVal && (this.update(), this.oldVal = newVal) //更新视图后将新值赋到oldVal上
    }

}

observer.js中的defineReactive

    // 响应拦截器,递归监听所有层级
    defineReactive(data, key, val) {
        this.initObserver(val) //劫持子项
        const dep = new Dep() //将observer与watcher连接,当watcher触发数据变化后,将watcher中的回调函数注册到dep中
        Object.defineProperty(data, key, {
            enumerable: true, // 允许枚举
            configurable: false, // 不能被定义
            get: _ => {
                Dep.target && dep.subscribe(Dep.target); //获取属性值时,将watcher中的回调函数注册到dep中(在页面初始化时调用)
                return val
            },
            set: newVal => newVal !== val && (val = newVal) //设置属性时,对比新值和旧值有无差别
        })
    }

现在,我们只剩下当数据发生改变时,如何通知watcher,因为上述的defineReactive中已经将watcher注册到了dep,此时我们只需在数据变化时也就是defineReactive的set中对数据更新进行响应,当某条数据被设置时,我们将dep中watcher触发即可

    // 响应拦截器,递归监听所有层级
    defineReactive(data, key, val) {
        this.initObserver(val) //劫持子项
        const dep = new Dep() //将observer与watcher连接,当watcher触发数据变化后,将watcher中的回调函数注册到dep中
        Object.defineProperty(data, key, {
            enumerable: true, // 允许枚举
            configurable: false, // 不能被定义
            get: _ => {
                Dep.target && dep.subscribe(Dep.target); //获取属性值时,将watcher中的回调函数注册到dep中(在页面初始化时调用)
                return val
            },
            set: newVal => newVal !== val && (val = newVal, this.initObserver(newVal), dep.fireEvent()) //设置属性时,对比新值和旧值有无差别,若修改的值是引用型时,将属性重新注册到dep中,并更新视图
        })
    }

至此,流程图中的所有功能均已实现,让我们在vue.js中实例化observer试试效果

class VueDemo {
    constructor(options) {
        this.options = options //配置信息
        this.$data = options.data;
        // 判断options.el是否存在
        (this.el = options.el) && Object.defineProperties(this, {
            //observer和compile的顺序不要错,否则监听不到compile中的数据
            observer: {
                value: new Observer(options.data) // 数据监听器
            },
            proxy: {
                value: new DataProxy(options.data, this) //data代理到this
            },
            compile: {
                value: new Compile(options.el, this) //指令解析器
            }
        })
    }
}

写在最后

感谢你看到了最后,希望文章能对你有帮助,同时也欢迎你提出宝贵的建议

最后附上源码地址
喜欢这篇文章别忘了点个赞,你的支持是作者创作的动力

posted @ 2021-08-08 23:08 阿宇的编程之旅 阅读(306) 评论(0) 推荐(0) 编辑
摘要: 目录 前言 "鸭子类型" 子类型化 定义 特点 赋值兼容性 反身性 传递性 协变 逆变 双变 不变 思考 看个例子 原因是什么? 返回值 参数 总结 相关文章 前言 本文收录于TypeScript知识总结系列文章,欢迎指正! 第一次接触到变体这个概念是在深入理解TypeScript中,类型之间的转换 阅读全文
posted @ 2023-05-15 18:08 阿宇的编程之旅 阅读(156) 评论(0) 推荐(0) 编辑
摘要: 目录 前言 Iframe通信 Worker通信 实现思路 实现过程 MessageCenter类 IPC类 Server类 Client类 PeerToPeer 功能演示 基础功能 父子通信 兄弟通信 父子兄弟通信 线程通信 其他功能 函数调用 索引标识 卸载页面 重置页面 批量执行 批量操作 总结 阅读全文
posted @ 2023-05-09 15:44 阿宇的编程之旅 阅读(229) 评论(0) 推荐(0) 编辑
摘要: 前言 在2021年我实现了一个Node导出博客的功能:爬取接口及博客页面并导出为md文件格式。中途有许多迭代及优化以及解决了一些关键问题,写篇文章做个记录和review 博客更新功能 在原有的导出功能上增加了博客更新的功能,避免了每次都全部导出,是否消耗时间。在命令中新增-update命令进行升级操 阅读全文
posted @ 2023-04-24 10:09 阿宇的编程之旅 阅读(21) 评论(0) 推荐(0) 编辑
摘要: 目录 引言 d.ts声明文件 declare关键字 全局声明 全局声明方式 全局声明一般用作 函数声明 在.ts中使用declare 外部模块(文件模块) 模块关键字module 声明模块 模块声明方式 模块通配符 模块导出 模块嵌套 模块的作用域 模块别名 内部模块(命名空间) 命名空间 OR 模 阅读全文
posted @ 2023-04-18 16:54 阿宇的编程之旅 阅读(277) 评论(0) 推荐(0) 编辑
摘要: 目录 前言 准备工作 工作原理 功能设计 实现过程 基础概念 代理 请求 socket 控制台输入模块 配置文件 bingServer请求 bingSocket消息 子线程入口部分 主线程部分 工具函数 效果展示 写在最后 前言 ChatGPT在当下已然成为炙手可热的话题了,随着GPT-4的推出,网 阅读全文
posted @ 2023-04-13 11:31 阿宇的编程之旅 阅读(84) 评论(0) 推荐(0) 编辑
摘要: 目录 前言 Partial Required Readonly Pick,> Exclude,> Omit,> Record,> NonNullable ReturnType Parameters ConstructorParameters InstanceType ThisParameterTyp 阅读全文
posted @ 2023-04-09 19:39 阿宇的编程之旅 阅读(89) 评论(0) 推荐(0) 编辑
摘要: 目录 前言 泛型约束 联合类型+泛型约束 交叉类型+泛型约束 泛型约束泛型 递归类型别名 条件类型 分发条件类型 类型过滤 类型推导 infer关键字 回到类型推导 映射&索引类型 索引访问类型 映射类型 必选属性 可变属性 结语 相关文章 前言 本文收录于TypeScript知识总结系列文章,欢迎 阅读全文
posted @ 2023-04-03 13:18 阿宇的编程之旅 阅读(32) 评论(0) 推荐(0) 编辑
摘要: 目录 前言 定义 基本用法 泛型命名约定 泛型&类型别名 泛型&接口 泛型&函数 泛型&类 泛型默认值 结语 相关文章 前言 本文收录于TypeScript知识总结系列文章,欢迎指正! 代码复用是开发人员老生常谈的问题了,我们通过定义变量,使用函数或类减少代码重复编写。 在TS中我们可以把编写一个类 阅读全文
posted @ 2023-03-30 19:33 阿宇的编程之旅 阅读(43) 评论(0) 推荐(0) 编辑
摘要: 目录 前言 Why Not TS? 环境搭建 工具配置 编写代码 打包&发布 总结 示例代码 相关资料 前言 说到Rollup,大家可能并不陌生,它是一款JS的模块打包器,适合对工具库和组件进行打包,将多个模块合并成单个文件,与Webpack,Browserify等不太一样,其对更小更快的库比较友好 阅读全文
posted @ 2023-03-29 22:15 阿宇的编程之旅 阅读(297) 评论(0) 推荐(0) 编辑
摘要: 目录 前言 定义 类装饰器 基本用法 操作方式 操作类的原型 类继承操作 方法装饰器 属性装饰器 存取器装饰器 参数装饰器 基本用法 参数过滤器 元数据函数实现 参数过滤 效果实践 装饰器优先级 相同装饰器 不同装饰器 装饰器工厂 hooks与class兼容 结语 相关文章 前言 本文收录于Type 阅读全文
posted @ 2023-03-27 16:25 阿宇的编程之旅 阅读(506) 评论(0) 推荐(0) 编辑
点击右上角即可分享
微信分享提示