深入解析vue 1實現原理,并實現vue雙向數據綁定模型
vueImitate
,此模型(vueImitate)只適用于學習和了解vue實現原理;無法作為項目中使用,沒有進行任何異常錯誤處理及各種使用場景的兼容;但通過此項目,可以讓你:
- 深入了解vue實現原理
- 親手一步一步自己實現vue相應功能,包括雙向綁定、指令如v-model、v-show、v-bind等
整體效果如下:
下面我們重頭開始框架的實現,我們知道,vue的使用方式如下:
var vm = new Vue({
el: 'root',
data() {
return {
message: 'this is test',
number: 5,
number1: 1,
number2: 2,
showNode: false
}
},
methods: {
add() {
this.number1 += 1;
this.number += 1;
},
show() {
this.showNode = !this.showNode;
}
}
})
由此可見,vue為一個構造函數,并且調用時傳入一個對象參數,所以主函數vueImitate
可以如下,源碼可見這里;并對參數進行對應的初始化處理:
// init.js
export default function vueImitate(options) {
this.options = options || {};
this.selector = options.el ? ('#' + options.el) : 'body'; // 根節點selector
this.data = typeof options.data === 'function' ? options.data() : options.data; // 保存傳入的data
this.el = document.querySelectorAll(this.selector)[0]; // 保存根節點
this._directives = [];
}
此時可以使用new vueImitate(options)
的方式進行調用,首先,我們需要界面上展示正確的數據,也就是將下面頁面進行處理,使其可以正常訪問;
我們可以參考vue的實現方式,vue將{{ }}
這種綁定數據的方式轉化為指令(directive),即v-text
類似;而v-text
又是如何進行數據綁定的呢?通過下面代碼可知,是通過對文本節點重新賦值方式實現,源碼見這里:
export default {
bind () {
this.attr = this.el.nodeType === 3
? 'data'
: 'textContent'
},
update (value) {
this.el[this.attr] = value
}
}
那么,問題來了,如果需要按照上面的方式實現數據的綁定,我們需要將現在的字符串{{number}}
轉化為一個文本節點,并對它進行指令化處理;這些其實也就是vue compile(編譯)、link過程完成的,下面我們就先實現上面功能需求;
compile
整個編譯過程肯定從根元素開始,逐步向子節點延伸處理;
export default function Compile(vueImitate) {
vueImitate.prototype.compile = function() {
let nodeLink = compileNode(this.el),
nodeListLink = compileNodeList(this.el.childNodes, this),
_dirLength = this._directives.length;
nodeLink && nodeLink(this);
nodeListLink && nodeListLink(this);
let newDirectives = this._directives.slice(_dirLength);
for(let i = 0, _i = newDirectives.length; i < _i; i++) {
newDirectives[i]._bind();
}
}
}
function compileNode(el) {
let textLink, elementLink;
// 編譯文本節點
if(el.nodeType === 3 && el.data.trim()) {
textLink = compileTextNode(el);
} else if(el.nodeType === 1) {
elementLink = compileElementNode(el);
}
return function(vm) {
textLink && textLink(vm);
elementLink && elementLink(vm);
}
}
function compileNodeList(nodeList, vm) {
let nodeLinks = [], nodeListLinks = [];
if(!nodeList || !nodeList.length) {
return;
}
for(let i = 0, _i = nodeList.length; i < _i; i++) {
let node = nodeList[i];
nodeLinks.push(compileNode(node)),
nodeListLinks.push(compileNodeList(node.childNodes, vm));
}
return function(vm) {
if(nodeLinks && nodeLinks.length) {
for(let i = 0, _i = nodeLinks.length; i < _i; i++) {
nodeLinks[i] && nodeLinks[i](vm);
}
}
if(nodeListLinks && nodeListLinks.length) {
for(let i = 0, _i = nodeListLinks.length; i < _i; i++) {
nodeListLinks[i] && nodeListLinks[i](vm);
}
}
}
}
如上代碼,首先,我們通過定義一個Compile
函數,將編譯方法放到構造函數vueImitate.prototype
,而方法中,首先主要使用compileNode
編譯根元素,然后使用compileNodeList(this.el.childNodes, this)
編譯根元素下面的子節點;而在compileNodeList
中,通過對子節點進行循環,繼續編譯對應節點及其子節點,如下代碼:
// function compileNodeList
for(let i = 0, _i = nodeList.length; i < _i; i++) {
let node = nodeList[i];
nodeLinks.push(compileNode(node)),
nodeListLinks.push(compileNodeList(node.childNodes, vm));
}
然后進行遞歸調用,直到最下層節點:而在對節點進行處理時,主要分為文本節點和元素節點;文本節點主要處理上面說的{{number}}
的編譯,元素節點主要處理節點屬性如v-model
、v-text
、v-show
、v-bind:click
等處理;
function compileTextNode(el) {
let tokens = parseText(el.wholeText);
var frag = document.createDocumentFragment();
for(let i = 0, _i = tokens.length; i < _i; i++) {
let token = tokens[i], el = document.createTextNode(token.value)
frag.appendChild(el);
}
return function(vm) {
var fragClone = frag.cloneNode(true);
var childNodes = Array.prototype.slice.call(fragClone.childNodes), token;
for(let j = 0, _j = tokens.length; j < _j; j++) {
if((token = tokens[j]) && token.tag) {
let _el = childNodes[j], description = {
el: _el,
token: tokens[j],
def: publicDirectives['text']
}
vm._directives.push(new Directive(vm, _el, description))
}
}
// 通過這兒將`THIS IS TEST {{ number }} test` 這種轉化為三個textNode
if(tokens.length) {
replace(el, fragClone);
}
}
}
function compileElementNode(el) {
let attrs = getAttrs(el);
return function(vm) {
if(attrs && attrs.length) {
attrs.forEach((attr) => {
let name = attr.name, description, matched;
if(bindRE.test(attr.name)) {
description = {
el: el,
def: publicDirectives['bind'],
name: name.replace(bindRE, ''),
value: attr.value
}
} else if((matched = name.match(dirAttrRE))) {
description = {
el: el,
def: publicDirectives[matched[1]],
name: matched[1],
value: attr.value
}
}
if(description) {
vm._directives.push(new Directive(vm, el, description));
}
})
}
}
}
這里,先主要說明對文本節點的處理,我們上面說過,我們需要對{{number}}
之類進行處理,我們首先必須將其字符串轉化為文本節點,如this is number1: {{number1}}
這種,我們必須轉換為兩個文本節點,一個是this is number1:
,它不需要進行任何處理;另一個是{{number1}}
,它需要進行數據綁定,并實現雙向綁定;因為只有轉化為文本節點,才能使用v-text
類似功能實現數據的綁定;而如何進行將字符串文本分割為不同的文本節點呢,那么,就只能使用正則方式let reg = /\{\{(.+?)\}\}/ig;
將{{ number }}
這種形式數據與普通正常文本分割之后,再分別創建textNode
,如下:
function parseText(str) {
let reg = /\{\{(.+?)\}\}/ig;
let matchs = str.match(reg), match, tokens = [], index, lastIndex = 0;
while (match = reg.exec(str)) {
index = match.index
if (index > lastIndex) {
tokens.push({
value: str.slice(lastIndex, index)
})
}
tokens.push({
value: match[1],
html: match[0],
tag: true
})
lastIndex = index + match[0].length
}
return tokens;
}
通過上面parseText
方法,可以將this is number: {{number}}
轉化為如下結果:
轉化為上圖結果后,就對返回數組進行循環,分別通過創建文本節點;這兒為了性能優化,先創建文檔碎片,將節點放入文檔碎片中;
// function compileTextNode
// el.wholeText => 'this is number: {{number}}'
let tokens = parseText(el.wholeText);
var frag = document.createDocumentFragment();
for(let i = 0, _i = tokens.length; i < _i; i++) {
let token = tokens[i], el = document.createTextNode(token.value)
frag.appendChild(el);
}
而在最后編譯完成,執行linker
時,主要做兩件事,第一是對需要雙向綁定的節點創建directive
,第二是將整個文本節點進行替換;怎么替換呢?如最開始是一個文本節點this is number: {{number}}
,經過上面處理之后,在frag
中其實是兩個文本節點this is number:
和{{number}}
;此時就使用replaceChild
方法使用新的節點替換原始的節點;
// compile.js
function compileTextNode(el) {
let tokens = parseText(el.wholeText);
var frag = document.createDocumentFragment();
for(let i = 0, _i = tokens.length; i < _i; i++) {
let token = tokens[i], el = document.createTextNode(token.value)
frag.appendChild(el);
}
return function(vm) {
var fragClone = frag.cloneNode(true);
var childNodes = Array.prototype.slice.call(fragClone.childNodes), token;
// 創建directive
......
// 通過這兒將`THIS IS TEST {{ number }} test` 這種轉化為三個textNode
if(tokens.length) {
replace(el, fragClone);
}
}
}
// util.js
export function replace (target, el) {
var parent = target.parentNode
if (parent) {
parent.replaceChild(el, target)
}
}
替換后結果如下圖:
經過與最開始圖比較可以發現,已經將this is number: {{number}} middle {{number2}}
轉化為this is number: number middle number2
;只是此時,仍然展示的是變量名稱,如number
,number2
;那么,我們下面應該做的肯定就是需要根據我們初始化時傳入的變量的值,將其進行正確的展示;最終結果肯定應該為this is number: 5 middle 2
;即將number
替換為5
、將number2
替換為2
;那么,如何實現上述功能呢,我們上面提過,使用指令(directive)的方式;下面,就開始進行指令的處理;
Directive(指令)
對于每一個指令,肯定是隔離開的,互相不受影響且有自己的一套處理方式;所以,我們就使用對象的方式;一個指令就是一個實例化的對象,彼此之間互不影響;如下代碼:
export default function Directive(vm, el, description) {
this.vm = vm;
this.el = el;
this.description = description;
this.expression = description ? description.value : '';
}
在創建一個指令時,需要傳入三個參數,一個是最開始初始化var vm = new vueImitate(options)
時實例化的對象;而el是需要初始化指令的當前元素,如<p v-show="showNode">this is test</p>
,需要創建v-show
的指令,此時的el
就是當前的p
標簽;而description
主要包含指令的描述信息;主要包含如下:
// 源碼見 './directives/text.js'
var text = {
bind () {
this.attr = this.el.nodeType === 3
? 'data'
: 'textContent'
},
update (value) {
this.el[this.attr] = value
}
}
// 如,'{{number}}'
description = {
el: el, // 需要創建指令的元素
def: text, // 對指令的操作方法,包括數據綁定(bind)、數據更新(update),見上面 text
name: 'text', // 指令名稱
value: 'number' // 指令對應數據的key
}
通過new Directive(vm, el, description)
就創建了一個指令,并初始化一些數據;下面就先通過指令對界面進行數據渲染;所有邏輯就放到了_bind
方法中,如下:
// directive.js
Directive.prototype._bind = function() {
extend(this, this.description.def);
if(this.bind) {
this.bind();
}
var self = this, watcher = new Watcher(this.vm, this.expression, function() {
self.update(watcher.value);
})
if(this.update) {
this.update(watcher.value);
}
}
// util.js
export function extend(to, from) {
Object.keys(from).forEach((key) => {
to[key] = from[key];
})
return to;
}
方法首先將傳入的指令操作方法合并到this
上,方便調用,主要包括上面說的bind
、update
等方法;其主要根據指令不同,功能不同而不同定義;所有對應均在./directives/*
文件夾下面,包括文本渲染text.js、事件添加bind.js、v-model對應model.js、v-show對應show.js等;通過合并以后,就執行this.bind()
方法進行數據初始化綁定;但是,目前為止,當去看界面時,仍然沒有將number
轉化為5
;為什么呢?通過查看代碼:
export default {
bind () {
this.attr = this.el.nodeType === 3
? 'data'
: 'textContent'
},
update (value) {
this.el[this.attr] = value
}
}
bind
并沒有改變節點展示值,而是通過update
; 所以,如果調用this.update(123)
,可發現有如下結果:
其實我們并不是直接固定數值,而是根據初始化時傳入的值動態渲染;但是目前為止,至少已經完成了界面數據的渲染,只是數據不對而已;
然后,我們回頭看下編譯過程,我們需要在編譯過程去實例化指令(directive),并調用其_bind
方法,對指令進行初始化處理;
// 見compile.js 'function compileTextNode'
let _el = childNodes[j], description = {
el: _el,
name: 'text',
value: tokens[j].value,
def: publicDirectives['text']
}
vm._directives.push(new Directive(vm, _el, description));
// 見compile.js 'function compile'
let newDirectives = this._directives.slice(_dirLength);
for(let i = 0, _i = newDirectives.length; i < _i; i++) {
newDirectives[i]._bind();
}
上面說了,目前還沒有根據傳入的數據進行綁定,下面,就來對數據進行處理;
數據處理
數據處理包括以下幾個方面:
- 數據雙向綁定
- 數據變化后,需要通知到ui界面,并自動變化
- 對于輸入框,使用v-model時,需要將輸入內容反應到對應數據
數據雙向綁定
需要實現雙向綁定,就是在數據變化后能夠自動的將對應界面進行更新;那么,如何監控數據的變化呢?目前有幾種方式,一種是angular的臟檢查方式,就是對用戶所以操作、會導致數據變化的行為進行攔截,如ng-click
、$http
、$timeout
等;當用戶進行請求數據、點擊等時,會對所有的數據進行檢查,如果數據變化了,就會觸發對應的處理;而另一種是vue的實現方式,使用Object.definProperty()
方法,對數據添加setter
和getter
;當對數據進行賦值時,會自動觸發setter
;就可以監控數據的變化;主要處理如下, 源碼見這里:
export function Observer(data) {
this.data = data;
Object.keys(data).forEach((key) => {
defineProperty(data, key, data[key]);
})
}
export function observer(data, vm) {
if(!data || typeof data !== 'object') {
return;
}
let o = new Observer(data);
return o;
}
function defineProperty(data, key, val) {
let _value = data[key];
let childObj = observer(_value);
let dep = new Dep(); //生成一個調度中心,管理此字段的所有訂閱者
Object.defineProperty(data, key, {
enumerable: true, // 可枚舉
configurable: false, // 不能再define
get: function() {
if (Dep.target) {
dep.depend();
}
return val;
},
set: function(value) {
val = value;
childObj = observer(value);
dep.notify();
}
})
}
Observer
是一個構造函數,主要對傳入的數據進行Object.defineProperty
綁定;可以監控到數據的變化;而在每一個Observer中,會初始化一個Dep
的稱為‘調度管理器’的對象,它主要負責保存界面更新的操作和操作的觸發;
界面更新
在通過上面Observer
實現數據監控之后,如何通知界面更新呢?這里使用了‘發布/訂閱模式’;如果需要對此模式進行更深入理解,可查看此鏈接;而每個數據key都會維護了一個獨立的調度中心Dep
;通過在上面defineProperty
時創建;而Dep
主要保存數據更新后的處理任務及對任務的處理,代碼也非常簡單,就是使用subs
保存所有任務,使用addSub
添加任務,使用notify
處理任務,depend
作用會在下面watcher
中進行說明:
// Dep.js
let uid = 0;
// 調度中心
export default function Dep() {
this.id = uid++;
this.subs = []; //訂閱者數組
this.target = null; // 有何用處?
}
// 添加任務
Dep.prototype.addSub = function(sub) {
this.subs.push(sub);
}
// 處理任務
Dep.prototype.notify = function() {
this.subs.forEach((sub) => {
if(sub && sub.update && typeof sub.update === 'function') {
sub.update();
}
})
}
Dep.prototype.depend = function() {
Dep.target.addDep(this);
}
那么,處理任務來源哪兒呢?vue中又維護了一個watcher
的對象,主要是對任務的初始化和收集處理;也就是一個watcher
就是一個任務;而整個watcher
代碼如下, 線上源碼見這里:
export default function Watcher(vm, expression, cb) {
this.cb = cb;
this.vm = vm;
this.expression = expression;
this.depIds = {};
if (typeof expression === 'function') {
this.getter = expOrFn;
} else {
this.getter = this.parseGetter(expression);
}
this.value = this.get();
}
let _prototype = Watcher.prototype;
_prototype.update = function() {
this.run();
}
_prototype.run = function() {
let newValue = this.get(), oldValue = this.value;
if(newValue != oldValue) {
this.value = newValue;
this.cb.call(this.vm, newValue);
}
}
_prototype.addDep = function(dep) {
// console.log(dep)
if (!this.depIds.hasOwnProperty(dep.id)) {
dep.addSub(this);
this.depIds[dep.id] = dep;
}
}
_prototype.get = function() {
Dep.target = this;
var value = this.getter && this.getter.call(this.vm, this.vm);
Dep.target = null;
return value;
}
_prototype.parseGetter = function(exp) {
if (/[^\w.$]/.test(exp)) return;
var exps = exp.split('.');
return function(obj) {
let value = '';
for (var i = 0, len = exps.length; i < len; i++) {
if (!obj) return;
value = obj[exps[i]];
}
return value;
}
}
在初始化watcher
時,需要傳入vm(整個項目初始化時實例化的vueImitate對象,因為需要用到里面的對應數據)、expression(任務對應的數據的key,如上面的‘number’)、cb(一個當數據變化后,界面如何更新的函數,也就是上面directive里面的update方法);我們需要實現功能有,第一是每個任務有個update
方法,主要用于在數據變化時,進行調用,即:
// 處理任務
Dep.prototype.notify = function() {
this.subs.forEach((sub) => {
if(sub && sub.update && typeof sub.update === 'function') {
sub.update();
}
})
}
第二個是在初始化watcher
時,需要將實例化的watcher(任務)放入調度中心dep
的subs
中;如何實現呢?這里,使用了一些黑科技,流程如下,這兒我們以expression
為'number'為例:
1、在初始化watcher時,會去初始化一個獲取數據的方法this.getter
就是,能夠通過傳入的expression
取出對應的值;如通過number
取出對應的初始化時的值5
;
2、調用this.value = this.get();
方法,方法中會去數據源中取值,并將此時的watcher放入Dep.target
中備用,并返回取到的值;
// watcher.js
_prototype.get = function() {
Dep.target = this;
var value = this.getter && this.getter.call(this.vm, this.vm);
Dep.target = null;
return value;
}
3、因為我們在上面Observer
已經對數據進行了Object.defineProperty
綁定,所以,當上面2步取值時,會觸發對應的getter
,如下, 觸發get函數之后,因為上面2已經初始化Dep.target = this;
了,所以會執行dep.depend();
,就是上面說的depend
函數了:
// Observer.js
let dep = new Dep(); //生成一個調度中心,管理此字段的所有訂閱者
Object.defineProperty(data, key, {
enumerable: true, // 可枚舉
configurable: false, // 不能再define
get: function() {
if (Dep.target) {
dep.depend();
}
return val;
},
set: function(value) {
val = value;
childObj = observer(value);
dep.notify();
}
})
3、觸發dep.depend();
之后,如下代碼,會執行Dep.target.addDep(this);
, 此時的this
就是上面實例化的dep
, Dep.target
則對應的是剛剛1步中實例化的watcher
,即執行watcher.addDep(dep)
;
// Dep.js
Dep.prototype.depend = function() {
Dep.target.addDep(this);
}
4、觸發watcher.addDep(dep)
,如下代碼,如果目前還沒此dep;就執行dep.addSub(this);
,此時的this
就是指代當前watcher
,也就是1步時實例化的watcher;此時dep是步驟3中實例化的dep
; 即是,dep.addSub(watcher);
// watcher.js
_prototype.addDep = function(dep) {
// console.log(dep)
if (!this.depIds.hasOwnProperty(dep.id)) {
dep.addSub(this);
this.depIds[dep.id] = dep;
}
}
5、最后執行dep.addSub(watcher);
,如下代碼,到這兒,就將初始化的watcher
添加到了調度中心的數組中;
// Dep.js
Dep.prototype.addSub = function(sub) {
this.subs.push(sub);
}
那么,在哪兒去初始化watcher
呢?就是在對指令進行_bind()
時,如下代碼,在執行_bind
時,會實例化Watcher
; 在第三個參數的回調函數里執行self.update(watcher.value);
,也就是當監控到數據變化,會執行對應的update
方法進行更新;
// directive.js
Directive.prototype._bind = function() {
extend(this, this.description.def);
if(this.bind) {
this.bind();
}
var self = this,
watcher = new Watcher(this.vm, this.expression, function() {
self.update(watcher.value);
})
if(this.update) {
this.update(watcher.value);
}
}
而前面說了,開始時沒有數據,使用this.update(123)
會將界面對應number
更新為123,當時沒有對應number
真實數據;而此時,在watcher中,獲取到了對應數據并保存到value
中,因此,就執行this.update(watcher.value);
,此時就可以將真實數據與界面進行綁定,并且當數據變化時,界面也會自動進行更新;最終結果如下圖:
為什么所有數據都是undefined
呢?我們可以通過下面代碼知道, 在實例化watcher
時,調用this.value = this.get();
時,其實是通過傳入的key在this.vm
中直接取值;但是我們初始化時,所有值都是通過this.options = options || {};
放到this.options
里面,所以根本無法取到:
// watcher.js
_prototype.get = function() {
Dep.target = this;
var value = this.getter && this.getter.call(this.vm, this.vm);
Dep.target = null;
return value;
}
_prototype.parseGetter = function(exp) {
if (/[^\w.$]/.test(exp)) return;
var exps = exp.split('.');
return function(obj) {
let value = '';
for (var i = 0, len = exps.length; i < len; i++) {
if (!obj) return;
value = obj[exps[i]];
}
return value;
}
}
那么,我們如何能直接可以通過諸如this.number
取到值呢?只能如下,通過下面extend(this, data);
方式,就將數據綁定到了實例化的vueImitate
上面;
import { extend } from './util.js';
import { observer } from './Observer.js';
import Compile from './compile.js';
export default function vueImitate(options) {
this.options = options || {};
this.selector = options.el ? ('#' + options.el) : 'body';
this.data = typeof options.data === 'function' ? options.data() : options.data;
this.el = document.querySelectorAll(this.selector)[0];
this._directives = [];
this.initData();
this.compile();
}
Compile(vueImitate);
vueImitate.prototype.initData = function() {
let data = this.data, self = this;
extend(this, data);
observer(this.data);
}
處理后結果如下:
數據也綁定上了,但是當我們嘗試使用下面方式對數據進行改變時,發現并沒有自動更新到界面,界面數據并沒有變化;
methods: {
add() {
this.number1 += 1;
this.number += 1;
}
}
為什么呢?通過上面代碼可知,我們其實observer
的是vueImitate
實例化對象的data
對象;而我們更改值是通過this.number += 1;
實現的;其實并沒有改vueImitate.data.number
的值,而是改vueImitate.number
的值,所以也就不會觸發observer
里面的setter
;也不會去觸發對應的watcher
里面的update
;那如何處理呢?我們可以通過如下方式實現, 完整源碼見這里:
// init.js
vueImitate.prototype.initData = function() {
let data = this.data, self = this;
extend(this, data);
Object.keys(data).forEach((key) => {
Object.defineProperty(self, key, {
set: function(newVal) {
self.data[key] = newVal;
},
get: function() {
return self.data[key];
}
})
})
observer(this.data);
}
這里通過對vueImitate
里對應的data
的屬性進行Object.defineProperty
處理,當對其進行賦值時,會再將其值賦值到vueImitate.data
對應的屬性上面,那樣,就會去觸發observer(this.data);
里面的setter
,從而去更新界面數據;
至此,整個數據處理就已經完成,總結一下:
1、首先,在初始化vueImitate
時,我們會將初始化數據通過options.data
傳入,后會進行處理,保存至this.data
中;
2、通過initData
方法將數據綁定到vueImitate
實例化對象上面,并對其進行數據監控,然后使用observer
對this.data
進行監控,在實例化Observer
時,會去實例化一個對應的調度中心Dep
;
3、在編譯過程中,會創建指令,通過指令實現每個需要處理節點的數據處理和雙向綁定;
4、在指令_bind()
時,會去實例化對應的watcher
,創建一個任務,主要實現數據獲取、數據變化時,對應界面更新(也就是更新函數的調用)、并將生成的watcher存儲到對應的步驟2中實例化的調度中心中;
5、當數據更新時,會觸發對應的setter
,然后調用dep.notify();
觸發調度中心中所有任務的更新,即執行所有的watcher.update
,從而實現對應界面的更新;
到目前為止,整個框架的實現基本已經完成。其中包括compile、linker、oberver、directive(v-model、v-show、v-bind、v-text)、watcher;如果需要更深入的研究,可見項目代碼; 可以自己clone
下來,運行起來;文中有些可能思考不夠充分,忘見諒,也歡迎大家指正;