仿vue实现数据双向绑定

        前段时间笔者学习了一下Vue的源码,也看了一些前辈对Vue源码研究的博客,然后用es实现了一个基础的数据双向绑定框架Hue,作为学习成果之一,在此分享给大家。Hue实现了@click,v-model, watch监听属性变化这几个基本的功能,后续如有需要大家可以自行扩展,比如hook之类的,整个框架的组织架构也可以自行调整。其中很多内容的设计和实现都是参照了Vue源码来实现的,后面会对代码实现做进一步的阐述。

       先看一下实现的效果动态图:

       下图是Hue实例化的运行过程,也是整个Hue实现数据双向绑定的过程总结:

 下图是Hue的代码文件结构:

浏览器运行index.html就能看到动图中的内容,以下是index.html的代码:

<div id="app">
    <span>Welcome to {{project}}, it's an example</span>
    <div style="margin-top: 10px">
        <div class="row">
            <div class="label">name: </div>
            <input v-model="people.name" />
        </div>
        <div class="row">
            <div class="label">height:</div>
            <input v-model="people.height" />
        </div>
    </div>
    <div>Hello,  I am called {{people.name}} and my height is {{people.height}} cm</div>
    <div>
        <button @click="getTxt(people.name, people.height)">信息输出</button>
        <button @click="change('code', 173)">设置</button>
    </div>
</div>
<script src="./util.js"></script>
<script src="./observer.js"></script>
<script src="./watcher.js"></script>
<script src="./dep.js"></script>
<script src="./compiler.js"></script>
<script src="./main.js"></script>
<script>
new Hue({
    el: '#app',
    data: {
        count: 1,
        project: 'Hue',
        txt: '35',
        people: {
            name: 'Alice',
            height: 160
        }
    },
    watch: {
        'people.name': {
            handler (n, o) {
                console.log('watch ===> newName:', n, 'oldName:', o)
            }
        },
        // 对象监听
        // 'people': {
        //     deep: true,
        //     handler (n, o) {
        //         console.log('people changed...')
        //     }
        // }
    },
    methods: {
        change (name, height) {
            this.people.name = name
            this.people.height = height
        },
        getTxt (name, height) {
            console.log('name:', name, 'height:', height)
        }
    }
})
</script>
<style>
    .label{
        width: 60px;
        text-align: right;
        margin-right: 10px;
    }
    .row{
        display: flex;
        margin-bottom: 10px;
    }
</style>

        整个风格也是照着Vue来实现的,这个例子测试了三部分的内容:1 数据双向绑定;2属性变化监听;3不同参数类型的函数绑定(针对基本数据类型和data中的属性)。从Hue实例化开始,数据双向绑定的大门就被打开了,接下来让我们一起解开那些背后的秘密。以下是main.js文件:

class Hue {
    constructor (options) {
        let vm = this
        vm.$options = options
        vm._data = options.data
        for (let key in vm._data) {
            proxy(vm, '_data', key)
        }
        // 初始化$watch监听函数
        vm.$watch = function (key, cb) {
            new Watcher(vm, key, cb)
        }
        initOptions(vm)
        // watch 选项解析
        resolveWatch(vm)
        // 编译模板
        new Compiler(vm.$options.el, vm)
    }
}

        这里做的第一件事情就是把Hue实例化中的参数挂载到实例的$options上去,方便Hue中的函数获取相关的数据,其中proxy函数对_data的内容做了一层拦截,起到在实例内部简化调用的作用(例如data中有个属性name, 可以通过this._data.name获得,经过proxy代理之后可以通过this.name获取,符合我们的调用习惯)。紧接着我们开始初始化$watch,函数内部实例化了一个Watcher,这是为data数据添加回调函数的。后面陆续进行了options的数据劫持,实例watch的解析,模板的编译。当中的函数来自util.js文件,以下是文件的内容:

function proxy (target, sourceKey, key) {
    Reflect.defineProperty(target, key, {
        set (newVal) {
            target[sourceKey][key] = newVal
        },
        get () {
            return target[sourceKey][key]
        }
    })
}

function observe (data) {
    if (typeof data !== 'object') return false
    return new Observer(data)
}

function defineReactive (obj, key, value) {
    let dep = new Dep()
    Reflect.defineProperty(obj, key, {
        get () {
            if (Dep.target) {
                dep.addDepend()
            }
            return value
        },
        set (newVal) {
            value = newVal
            dep.notify()
        }
    })
}

function pushTarget (watcher) {
    Dep.target = watcher
}

function popTarget () {
    Dep.target = null
}

function resolveWatch (vm) {
    let watch = vm.$options.watch
    if (!watch) return 'no watch'
    Object.keys(watch).forEach(item => {
        if (typeof watch[item] === 'function') {
            vm.$watch(item, watch[item])
        }
        if (typeof watch[item] === 'object') {
            if (watch[item].deep) {
                Object.keys(vm[item]).forEach(key => {
                    vm.$watch(item + '.' + key, watch[item].handler)
                })
            } else {
                vm.$watch(item, watch[item].handler)
            }
        }
    })
}

function initOptions (vm) {
    observe(vm._data)
}

        我们看到proxy函数里面使用了Reflect而不是Object上的defineProperty函数去进行对象属性的setter,getter设置,这是因为Reflect对以往Object内部的一些方法的错误行为结果进行了return false的处理而不是直接报错,以及它收录了以往的一些命令式的操作,把他们都函数化了,例如 delete o.name 变成了Reflect.deleteProperty(o, 'name'), 'name' in obj 变成了Reflect.has(obj, 'name') 等等,以上仅仅是Reflect对象的一部分功能,详情可以去参考es6 Reflect对象的相关内容。

        接下来我们看$watch函数中实例化的Watcher内容,以下是watcher.js内容:

class Watcher {
    constructor (vm, expression, cb) {
        this.vm = vm
        this.expression = expression
        this.cb = cb
        this.value = this.get()
    }
    get () {
        let val = this.vm
        pushTarget(this)
        this.expression.split('.').forEach(item => {
            val = val[item]
        })
        popTarget()
        return val
    }
    update () {
        let val = this.vm
        this.expression.split('.').forEach((key) => {
            val = val[key]
        })
        this.cb.call(this.vm, val, this.value)
        this.value = val
    }
    addDep (dep) {
        dep.addSub(this)
    }
}

        这是发布-订阅模式中的订阅者,构造函数中传入了Hue实例,订阅的属性,回调函数。this.value用以存储当前的属性值,之后会随着属性值的变化而更新。接下来我们看watcher的get函数,pushTarget的目的是把当前的watcher实例赋值给Dep.target,接下来我们根据expression对属性取值,这时候会触发属性的getter,发布者会进行依赖收集(defineReactive进行数据劫持的时候会在详细讲解)。最后我们通过popTarget函数把Dep.target赋值为null,返回属性值,get函数的功能就完了。pushTarget,popTarget这一组函数用来对Dep.target绑定和释放当前的watcher实例。vue源码里面会复杂一点,通过数组targetStack来追踪记录watcher,函数仍然是pushTarget和popTarget这一组。

        initOptions开始处理data数据了,我们看到最后是调用了util中的observe函数,如果data不是对象,直接返回false。如果是对象那就开始Observer的实例化。下面是observer.js的内容:

class Observer {
    constructor (data) {
        this.walk(data)
    }
    walk (data) {
        for (let key in data) {
            if (typeof data[key] === 'object') {
                this.walk(data[key])
                continue
            }
            defineReactive(data, key, data[key])
        }
    }
}

        Observer实例化的时候通过walk函数遍历了data的属性,通过defineReactive函数进行数据劫持,属性值为对象的则继续递归遍历。

function defineReactive (obj, key, value) {
    let dep = new Dep()
    Reflect.defineProperty(obj, key, {
        get () {
            if (Dep.target) {
                dep.addDepend()
            }
            return value
        },
        set (newVal) {
            value = newVal
            dep.notify()
        }
    })
}

        整个数据双向绑定的灵魂就在于数据的获取和设置是如何被监听的,一旦监听到变化我们就可以做相应的操作,defineProperty定义的getter和setter天然就提供了这么一种监听的能力。当我们访问一个对象属性的时候,getter就会被触发,当我们设置对象属性的时候setter就会被触发。defineProperty 定义getter和setter的过程就是所谓的数据劫持了。我们看到defineReactive函数先实例化了一个Dep(发布者),接着开始进行数据劫持,当Dep.target有绑定watcher(订阅者)的时候就开始进行依赖收集(将当前订阅者添加到发布者的sub数组中,详情可见下文Dep对象的介绍)。当数据更新时会触发setter,这时将新值赋值给value用于getter的返回值,同时dep开始通知(notify)他的订阅者(sub数组中的所有watcher实例)进行相应的操作(dom更新,或者是执行相关的回调函数)。下面我们来看发布者Dep对象的内容,以下是dep.js的内容:

class Dep {
    constructor () {
        // 存放watcher
        this.sub = []
    }
    // 依赖添加
    addDepend () {
        if (Dep.target) {
          Dep.target.addDep(this)
        }
    }
    addSub (sub) {
        this.sub.push(sub)
    }
    notify () {
        for (let sub of this.sub) {
            sub.update()
        }
    }
}

        这里比较有意思的一点就是依赖添加的时候是从dep的addDepend绕到了watcher的addDep,最后又绕到了dep的addSub去进行最终的添加,而不是在addDepend中直接把Dep.target所绑定的watcher直接加到sub中去,这样做的目的是watcher需要记录发布者的信息,以防重复添加相同的发布者,在vue源码中有所体现,而本文做极简处理就不考虑dep的记录了。以下是vue源码的关于addDep函数的代码,位置在源码src\core\observer\watcher.js处:

/**
   * Add a dependency to this directive.
   */
  addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }

      最后我们看一下notify函数,这里就是通知sub中所有的watcher进行更新操作了。

      到这里为止发布-订阅的模式就已经都实现了,接下来就是考虑如何更新{{}}中的内容了,也是就实例化Compiler,开始编译$el的内容。让我们看一下compiler.js的内容:

class Compiler {
  constructor (el, vm) {
    vm.$el = document.querySelector(el)
    this.replace(vm.$el, vm)
  }
  replace (el, vm) {
    let childNodes = [...el.childNodes]
    let self = this
    childNodes.forEach(node => {
      let txt = node.textContent
      // 正则匹配{{}}
      let reg = /\{\{(.*?)\}\}/g
      if (node.nodeType === Node.TEXT_NODE && reg.test(txt)) {
        // 考虑文本内容出现多次{{}}
        let moustache = txt.match(reg)
        moustache.forEach(subMoustache => {
          // 为{{}}中的属性绑定watcher
          this.watch(node, subMoustache, vm, moustache, txt)
        })
      }
      // 如果是元素节点
      if (node.nodeType === Node.ELEMENT_NODE) {
        let nodeAttr = [...node.attributes]
        nodeAttr.forEach(attr => {
          let name = attr.name
          let exp = attr.value
          switch (name) {
            case 'v-model':
                node.value = this.getExpValue(exp, vm)
                vm.$watch(exp, function(newVal) {
                  node.value = newVal
                })
                node.addEventListener('input', e => {
                  let newVal = e.target.value
                  self.setExpValue(exp, newVal, vm)
                })
                break
            case '@click':
                node.addEventListener('click', e => {
                  let tep = /(.+)\((.*)\)/.exec(exp)
                  let [func, params] = [tep[1], tep[2].split(',')]
                  // 判断参数是否来自data选项,若否则直接以字符形式作为入参
                  params = params.map(key => {
                    return self.getExpValue(key, vm) || key
                  })
                  vm.$options.methods[func].apply(vm, params)
                })
                break
          }
        })
      }
      // 如果还有子节点,继续递归replace
      if (node.childNodes && node.childNodes.length) {
        this.replace(node, vm);
      }
    })
  }
  watch (node, content, vm, moustache, txt) {
    let self = this
    let prop = (/\{\{(\S*)\}\}/).exec(content)[1]
    self.replaceContent(node, moustache, vm, txt)
    vm.$watch(prop, function () {
      self.replaceContent(node, moustache, vm, txt)
    })
  }
  replaceContent (node, moustache, vm, txt) {
    for (let mkey of moustache) {
      let prop = (/\{\{(\S*)\}\}/).exec(mkey)[1]
      let value = this.getExpValue(prop, vm)
      txt = txt.replace(mkey, value).trim()
    }
    node.textContent = txt
  }
  getExpValue (exp, vm) {
    if (/^\'(.*)\'$/.test(exp) || /^(\d+)$/.test(exp)) return RegExp.$1
    let arr = exp.trim().split('.')
    let val = vm
    for (let key of arr) {
      val = val[key]
    }
    return val
  }
  setExpValue (exp, value, vm) {
    let arr = exp.split('.')
    let val = vm
    arr.forEach((key, i)=> {
      if (i === arr.length - 1) {
        val[key] = value
        return
      }
      val = val[key]
    })
  }
}

      首先获取el标识的dom,然后开始递归遍历dom的子节点(childNodes),如果是元素节点那就遍历节点的属性开始实现自定义的指令比如v-model, @click等。如果是文本节点那就匹配文本中{{}}出现的属性,在watch函数里面用属性值先替换掉{{}}中的内容,然后通过$watch监听这些属性变化,在回调函数里面放置replaceContent以便更新节点的textContent(文本内容)。到此为止一个基础版的es数据双向绑定框架Hue就初步完成了。其中compiler里的难点就是同一个文本节点出现多个{{}}的时候如何替换,更进一步的话还需要考虑{{}}中是表达式的情况,这个时候就需要进一步的解析。这里就不再进行更多的实现了,大家有兴趣的话可以自行实现。判断节点类型的时候笔者用到了Node.ELEMENT_NODE而不是1,用常量去代替具体的值,这是一个比较好的习惯,一方面让代码更具可读性,另一方面也可以使之更加容易维护。

    仿vue实现数据双向绑定的分享到此就结束了,如果对于本文有任何疑问可以在下方留言。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章