彻底学会实现双向绑定——把双向绑定刻在DNA里

双向绑定

本文适合初级前端开发者,但是如果你没有学过前端,或者是前端先辈大佬,也不要停下来啊!!

随便问前端,vue核心是什么?大家都会告诉你双向绑定!

面试官:首先能告诉我你的 年龄 职业吗?

答:是前端。

面试官:哦,是前端(轻蔑),还在写jquery吗?

答:(一转攻势)在写vue单页面应用。

面试官:噢,在写vue,基础不错,蛮扎实的吗(在杰难逃)来,给我康康~手写双向绑定

答:不要啊,杰哥!

面试挂~~


​ 为了应对上面不会手写双向绑定,本文将尽力讲双 向 绑 定,为的就是将双 向 绑 定刻在快没多少位置的DNA里面。

什么是双向绑定?

双向绑定就是,视图更新数据,数据更新视图视图与数据的双向更新绑定。

实现单一的双向绑定,确实不难。

首先,视图更新数据。

我们可以监听事件,在事件中更新数据,如

监听输入框的input事件(在输入框值改变时触发),修改我们的data

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>Learn Vue</title>
</head>
<body>
    <input type="text" name="id" id="id">
    <p id="p"></p>
    <script>
        var id = "114514"
        var id_input = document.querySelector("#id")
        id_input.addEventListener("input", updata)
        id_input.value = id
        var p = document.querySelector("#p")
        p.innerText = id;

        function updata(event) {
            id = event.target.value
            p.innerText = id
        }
    </script>
</body>
</html>

很简单~~,连道场附近的小鬼都会~~

那么怎么实现,数据更新视图呢?也就是我们为id赋值的时候,让id_input也变化?

怎么实现数据更新视图

我们从技术层面分析,有两种方法:

  1. 自动化轮询

    我们设置定时器观察id,如果值与原来的id不一样时,我们就去更新视图。这种方法比较耗费资源效率很低

  2. 脏检查

    与轮询道理是差不多的,但是我们并没有定时器去一遍一遍检查,而是等到在逻辑上会发生数据更新视图的时候检查,什么意思?具有良好逻辑的代码,在使用数据更新视图的时候一般在**ajaxUI事件**的时候,所以我们只在上面的情况中进行检查,称之为脏检查。

  3. 自动化的手动更新视图

    比较绕口。大致的更新情况是这样的:当我们数据改变时,我们调用我们自己封装的更新视图方法。

    比较常见的是React的setState()和微信小程序的setData()

  4. 数据劫持/拦截

    我们的本意是在数据发生变化的时候渲染视图,那么有没有数据发生变化时的钩子(Hook)函数

    您好,有的。挺多语言都有,这里举js的例子Object.definePropertyProxy

从技术层面我们可以通过上面4种方法实现,除了第一种比较拉胯,其他的都比较不错,所以造就了现在前端框架三足鼎立之势:React手动通知、AngularJS脏检查、Vue数据劫持。

今天我们是探究数据劫持的,所以我要拿Vue做例子。好了废话不多说,直接开冲!

Object.defineProperty

Object.defineProperties() 方法直接在一个对象上定义新的属性或修改现有属性,并返回该对象。

这个方法最主要能为我们提供属性值的getset方法的自定义。

let obj = {}
Object.defineProperty(obj, "name", {
    get() {
        console.log("get!")
        return this.value
    },
    set(value) {
        console.log("set!")
        this.value = value
    }
})
obj.name = "123"
console.log(obj.name)

现在使用我们的新知识改造我们上面的例子。

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>Learn Vue1.1</title>
</head>
<body>
    <input type="text" name="id" id="id">
    <p id="p"></p>
    <script>
        var id_input = document.querySelector("#id")
        var p = document.querySelector("#p")
        var obj = {}
        Object.defineProperty(obj,"id",{
            get(){
                return this.value
            },
            set(value){
                this.value=value
                id_input.value = value
                p.innerText = value
            }
        })        
        obj.value = "114514"
        id_input.addEventListener("input", updata)
        id_input.value = obj.id
        p.innerText = obj.id;
        function updata(event) {
            obj.id = event.target.value
            p.innerText = obj.id
        }
    </script>
</body>
</html>

看上去也不难,而且和Vue有点区别,别着急我们接着讲。

有一说一,确实。实现双向绑定不难,难的是解耦自动化。就像上面的例子,强耦合,且是我们手动绑定,这很不爽。所以我们要开始解耦自动化

正如我们所熟知的解耦必定提高复杂性,接下来一套操作可能会有点复杂,请细细品味。

观察者模式 Observer 实现

​ 为了实现解耦自动化,我们使用观察者模式。好好思考我们的核心问题。我们定义了一个需要双向绑定的对象,当对象属性改变时调用更新视图方法,为了解耦,我们将对象属性改变时调用这个操作委托给另一个组件来做这件事,这个组件就是观察者(Observer),而将调用更新视图方法这个操作委托给监听者(Watcher)

观察者(Observer)的工作是监听属性的set方法调用。当观察者(Observer) 察 觉 了属性值被改变,将告诉监听者(Watcher),**监听者(Watcher)**会调用更新视图的方法。

​ 为了让大伙更好理解,我们自上而下构建,即框架先搭出来,内容后填。注意我会用大量ES6语法,一定不要让ES6语法拉了胯啊。

首先,构建一个像Vue的语法。

let vm = MyVue({
    el:"#root",
    data:{
        name:"yhy"
    }
})
//ok 很像了 我们来内部实现一下
class MyVue{
    constructor(options) {
        this.$data = options.data;//挂载在$data上
        this.$el = document.querySelector(options.el);
    }
}

现在,为了监听$data内部set方法,我们必须实现Observer,让它来处理Object.defineProperty

ok,继续

function isObject(obj) {//全局方法,检测是否为Object类型
	return (obj && typeof obj === "object")
}
class Observer {
            constructor(data) {//data是我们要监听的对象
                this.init(data)
            }
            init(data) {
                if (isObject(data)) {//对data每一个属性添加我们自定的get set方法
                    Object.keys(data).forEach((key, index) => {
                        this.defineReactive(data, key, data[key])
                    })
                } else {
                    throw new Error("Observer必要Object参数!")
                }
            }
            defineReactive(data, key, value) {
                let watcher = null;//每一个被监听属性维护的观察者
                Object.defineProperty(data, key, {
                    get: function () {
                        //我们应该在此处加上订阅者,添加进订阅器内。
                        return value
                    },
                    set: function (newValue) {
                        if (newValue !== value) {
							//我们在此处应该通知 订阅器,告诉它值变化了。
                        }
                        console.log("监听到了!")
                    },
                })
            }
        }

为什么在get中添加订阅者?

好好想一下,每一个被监听的属性是不是要维护一个观察者(Watch),他们是一对一的,但是属性本身没有内部成员了,我们无法像调用Object那样为它添加观察者(Watch),那么,我们要把它们(被监听属性与观察者(Watch))的关系映射到另一个对象中吗?如hashMap?

请不要这样做,这样的逻辑是不合理的!观察者(Watch)本身就是被监听属性的成员(姑且这么叫它),为了更好维护,我们必须让它们结合在一起,所以我们可以使用闭包,让get访问到外部变量(毕竟内部无法设置变量),且是唯一变量。get、与set本身就是闭包函数,所以能唯一访问上面代码中的watcher变量。

那么为什么要在get中添加呢?上面说了get、set都是闭包函数,为什么我么要在get中进行?

答:get符合逻辑

​ 当我们要为属性添加观察者(Watcher)的时候,我们应当调用get或者set,如果我们调用set等于无意义的赋值,不如使用get方法,vm.$data[prop]简单直接。

好的,不是很难理解吧!我们拒绝弹射起步!!!

现在,我们要把MyVueObserve合并一下,先实现对MyVue中的属性修改。让其通过通过我们的setget方法!

class MyVue {
    constructor(options) {
        this.$options = options;
        this.$data = options.data;
        this.$el = document.querySelector(options.el);
        this.init();
    }
    init(){
        new Observer(this.$data)
    }
}
function isObject(obj) {
    return (obj && typeof obj === "object")
}
class Observer {
    constructor(data) {
        this.init(data)
    }
    init(data) {
        if (isObject(data)) {
            Object.keys(data).forEach((key, index) => {
                this.defineReactive(data, key, data[key])
            })
        } else {
            throw new Error("Observer必要Object参数!")
        }
    }
    defineReactive(data, key, value) {
        Object.defineProperty(data, key, {
            get: function () {
                return value
            },
            set: function (newValue) {
                if (newValue !== value) {
                }
                console.log("监听到了!")
            },
        })
    }
}
let vm = new MyVue({
    el:"#root",
    data:{
        name:"田所浩二"
    }
})
vm.$data.name = "野 兽 先 辈"

打开控制台,明显看到监听到了!字样,很好,再试试别的。

let vm = new MyVue({
    el:"#root",
    data:{
        obj:{
        	name:"田所浩二"
        }
    }
})
vm.$data.obj.name = "野 兽 先 辈"

我们把name放在obj之中,加深了一层,这次运行我们发现**没有输出监听到了!**字样。怎么大家都挺猛到你这拉了胯呢?这是因为我们设置Object.defineProperty的时候,只是为第一层设置了,所以我们要改进我们代码,为data所有Object也设置,同时考虑到Object嵌套问题,我们可以使用递归来设置它们的Object.defineProperty

好的,改进我们代码!

class Observer{
    /*some thing*/
    init(data) {
        if (isObject(data)) {
            Object.keys(data).forEach((key, index) => {
                 if (isObject(data[key])) {
                     this.init(data[key])
                 }
                this.defineReactive(data, key, data[key])
            })
        } else {
            throw new Error("Observer必要Object参数!")
        }
    }
}

ok,解决了这些问题,我们要开始实现Watch

Watch实现

class Watch {
    //为了对MyVue对象设置 我们必须得有三个参数
    constructor(vm, prop, callback) {
        this.vm = vm;//MyVue实例
        this.prop = prop;//为MyVue实例prop属性添加的回调
        this.callback = callback;//回调方法
        this.setObserverTarget()//设置watch
    }
    //调用回调方法
    updata(value) {
        this.callback(value)
    }
    setObserverTarget() {
        //将属性与watch联系起来
        vm.$data[prop]//调用get方法
    }
}

因为我们在与get方法中设置,在set方法中调用。所以我们肯定要修改Observe中的getset方法

// some things
let watcher = null
Object.defineProperty(data, key, {
    get: function () {
        if (!watcher) {
            watcher = new Watch(vm,prop,()=>{
                //do somethings
            })
        }
        return value
    },
    set: function (newValue) {
        if (newValue !== value) {
            value = newValue
            watch.updata(value)
        }
    },
})

到这一步,大家应该都能明白把,现在我们属性Watcher属于一对一的关系,当属性改变时调用watch.updata方法。但是这样的逻辑是不严谨的,我们一个属性可能与多个事件绑定!那么,我们与其让属性维护一个watcher不如直接让它维护一个WatchList,在WatchList中我们放上所有Watch,触发set方法时,我们去广播通知WatchList中所有的Watcher

WatchList

class WatchList extends Array {
	constructor(...arr) {
    	super(...arr)
	}
    notify(value) {
        for (let i in this) {
            this[i].updata(value) ///调用每一个Watch的updata方法
        }
    }
}

现在对了,再次修改我们上面的代码

defineReactive(data, key, value) {
    let watchList = new WatchList();
    Object.defineProperty(data, key, {
        get: function () {
            if (/*这里面放什么?*/) {
            }
            return value
        },
        set: function (newValue) {
            if (newValue !== value) {
                value = newValue
                watchList.notify(value)
            }
            console.log("监听到了!")
        },
    })
}

现在有个问题,看到我们上面的代码,if()里面我们应该放什么?

if里面应该添加我们的watch,那么一一对应的watch应该保存在哪?

这里我们可以用静态变量来做,因为添加属性的watch,不是get的事情,而是我们Watch的事情,而watch又是watchlist维护的,那么我们可以这么写。

下面应该是你的代码的终极版!

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>Learn Vue1.4</title>
</head>
<body>
    <input type="text" id="name"/>
    <script>
        function isObject(obj) {
            return (obj && typeof obj === "object")
        }
        class MyVue {
            constructor(options) {
                this.$options = options;
                this.$data = options.data;
                this.$el = document.querySelector(options.el);
                this.init();
            }
            init(){
                new Observer(this.$data)
            }
        }
        class Observer {
            constructor(data) {
                this.init(data)
            }
            init(data) {
                if (isObject(data)) {
                    Object.keys(data).forEach((key, index) => {
                         if (isObject(data[key])) {
                             this.init(data[key])
                         }
                        this.defineReactive(data, key, data[key])
                    })
                } else {
                    throw new Error("Observer必要Object参数!")
                }
            }
            defineReactive(data, key, value) {
                let watchList = new WatchList();
                Object.defineProperty(data, key, {
                    get: function () {
                        if (WatchList.target) {
                            watchList.push(WatchList.target)
                        }
                        return value
                    },
                    set: function (newValue) {
                        if (newValue !== value) {
                            value = newValue
                            watchList.notify(value)
                        }
                    },
                })
            }
        }
        class WatchList extends Array {
            static target = null;
            constructor(...arr) {
                super(...arr)
            }
            notify(value) {
                for (let i in this) {
                    this[i].updata(value) ///调用每一个Watch的updata方法
                }
            }

        }
        class Watch {
            constructor(vm, prop, callback) {
                this.vm = vm;
                this.prop = prop;
                this.callback = callback;
                this.setObserverTarget()
            }
            updata(value) {
                this.callback(value)
            }
            setObserverTarget() {
                WatchList.target = this
                const value = this.vm.$data[this.prop]
                WatchList.target = null;
            }
        }
        let vm = new MyVue({
            el:"#root",
            data:{
                name:"123",
                obj:{
                    name:"1"
                }
            }
        })
        new Watch(vm,"name",(value)=>{
            document.querySelector("#name").value = value
        })
    </script>
</body>
</html>

这一版的效果了不得啊,我们已经实现了数据更新视图,而且是低耦合,你现在可以打开F12控制台工具,在console里面输入vm.$data.name = "你好vue",不出意外(什么叫TM的意外?意外就是你还用IE8浏览器学代码!),就可以看到输入框的值变化了!

Compile 解析器 实现

上面我们实现了单向数据绑定解耦,为了实现双向自动绑定自动化

我们的自动化一定是期望使用vue模板语法来实现的就像下面例子一样。

<input v-model="name"/>
<p>{{name}}<p>

所以这一节我们会大量使用DOM操作

class Compile {
    constructor(vm) {
        this.vm = vm;
        this.el = vm.$el;
        this.fragment = this.nodeFragment();
        this.compileElement(this.fragment, this.vm)
        this.el.appendChild(this.fragment)
    }
    nodeFragment() {
        let el = this.el;
        const fragment = document.createDocumentFragment();
        let child = el.firstChild;
        while (child) {
            fragment.appendChild(child);
            child = el.firstChild;
        }
        return fragment;
    }
    compileElement(node, vm) {
        var reg = /\{\{(.*)\}\}/;
        //节点类型为元素(input元素这里)
        for (let item of Array.from(node.children)) {
            if (item.nodeType === 1) {
                let nodeAttrs = item.attributes;
                [...nodeAttrs].forEach(attr => {
                    let name = attr.name;
                    if (this.isDirective(name)) {
                        let value = attr.value;
                        if (name === "v-model") {
                            this.compileModel(item, value);
                        }
                    }
                });
                let text = item.textContent;
                if (reg.test(text)) {
                    let prop = reg.exec(text)[1];
                    this.compileText(item, prop); //替换模板
                }
            }
        }
    }
    compileText(node, prop) {
        let text = this.vm.$data[prop];
        this.updataText(node, text);
        new Watch(this.vm, prop, (value) => {
            this.updataText(node, value);
        });
    }
    isDirective(attr) {
        return attr.indexOf('v-') !== -1;
    }
    updateModel(node, value) {
        node.value = typeof value == 'undefined' ? '' : value;
    }
    updataText(node, value) {
        node.innerText = typeof value == 'undefined' ? '' : value;
    }
    compileModel(node, prop) {
        let val = this.vm.$data[prop];
        node.value = val
        new Watch(this.vm, prop, (value) => {
            this.updateModel(node, value)
        });
        node.addEventListener('input', e => {
            let newValue = e.target.value;
            if (val === newValue) {
                return;
            }
            this.vm.$data[prop] = newValue;
        });
    }
}

逻辑比较简单,稍微说一下吧,首先为了性能和尽量同一更改document对象,我们使用了createDocumentFragment来创建一个文档碎片,可以把它理解为不在document中的节点。

然后我们遍历这个文档碎片,对其中我们要替换的/绑定的元素,进行设置Watch

ok,现在我们的解耦自动化双向绑定就实现了,里面还有一些问题,我把它留给大家,请思考解决v-model="obj.name"这种问题如何绑定?

最后

脑瘫码农 纯属自学 如有错误 望请指正 共同学习 不胜感激

参考

发布了31 篇原创文章 · 获赞 38 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/yhy1315/article/details/100184530