youngwind / blog

梁少峰的个人博客
4.66k stars 385 forks source link

vue早期源码学习系列之四:如何实现动态数据绑定 #87

Open youngwind opened 7 years ago

youngwind commented 7 years ago

前言

在上一篇中, #86 ,我们已经掌握了如何监听数据的变化,以及使用观察者模式和事件传递响应变化事件。那么,今天我们来看看,基于watch库,如何实现动态数据绑定?

问题具象

我们可以把问题具象化为下面的例子

// html
<div id="app">
    <p>姓名:{{user.name}}</p>
    <p>年龄:{{user.age}}</p>
</div>
// js
const app = new Bue({
    el: '#app',
    data: {
        user: {
            name: 'youngwind',
            age: 24
        }
    }
});

问题是:如何做到当user.name或者user.age发生改变的时候,html上的DOM元素也发生相应的改变呢?

笨拙的做法

先来看看我一开始采取的办法。 思路:因为数据所有属性的变化都会冒泡到顶层,所以我只需要在数据顶层注册一个事件。当任意一个属性发生改变的时候,我都重新遍历DOM模板,把{{user.name}}这些转换成实际值,在内存中拼接成fragment,最后把生成的新fragment整块地替换掉原先的DOM结构。 这里比较简单,我就不多说了,可以直接去看这个版本的源码。 实现效果如下图所示。 demo

但是这样的做法非常粗暴,存在不少问题。

  1. 当我修改非DOM相关的数据的时候,居然也会触发DOM的重新渲染!(如图,city压根就没渲染在DOM中,所以修改它不应该触发DOM的更新)
  2. 当我修改DOM相关的任意一个属性的时候,它会渲染更新整个DOM,而不是我改哪儿它更新哪儿。

基于上述两个缺陷,这种做法肯定不能忍。那么,我们来看看怎么解决它们。

指令Directive

想要做到:只更新数据变动相关的DOM,必须有个这样的对象,将DOM节点和对应的数据一一映射起来,这里引入Directive(指令)的概念,它的构造函数和原型方法如下所示。

/**
 * 指令构造函数
 * @param name {string} 值为"text", 代表是文本节点
 * @param el {Element} 对应的DOM元素
 * @param vm {Bue} bue实例
 * @param expression {String} 指令表达式,例如 "name"
*  @param attr {String} 值为'nodeValue', 代表数据值对应的书节点的值
 * @constructor
 */
function Directive(name, el, vm, expression) {
    this.name = name;  // 指令的名称, 对于普通的文本节点来说,值为"text"
    this.el = el;              // 指令对应的DOM元素
    this.vm = vm;          // 指令所属bue实例
    this.expression = expression;       // 指令表达式,例如 "name"
    this.attr = 'nodeValue';        
    this.update();
}

// 这是指令的更新方法。当对应的数据发生改变了,就会执行这个方法
// 可以看出来,这个方法就是用来更新nodeValue的
Directive.prototype.update = function () {
    this.el[this.attr] = this.vm.$data[this.expression];
    console.log(`更新了DOM-${this.expression}`);
};

关键实现思路: 在遍历DOM模板的过程中,当遍历到文本节点:"{{name}}"的时候,会先将其中的表达式"name"匹配出来,然后新建一个空的textNode,也就是上面的this.el,插入到这个文本节点的前面,最后remove掉这个文本节点。这样,就实现了用一个程序生成的textNode代替原来的textNode,从而实现每个textNode都跟它的表达式一一对应起来。 可以直接去看这个版本的源码 具体实现的效果如下图所示。 demo

从图中我们可以看到,bue的实例app中多出了_directive属性,它是一个数组,存放的是在遍历DOM模板的时候解析出来的若干条指令。当数据发生变化的时候,会找到对应的指令,然后执行对应指令上面的update方法。 这样我们就实现了只更新数据变动对应的那一个部分DOM

然而,这样子还是存在其他问题。

  1. 每次数据发生改变的时候,我都需要循环_directive数组,匹配expression的值才能找到对应的指令,这显然是效率很低的,最好是能够做到对象键值索引的那种。
  2. 倘若我想实现像vue的$watch那样的API,该怎么办呢?显然光靠Directive是不行的。因为$watch对应仅仅是一个回调函数,根本与DOM无关,不会有el和attr这些东西。

    Binding、Watcher

为了解决上述的两个问题,我们引入Binding和Watcher这两个“类”(Binding是为了解决键值索引,Watcher是为了解决$watch)。那么,Binding、Watcher、Directive这三者之间是什么关系呢?我们来看看下面这张图。(这是本文最重要的图) demo

从图中我们可以看到。有一个_rootBind对象,它的属性就是按照DOM模板中用到的数据层层深入排列下去的。(因为我们在模板中只用到了user.name和user.age,所以这里只有这两个属性)。而且,在每个属性上有一个_subs数组。这个数组其实就是subscibe订阅的意思,里面存放的是一系列Watcher。这些Watcher代表着当此属性数据发生改变的时候,就会循环遍历_subs里面的Watcher,执行里面的update方法。 那么,Watcher又跟我们上面实现的Directive什么关系呢?是包含的关系。也就是说,Watcher是一个观察容器,它既可以装载Directive,这时候cb是更新DOM的函数,从而实现数据变动的时候更新DOM;也可以装载$watch,这时候cb是自定义的回调函数,从而实现数据变动的时候执行自定义回调函数。 这就是vue实现动态数据绑定的三大核心概念。 实现效果如下: demo

不多说了,直接上代码

参考资料

  1. http://6174.github.io/articles/thinking-in-vue-two.html
  2. http://jiongks.name/blog/vue-code-review/
  3. http://www.mamicode.com/info-detail-490.html

    后话

此次学习vue,我checkout到的是vue的这个版本。然而,这个版本相比于我在学习写watch库的时候,代码量剧增,从原先的七八百行增加到差不多五千行,看起来非常地费劲。但是没有办法,因为这个commit是实现动态数据绑定功能最早的commit了,我只能从这儿开始看起。特别是Binding、Watcher和Directive这几个核心概念,一开始简直要把人绕晕了,压根不知道为什么要有这些东西。经过多日的思考和不断的debug,我才慢慢地想通。 经过这一次,我发现了学习别人的源码,一个很大的困难不在于明白作者写的是什么(因为往往会有注释),而在于明白作者为什么非这样写不可,在于明白作者设计的思路,然而注释通常不会提到这些。这就只能靠大胆的想象和不断的尝试了。

另外,上面通过Binding、Watcher、Directive构建起来的动态数据绑定体系还有一个重大的缺陷,我们把它留到下一篇来专门阐述。

yutingzhao1991 commented 7 years ago

cool !

youngwind commented 7 years ago

@yutingzhao1991 只是一些粗浅的理解,还希望跟大家一起多交流交流。

F-12 commented 7 years ago

这里Binding class是为了实现Vue代码里的linker function的功能吗?

youngwind commented 7 years ago

@F-12 不是。

  1. Binding的存在是为了直接通过path查找到相应的watchers。比如当数据user.name发生变动的时候,path为"user.name",程序就会找到对应的app._rootBind.user.name._subs里面的watchers。其实就是将watchers按照数据的层次组织起来。
  2. 后来我仔细想了想,如果仅仅是为了实现这个功能的话,其实是不一定需要构造Binding这个类的,直接定义_rootBind里面的属性和值也是可以的。
  3. 在Vue的早期版本里面(比如本篇参考的),是不存在linker这些东西的。linker是后来才加入的,不过我没看懂linker那一块。
freeozyl80 commented 7 years ago

看了下代码,求科普 ...require('') 是什么用法,那三个点?

henryzp commented 7 years ago

@freeozyl80 。。。es6的语法糖,先去看一下ES6的相关知识吧

ycz2431125 commented 7 years ago

我正纳闷怎么更新指定数据了 看了这个文章 帮助很多

cxliustc commented 7 years ago

牛逼

huyansheng3 commented 6 years ago

老铁 这一系列的博客很赞啊!👍

cobish commented 6 years ago

你好,有些困惑。

  1. compile.js 的第 50 行 代码中,不将 new Directive 的实例 push 到 app. _directive 中代码也没问题(因为不用循环它了),请问这个 _directive 有啥作用?

  2. observer.js 的第 77 行 的这个监听是否没有用处了?

cllee1214 commented 6 years ago

你好,按照你的思路我自己实现了一下。有一个不明白的地方:

Directive.prototype.update = function () { this.el[this.attr] = this.vm.$data[this.expression]; console.log(更新了DOM-${this.expression}); };

如果按照上面的代码,更新文本节点的nodeValue,你的列子没有问题。如果模板是这样的如下: \<div>姓名:{{user.name}} 年龄:{{user.age}}\<\/div> 也就是同一个文本节点里面需要更新两个值,是如何做到的?

ZYSzys commented 6 years ago

@cllee1214 是用 正则表达式解析出来的

cllee1214 commented 5 years ago

@ZYSzys 那是不是指令里面存的表达式是一个整个字符串?能不能说详细一点。望指教。