🖖vue双向绑定实现

699 阅读6分钟

前置知识

1. Object.defineProperty,可以看这篇文章

2. 观察者模式,可以看我的笔记

双向绑定

双向绑定,一方面是指视图改变数据,比如 input 中输入内容,data 中的数据同步变化,这个简单,可以通过监听 input 事件实现

另一方面是指数据改变视图,比如还是以刚刚例子来说,data 中的数据改变,页面上 input 的内容就要同步变化,这个比较复杂,下面看看怎么实现吧

实现概览

实现双向绑定,要做以下几件事

  • Observer:劫持属性的 getter 和 setter,当数据变动时,通知 Watcher 执行视图更新。
  • proxy:代理属性,使得不需要通过 this.data 读取,而是直接通过 this 读取
  • Dep:观察者系统,用于订阅与通知执行 Watcher 
  • Watcher:Watcher 包含一个更新视图的 Update 方法,当 Observer 监听到数据改变时就会通知 Watcher 执行 update 方法更新视图 
  • Compile:扫描模版,初始化视图,为绑定 v-model 的元素监听 input 事件,为双向绑定的属性订阅 Watcher

整体流程大概是:Observer 用于劫持属性的 getter 和 setter,Compile 会从模版中找出双向绑定的属性,为属性实例化 Watcher,Watcher 会触发该属性的 getter,从而让属性使用 Dep 将自己收集起来,当属性的值改变时,会触发setter,通知收集好的 watcher 执行 update 方法更新视图,有点绕先大概了解流程,再往下看代码会更好理解。

从Vue构造函数开始

网上很多文章都是一步步实现上面说到的方法或者类的,也就是从分散到整体的,要看到最后才能把整个流程串通起来,我从Vue构造函数开始,从整体拆开来分析,应该会更好理解

首先实现下Vue构造函数,看看实例化Vue时要做哪些事情

function Vue(options) {
    // vue的data是一个工厂函数,需要执行下取得其返回的对象
    this.data = options.data(); 
    
    // 挂载的dom节点
    const dom = document.querySelector(options.el);

    // 代理属性,使得可以直接通过this访问this.data中的属性    
    for (let key of Object.keys(this.data)) {
        this.proxy(key);    
    }
    
    // 劫持data中的属性,订阅watcher,以及监听到数据变化时通知订阅者更新
    observe(this.data);    // 解析dom,初始化视图,为双向绑定的属性监听input,并且生成watcher实例
    new Compile(dom, this); // 找出双向绑定的属性做处理
}

proxy实现

首先要看的是 proxy 这部分,平时在写 vue 时,我们直接使用 this 就能读取到 data 中的数据,而不需要通过 this.data 去读取,为什么可以这样呢?其实是因为 Vue 给 data 中的属性做了代理

循环data中每个属性,调用proxy方法

for (let key of Object.keys(this.data)) {
    this.proxy(key);    
}

proxy 方法通过 defineProperty 劫持 this 的 getter 和 setter,使得读取 this[key] 时,实际上会访问 this.data[key],同理,设置 this[key] 时,实际上会设置 this.data[key]

// 在Vue原型上定义proxy方法
Vue.prototype.proxy = function (key){
    Object.defineProperty(this, key, {
        configurable: false,
        enumerable: true,
        // 读取this[key]时,实际上会读取 this.data[key]
        get () {
            return this.data[key];
        },
        // 设置this[key]时,实际上会设置 this.data[key]
        set (newVal) {
            this.data[key] = newVal;
        }
    });
}

观察者系统Dep

订阅器其实是观察者系统,包含两个方法

1. addSub:添加订阅者

2. notify:通知订阅者执行 update 方法更新视图

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

Dep.prototype = {
    addSub: function (sub) {
        this.subs.push(sub);
    },

    notify: function () {
        this.subs.forEach(function (sub) {
            sub.update(); // 执行watcher的更新视图的方法。
        });
    }
}

Watcher构造函数

下面再实现下 Watcher 类,包含三个参数

  • vm:传入vue实例,也就是this
  • exp:属性的key
  • cb:操作dom,更新视图的回调函数

每个双向绑定的属性(也就是v-model或者双花括号中的属性)都会创建一个 Watcher 实例,Watcher 的作用是为属性提供一个 update 方法,用于更新视图

function Watcher(vm, exp, cb) {
    this.cb = cb; // 一个更新视图的方法 
    this.vm = vm;
    this.exp = exp;   
 
    // 绑定自己到 Dep.target
    Dep.target = this;

    // 此处读取一下自己的值,从而触发 getter,订阅自己(Dep.target)
    this.value = this.vm[this.exp];

    // 释放Dep.target
    Dep.target = null;
}

Watcher.prototype = {
    update () {
        let newValue = this.vm[this.exp];
        let oldValue = this.value;

        if (newValue !== oldValue) {
            this.cb.call(this.vm, newValue, oldValue)
        }
    }
}

看看上面代码的注释,这里会先把 this 绑定到 Dep.target,也就是把 watcher 绑定到 Dep.target  上了,然后再读取一下属性,目的是触发该属性的 getter,在 getter 中会将 Dep.target 收集起来,由于 Dep.target 其实就是 watcher ,所以这里其实是把 watcher 手机起来了,具体看下下面劫持 data 中属性的 observe 方法

observe实现

刚刚已经实现了 Dep 和 Watcher,现在可以在 observe 中使用了,observe 的目的劫持属性gettter 和 setter ,在 getter 中收集 watcher  ,当数据发生改变时,在 setter 中通知 watcher 更新视图

function observe(data) {
    if (typeof data !== 'object') return;
    for (let key of Object.keys(data)) {
        defineReactive(data, key, data[key]);
    }
}

function defineReactive(data, key, val) {
    observe(val);

    let dep = new Dep();
    Object.defineProperty(data, key, {
        enumerable: true,

        configurable: true,

        get() {
            /* 
               在getter中订阅watcher,在实例化该属性的watcher时,
               会把watcher绑定到Dep的静态属性target上,然后读取一下该属性,
               从而进入getter这里执行这个订阅操作。
            */
            if (Dep.target) {
                dep.addSub(Dep.target);
            }

            return val;
        },

        set(newval) {
            val = newval;

            // 触发观察者,从而执行watcher的update方法,更新视图
            dep.notify();
        }
    })
}

可以看到在 getter 中会订阅 Dep.target 也就是订阅该属性的 watcher,在 setter 中会执行dep.notify 通知 watcher 更新视图

Compile实现

observe、Dep 、Watcher 已经实现了,现在要通过 Compile 类把这几个串通起来

Compile 会循环扫描模版节点,找到双向绑定的属性,也就是 v-model 绑定的属性 或者 {{}} 中的属性,然后完成初始化视图,监听input事件,为属性订阅watcher这几个操作

function Compile (el, vm) {
    this.el = el;
    this.vm = vm;

    this.compileElement(el);
}

Compile.prototype = {
    compileElement (el) {
        let childs = el.childNodes;
        Array.from(childs).forEach(node => {
            let reg = /\{\{(.*)\}\}/; 
            let text = node.textContent;

            if (this.isElementNode(node)) // 元素节点
                this.compile(node)
            else if (this.isTextNode(node) && reg.test(text)) { // 文本节点
                this.compileText(node, reg.exec(text)[1]);
            }

            if (node.childNodes && node.childNodes.length) {
                this.compileElement(node);
            }
        })
    },

    compile (node) {
        let nodeAttr = node.attributes;
        Array.from(nodeAttr).forEach(attr => {
            if (this.isDirective(attr.nodeName)) { // v-model属性
               node.value = this.vm[attr.nodeValue]; // 初始化

               // 绑定input事件,达到视图更新数据目的 
               node.addEventListener('input', () => {
                    this.vm[attr.nodeValue] = node.value;
               })

               new Watcher(this.vm, attr.nodeValue, val => {
                    node.value = val;
               })
            }
       })    
   },

   compileText (node, exp) {
        node.textContent = this.vm[exp]; // 初始化

        new Watcher(this.vm, exp, val => {
            node.textContent = val;
        });
    },

    isElementNode (node) {
        return node.nodeType === 1;
    },

    isTextNode (node) {
        return node.nodeType === 3;
    },    
    
    isDirective (attr) {
        return attr === 'v-model';
    }
}

可以看到,对于元素节点,compile 会判断该节点是否有绑定 v-model 这个属性,有的话初始化 node.value,然后监听 input 事件,实现视图改变时数据随之改变,再为该属性实例化 watcher, watcher 的功能上面已经说过了,watcher 会读取一下属性,从而进入属性的 getter 中收集自己,当数据改变时,会进入 setter 中,setter 中会通知 watcher 更新视图

对于文本节点,compile 会用正则匹配 {{}} 中的属性,初始化 node.textContent,然后再为该属性实例化 watcher

至此双向绑定已经整体实现了,代码已提交到 github

总结

对于视图改变数据:Vue 会通过 compiler 扫描 template,为绑定了 v-model 的元素监听 input 事件,当触发 input 时,改变 data

对于数据改变视图:首先 Vue 会劫持所有属性的 getter 和 setter,Vue 会通过 compiler 扫描 template ,为绑定到 v-model 的属性或者 {{}} 双花括号中的属性实例化一个 watcher ,watcher 会读取一下属性,从而触发 getter ,在 getter 中将自己收集到观察者系统 Dep 中,然后当数据改变时,会进入到属性的 setter 中,setter 中会通知 Dep 中收集起来的 watcher 更新视图。

参考

vue的双向绑定原理及实现