深入理解vue双向绑定原理,一文手把手带你用原生js实现vue双向绑定

一、vue双向绑定简介

        双向绑定简单来说,其实就是数据能影响视图,而视图也能反过来影响数据,例如当一个输入框和js对象的某个属性实现了双向绑定,那么属性值的变化会影响输入框中的值,输入框中的值变化也会影响属性的值,接下来,笔者将在此基础上,用原生js从零开始实现vue的双向绑定。 

 

二、代码编写

1. 事前准备

我们先准备好这样一个简单html页面,写过vue的同学应该都不会陌生,当然这里并没有引入vue的js,而是我们待会需要实现的MyVue.js

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="main">
        <div>
            <input type="text" v-model="name">
            <input type="text" v-model="msg.text"
        </div>
        <div>
            <div>name: {{ name }}</div>
            <div>msg: {{ msg.text }}</div>
        </div>
    </div>
    <script src="MyVue.js"></script>
    <script>
        const vue = new MyVue({
            el: '#main',
            data: {
                name: 'jonny',
                msg: {
                    text: 'hello'
                }
            }
        })    
    </script>
</body>
</html>

下面就是页面最开始的样子,视图和数据之间没有任何联系

接下来我们开始编写MyVue.js的逻辑

2.MyVue.js中的MyVue类

MyVue类是MyVue.js中的主体部分,先创建一个MyVue.js文件,之后我们先编写MyVue类的构造方法,先把传入对象的el属性和data属性赋值给成员属性

class MyVue {
    constructor(data_instance) {
        this.$data = data_instance.data
        this.$el = document.querySelector(data_instance.el)
    }
}

 3.设置getter和setter进行数据监听

        接下来就是第一个重点,数据监听,这里我们使用Object.defineProperty这个方法来实现数据劫持并实现数据监听,使用这个方法,来为$data中的每一个属性都设置getter和setter,当获取该属性值时,就会执行getter函数的代码,当为该属性设置值时,就会执行setter函数的代码,由于$data中的属性也有可能是一个对象,所以我们可以递归的去遍历每一个属性,并为其设置监听,当传入的值不是一个对象或者为null或undefined时,递归便可终止,我们可以在MyVue类中实现名叫ListenData的方法来设置监听,并在MyVue的构造方法中调用。我们可以在控制台打印一下看看数据劫持有没有成功。

class MyVue {
    constructor(data_instance) {
        this.$data = data_instance.data
        this.$el = document.querySelector(data_instance.el)
        //调用方法,进行数据监听
        MyVue.listenData(this.$data)
    }

    static listenData(data_instance) {
        //当传入的参数不是对象或者为null和undefined时,递归终止
        if (!data_instance || typeof data_instance !== 'object') return 

        //Object.keys方法可获取对象中的所有属性名,返回一个数组,遍历数组并进行数据劫持
        Object.keys(data_instance).forEach(key => {

        //这里需要用临时变量value保存一下属性值,因为调用了设置了get和set后数据就会被劫持
            let value = data_instance[key]
            MyVue.listenData(value)
            Object.defineProperty(data_instance, key, {

                //设置getter
                get: () => {
                    
                    //打印测试
                    console.log('get value')
                    return value
                },

                //设置setter
                set: (newVal) => {

                    //打印测试
                    console.log('set value')
                    value = newVal
                    
                    //每次设置新值时我们也要把新的值递归遍历设置监听
                    MyVue.listenData(value)
                }
            })
        })
    }
}

我们可以在控制台测试一下,成功打印信息,getter setter设置成功

4.解析页面信息 

接下来我们就需要解析页面的重点信息,把带有v-model和有{{}}符号的信息提取出来,这里我们将使用js的文档碎片来实现,即document fragment。文档碎片是一个轻量级的document对象,我们可以在文档碎片中解析信息,然后再把它拼接回去,我们再MyVue类中实现docParse方法,并在构造方法中调用

constructor(data_instance) {
    this.$data = data_instance.data
    this.$el = document.querySelector(data_instance.el)
    MyVue.listenData(this.$data)

    //调用方法
    MyVue.docParse(this)
}
static docParse(vm) {
    
    //获取文档碎片
    let fragment = document.createDocumentFragment()
    let node

    //将页面中的节点循环加入到文档碎片中
    while (node = vm.$el.firstChild) {
        fragment.append(node)
    }

}

 这时刷新你的页面,如果你发现页面空空如也,那么就说明节点加入文档碎片成功了

接下来我们开始对文档碎片中的内容进行处理,实现名为processFragment的方法,把文档碎片对象和vm传入,我们首先解析像{{xxx}}这样格式的文本信息我们可以用正则匹配来实现,即/\{\{\s*(\S+)\s*\}\}/,\s*是因为前后可能有空格,\S+则是我们真正需要的字符串,即data对象中的属性,当然,节点可以包含节点,所以我们可以用递归的方法来遍历所有节点,把节点类型为3(即文本节点)并且能够通过正则匹配的节点文本信息挑选出来,调用exec进行正则匹配,然后把文本信息替换为$data中的属性信息,最后再把文档碎片放回页面中。

 

    static docParse(vm) {
        let fragment = document.createDocumentFragment()
        let node
        while (node = vm.$el.firstChild) {
            fragment.append(node)
        }
        MyVue.processFragment(fragment, vm)

        //一定要把文档碎片加回去,否则页面不会显示
        vm.$el.appendChild(fragment)
    }

    static processFragment(node, vm) {

        //正则表达式
        let match = /\{\{\s*(\S+)\s*\}\}/

        //获取节点类型为3的节点
        if (node.nodeType === 3) {
        
            //用exec进行正则匹配
            let res = match.exec(node.nodeValue)

            //判断是否匹配成功
            if (res) {

                //打印测试
                console.log(res)
            }
            return
        }
        
        //递归的遍历每一个节点的子节点
        node.childNodes.forEach(e => MyVue.processFragment(e, vm));
    }

刷新页面后控制台就会打印筛选之后的信息

发现返回的是数组,而且数组的第二个元素,也就是下标为1的元素才是我们所需要的,里面的文本可以帮助我们获取$data中的属性值,可以直接用$data['name']这样的方式获取值,但是有些属性是嵌套的,例如msg.text,直接用$data['msg.text']是获取不到的,所以这里我们需要链式获取,把字符串用'.'进行分割获取字符串数组, 然后用数组的reduce方法进行链式获取获取信息后我们再把{{}}中的信息替换为$data中属性的值

static docParse(vm) {
        let fragment = document.createDocumentFragment()
        let node
        while (node = vm.$el.firstChild) {
            fragment.append(node)
        }
        MyVue.processFragment(fragment, vm)
        vm.$el.appendChild(fragment)
    }

    static processFragment(node, vm) {
        let match = /\{\{\s*(\S+)\s*\}\}/
        if (node.nodeType === 3) {
            let res = match.exec(node.nodeValue)
            if (res) {

                /*将我们需要的字符串的下标用'.'进行分割获取字符串数组,然后使用
                   reduce方法进行链式获取,reduce中初始值的参数为$data,这样即便
                    是嵌套的属性也能获取到属性值*/
                let text = res[1].split('.').reduce((pre, next) => pre[next], vm.$data)

                //将节点文本中的{{xxx}}替换为$data中的数据
                node.nodeValue = node.nodeValue.replace(match, text)
            }
            return
        }
        node.childNodes.forEach(e => MyVue.processFragment(e, vm));
    }

当我们再次刷新页面时,页面中的{{xxx}}已经成功替换成了$data中的数据了

 

 5.实现视图绑定数据

虽然信息可以再页面成功显示,但是当数据发生变化时,视图并不会发生变化,要实现数据变化影响视图变化,这里就需要发布者-订阅者模式。我们先创建发布者类Publish,该类维护了一个订阅者数组,Publish类负责通知订阅者更新数据

class Publish {
    //构造方法,创建订阅者数组
    constructor() {
        this.subs = []
    }

    //向订阅者数组中添加订阅者
    addSub(sub) {
        this.subs.push(sub)
    }

    //通知每个订阅者更新数据
    notify() {
        this.subs.forEach(e => e.update())
    }
}

 接着我们再创建一个订阅者类,为了方便,我们向构造方法中传入vm, 属性字符串(例如'msg.text'), 和一个回调函数,用来告诉订阅者如何更新自己的数据,这里使用回调是因为后面可以用闭包,可以少传参数

class Subscribe {

    //构造方法
    constructor(vm, attribute, callback) {
        this.$vm = vm
        this.$attribute = attribute
        this.$callback = callback
    }

    //订阅者更新方法,具体逻辑我们稍后来实现
    update() {

    }
}

 创建好订阅者和发布者之后,我们的问题就变成了什么时候创建订阅者和发布者对象

先说订阅者,我们其实在解析页面的时候就可以创建订阅者了,我们回到processFragment方法,加上创建订阅者的代码。传入属性字符串和负责更新的回调函数。

    static processFragment(node, vm) {
        let match = /\{\{\s*(\S+)\s*\}\}/
        if (node.nodeType === 3) {
            let res = match.exec(node.nodeValue)

            //这里添加一个临时变量来存储一下nodeValue
            let nodeTemp = node.nodeValue
            if (res) {
                let text = res[1].split('.').reduce((pre, next) => pre[next], vm.$data)
                node.nodeValue = node.nodeValue.replace(match, text)

                //创建订阅者对象
                new Subscribe(vm, res[1], newVal => {

                    //这里是数据更新的方式,就是更新时,把页面节点的值替换为新的值
                    node.nodeValue = nodeTemp.replace(match, newVal)
                })
            }
            return
        }
        node.childNodes.forEach(e => MyVue.processFragment(e, vm));
    }

 之后我们就需要把订阅者对象添加到发布者对象的订阅者数组里了,我们可以在订阅者类的构造方法中加入新的逻辑,把订阅者自己存在一个临时变量里,然后触发设置了监听属性的getter方法,把订阅者加入发布者的订阅者数组中

class Subscribe {
    constructor(vm, attribute, callback) {
        this.$vm = vm
        this.$attribute = attribute
        this.$callback = callback

        //将自己放入发布者类的一个临时变量中
        Publish.temp = this

        //这里是为了能够触发每一个属性的getter方法,来把该订阅者添加到发布者中
        attribute.split('.').reduce((pre, next) => pre[next], vm.$data)
    
        //放到发布者的订阅者数组中后,要将其置为空
        Publish.temp = null
    }
    update() {

    }
}

 因为我们是在设置监听时的getter中把订阅者加入订阅者数组的,所以我们需要回到listenData方法中去添加代码,首先我们要在方法里创建发布者类,在getter中添加订阅者,在setter中调用notify方法来通知订阅者更新节点信息

    static listenData(data_instance) {
        if (!data_instance || typeof data_instance !== 'object') return 

        //创建发布者对象
        const publish = new Publish()
        Object.keys(data_instance).forEach(key => {
            let value = data_instance[key]
            MyVue.listenData(value)
            Object.defineProperty(data_instance, key, {
                get: () => {
                    
                    //判断发布者中的临时变量是否存在,存在就将其加入到订阅者数组中
                    if (Publish.temp) {
                        publish.addSub(Publish.temp)
                    }
                    return value
                },
                set: (newVal) => {
                    value = newVal
                    MyVue.listenData(value)

                    //设置完新之后,通知订阅者更新页面的节点信息
                    publish.notify()
                }
            })
        })
    }

到这一步之后,我们就可以回去完成订阅者类的update方法了

class Subscribe {
    constructor(vm, attribute, callback) {
        this.$vm = vm
        this.$attribute = attribute
        this.$callback = callback
        Publish.temp = this
        attribute.split('.').reduce((pre, next) => pre[next], vm.$data)
        Publish.temp = null
    }
    update() {

        //获取$data中对应的属性的值
        let val = this.$attribute.split('.').reduce(
            (pre, next) => pre[next], this.$vm.$data
        )

        //调用传入的回调函数,也就是更新页面节点的值
        this.$callback(val)
    }
}

到这,视图绑定数据就完成了,可以测试一下,当$data中的值改变时,视图的值也会一起发生变化

6.实现数据绑定视图 

视图变化,数据也要发生变化。我们需要在页面中解析出带有v-model属性的input元素,然后当input的value发生变化时,$data中的数据也要一起发生变化。 我们回到processFragment方法,向里面添加筛选input标签的代码,其具体逻辑和之前处理文本节点大致相同

    static processFragment(node, vm) {
        let match = /\{\{\s*(\S+)\s*\}\}/
        if (node.nodeType === 3) {
            let res = match.exec(node.nodeValue)
            let nodeTemp = node.nodeValue
            if (res) {
                let text = res[1].split('.').reduce((pre, next) => pre[next], vm.$data)
                node.nodeValue = node.nodeValue.replace(match, text)
                new Subscribe(vm, res[1], newVal => {
                    node.nodeValue = nodeTemp.replace(match, newVal)
                })
            }
            return
        }

        //判断是否为input节点
        if (node.nodeType === 1 && node.nodeName === 'INPUT') {
            let attr = Array.from(node.attributes)

            //遍历节点属性值,判断是否有v-model标签
            attr.forEach(e => {
                if (e.nodeName === 'v-model') {
                    let v = e.nodeValue.split('.').reduce(
                        (pre, next) => pre[next], vm.$data
                    )
                    node.value = v
                
                    //同样需要一个订阅者来实时更新input中的value值
                    new Subscribe(vm, e.nodeValue, newVal => {
                        node.value = newVal
                    })
                }
            })
        }

        node.childNodes.forEach(e => MyVue.processFragment(e, vm));
    }

 再次刷新页面,就可以发现输入框中也已经和数据实现了绑定

接下来,就是最后一步了,当input框中的内容发生变化时,也要影响$data中的数据,这里可以给input元素添加监听事件来实现,然后把文本值赋值给$data中的属性。由于这里是给属性设置值,所以需要用一个中间数组转换一下。

 

static processFragment(node, vm) {
        let match = /\{\{\s*(\S+)\s*\}\}/
        if (node.nodeType === 3) {
            let res = match.exec(node.nodeValue)
            let nodeTemp = node.nodeValue
            if (res) {
                let text = res[1].split('.').reduce((pre, next) => pre[next], vm.$data)
                node.nodeValue = node.nodeValue.replace(match, text)
                new Subscribe(vm, res[1], newVal => {
                    node.nodeValue = nodeTemp.replace(match, newVal)
                })
            }
            return
        }
        if (node.nodeType === 1 && node.nodeName === 'INPUT') {
            let attr = Array.from(node.attributes)
            attr.forEach(e => {
                if (e.nodeName === 'v-model') {
                    let v = e.nodeValue.split('.').reduce(
                        (pre, next) => pre[next], vm.$data
                    )
                    node.value = v
                    new Subscribe(vm, e.nodeValue, newVal => {
                        node.value = newVal
                    })


                    //为input节点添加事件监听,当输入文本时便更新$data中的数据
                    node.addEventListener('input', ev => {
                        let temp = e.nodeValue.split('.')
                        let temp_1 = temp.splice(0, temp.length - 1)
                        let temp_2 = temp_1.reduce((pre, next) => pre[next], vm.$data)

                        //将$data中对应属性的值设置为input中的值
                        temp_2[temp[temp.length - 1]] = ev.target.value
                    })
                }
            })
        }

        node.childNodes.forEach(e => MyVue.processFragment(e, vm));
    }

至此,双向绑定完成,刷新页面,输入框中值变化时,页面中的其他文本包括数据也会一起变化。

三、总结

vue2双向绑定的核心就是利用Object.defineProperty方法,对vm对象进行数据劫持,最终实现双向绑定的功能,vue3采用的是Proxy,整体上的原理大同小异,最后附上MyVue.js的全部源码

class MyVue {
    constructor(data_instance) {
        this.$data = data_instance.data
        this.$el = document.querySelector(data_instance.el)
        MyVue.listenData(this.$data)
        MyVue.docParse(this)
    }

    static docParse(vm) {
        let fragment = document.createDocumentFragment()
        let node
        while (node = vm.$el.firstChild) {
            fragment.append(node)
        }
        MyVue.processFragment(fragment, vm)
        vm.$el.appendChild(fragment)
    }

    static processFragment(node, vm) {
        let match = /\{\{\s*(\S+)\s*\}\}/
        if (node.nodeType === 3) {
            let res = match.exec(node.nodeValue)
            let nodeTemp = node.nodeValue
            if (res) {
                let text = res[1].split('.').reduce((pre, next) => pre[next], vm.$data)
                node.nodeValue = node.nodeValue.replace(match, text)
                new Subscribe(vm, res[1], newVal => {
                    node.nodeValue = nodeTemp.replace(match, newVal)
                })
            }
            return
        }

        if (node.nodeType === 1 && node.nodeName === 'INPUT') {
            let attr = Array.from(node.attributes)
            attr.forEach(e => {
                if (e.nodeName === 'v-model') {
                    let v = e.nodeValue.split('.').reduce(
                        (pre, next) => pre[next], vm.$data
                    )
                    node.value = v
                    new Subscribe(vm, e.nodeValue, newVal => {
                        node.value = newVal
                    })
                    node.addEventListener('input', ev => {
                        let temp = e.nodeValue.split('.')
                        let temp_1 = temp.splice(0, temp.length - 1)
                        let temp_2 = temp_1.reduce((pre, next) => pre[next], vm.$data)
                        temp_2[temp[temp.length - 1]] = ev.target.value
                    })
                }
            })
        }
        node.childNodes.forEach(e => MyVue.processFragment(e, vm));
    }

    static listenData(data_instance) {
        if (!data_instance || typeof data_instance !== 'object') return 
        const publish = new Publish()
        Object.keys(data_instance).forEach(key => {
            let value = data_instance[key]
            MyVue.listenData(value)
            Object.defineProperty(data_instance, key, {
                get: () => {
                    if (Publish.temp) {
                        publish.addSub(Publish.temp)
                    }
                    return value
                },
                set: (newVal) => {
                    value = newVal
                    MyVue.listenData(value)
                    publish.notify()
                }
            })
        })
    }
}

class Publish {
    constructor() {
        this.subs = []
    }
    addSub(sub) {
        this.subs.push(sub)
    }
    notify() {
        this.subs.forEach(e => e.update())
    }
}

class Subscribe {
    constructor(vm, attribute, callback) {
        this.$vm = vm
        this.$attribute = attribute
        this.$callback = callback
        Publish.temp = this
        attribute.split('.').reduce((pre, next) => pre[next], vm.$data)
        Publish.temp = null
    }
    update() {
        let val = this.$attribute.split('.').reduce(
            (pre, next) => pre[next], this.$vm.$data
        )
        this.$callback(val)
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值