Vue.js核心原理解析
使用Vue.js一段时间后,对其底层原理有了更深入的理解,本文将总结Vue.js的核心实现机制。
Vue.js包含两个核心功能:响应式数据绑定和组件系统。大多数MVC框架实现了单向数据绑定,而双向绑定则是在单向绑定的基础上,为可输入元素添加change事件,从而动态更新model和view。
1. MVC、MVP与MVVM架构模式
1.1 MVC模式
MVC模式将软件系统分为三个核心部分:
1.视图(View):用户界面呈现 2.控制器(Controller):业务逻辑处理 3.模型(Model):数据存储与管理
各组件间的通信流程如下:
1.视图向控制器发送用户指令 2.控制器完成业务逻辑后请求模型更新状态 3.模型将新数据发送到视图,界面相应更新
所有通信均为单向流动。接收用户指令时,MVC有两种方式:通过视图传递指令给控制器,或用户直接向控制器发送指令。
实际应用中可能更加灵活,以Backbone.js为例:
1.用户可向视图(View)发送指令(DOM事件),再由View直接请求Model改变状态。 2.用户也可向Controller发送指令(改变URL触发hashChange事件),再由Controller传递给View。 3.Controller层较薄,主要起路由作用,而View层较厚,业务逻辑集中在View。因此Backbone简化了Controller,只保留Router(路由器)。
MVC模式体现了"关注点分离"的设计原则,将人机交互应用的功能分为三部分:Model对应应用状态和业务功能的封装,可理解为包含数据和行为的领域模型;View负责界面呈现和用户交互;Controller作为Model和View之间的连接器,控制应用流程。
1.2 MVP模式
MVP模式将Controller重命名为Presenter,并改变了通信方向:
1.各组件间通信变为双向。 2.视图(View)与模型(Model)不直接交互,所有通信通过**表现器(Presenter)**进行。 3.View层较薄,不包含业务逻辑,称为被动视图(Passive View),而Presenter层较厚,包含所有逻辑。
MVP适用于事件驱动的应用架构,如ASP.NET Web Form、Windows Forms应用。
1.3 MVVM模式
MVVM模式将Presenter层替换为ViewModel,其他方面与MVP基本一致。其与MVP的关键区别是采用双向绑定,视图层(View)的变更自动反映在ViewModel,反之亦然。Angular、Vue和React均采用这种方式。
MVVM源于WPF,主要用于分离应用界面层和业务逻辑层。在MVVM中,一个ViewModel与一个View匹配,完全绑定,View中的任何修改都会更新到ViewModel,同时ViewModel的任何变化都会同步到View。这种自动同步的实现依赖于ViewModel中的属性实现了observable接口,即当使用属性的set方法时,会触发属性修改事件,使绑定的UI自动刷新。
2. JavaScript访问器属性
访问器属性是一种特殊属性,不能直接在对象中定义,必须通过Object.defineProperty()方法定义。
Object.defineProperty()方法直接在对象上定义或修改属性,并返回该对象。该方法允许精确添加或修改对象的属性。通过赋值操作添加的普通属性是可枚举的,而使用Object.defineProperty()添加的属性默认不可修改。
该方法原型如下:
Object.defineProperty(obj, prop, descriptor)
参数说明:
- obj: 要在其上定义属性的对象
- prop: 要定义或修改的属性名
- descriptor: 将被定义或修改的属性描述符
对象属性描述符分为两类:数据描述符和存取描述符。数据描述符是一个具有值的属性,存取描述符则由getter和setter函数描述。描述符必须是这两类之一,不能同时兼具。
数据描述符和存取描述符共有以下可选键值:
- configurable: 当且仅当该属性的configurable为true时,该属性描述符才能被修改,同时该属性也能从对象上删除。默认为false。
- enumerable: 当且仅当该属性的enumerable为true时,该属性才能出现在对象的枚举属性中。默认为false。
数据描述符额外具有以下可选键值:
- value: 该属性对应的值。可以是任何有效的JavaScript值。默认为undefined。
- writable: 当且仅当该属性的writable为true时,value才能被赋值运算符改变。默认为false。
存取描述符额外具有以下可选键值:
- get: 一个提供属性getter的方法,访问属性时执行。默认为undefined。
- set: 一个提供属性setter的方法,属性值被修改时触发。默认为undefined。
如果一个描述符没有value、writable、get或set中的任何一个,它将被视为数据描述符。如果一个描述符同时包含(value或writable)和(get或set),将抛出异常。
2.1 定义访问器属性
以下示例展示如何使用Object.defineProperty()定义访问器属性:
var obj = {};
Object.defineProperty(obj, 'hello', {
get: function() {
console.log('get方法被调用');
},
set: function(v) {
console.log("set方法被调用了,参数是" + v);
}
});
obj.hello; // get方法被调用
obj.hello = 'abc'; // set方法被调用了,参数是abc
访问器属性可以像普通属性一样读取和设置,实际上是通过内部get和set方法操作属性。为属性赋值就是调用set方法并使用参数赋值。get和set方法内部的this指针指向对象本身,意味着它们可以操作对象内部的值。访问器属性会覆盖同名的普通属性,因为访问器属性优先级更高。
2.2 实现自存档对象
以下示例展示如何实现一个自存档对象,当设置temperature属性时,archive数组会记录日志:
function Archiver() {
var temperature = null;
var archive = [];
Object.defineProperty(this, 'temperature', {
get: function() {
console.log('get!');
return temperature;
},
set: function(value) {
temperature = value;
archive.push({val: temperature});
}
});
this.getArchive = function() {
return archive;
};
}
var arc = new Archiver();
console.log(arc.temperature); // 输出get,但arc.temperature为null
arc.temperature = 11; // 触发archive.push({val: 11})
arc.temperature = 13; // 触发archive.push({val: 13})
console.log(arc.getArchive()); // 输出[{val: 11}, {val: 13}]
3. Vue.js双向绑定实现
3.1 基本双向绑定
Vue.js最重要的概念是数据双向绑定,也是MVVM的主要特点。以下是一个极简实现:
HTML代码:
<input type="text" id="a">
<span id="b"></span>
JavaScript代码:
var obj = {};
Object.defineProperty(obj, 'hello', {
set: function(newVal) {
document.getElementById('a').value = newVal;
document.getElementById('b').innerHTML = newVal;
}
});
document.addEventListener('keyup', function(e) {
obj.hello = e.target.value;
});
这个实现在文本框中输入的值会显示在旁边的标签中。为了实现更接近Vue.js的写法,我们通常使用以下方式:
HTML代码:
<input type="text" v-model="text">
{{ text }}
JavaScript代码:
var vm = new Vue({
el: 'app',
data: {
text: 'hello world'
}
});
Vue.js背后做了大量工作来实现这种简洁的写法,主要包括:
- 输入框及文本节点与data中的数据绑定显示
- 输入框变化时,data中的数据同步变化(MVVM中view=>viewmodel)
- data中数据变化时,文本节点显示内容同步更新(MVVM中viewmodel=>view)
3.2 数据初始化绑定
在实现数据初始化绑定前,需要了解DocumentFragment。DocumentFragment(文档片段)可视为节点容器,包含多个子节点,插入DOM时只有其子节点会被插入目标节点,可视为一组节点容器。使用DocumentFragment处理节点速度和性能优于直接操作DOM。Vue进行编译时,会将挂载目标的所有子节点劫持到DocumentFragment中,处理后再将DocumentFragment整体返回到挂载目标。
示例代码:
var dom = nodeToFragment(document.getElementById("app"));
console.log(dom);
function nodeToFragment(node, vm) {
var flag = document.createDocumentFragment();
var child;
while (child = node.firstChild) {
flag.appendChild(child); // 劫持node的所有节点
}
return flag;
}
document.getElementById("app").appendChild(dom);
HTML代码:
<div id="app">
<input type="text" v-model="text">
{{text}}
</div>
JavaScript代码:
function compile(node, vm) {
var reg = /\{\{(.*)\}\}/;
// 节点类型为元素
if (node.nodeType === 1) {
var attr = node.attributes;
// 解析属性
for (var i = 0; i < attr.length; i++) {
if (attr[i].nodeName === 'v-model') {
var name = attr[i].nodeValue; // 获取v-model绑定的属性名
node.value = vm.data[name]; // 将data的值赋给该node
node.removeAttribute('v-model');
}
}
}
// 节点类型为文本
if (node.nodeType === 3) {
if (reg.test(node.nodeValue)) {
var name = RegExp.$1; // 获取匹配到的字符串
name = name.trim();
node.nodeValue = vm.data[name]; // 将该data的值赋给该node
}
}
}
function nodeToFragment(node, vm) {
var flag = document.createDocumentFragment();
var child;
while (child = node.firstChild) {
compile(child, vm);
// 将子节点劫持到文档片段中
flag.appendChild(child);
}
return flag;
}
// 构造函数
function Vue(options) {
this.data = options.data;
var id = options.el;
var dom = nodeToFragment(document.getElementById(id), this);
// 编译完成后把dom返回到app中
document.getElementById(id).appendChild(dom);
}
var vm = new Vue({
el: 'app',
data: {
text: 'hello world'
}
});
3.3 响应式数据绑定
初始化绑定仅实现了第一步,接下来需要实现文本框中输入内容时,Vue实例中的属性值也跟着变化。思路是在文本框输入数据时触发input事件(也可用keyup或change),在事件处理程序中获取输入内容赋值给Vue实例vm的text属性。这里利用Object.defineProperty()方法给Vue实例中data的属性重新定义为访问器属性,添加get和set存取描述符,这样给vm.text赋值时就会触发set方法,在set方法中更新Vue实例属性的值。
HTML代码:
<div id="app">
<input type="text" v-model="text"/>
{{ text }}
</div>
JavaScript代码:
/**
* 使用defineProperty将data中的text设置为vm的访问器属性
* @param obj 对象
* @param key 属性名
* @param val 属性值
*/
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get: function() {
return val;
},
set: function(newVal) {
if (newVal === val) return;
val = newVal;
// 输出日志
console.log(`set方法触发属性值变化${val}`);
}
});
}
/**
* 给vue实例定义访问器属性
* @param obj vue实例中的数据
* @param vm vue对象
*/
function observe(obj, vm) {
Object.keys(obj).forEach(function(key) {
defineReactive(vm, key, obj[key]);
});
}
/**
* 编译过程,给子节点初始化绑定vue实例中的属性值
* @param node 子节点
* @param vm vue实例
*/
function compile(node, vm) {
let reg = /\{\{(.*)\}\}/;
// 节点类型为元素
if (node.nodeType === 1) {
let attr = node.attributes;
// 解析属性
for (let i = 0; i < attr.length; i++) {
if (attr[i].nodeName === 'v-model') {
// 获取v-model绑定的属性名
let name = attr[i].nodeValue;
// 添加监听事件
node.addEventListener('input', function(e) {
// 给相应的data属性赋值,进而触发该属性的set方法
vm[name] = e.target.value;
});
// 将data的值赋给该node
node.value = vm.data[name];
node.removeAttribute('v-model');
}
}
}
// 节点类型为文本
if (node.nodeType === 3) {
if (reg.test(node.nodeValue)) {
// 使用正则表达式获取匹配到的字符串
let name = RegExp.$1;
name = name.trim();
// 将data的值赋给该node.nodeValue
node.nodeValue = vm.data[name];
}
}
}
/**
* DocumentFragment文档片段,可以看作节点容器
* @param node 节点
* @param vm vue实例
*/
function nodeToFragment(node, vm) {
var flag = document.createDocumentFragment();
var child;
while (child = node.firstChild) {
compile(child, vm);
flag.appendChild(child);
}
return flag;
}
/*vue类*/
function Vue(options) {
this.data = options.data;
let data = this.data;
// 给vue实例的data定义访问器属性,覆盖原来的同名属性
observe(data, this);
let id = options.el;
let dom = nodeToFragment(document.getElementById(id), this);
// 编译,劫持完成后将dom返回到app中
document.getElementById(id).appendChild(dom);
}
/*定义一个vue实例*/
let vm = new Vue({
el: 'app',
data: {
text: 'hello world!'
}
});
set方法被触发后,Vue实例的text属性跟着变化,但内容没有更新。接下来使用"订阅/发布模式"解决这个问题。
3.4 订阅/发布模式
订阅/发布模式(观察者模式)定义一对多关系,让多个观察者同时监听一个主题对象,主题对象状态改变时所有观察者都会收到通知。
发布者发出通知 => 主题对象收到通知并推送给订阅者 => 订阅者执行相应操作
示例代码:
/**
* 定义一个发布者publisher
*/
var pub = {
publish: function() {
dep.notify();
}
}
/**
* 三个订阅者
*/
var sub1 = {
update: function() {
console.log(1);
}
};
var sub2 = {
update: function() {
console.log(2);
}
};
var sub3 = {
update: function() {
console.log(3);
}
}
/**
* 一个主题对象
*/
function Dep() {
this.subs = [sub1, sub2, sub3];
}
Dep.prototype.notify = function() {
this.subs.forEach(function(sub) {
sub.update();
});
}
// 发布者发布消息,主题对象执行notify方法,触发所有订阅者响应
var dep = new Dep();
pub.publish();
3.5 完整双向绑定实现
实现完整双向绑定的关键是利用订阅/发布模式,将Watcher添加到关联属性的dep中。
HTML代码:
<div id="app">
<input type="text" v-model="text">
{{ text }}
</div>
JavaScript代码:
/**
* 使用defineProperty将data中的text设置为vm的访问器属性
* @param obj 对象
* @param key 属性名
* @param val 属性值
*/
function defineReactive(obj, key, val) {
// 发布者对象
var dep = new Dep();
Object.defineProperty(obj, key, {
get: function() {
// 依赖收集
if (Dep.target) dep.addSub(Dep.target);
return val;
},
set: function(newVal) {
if (newVal === val) return;
val = newVal;
// 通知变更
dep.notify();
}
});
}
/**
* 给vue实例定义访问器属性
* @param obj vue实例中的数据
* @param vm vue对象
*/
function observe(obj, vm) {
Object.keys(obj).forEach(function(key) {
defineReactive(vm, key, obj[key]);
});
}
/**
* DocumentFragment文档片段
* @param node 节点
* @param vm vue实例
*/
function nodeToFragment(node, vm) {
var flag = document.createDocumentFragment();
var child;
while (child = node.firstChild) {
compile(child, vm);
flag.appendChild(child);
}
return flag;
}
/**
* 编译过程,给子节点初始化绑定vue实例中的属性值
* @param node 子节点
* @param vm vue实例
*/
function compile(node, vm) {
var reg = /\{\{(.*)\}\}/;
// 节点类型为元素
if (node.nodeType === 1) {
var attr = node.attributes;
// 解析属性
for (var i = 0; i < attr.length; i++) {
if (attr[i].nodeName === 'v-model') {
var name = attr[i].nodeValue;
node.addEventListener('input', function(e) {
vm[name] = e.target.value;
});
node.value = vm[name];
node.removeAttribute('v-model');
}
}
}
// 节点类型为文本
if (node.nodeType === 3) {
if (reg.test(node.nodeValue)) {
var name = RegExp.$1;
name = name.trim();
new Watcher(vm, node, name, 'text');
}
}
}
/**
* 观察者
* @param vm vue实例
* @param node 节点
* @param name 属性名
* @param nodeType 节点类型
* @constructor
*/
function Watcher(vm, node, name, nodeType) {
// 将当前对象赋值给全局变量Dep.target
Dep.target = this;
this.name = name;
this.node = node;
this.vm = vm;
this.nodeType = nodeType;
// 更新
this.update();
// 设置为空,避免重复添加订阅者
Dep.target = null;
}
Watcher.prototype = {
update: function() {
this.get();
if (this.nodeType === 'text') {
this.node.nodeValue = this.value;
}
if (this.nodeType === 'input') {
this.node.value = this.value;
}
},
get: function() {
this.value = this.vm[this.name];
}
}
/**
* 主题对象
* @constructor
*/
function Dep() {
this.subs = [];
}
Dep.prototype = {
// 添加订阅者
addSub: function(sub) {
this.subs.push(sub);
},
// 通知变化
notify: function() {
this.subs.forEach(function(sub) {
sub.update();
});
}
};
/**
* Vue构造函数
* @param options Vue参数选项
* @constructor
*/
function Vue(options) {
this.data = options.data;
var data = this.data;
observe(data, this);
var id = options.el;
var dom = nodeToFragment(document.getElementById(id), this);
document.getElementById(id).appendChild(dom);
}
// 定义Vue实例
var vm = new Vue({
el: 'app',
data: {
text: 'hello world'
}
});
4. 总结
本文介绍了Vue.js的核心实现原理及相关知识,包括MVC、MVP、MVVM架构模式,JavaScript访问器属性,DocumentFragment文档片段,以及观察者模式(订阅/发布模式)。Vue.js的实现主要涉及数据编译(compilation)、通过DocumentFragment实现数据劫持挂载,以及通过观察者模式实现数据双向绑定等核心机制。
