Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
youngwind opened this issue Sep 6, 2016 · 13 comments
Open
Labels

Comments

@youngwind
Copy link
Owner

youngwind commented Sep 6, 2016

前言

在上一篇中, #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. https://6174.github.io/articles/thinking-in-vue-two.html
  2. https://jiongks.name/blog/vue-code-review/
  3. https://www.mamicode.com/info-detail-490.html

后话

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

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

@yutingzhao1991
Copy link

cool !

@youngwind
Copy link
Owner Author

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

@F-12
Copy link

F-12 commented Oct 24, 2016

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

@youngwind
Copy link
Owner Author

@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
Copy link

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

@henryzp
Copy link

henryzp commented Oct 24, 2016

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

@ycz2431125
Copy link

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

@cxliustc
Copy link

cxliustc commented Jul 6, 2017

牛逼

@huyansheng3
Copy link

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

@cobish
Copy link

cobish commented Sep 14, 2017

你好,有些困惑。

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

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

@cllee1214
Copy link

cllee1214 commented May 18, 2018

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

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
Copy link

ZYSzys commented Jul 10, 2018

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

@cllee1214
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests