vuejs是个简单而强大的MVVM库,它可以帮助我们搭建现代web用户界面。
写这篇文章时,Vue在github上有36,312个stars。并且每月有230,250的npm下载量。Vue2.0更是为渲染层引入了轻量的虚拟dom实现,这为服务端渲染和原生端组件渲染带来了可能
作者写这篇文章的时候,Vue在github上有36,312个stars,而译者翻译这篇文章时,Vue已经有107,032个Stars,已经超过了React的106,047个
vuejs宣称是一种渐进式javascript框架,尽管vuejs的核心很小,但是它配套了很多相应的工具和库,可以用来创建大型应用。
我们首先来熟悉一下Vue内部的核心组件,vue的内部可以拆分成几部分
一个新创建的Vue实例会经历多个阶段,比如观察数据,初始化事件,编译模板,渲染。你可以在特定的阶段注册生命周期钩子,这些生命周期钩子将会被调用。
所谓的响应式系统就是vue‘数据-视图’绑定的黑魔法来源。当你设置vue实例的数据,视图相应的更新,反之亦然。
Vue使用Object.defineProperty使数据对象的属性'响应'。另外使用众所周知的观察者模式来连接数据变化和视图渲染
虚拟DOM是在内存里的javascript对象树,用来映射真实的DOM树。
当数据变化了,vue将会渲染一颗全新的虚拟DOM树,并保存老的树。虚拟DOM模块会diff两颗树的不同,并把变化补丁到真实的DOM树上。
Vue使用snabbdom作为虚拟DOM的实现基础,为了和Vue的其他组件兼容,在这个基础上,做了些稍微的修改。
编译器的工作就是把模板编译成渲染函数(抽象语法树 ASTs).编译器把混杂着Vue指令(Vue的指令只是些HTML属性)的HTML以及其他实体解析成一颗树,并会最大化检测出所有的静态子树(所有没有动态绑定的子树),把它们移出渲染。vue使用的html解析器是由John Resig所写
我们不会在这里讲解编译器的实现细节。我们可以在构建环节使用构建工具把Vue模板都编译成渲染函数,所以编译器并不包含在Vue的运行时中。另外我们甚至可以直接写渲染函数,所以编译器并不是理解Vue机制的核心部分。
创建我们自己的vuejs之前,我们首先要做一些环境配置,包括模块打包工具以及测试工具。因为我们将采用测试-驱动的流程
因为这是一个javascript项目,我们将使用一些新颖的工具。首先就是跑‘npm init’命令并设置一些项目信息
我们将使用Rollup来打包模块。Rollup是一个js模块打包工具.它允许你为你的应用或库使用ES6的import/export语法来进行模块化开发。vuejs也是使用Rollup来打包模块的。
我们将为Rollup创建一个配置文件。在根目录下,创建rollup.conf.js文件:
export default {
input: 'src/instance/index.js',
output: {
name: 'Vue',
file: 'dist/vue.js',
format: 'iife'
},
};
不要忘了跑‘npm install rollup rollup-watch --save-dev’命令
测试需要安装一些包,跑下面的命令:
npm install karma jasmine karma-jasmine karma-chrome-launcher
karma-rollup-plugin karma-rollup-preprocessor buble rollup-plugin-buble --save-dev
在根目录下,创建karma.conf.js文件:
module.exports = function(config) {
config.set({
files: [{ pattern: 'test/**/*.spec.js', watched: false }],
frameworks: ['jasmine'],
browsers: ['Chrome'],
preprocessors: {
'./test/**/*.js': ['rollup']
},
rollupPreprocessor: {
plugins: [
require('rollup-plugin-buble')(),
],
output: {
format: 'iife',
name: 'Vue',
sourcemap: 'inline'
}
}
})
}
整个目录结构如下:
- package.json
- rollup.conf.js
- node_modules
- dist
- test
- src
- observer
- instance
- util
- vdom
我们方便起见,将添加一些npm脚本 package.json
"scripts": {
"build": "rollup -c",
"watch": "rollup -c -w",
"test": "karma start"
}
为了启动我们自己的vuejs,先写我们的第一个测试用例。 test/options/options.spec.js
import Vue from "../../src/instance/index";
describe('Proxy test', function () {
it('should proxy vm._data.a = vm.a', function () {
var vm = new Vue({
data: {
a: 2
}
})
expect(vm.a).toEqual(2);
});
});
该测试用例测试vm上data的属性(如vm._data.a),是否都代理到vm上了(比如vm.a)。这是vue其中的一个小技巧。
所以我们可以为我们的vue写下第一行真正代码 src/instance/index.js
import { initMixin } from './init'
function Vue (options) {
this._init(options)
}
initMixin(Vue)
export default Vue
粗看只是Vue的构造函数调用了this._init,没什么特别的地方。所以我们来看下‘initMixin’干了什么: src/instance/init.js
import { initState } from './state'
export function initMixin (Vue) {
Vue.prototype._init = function (options) {
var vm = this
vm.$options = options
initState(vm)
}
}
Vue的实例方法用织入模式来注入。我们将会在后面发现Vue经常使用织入模式来添加实例方法。Minxin只是一个函数,它接收一个构造函数的参数,添加一些方法到该构造函数的原型上,并返回这个构造函数。
所以'initMixin‘添加了'_init'方法到'Vue.prototype'。而_init方法调用state.js下的initState方法 src/instance/state.js
export function initState(vm) {
initData(vm)
}
function initData(vm) {
var data = vm.$options.data
vm._data = data
// proxy data on instance
var keys = Object.keys(data)
var i = keys.length
while (i--) {
proxy(vm, keys[i])
}
}
function proxy(vm, key) {
Object.defineProperty(vm, key, {
configurable: true,
enumerable: true,
get: function proxyGetter() {
return vm._data[key]
},
set: function proxySetter(val) {
vm._data[key] = val
}
})
}
最后,我们终于看到了proxy-代理。initState调用initData方法,initData遍历vm._data上的所有key,在每个value上调用proxy方法。
proxy用同样的key在vm上定义属性,并且这个属性有setter和getter方法,而getter和setter方法就是从vm._data上获取和设置数据。
这就是vm.a怎么代理到vm._data.a的。
跑‘npm run build’ 和 ‘npm run test’,你应该会看到下面的结果:
很棒!你现在已经启动了自己的vuejs,继续努力!
Vue的响应式系统使model和view之间的数据绑定显得简单自然。数据就是一个javascript对象,当data变更,视图就根据最后的状态相应地更新。堪称完美。 在内部,vuejs将遍历data的所有属性并把它们用Object.defineProperty转成 getter/setter方法 data中的每一个原始键值对,都分配一个Observer实例,Observer会先通知watchers都是谁订阅了这些值的变化事件。 每一个Vue实例都有一个Watcher实例,在组件作为依赖渲染的时候来收集所有'被触碰过'的属性。当数据变化了之后,watcher会重新收集依赖,并跑那些在初始化watcher的时候传过来的回调。 那么,如何通知watcher数据变化了呢?观察者模式来了!我们定义一个新的类叫Dep。作为中介者,它的意思就是“依赖”。Oberserver实例有对所有当数据变动它需要去通知的deps的引用,而每个dep实例知道哪个watcher需要去更新。 如果从上层看,这就是响应式系统运行的机制。下一节,我们详细看下这个响应系统的具体实现细节。
Dep的实现很简单,每个dep实例有个uid来标识。subs数组记录了所有订阅了这个dep实例的watcher. Dep.prototype.notify 调研subs数组中每个订阅者的更新方法。Dep.prototype.depend是在watcher的重新检查的时候收集依赖。我们一会再讲watcers。你现在只需要知道Dep.target就是在当时要重新检查的watcher实例。因为这个属性是静态的,所以Dep.target是全局的,并且一次只指向一个watcher。
src/observer/dep.js
var uid = 0
// Dep contructor
export default function Dep(argument) {
this.id = uid++
this.subs = []
}
Dep.prototype.addSub = function(sub) {
this.subs.push(sub)
}
Dep.prototype.removeSub = function(sub) {
remove(this.subs, sub)
}
Dep.prototype.depend = function() {
if (Dep.target) {
Dep.target.addDep(this)
}
}
Dep.prototype.notify = function() {
var subs = this.subs.slice()
for (var i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
Dep.target = null
我们先来个模板:
// Observer constructor
export function Observer(value) {
}
// API for observe value
export function observe (value){
}
实现Oberver之前,我们先写个测试用例。
test/observer/observer.spec.js
import {
Observer,
observe
} from "../../src/observer/index"
import Dep from '../../src/observer/dep'
describe('Observer test', function() {
it('observing object prop change', function() {
const obj = { a:1, b:{a:1}, c:NaN}
observe(obj)
// mock a watcher!
const watcher = {
deps: [],
addDep (dep) {
this.deps.push(dep)
dep.addSub(this)
},
update: jasmine.createSpy()
}
// observing primitive value
Dep.target = watcher
obj.a
Dep.target = null
expect(watcher.deps.length).toBe(1) // obj.a
obj.a = 3
expect(watcher.update.calls.count()).toBe(1)
watcher.deps = []
});
});
我们先定义了一个js对象obj来模拟数据,然后我们用oberseve方法使数据响应式,因为我们还没有实现watcher,我们需要模拟一个watcer。一个watcer有一个订阅依赖的deps数组,当数据变化时候update方法将被调用。我们后面再看addDep方法。 这里我们使用一个jasmine的监控方法占位。一个监控方法不做任何事。它只记录方法被调用次数和被调用时候传入的参数。 然后我们把全局的Dep.target指向watcher,然后设置obj.a。如果数据是响应式的,watcher的更新方法将会被调用。 所以我们先专注于observe方法。下面就是代码。observe方法先检查值是不是对象。如果是的话,通过检查它的__ob__属性,检查该值是不是已经挂了一个Observer实例。 如果不存在Observer实例,observe就会给该值实例化一个Observer实例并返回。
src/observer/index.js
import {
hasOwn,
isObject
}
from '../util/index'
export function observe (value){
if (!isObject(value)) {
return
}
var ob
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else {
ob = new Observer(value)
}
return ob
}
这里我们需要一个小工具方法hasOwn,它其实就是对Object.prototype.hasOwnProperty的一个简单封装:
src/util/index.js
var hasOwnProperty = Object.prototype.hasOwnProperty
export function hasOwn (obj, key) {
return hasOwnProperty.call(obj, key)
}
还有另一个工具方法isObject:
src/util/index.js
···
export function isObject (obj) {
return obj !== null && typeof obj === 'object'
}
现在我们来看下Observer的构造函数。它会实例化一个Dep实例,传入值调用walk方法。并会把observer以__ob__的属性挂在值上。 src/observer/index.js
import {
def, //new
hasOwn,
isObject
}
from '../util/index'
export function Observer(value) {
this.value = value
this.dep = new Dep()
this.walk(value)
def(value, '__ob__', this)
}
这里的def是个新的工具方法,它会用Object.defineProperty() API来给对象定义属性 src/util/index.js
···
export function def (obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
})
}
walk方法就是遍历对象,对每个值调用defineReactive方法。 src/observer/index.js
Observer.prototype.walk = function(obj) {
var keys = Object.keys(obj)
for (var i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
defineReactive里就要用到Object.defineProperty了
src/observer/index.js
export function defineReactive (obj, key, val) {
var dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
var value = val
if (Dep.target) {
dep.depend()
}
return value
},
set: function reactiveSetter (newVal) {
var value = val
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
val = newVal
dep.notify()
}
})
}
reactiveGetter方法检查Dep.target是否存在,如果存在,表示getter方法在watcher手机依赖的时候被触发了。当这种情况时,我们通过调用dep.depend()来添加依赖。dep.depend方法实际上调用的是Dep.target.addDep(dep)。因为Dep.target就是一个watcher,Dep.target.addDep(dep)就等同于watcher.addDep(dep),我们看看addDep做了什么:
addDep (dep) {
this.deps.push(dep)
dep.addSub(this)
}
它把dep放进watcher的deps数组。同时把目标watcher放入dep的subs数组。所以这就是依赖如何追踪的。
reactiveSetter方法仅仅在新值和旧值不同的时候设置成新值。同时通过dep.notify()方法来通知watcher来更新。我们回顾下之前Dep那节:
Dep.prototype.notify = function() {
var subs = this.subs.slice()
for (var i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
Dep.prototype.notify调用subs数组里的每个watcher的update方法。对,这些watchers就是在Dep.target.addDep(dep)的时候被放进subs数组的。现在都连起来了。 现在我们跑一下npm run test命令,我们之前写的用例应该通过了。
我们现在只能监控原始类型的简单对象。所以这一节我们将添加非原始类型,比如对象的监控支持。
首先我们把测试用例修改一下:
test/observer/observer.spec.js
it('observing object prop change', function() {
···
// observing non-primitive value
Dep.target = watcher
obj.b.a
Dep.target = null
expect(watcher.deps.length).toBe(3) // obj.b + b + b.a
obj.b.a = 3
expect(watcher.update.calls.count()).toBe(1)
watcher.deps = []
});
obj.b自己就是一个对象。所以我们通过检查改变obj.b是否被通知来判断对象监测是否支持。
解决方案很简单,我们对val递归地调用observe方法。因为如果val不是个对象,observe方法就会返回。所以当我们用defineReactive来监控一堆键值对的时候,我们调用observe方法并把返回值保存在childOb上。
src/observer/index.js
export function defineReactive (obj, key, val) {
var dep = new Dep()
var childOb = observe(val) // new
Object.defineProperty(obj, key, {
···
})
}
我们要存下子observer的引用的原因是,当getter被调用的时候,我们需要在子对象上重新收集依赖。
src/observer/index.js
···
get: function reactiveGetter () {
var value = val
if (Dep.target) {
dep.depend()
// re-collect for childOb
if (childOb) {
childOb.dep.depend()
}
}
return value
}
···
同时在setter被调用的时候我们也要重新监控子对象。
···
set: function reactiveSetter (newVal) {
var value = val
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
val = newVal
childOb = observe(newVal) //new
dep.notify()
}
···
Vue在监控数据方面有一些警示。因为Vue处理数据变化的方式,它不能监控到属性的添加和删除。数据只有在getter和setter被调用的时候才能被监控到,但设置或删除数据,getter和setter并不会被调用。
然后,使用Vue.set(object, key, value)方法给嵌套的对象添加响应属性,和Vue.delete(object, key, value)来删除响应属性是可能的。
像之前一样,我们先来写个测试用例:
test/observer/observer.spec.js
import {
Observer,
observe,
set as setProp, //new
del as delProp //new
}
from "../../src/observer/index"
import {
hasOwn,
isObject
}
from '../util/index' //new
describe('Observer test', function() {
// new test case
it('observing set/delete', function() {
const obj1 = {
a: 1
}
// should notify set/delete data
const ob1 = observe(obj1)
const dep1 = ob1.dep
spyOn(dep1, 'notify')
setProp(obj1, 'b', 2)
expect(obj1.b).toBe(2)
expect(dep1.notify.calls.count()).toBe(1)
delProp(obj1, 'a')
expect(hasOwn(obj1, 'a')).toBe(false)
expect(dep1.notify.calls.count()).toBe(2)
// set existing key, should be a plain set and not
// trigger own ob's notify
setProp(obj1, 'b', 3)
expect(obj1.b).toBe(3)
expect(dep1.notify.calls.count()).toBe(2)
// should ignore deleting non-existing key
delProp(obj1, 'a')
expect(dep1.notify.calls.count()).toBe(3)
});
···
}
我们在Observer的测试用例里添加了'监控设置/删除'的新测试用例。
现在我们来实现这两个方法:
src/observer/index.js
export function set (obj, key, val) {
if (hasOwn(obj, key)) {
obj[key] = val
return
}
const ob = obj.__ob__
if (!ob) {
obj[key] = val
return
}
defineReactive(ob.value, key, val)
ob.dep.notify()
return val
}
export function del (obj, key) {
const ob = obj.__ob__
if (!hasOwn(obj, key)) {
return
}
delete obj[key]
if (!ob) {
return
}
ob.dep.notify()
}
set方法先检测属性是否存在,如果存在,设置新值并返回。然后我们通过obj.__ob__检测该对象是否是响应的,如果不是,返回。如果key不存在,就用defineReactive创建键值对,并调用ob.dep.notify()通知该对象的值已经改变了。
delete方法就如预期的那样,通过delete操作符删除了对应的值。
我们的实现还有一个问题,不能监控数组变化。因为通过下标访问数组元素并不会触发getter方法,所以我们之前的getter/setter方法不适用于数组监控。
为了监控数组变化,我们需要劫持一些数组方法,比如Array.prototype.pop()和Array.prototype.shift(),并且我们将使用在最后实现的Vue.set API,来替代通过下标设置数组的值。 下面是”监控数组变化“的测试用例,当我们使用数组API来改变数组,变化将被监控到。每一个数组元素也会被监控到。
test/observer/observer.spec.js
describe('Observer test', function() {
// new
it('observing array mutation', () => {
const arr = []
const ob = observe(arr)
const dep = ob.dep
spyOn(dep, 'notify')
const objs = [{}, {}, {}]
arr.push(objs[0])
arr.pop()
arr.unshift(objs[1])
arr.shift()
arr.splice(0, 0, objs[2])
arr.sort()
arr.reverse()
expect(dep.notify.calls.count()).toBe(7)
// inserted elements should be observed
objs.forEach(obj => {
expect(obj.__ob__ instanceof Observer).toBe(true)
})
});
···
}
第一步是在Oberver方法里处理数组
src/observer/index.js
export function Observer(value) {
this.value = value
this.dep = new Dep()
//this.walk(value) //deleted
// new
if(Array.isArray(value)){
this.observeArray(value)
}else{
this.walk(value)
}
def(value, '__ob__', this)
}
observeArray仅仅是遍历数组,对每一个元素调用oberve方法。
src/observer/index.js
···
Observer.prototype.observeArray = function(items) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
下面我们通过修改Array原型链来覆盖原来的Array方法
首先我们创建一个单例,它拥有所有被改变了的数组方法。这些数组方法为了处理变化检测,加上了别的逻辑。
src/observer/array.js
import { def } from '../util/index'
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
/**
* Intercept mutating methods and emit events
*/
;[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
.forEach(function (method) {
// cache original method
const original = arrayProto[method]
def(arrayMethods, method, function mutator () {
let i = arguments.length
const args = new Array(i)
while (i--) {
args[i] = arguments[i]
}
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
inserted = args
break
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted)
// notify change
ob.dep.notify()
return result
})
})
arrayMethods就是那个拥有所有已经被改变方法的单例。
对于数组里的所有方法
['push','pop','shift','unshift','splice','sort','reverse']
我们定义了一个mutator方法来改变原来的方法。
在mutator里,我们首先获取所有的参数放到一个数组里,然后对这个参数数组调用所有的原来数组方法,并保存结果。
当对数组添加新元素时,我们对新元素数组调用observeArray方法。
最后我们通过ob.dep.notify()来通知变化,并返回结果。
第二步,我们需要把这个单例加到原型链里面。
如果我们在当前浏览器环境下,可以直接使用__proto__下标的话,我们就把数组的原型链直接指向我们创建的单例。
如果浏览器不支持__proto__的话,我们就把arrayMethods单例混入监控的数组。
所以我们添加一些工具函数:
src/observer/index.js
// helpers
/**
* Augment an target Object or Array by intercepting
* the prototype chain using __proto__
*/
function protoAugment (target, src) {
target.__proto__ = src
}
/**
* Augment an target Object or Array by defining
* properties.
*/
function copyAugment (target, src, keys) {
for (let i = 0, l = keys.length; i < l; i++) {
var key = keys[i]
def(target, key, src[key])
}
}
在Observer方法里,我们根据是否可以使用__proto__,来决定调用protoAugment或copyAugment。给我们的原始数组增强功能。
import {
def,
hasOwn,
hasProto, //new
isObject
}
from '../util/index'
export function Observer(value) {
this.value = value
this.dep = new Dep()
if(Array.isArray(value)){
//new
var augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value)
}else{
this.walk(value)
}
def(value, '__ob__', this)
}
hasProto的定义很简单
src/util/index.js
···
export var hasProto = '__proto__' in {}
现在应该能通过那个”监控数组变化“的测试了
我们在之前模拟了Watcher:
const watcher = {
deps: [],
addDep (dep) {
this.deps.push(dep)
dep.addSub(this)
},
update: jasmine.createSpy()
}
所以这里watcher在这里只是一个有deps属性的对象,deps用来记录该watcher所以的依赖。它还有一个addDep方法来添加依赖。一个update方法,当监控的数据变化时候被调用。
我们来看下Watcher的构造函数签名:
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: Object
)
可以看到Watcher的构造函数接收一个expOrFn和一个cb回调函数。expOrFn是一个在初始化watcher的时候就执行的表达式或方法。回调函数是在当watcher需要执行回调的时候被调用的。
下面的测试用例应该能揭开worker的神秘面纱。
test/observer/watcher.spec.js
import Vue from "../../src/instance/index";
import Watcher from "../../src/observer/watcher";
describe('Wathcer test', function() {
it('should call callback when simple data change', function() {
var vm = new Vue({
data:{
a:2
}
})
var cb = jasmine.createSpy('callback');
var watcher = new Watcher(vm, function(){
var a = vm.a
}, cb)
vm.a = 5;
expect(cb).toHaveBeenCalled();
});
}
expOrFn被执行,所以vm数据的响应式getter会被调用(这里是vm.a的getter).watcher把自己设置为Dep的target.所以vm.a的dep就会把该watcher实例放到自己的subs数组里。watcher会把vm.a的dep推进自己的deps数组。当vm.a的setter被调用。vm.a下,dep的subs数组就会被遍历并且subs数组中的每个watcher的update方法将会被调用。最后watcher的回调将会被调用。
现在我们来实现watcher类:
src/observer/watcher.js
let uid = 0
export default function Watcher(vm, expOrFn, cb, options) {
options = options ? options : {}
this.vm = vm
vm._watchers.push(this)
this.cb = cb
this.id = ++uid
// options
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
this.getter = expOrFn
this.value = this.get()
}
Watcher类会初始化一些属性。每个Watcher实例会保留一个id以备后用。这个id通过'this.id = ++ uid'设置。this.deps和this.newDeps是存放deps对象的数组,用来管理Deps.我们后面将知道为什么需要两个数组。this.depIds和this.newDepIds是相对应的id集合。我们可以通过这些集合快速查找对应的dep实例是否存在。
src/observer/watcher.js
Watcher.prototype.get = function() {
pushTarget(this)
var value = this.getter.call(this.vm, this.vm)
popTarget()
this.cleanupDeps()
return value
}
Watcher.prototype.get 方法首先push当前的Watcher实例