快速实现一个数据的双向绑定

114 阅读4分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第4天,点击查看活动详情

要实现类似Vue这种类mvvm框架的双向绑定功能,就必须要实现以下几点:

  • 1、实现一个数据监听器Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者;

  • 2、实现一个指令解析器Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数;

  • 3、实现一个Watcher,作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图;主要做3件事——1)在自身实例化时往属性订阅器(dep)里面添加自己;2)自身必须有一个update()方法;3)待属性变动dep.notify()通知时,能调用自身的update()方法,并触发Compile中绑定的回调;

  • 4、mvvm入口函数,整合以上三者。通过Observer来监听自己的model数据变化,通过 Compile来解析编译模版指令,最终利用Watcher搭起Observer和Compile之间的通信桥梁,达到“数据变化->视图更新;视图交互变化(input)->数据model变更”的双向绑定效果。

index.html文件内容如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>手写双向绑定</title>
    <style>
        #app {
            text-align: center;
        }
    </style>
</head>
<body>
    <div id="app">
        <h2>{{title}}</h2>
        <input v-model="name">
        <h1>{{name}}</h1>
        <button v-on:click="clickMe">click me!</button>
    </div>
      <script src="./src/observer.js"></script>
      <script src="./src/watcher.js"></script>
      <script src="./src/compile.js"></script>
      <script src="./src/index.js"></script>
      <script>
        new FakeVue({
          el: '#app',
          data: {
            title: 'hello world',
            name: 'Sage'
          },
          methods: {
            clickMe: function () {
                this.title = 'hello world';
            }
          },
          mounted: function () {
            window.setTimeout(() => {
                this.title = '你好';
            }, 1000);
          }
        });
      </script>
</body>
</html>

mvvm入口函数——iindex.js文件定义FakeVue的MVVM方法:

class FakeVue {
    constructor (options) {
        this.data = options.data
        this.methods = options.methods

        // 在new FakeVue时做一个代理处理,使得赋值时的FakeVue.data.name = 'sage'变成理想形式FakeVue.name = 'sage'
        Object.keys(this.data).forEach((key) => {
            this.proxyKeys(key)
        })

        observer(this.data)
        new Compile(options.el, this)
        // 所有事情处理好后执行mounted函数
        options.mounted.call(this)
    }

    proxyKeys (key) { // 让访问FakeVue的属性代理为访问FakeVue.data的属性
        var self = this
        Object.defineProperty(this, key, {
            get () { return self.data[key] },
            set (newVal) { self.data[key] = newVal }
        })
    }
}

数据监听器Observer——observer.js文件内定义observer类和Dep事件通道(订阅器):

// 监听器——用来劫持并监听所有属性,如果有变动的就通知订阅者
function observer (data) {
    if (!data || typeof data !== 'object') return
    // 取出所有属性遍历
    Object.keys(data).forEach(function (key) {
        defineReactive(data, key, data[key])
    })
}
function defineReactive (data, key, val) {
    observer(val) // 递归遍历所有子属性

    var dep = new Dep()

    // 通过Object.defineProperty()来劫持数据中各个属性的setter\getter
    Object.defineProperty(data, key, {
        get: function () {
            if (Dep.target) { // 判断是否需要添加订阅者
                dep.addSub(Dep.target)
            }
            return val
        },
        set: function (newVal) {
            observer(newVal)
            if (val !== newVal) {
                val = newVal
                dep.notify() // 数据变动时通过调度中心发布消息给订阅者来触发相应监听回调
            }
        }
    })
}

// 订阅器Dep(调度中心)——主要负责收集订阅者然后在属性变化的时候执行对应订阅者的更新函数
class Dep {
    constructor () {
        this.subs = []
    }
    addSub (sub) {
        this.subs.push(sub)
    }
    notify () {
        this.subs.forEach(function(sub) {
            sub.update();
        });
    }
}
Dep.target = null

Watcher订阅者——watcher.js文件:

// 订阅者——可以收到属性的变化通知并执行相应的函数从而更新视图
class Watcher {
    constructor (vm, key, callback) {
        this.callback = callback
        this.vm = vm
        this.key = key
        this.value = this.get() // 将自己添加到订阅器的操作
    }

    update () {
        this.run()
    }
    run () {
        var value = this.vm.data[this.key]
        var oldVal = this.value
        if (value !== oldVal) {
            this.value = value

            // 执行解析器Compile中绑定的回调updateText(node, value)或modelUpdater(node, value),更新视图
            this.callback.call(this.vm, value, oldVal)
        }
    }
    get () {
        Dep.target = this // 在Dep.target上缓存下订阅者
        var value = this.vm.data[this.key] // 强制执行监听器里的get函数
        Dep.target = null // 缓存成功后再将其去掉
        return value
    }
}

指令解析器Compile——compile.js文件:

// 解析器——可以扫描和解析每个节点的相关指令,并初始化数据以及初始化相应的订阅器
class Compile {
    constructor (el, vm) {
        this.vm = vm
        this.el = document.querySelector(el)
        this.fragment = null
        this.init()
    }

    init () {
        if (this.el) {
            this.fragment = this.nodeToFragment(this.el)
            this.compileElement(this.fragment)
            this.el.appendChild(this.fragment)
        } else {
            console.log('Dom元素不存在')
        }
    }

    // 为避免对dom频繁操作,将需要解析的dom节点存入fragment片段进行处理
    nodeToFragment (el) {
        var fragment = document.createDocumentFragment()
        var child = el.firstChild
        while (child) {
            fragment.appendChild(child) // 将Dom元素移入fragment中
            child = el.firstChild
        }
        return fragment
    }

    // 遍历各节点,对含有相关指定的节点进行特殊处理
    compileElement (el) {
        var childNodes = el.childNodes
        var self = this
        Array.prototype.slice.call(childNodes).forEach(function (node) {
            var reg = /{{(.*)}}/
            var text = node.textContent

            if (self.isElementNode(node)) {
                self.compile(node)
            } else if (self.isTextNode(node) && reg.test(text)) { // 判断是否是符合模版字符{{}}的指令
                self.compileText(node, reg.exec(text)[1])
            }

            if (node.childNodes && node.childNodes.length) {
                self.compileElement(node) // 继续递归遍历子节点
            }
        })
    }

    compile (node) {
        var nodeAttrs = node.attributes
        var self = this

        // 遍历所有节点属性
        Array.prototype.forEach.call(nodeAttrs, function (attr) {
            var attrName = attr.name
            // 再判断属性是否是指令属性
            if (self.isDirective(attrName)) {
                var exp = attr.value
                var dir = attrName.substring(2) // v-on:click和v-model处理成on:click和model
                // 如果是指令属性的话再区分是哪种指令再进行相应的处理
                if (self.isEventDirective(dir)) { // 事件指令,如v-on:click
                    self.compileEvent(node, self.vm, exp, dir)
                } else { // v-model 指令
                    self.compileModel(node, exp)
                }
                node.removeAttribute(attrName)
            }
        })
    }

    compileText (node, exp) {
        var self = this
        var initText = this.vm[exp]
        this.textUpdater(node, initText) // 将初始化的数据初始化到视图中
        new Watcher(this.vm, exp, function(value) { // 生成订阅器并绑定更新函数
            self.textUpdater(node, value)
        })
    }
    compileEvent (node, vm, exp, dir) {
        var eventType = dir.split(':')[1] // on:click处理成[on, click]并取第2个
        var callback = vm.methods && vm.methods[exp]

        if (eventType && callback) {
            node.addEventListener(eventType, callback.bind(vm))
        }
    }
    compileModel (node, exp) {
        var self = this
        var val = this.vm[exp]
        this.modelUpdater(node, val)
        new Watcher(this.vm, exp, function(value) {
            self.modelUpdater(node, value)
        })
        node.addEventListener('input', function(e) {
            var newValue = e.target.value
            if (val === newValue) {
                return
            }
            self.vm[exp] = newValue
            val = newValue
        })
    }
    textUpdater (node, value) {
        node.textContent = typeof value === 'undefined' ? '' : value
    }
    modelUpdater (node, value) {
        node.value = typeof value === 'undefined' ? '' : value
    }
    isDirective (attr) {
        return attr.indexOf('v-') === 0
    }
    isEventDirective (dir) { // 事件指令,如v-on:click
        return dir.indexOf('on:') === 0
    }
    isElementNode (node) {
        return node.nodeType === 1 // 元素节点
    }
    isTextNode (node) {
        return node.nodeType === 3 // 文字
    }
}

运行效果:jsbin