前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

实现数据双向绑定 MVVM 剖析 Vue 的原理

首页2018-02-25 17:12:32Front-End
MVVMVue响应式原理

「Vue 的双向绑定是怎么实现的」这个问题,多数人的答案停在「用 Object.defineProperty 劫持了 getter 和 setter」。这句话没错,但它只回答了一半。数据变了之后,Vue 怎么知道该更新页面上哪个节点?模板里的指令是什么时候被解析成更新函数的?谁负责把这两头接起来?这篇不去逐行抠代码,而是把整套架构拆成四个模块讲清楚各自的职责和边界,顺带把 Vue 之外的另外两种流派(Backbone 的发布订阅、Angular 的脏值检查)拿来做对照,看看数据劫持到底赢在哪。

想看逐行手写的完整实现,可以配合 Vue 响应式原理模拟 手写一个迷你版 Vue 那篇一起看;只关心 Object.defineProperty 这个 API 本身的描述符细节,看 Object.defineProperty 详解。这三篇的分工是:那篇讲 API,另一篇讲代码怎么敲,这篇讲架构为什么这么分。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 发布订阅、脏值检查、数据劫持三种流派各自的做法和代价
  • 为什么数据劫持能做到 vm.property = value 这种自然的写法
  • Observer 怎么把普通对象变成能广播变化的对象
  • Dep 这个消息订阅器为什么必须建在闭包里
  • Compile 为什么要先把 DOM 搬进 DocumentFragment 再解析
  • Watcher 作为桥梁,是靠什么完成依赖收集的
  • MVVM 入口的属性代理在解决什么问题
  • 这套实现和真实 Vue 之间还差什么,Vue 3 换成 Proxy 补上了哪些短板

先看要做出来的效果:

<div id="mvvm-app">
    <input type="text" v-model="word">
    <p>{{word}}</p>
    <button v-on:click="sayHi">change model</button>
</div>

<script src="observer.js"></script>
<script src="watcher.js"></script>
<script src="compile.js"></script>
<script src="mvvm.js"></script>
<script>
var vm = new MVVM({
    el: '#mvvm-app',
    data: {
        word: 'Hello World!'
    },
    methods: {
        sayHi: function() {
            this.word = 'Hi, everybody!';
        }
    }
    });
</script>
@前端进阶之旅: 代码已经复制到剪贴板

输入框里敲字,下面的段落跟着变;点按钮改数据,输入框和段落一起变:

输入框与文本双向同步的运行效果

# 一、几种实现双向绑定的做法

目前几种主流的 MVC(VM) 框架都实现了单向数据绑定,而所谓双向数据绑定,无非就是在单向绑定的基础上给可输入元素(input、textarea 等)添加了 change(input)事件,来动态修改 model 和 view,并没有多高深。所以无需太过介怀实现的是单向还是双向绑定。

三种主流做法是这样的:

  • 发布者订阅者模式(backbone.js)
  • 脏值检查(angular.js)
  • 数据劫持(vue.js)

# 1.1 发布者订阅者模式

一般通过 sub、pub 的方式实现数据和视图的绑定监听,更新数据的做法通常是 vm.set('property', value), 这里有篇文章讲得比较详细。

这种方式的问题在于用起来不自然。改一个数据得调方法,取一个数据得调 get,模型对象和普通 JavaScript 对象长得不一样,写着写着就忘了哪个是模型哪个是普通对象。我们更希望通过 vm.property = value 这种方式更新数据,同时自动更新视图,于是有了下面两种方式。

要说清楚的是,发布订阅这个模式本身并没有被淘汰。Vue 内部的 Dep 和 Watcher 用的就是这套思路,只是它不再暴露给使用者,而是藏进了 getter/setter 里面。被淘汰的是「让用户手动调 set」这种交互方式,不是模式本身。

# 1.2 脏值检查

angular.js 是通过脏值检测的方式比对数据是否有变更,来决定是否更新视图。最简单的方式是通过 setInterval() 定时轮询检测数据变动,当然 Google 不会这么做,Angular 只在指定的事件触发时进入脏值检测,大致有这几类:

  • DOM 事件,譬如用户输入文本、点击按钮等(ng-click)
  • XHR 响应事件($http)
  • 浏览器 Location 变更事件($location)
  • Timer 事件($timeout、$interval)
  • 执行 $digest() 或 $apply()

脏值检查的思路和数据劫持是反着来的。数据劫持是「数据变的那一刻我就知道」,脏值检查是「我不知道什么时候变,但在几个关键时机把所有值都跟上一次的快照比一遍」。

代价在于比对本身有成本。每次 $digest 要遍历所有 watcher,而且一轮比对里如果有 watcher 修改了别的数据,还得再来一轮,直到没有变化为止(Angular 里最多循环十次,超了就报错)。绑定项一多,一次交互跑几千次比对是常事。

好处也很明显:它不关心数据是怎么变的。用 arr[0] = x 改数组、用 delete obj.a 删属性、直接整个对象换掉,脏值检查一视同仁。而这几种情况恰恰是 Object.defineProperty 方案的软肋。

# 1.3 数据劫持

vue.js 采用的是数据劫持结合发布者订阅者模式,通过 Object.defineProperty() 来劫持各个属性的 setter、getter,在数据变动时发布消息给订阅者,触发相应的监听回调。

它的好处是精确。哪个属性变了就通知订阅了那个属性的人,不需要全量比对。代价是得在初始化时把整个 data 对象递归遍历一遍,把每个属性都改造成访问器属性,对象层级深、字段多的时候,这个初始化开销是实打实的。

先说结论,这三种做法解决的是同一个问题的不同侧面:怎么在数据变化和视图更新之间建立联系。发布订阅让用户手动报告,脏值检查靠定时比对,数据劫持靠改写属性描述符。Vue 选了第三种,代价换来的是最自然的书写体验。

# 二、实现思路

已经了解到 Vue 是通过数据劫持的方式来做数据绑定的,其中最核心的方法便是通过 Object.defineProperty() 来实现对属性的劫持,达到监听数据变动的目的。不熟悉 defineProperty 的话, MDN 上这一页 要先过一遍。

要实现 MVVM 的双向绑定,必须要实现以下几点:

  • 实现一个数据监听器 Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者
  • 实现一个指令解析器 Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数
  • 实现一个 Watcher,作为连接 Observer 和 Compile 的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图
  • mvvm 入口函数,整合以上三者

上述流程如图所示:

Observer、Compile、Watcher 三者的协作流程

这个四模块的划分值得多想一层。为什么不把监听和更新写在一起?因为它们变化的原因不一样。数据的形态千变万化(对象、数组、嵌套),指令的种类也在不断增加(v-text、v-html、v-model、v-on),如果耦在一起,加一个指令要改数据层的代码,加一种数据类型要改指令层的代码。拆成两半,中间用 Watcher 这个只做转发的桥梁连起来,两边就能各自演进。

# 2.1 实现 Observer

我们知道可以利用 Object.defineProperty() 来监听属性变动。那么将需要 observe 的数据对象进行递归遍历,包括子属性对象的属性,都加上 setter 和 getter。这样的话,给这个对象的某个值赋值,就会触发 setter,也就能监听到数据变化了:

var data = {name: 'kindeng'};
observe(data);
data.name = 'dmq'; // 监听到值变化了 kindeng --> dmq

function observe(data) {
    if (!data || typeof data !== 'object') {
        return;
    }
    // 取出所有属性遍历
    Object.keys(data).forEach(function(key) {
	    defineReactive(data, key, data[key]);
	});
};

function defineReactive(data, key, val) {
    observe(val); // 监听子属性
    Object.defineProperty(data, key, {
        enumerable: true, // 可枚举
        configurable: false, // 不能再 define
        get: function() {
            return val;
        },
        set: function(newVal) {
            console.log('监听到值变化了 ', val, ' --> ', newVal);
            val = newVal;
        }
    });
}
@前端进阶之旅: 代码已经复制到剪贴板

完整代码在 https://github.com/poetries/mvvm/blob/master/observer.js

这段代码里 val 这个参数是关键。它是 defineReactive 的形参,被 get 和 set 两个闭包共同引用,成了这个属性真正的值仓库。原来的属性已经被访问器属性覆盖掉了,如果 getter 里写 return data[key],那就是再次触发自己的 getter,死循环爆栈。这是新手手写响应式时最常见的一个错误。

configurable: false 表示定义完之后不能再改这个属性的描述符,也不能删除它。真实的 Vue 源码里没有这么写,因为要留出后续处理的余地。自己练手的话知道有这回事就行。

有了监听能力,接下来是怎么通知订阅者。很简单,维护一个数组用来收集订阅者,数据变动时触发 notify,再调用订阅者的 update 方法:

// ... 省略
function defineReactive(data, key, val) {
	var dep = new Dep();
    observe(val); // 监听子属性

    Object.defineProperty(data, key, {
        // ... 省略
        set: function(newVal) {
        	if (val === newVal) return;
            val = newVal;
            dep.notify(); // 通知所有订阅者
        }
    });
}

function Dep() {
    this.subs = [];
}
Dep.prototype = {
    addSub: function(sub) {
        this.subs.push(sub);
    },
    notify: function() {
        this.subs.forEach(function(sub) {
            sub.update();
        });
    }
};
@前端进阶之旅: 代码已经复制到剪贴板

if (val === newVal) return; 这一行别漏。赋一个和原来一样的值时直接返回,不触发通知,能省掉大量无意义的重渲染。

那么问题来了,谁是订阅者,怎么往订阅器里添加订阅者?

按前面的思路整理,订阅者应该是 Watcher。而 var dep = new Dep(); 是在 defineReactive 方法内部定义的,想通过 dep 添加订阅者,就必须在闭包内操作。所以可以在 getter 里面动手脚:

fe
  • 一、几种实现双向绑定的做法
    • 1.1 发布者订阅者模式
    • 1.2 脏值检查
    • 1.3 数据劫持
  • 二、实现思路
    • 2.1 实现 Observer
    • 2.2 实现 Compile
    • 2.3 实现 Watcher
  • 三、实现 MVVM 入口
  • 四、这套实现和真实 Vue 还差什么
  • 总结
  • 参考

← HTTP协议系列(二)Nginx学习篇,从编译安装到配置文件结构入门 →