一个菜鸡对vue数据双向绑定的理解

178 阅读9分钟

花了好几个小时,才把简易版的研究的七七八八了,我太菜了,写个文章加深一下理解吧,第一次写,欢迎批评和建议

(代码是参考别人的,末尾有参考文章的链接)

我尽量用最通俗易懂的方式来讲解,会详细到每一行代码,所以可能会有点啰嗦

首先说一下大致的思路。

1. compile,解析所有的模板标签和指令,添加对应的监听器
2. observe, 劫持并监听所有的数据,使用Object.defineProperty为每个属性设置getter和setter
3. watcher, 解析模板标签的时候 会生成一个watcher实例,当发布者通知,会调用watcher中的更新回调去更新视图
4. Dep,收集所有依赖,当依赖的数据发生变化,会发布消息,通知所有的订阅者

以下按照思路实现代码

ps: 下面所有的方法是对应这段代码的

<div id="app">
    <input type='text' v-model='text'/>
    {{text}}
</div>

compile的实现

    function nodeToFragment(node, vm) {
        let nodes = document.createDocumentFragment()
        let child
        while(child = node.firstChild) {
            compile(child, vm)
            nodes.appendChild(child)
        }
        return nodes
    }

    function compile(node, vm) {
        let reg = /\{\{(.*)\}\}/
        if (node.nodeType === 1) {
            let attrs = node.attributes
            for (let i = 0; i < attrs.length; i++) {
                if (attrs[i].nodeName === 'v-model') {
                    let name = attrs[i].nodeValue
                    node.addEventListener('input', e => {
                        vm[name] = e.target.value
                    })
                    new Watcher(node, name, vm)
                    node.removeAttribute('v-model')
                }
            }
        }
        if (node.nodeType === 3) {
            if (reg.test(node.nodeValue)) {
                let name = RegExp.$1
                new Watcher(node, name, vm)
            }
        }
    }

nodeToFragment方法中首先生成一个空的代码片段documentFragment,将节点一个个取出来交给compile方法处理之后再添加到documentFragment中,最后将documentFragment返回 分开来说,首先在nodeToFragment方法中,生成一个空的documentFragment,然后传进来的node也就是所有需要解析的代码,用while循环取出来,这个取得过程是这样的,每次往documentFragment中append一个,相当于是把那个节点元素从原来的地方挪到documenFragment中去了,所以每次firstChild取得都不一样,最后取完了,循环也就结束了。 在compile方法中首先判断元素类型,如果nodeType是1(Element类型),就遍历他所有的特性,然后找到V-model,取出他绑定的那个值,然后这里会绑定一个input方法,回调里面是把输入的值赋给vm实例中对应的那个属性,生成一个watcher实例,最后清除v-model特性。如果是 nodeType是3(Text类型),正则检测是否是模板标签,是的话取出里面的变量,然后在这里生成watcher的实例。在这里生成watcher实例,都是为了以后data的数据发生改变时,能通过watcher里的update方法来更新页面

watcher的实现

    function Watcher(node, name, vm) {
        Dep.target = this
        this.node = node
        this.name = name
        this.vm = vm
        this.update()
        Dep.target = null
    }

    Watcher.prototype = {
        update() {
            this.get()
            if (this.node.nodeType === 1) {
                this.node.value = this.value
            }
            this.node.nodeValue = this.value
        },

        get() {
            this.value = this.vm[this.name]
        }
    }

watcher构造函数中,首先将当前实例赋给全局的Dep.target,然后用传进来的参数初始化节点,变量名和vm实例,触发update方法,最后清空Dep.target,清空是因为这里面保存的是当前计算的watcher实例,所以始终只能有一个。原型上挂载两个方法,update方法:先调用get方法,在get方法中根据变量名在vm实例中找到对应的那个属性的值初始化一个value,然后回到update方法中,把这个value赋值给节点元素。注意在这里需要判断节点的类型,如果是input,是给节点的value赋值,如果是文本类型,就是给节点的nodeValue赋值

observe的实现

    function observe(data, vm) {
        let keys = Object.keys(data)
        keys.forEach(key => {
            defineReactive(vm, key, data[key])
        })
    }

    function defineReactive(vm, key, val) {
        let dep = new Dep()
        Object.defineProperty(vm, key, {
            get() {
                if (Dep.target) dep.addSub(Dep.target)
                return val
            },
            set(newVal) {
                if (val == newVal) return
                val = newVal
                dep.notify()
            }
        })
    }

observe方法,传进来的data取出里面所有的key,然后遍历,交给defineReactive方法处理,在defineReactive方法中,首先new一个Dep,是为了调用Dep中的方法,然后使用Object.defineProperty方法为每个key设置getter和setter,(注意这里是直接在vm实例上设置的,我一开始没想明白为什么要直接在vm上设置,人家原来明明是在vm里面的data中的,后来才想到这样用的时候就可以直接this.去用了),在get中首先判断Dep.target 是否为空,不是的话,将Dep.target(是保存的watcher实例吧)添加到依赖收集器中,接着返回值。在set中,调用dep的notify方法,也就是数据发生改变的时候发布消息

Dep 的实现

    function Dep(){
        this.subs = []
    }

    Dep.prototype = {
        addSub(sub) {
            this.subs.push(sub)
        },
        notify() {
            this.subs.forEach(sub => {
                sub.update()
            })
        }
    }

构造函数本身是初始化一个空数组,也就是依赖收集器了,然后原型上有两个方法,添加依赖的方法和发布消息的方法,在发布消息的方法中,是遍历依赖收集器中的所有依赖,触发他们的update方法,也就是watcher的update方法

最后再来一个vue的构造函数和new vue出来的实例

    function Vue(options) {
        this.data = options.data
        let data = this.data
        observe(data, this)
        let id = options.el
        let nodes = nodeToFragment(document.getElementById(id), this)
        document.getElementById(id).appendChild(nodes)
    }

    let vm = new Vue({
        el: 'app',
        data: {
            text: 'hello world'
        }
    })

在构造函数中,首先取到data,把data交给observe方法去处理,然后把app中的所有代码交给compile处理,处理完之后在添加到app中去。 new一个vue的实例传进去el和data

好了,所有的代码都说完了,看到这里,你可能会觉得每段代码都能看懂,但是连起来之后还是一头雾水,我当初就是这个状态。

现在,重新捋一遍,从解析到渲染,从input输入到数据更新再到文本更新,如果哪一步看不懂翻上去看代码片段的讲解,再返回来看

首先刚进来,执行的是new vue,传一个el和data,然后走vue构造函数,构造函数里,拿到data,然后data交给observer方法,还要带一个this(为vm实例),走到observer方法中,拿出data中所有的key,然后遍历,然后vm实例,key,key对应的value三个参数传给defineReactive方法,defineReactive方法中先new一个Dep,上面说过了,这是为了调用Dep的方法,然后给key设置set和get方法,这里只是先设置了,还没有走进去,然后退出defineReactive方法,接着退出observe方法,回到vue构造函数中,接着拿到el也就是app,然后连 this传给nodeToFragment方法,在nodeToFragment中,先创建一个空的代码片段,然后while循环取app里面的元素节点(这里只说input元素和模板标签,不说其他空的或者没用的了),然后元素节点(先是input)和vm实例传给compile方法,compile方法中,先一个正则,是为了匹配模板标签(也就是{{name}}这种),接着判断节点类型,因为这时进来的是一个input,所以会走到第一个if中,拿到它上面所有的特性,然后遍历,找到v-model,然后取出v-model绑定的那个值,然后绑定input事件,然后生成一个watcher实例,然后移除他的v-model 特性,接着回到nodeToFragment方法中,再取出一个,现在是文本类型的节点也就是模板标签,然后传给compile方法,走到第二个if中去,然后正则检测通过,拿到模板标签中的那个变量,现在是 text,然后用节点、变量名和vm实例new一个watcher实例,在watcher构造函数中,首先将当前watcher实例赋值给全局的Dep.target,然后把传进来的节点、变量名和vm实例绑在watcher的this上,然后调用他的update方法,update方法中,先调用它的get方法,get方法中,用变量名去vm实例中取值,触发了之前设置的getter,在getter中,现在Dep.target保存了刚才的watcher实例,所以把现在的watcher实例添加到依赖收集器中,为了以后数据变化的时候去通知他,然后返回值,回到watcher中,返回值初始化一个value挂到this上,再回到update方法中,this.value 赋给刚才的节点,到这就算是把{{text}}换成了text的值也就是hello world,退出watcher,接着退出compile,到这就算是解析完了,回到nodeToFragment方法中,刚才处理完的每个节点元素都已经添加到了代码片段中 ,最后返回这个代码片段,返回到最开始的vue构造函数中,拿到返回的代码片段, 添加到app里去,现在看,页面已经渲染好了。 接着说input输入到后面的文本更新。input输入东西之后,会触发我们刚才添加的input事件,现在来到input事件里,输入的值会赋给vm实例中对应的那个变量,现在就会触发setter,来到set方法中,更新值,然后调用dep的notify方法,也就是发布消息,来到notify方法里,遍历依赖收集器,刚才我们解析那个模板标签的时候往里面加过两个,然后调用它的update方法,update方法哪来的?别忘了,我们刚才添加的依赖是一个watcher实例,就是调watcher的update方法,来到update方法里,先调用get方法,get方法中去vm实例中取值,然后触发getter,返回值,赋给this.value,然后回到update方法中,this.value 赋给watcher里的元素节点,到这,就会看到后面的文本也更新了。 再简单说一下直接修改vm.text会发生什么,首先就是触发设置的setter,然后更新值,然后发布消息,在notify方法中,遍历依赖收集器,还是上面的思路,就不多说了。

其实上面这一大段可以通过断点调试一步一步调就可以明白里面的逻辑,我这里只是加了一点我自己的理解,还有就是我里面好多描述都是不太严谨的,只是为了更直观的理解。再结合mvvm的思想,你品,你细品,应该就能明白个七七八八了。

再说一次,这只是个简易版的,只是为了理解双向绑定的思路,其实里面好多逻辑都是省略掉的,比如,observe处理data里的数据,没有用递归处理嵌套的情况,也没有处理是数组的情况,compile里也没有处理子节点的情况。

估计没人像我这么写文章的,我只是为了更多在刚开始看这个知识点的兄弟少琢磨一会,毕竟代码这个东西,看不懂的话,耗起来是真的费劲,好多大佬写的文章都太,怎么说,反正我看完还是晕的。

最后,人在北京,正在隔离,求一份前端的工作。

参考文章链接: juejin.cn/post/684490…