实现一个类似vue的双向绑定

305 阅读3分钟

vue用了有段时间了,参考了github的文章,写了个简化版

github文章链接:github.com/DMQ/mvvm

如果看不懂就看我的啊,嘿嘿嘿

前言

使用Vue也有一段时间了,vue作为一个MVVM框架,最有名的就是双向绑定

一般来说当数据变化时,视图层跟着变化,这是单向的

当视图层变化时,数据也跟着变,这就是双向的

比如在vue中通过v-model绑定的输入框,当输入框内值变化时数据也跟着变化

今天就来实现一个类似于vue的双向绑定,预期效果:

// 视图层
<div id="app">
    this is {{name}}, {{age}} years old!
    <h1>
        {{name}}
    </h1>
</div>

// 数据层
var vm = new MVVM({
    el: '#app',
    data: {
        name: 'jianqi',
        age: 3
    }
})

Object.defineProperty的使用

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。 MDN:dwz.cn/umKlHoaq

定义对象descriptor时注意事项:

  1. 属性描述(configurable,enumerable): 通过这个定义的属性默认不可被删除(delete obj.xxx),默认不可遍历(for in)
  2. 值描述(value, writable): value和writable,value默认为undefined,writable默认为false值不能被改变
  3. 存取描述:get,set函数
  4. 值描述符和存取描述符不能同时存在

观察者模式

Subject是一个主题(网站)。Observer相当于一个个的观察者(网民),他们可以订阅Subject,当Subject更新时通知Observer,触发Observer之前定义的回调。

//es6实现
class Subject {
    constructor(){
        this.state = 0
        this.observers = []
    }
    getState() {
        return this.state
    }
    setState(state){
        this.state = state
        this.notifyAllObservers()
    }
    notifyAllObservers(){
        this.observers.forEach(observer=>{
            observer.update()
        })
    }
    attach(observer) {
        this.observers.push(observer)
    }
}

class Observer {
    constructor(name, subject){
        this.name = name
        this.subject = subject
        this.subject.attach(this)
    }
    update() {
        console.log(`${this.name} update,state:${this.subject.getState()}`)
    }
}

let s = new Subject()
let o = new Observer('o', s)
let o2 = new Observer('o2', s)
s.setState(1)

实现原理

通过使用Object.defineProperty(数据拦截)和观察者模式实现双向绑定

  1. 主题是什么? data中的一个个key,比如name
  2. 观察者是什么? 视图里面的{{xxxx}},需要被替换成数据的地方
  3. 观察者什么时候订阅?
    一开始执行MVVM初始化时候根据el遍历dom节点,发现{{xxxx}}时候时订阅对应主题xxxx
  4. 主题什么时候通知更新?
    当xxxx改变时,通知观察者更新内容。可以在一开始就监控data通过Object.defineProperty()实现

实现单向数据流(数据=>视图)


// 总入口
class MVVM {
    constructor(opts){
    	// 初始化,绑定数据到vm对象上
        this.init(opts)
        // 遍历所有data值,设置get和set方法
        observe(this.$data)
        // 遍历dom树,查找{{}}的特殊标记
        this.compile()
    }
    init(opts){
        this.$el = document.querySelector(opts.el)
        this.$data = opts.data
    }
    compile(){
        this.traverse(this.$el)
    }
    traverse(node){
        if(node.nodeType === 1){
            node.childNodes.forEach(childNode=>{
                this.traverse(childNode)
            })
        } else if(node.nodeType === 3){
            this.renderText(node)
        }
    }
    renderText(node){
        let reg = /{{(.+?)}}/g
        let match
        while (match = reg.exec(node.nodeValue)){
            let raw = match[0]
            let key = match[1].trim()
            
            // 初始化时绑定data里的数据到视图对应节点
            node.nodeValue = node.nodeValue.replace(raw, this.$data[key])

            // 对当前的key设置监听
            new Observer(this, key, function (val, oldVal) {
                node.nodeValue = node.nodeValue.replace(oldVal, val)
            })
        }
    }
}



let currentObserver = null

// 遍历data,添加监听的observe方法
function observe(data) {
    if(!data || typeof data !== 'object') return
    for(var key in data){

    	//如果下面的get方法直接返回data[key]会引起循环调用导致栈溢出
        let val = data[key]

        //每一个key都有一个自己的subject
        let subject = new Subject()
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
            get: function () {
                console.log('run get')

                // currentObserver是一个全局的对象
                if(currentObserver){

                	// 订阅某一个subject(data中的某一个键值对的key)
                    currentObserver.subscribeTo(subject)
                }
                return val
            },
            set: function (newVal) {
                val = newVal
                console.log('run set')

                // 更改了值,通知所有订阅者
                subject.notify()
            }
        })
        if(typeof val === 'object'){
            observe(val)
        }
    }
}



// 主题Subject,data中的每一个key都是一个subject
class Subject {
    constructor(){
        // 一个主题会有多个订阅者,比如说在视图中会有多个地方有{{name}}
        this.observers = []
    }
    addObserver(observer){
        this.observers.push(observer)
    }
    notify(){
        this.observers.forEach(observer=>{
            observer.update()
        })
    }
}



// 订阅者Observer
class Observer {
    constructor(vm, key, cb){
        this.vm = vm
        this.key = key
        this.cb = cb

        // currentObserver是一个全局的变量,设置他有值
        // 并执行下一行触发get方法,会运行observe方法中的订阅操作
        currentObserver = this
        this.value = this.getValue()
        currentObserver = null
    }
    update(){
        let oldVal = this.value
        let value = this.getValue()
        if(value !== oldVal){
            this.value = value
            this.cb.bind(this.vm)(value, oldVal)
        }
    }
    subscribeTo(subject){
        subject.addObserver(this)
    }
    getValue(){

        // 这里会触发data中元素的get函数
        let value = this.vm.$data[this.key]
        return value
    }
}
// 调用
<div id="app">
    this is {{name}}, {{age}} years old!
</div>
    
<script type="text/javascript">
    var vm = new MVVM({
        el: '#app',
        data: {
            name: 'jianqi',
            age: 3
        }
    })
</script>

实现双向绑定

在vue中,如果要对一个input框进行双向绑定,需要设置v-model指令

这里我们进行相同的设置,对于设置了v-model的input实现双向绑定

在前面已经实现单向数据流的基础上很容易实现双向绑定

在解析dom时候检测v-model指令,对应的元素绑定监听事件,当值改变时触发设置data即可

// 修改上面代码的MVVM
class MVVM {

    // ......
    traverse(node){
        if(node.nodeType === 1){
            this.compileNode(node)
            node.childNodes.forEach(childNode=>{
                this.traverse(childNode)
            })
        } else if(node.nodeType === 3){
            this.renderText(node)
        }
    }

    // ......
    compileNode(node){
        let attrs = [...node.attributes]
        attrs.forEach(attr=>{
            if(this.isDirective(attr.name)){
                let key = attr.value
                node.value = this.$data[key]
                new Observer(this, key, function (newVal) {
                    node.value = newVal
                })
                node.oninput = (e)=>{
                    this.$data[key] = e.target.value
                }
            }
        })
    }

    isDirective(attrName){
        return attrName === 'v-model'
    }
}

调用

// 调用
<div id="app">
    this is {{name}}, {{age}} years old!
    <h1>
        {{name}}
    </h1>
    <input type="text" v-model="name">
</div>
    
<script type="text/javascript">
    var vm = new MVVM({
        el: '#app',
        data: {
            name: 'jianqi',
            age: 3
        }
    })
</script>