讓我們手寫一個 mini 版本的 vue2-x 和 vue3-x 框架
作者:夕水
來源:SegmentFault 思否社區
mini 版本的 vue.js2.X 版本框架
==============================
模板代碼
首先我們看一下我們要實現的模板代碼:
<div id="app">
<h3>{{ msg }}</h3>
<p>{{ count }}</p>
<h1>v-text</h1>
<p v-text="msg"></p>
<input type="text" v-model="count">
<button type="button" v-on:click="increase">add+</button>
<button type="button" v-on:click="changeMessage">change message!</button>
<button type="button" v-on:click="recoverMessage">recoverMessage!</button>
</div>
邏輯代碼
然後就是我們要編寫的 javascript 代碼。
const app = new miniVue({
el:"#app",
data:{
msg:"hello,mini vue.js",
count:666
},
methods:{
increase(){
this.count++;
},
changeMessage(){
this.msg = "hello,eveningwater!";
},
recoverMessage(){
console.log(this)
this.msg = "hello,mini vue.js";
}
}
});
運行效果
我們來看一下實際運行效果如下所示:
思考一下,我們要實現如上的功能應該怎麼做呢? 你也可以單獨打開以上示例:
點擊此處。
源碼實現 - 2.x
miniVue 類
首先,不管三七二十一,既然是實例化一個mini-vue
,那麼我們先定義一個類, 並且它的參數一定是一個屬性配置對象。如下:
class miniVue {
constructor(options = {}){
//後續要做的事情
}
}
現在,讓我們先初始化一些屬性,比如 data,methods,options 等等。
//在miniVue構造函數的內部
//保存根元素,能簡便就儘量簡便,不考慮數組情況
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el;
this.$methods = options.methods;
this.$data = options.data;
this.$options = options;
初始化完了之後,我們再來思考一個問題,我們是不是可以通過在 vue 內部使用 this 訪問到 vue 定義的數據對象呢?那麼我們應該如何實現這一個功能呢?這個功能有一個專業的名詞,叫做代理 (proxy)。
代理數據
因此我們來實現一下這個功能,很明顯在這個 miniVue 類的內部定義一個 proxy 方法。如下:
//this.$data.xxx -> this.xxx;
//proxy代理實例上的data對象
proxy(data){
//後續代碼
}
接下來,我們需要知道一個 api, 即Object.defineProperty
,通過這個方法來完成這個代理方法。如下:
//proxy方法內部
// 因爲我們是代理每一個屬性,所以我們需要將所有屬性拿到
Object.keys(data).forEach(key => {
Object.defineProperty(this,key,{
enumerable:true,
configurable:true,
get:() => {
return data[key];
},
set:(newValue){
//這裏我們需要判斷一下如果值沒有做改變就不用賦值,需要排除NaN的情況
if(newValue === data[key] || _isNaN(newValue,data[key]))return;
data[key] = newValue;
}
})
})
接下來,我們來看一下這個_isNaN
工具方法的實現,如下:
function _isNaN(a,b){
return Number.isNaN(a) && Number.isNaN(b);
}
定義好了之後,我們只需要在 miniVue 類的構造函數中調用一次即可。如下:
// 構造函數內部
this.proxy(this.$data);
代理就這樣完成了,讓我們繼續下一步。
數據響應式觀察者 observer 類
我們需要對數據的每一個屬性都定義一個響應式對象,用來監聽數據的改變,所以我們需要一個類來管理它,我們就給它取個名字叫Observer
。如下:
class Observer {
constructor(data){
//後續實現
}
}
我們需要給每一個數據都添加響應式對象,並且轉換成 getter 和 setter 函數,這裏我們又用到了Object.defineProperty
方法,我們需要在 getter 函數中收集依賴,在 setter 函數中發送通知,用來通知依賴進行更新。我們用一個方法來專門去執行定義響應式對象的方法,叫 walk,如下:
//再次申明,不考慮數組,只考慮對象
walk(data){
if(typeof data !== 'object' || !data)return;
// 數據的每一個屬性都調用定義響應式對象的方法
Object.keys(data).forEach(key => this.defineReactive(data,key,data[key]));
}
接下來我們來看defineReactive
方法的實現,同樣也是使用Object.defineProperty
方法來定義響應式對象,如下所示:
defineReactive(data,key,value){
// 獲取當前this,以避免後續用vm的時候,this指向不對
const vm = this;
// 遞歸調用walk方法,因爲對象裏面還有可能是對象
this.walk(value);
//實例化收集依賴的類
let dep = new Dep();
Object.defineProperty(data,key,{
enumerable:true,
configurable:true,
get(){
// 收集依賴,依賴存在Dep類上
Dep.target && Dep.add(Dep.target);
return value;
},
set(newValue){
// 這裏也判斷一下
if(newValue === value || __isNaN(value,newValue))return;
// 否則改變值
value = newValue;
// newValue也有可能是對象,所以遞歸
vm.walk(newValue);
// 通知Dep類
dep.notify();
}
})
}
Observer
類完成了之後,我們需要在 miniVue 類的構造函數中實例化一下它, 如下:
//在miniVue構造函數內部
new Observer(this.$data);
好的,讓我們繼續下一步。
依賴類
defineReactive
方法內部用到了Dep
類,接下來,我們來定義這個類。如下:
class Dep {
constructor(){
//後續代碼
}
}
接下來,我們來思考一下,依賴類裏面,我們需要做什麼,首先根據defineReactive
中,我們很明顯就知道會有add
方法和notify
方法,並且我們需要一種數據結構來存儲依賴,vue 源碼用的是隊列,而在這裏爲了簡單化,我們使用 ES6 的 set 數據結構。如下:
//構造函數內部
this.deps = new Set();
接下來,就需要實現add
方法和notify
方法, 事實上這裏還會有刪除依賴的方法,但是這裏爲了最簡便,我們只需要一個add
和notify
方法即可。如下:
add(dep){
//判斷dep是否存在並且是否存在update方法,然後添加到存儲的依賴數據結構中
if(dep && dep.update)this.deps.add(dep);
}
notify(){
// 發佈通知無非是遍歷一道dep,然後調用每一個dep的update方法,使得每一個依賴都會進行更新
this.deps.forEach(dep => dep.update())
}
Dep 類算是完了,接下來我們就需要另一個類。
Watcher 類
那就是爲了管理每一個組件實例的類,確保每個組件實例可以由這個類來發送視圖更新以及狀態流轉的操作。這個類,我們把它叫做Watcher
。
class Watcher {
//3個參數,當前組件實例vm,state也就是數據以及一個回調函數,或者叫處理器
constructor(vm,key,cb){
//後續代碼
}
}
再次思考一下,我們的 Watcher 類需要做哪些事情呢? 我們先來思考一下Watcher
的用法,我們是不是會像如下這樣來寫:
//3個參數,當前組件實例vm,state也就是數據以及一個回調函數,或者叫處理器
new Watcher(vm,key,cb);
ok, 知道了使用方式之後,我們就可以在構造函數內部初始化一些東西了。如下:
//構造函數內部
this.vm = vm;
this.key = key;
this.cb = cb;
//依賴類
Dep.target = this;
// 我們用一個變量來存儲舊值,也就是未變更之前的值
this.__old = vm[key];
Dep.target = null;
然後 Watcher 類就多了一個 update 方法,接下來讓我們來看一下這個方法的實現吧。如下:
update(){
//獲取新的值
let newValue = this.vm[this.key];
//與舊值做比較,如果沒有改變就無需執行下一步
if(newValue === this.__old || __isNaN(newValue,this.__old))return;
//把新的值回調出去
this.cb(newValue);
//執行完之後,需要更新一下舊值的存儲
this.__old = newValue;
}
編譯類 compiler 類
初始化
到了這一步,我們就算是完全脫離 vue 源碼了,因爲 vue 源碼的編譯十分複雜,涉及到 diff 算法以及虛擬節點 vNode,而我們這裏致力於將其最簡化,所以單獨寫一個 Compiler 類來編譯。如下:
class Compiler {
constructor(vm){
//後續代碼
}
}
注意: 這裏的編譯是我們自己根據流程來實現的,與 vue 源碼並沒有任何關聯,vue 也有 compiler,但是與我們實現的完全不同。
定義好了之後,我們在 miniVue 類的構造函數中實例化一下這個編譯類即可。如下:
//在miniVue構造函數內部
new Compiler(this);
好的,我們也看到了使用方式,所以接下來我們來完善這個編譯類的構造函數內部的一些初始化操作。如下:
//編譯類構造函數內部
//根元素
this.el = vm.$el;
//事件方法
this.methods = vm.$methods;
//當前組件實例
this.vm = vm;
//調用編譯函數開始編譯
this.compile(vm.$el);
compile 方法
初始化操作算是完成了,接下來我們來看 compile 方法的內部。思考一下,在這個方法的內部,我們是不是需要拿到所有的節點,然後對比是文本還是元素節點去分別進行編譯呢?如下:
compile(el){
//拿到所有子節點(包含文本節點)
let childNodes = el.childNodes;
//轉成數組
Array.from(childNodes).forEach(node => {
//判斷是文本節點還是元素節點分別執行不同的編譯方法
if(this.isTextNode(node)){
this.compileText(node);
}else if(this.isElementNode(node)){
this.compileElement(node);
}
//遞歸判斷node下是否還含有子節點,如果有的話繼續編譯
if(node.childNodes && node.childNodes.length)this.compile(node);
})
}
這裏,我們需要 2 個輔助方法,判斷是文本節點還是元素節點,其實我們可以使用節點的 nodeType 屬性來進行判斷, 由於文本節點的 nodeType 值爲 3,而元素節點的 nodeType 值爲 1。所以這 2 個輔助方法我們就可以實現如下:
isTextNode(node){
return node.nodeType === 3;
}
isElementNode(node){
return node.nodeType === 3;
}
編譯文本節點
接下來,我們下來看compileText
編譯文本節點的方法。如下:
//{{ count }}數據結構是類似如此的
compileText(node){
//後續代碼
}
接下來,讓我們思考一下,我們編譯文本節點,無非就是把文本節點中的{{ count }}
映射成爲 0, 而文本節點不就是 node.textContent 屬性嗎?所以此時我們可以想到根據正則來匹配{{}}
中的 count 值,然後對應替換成數據中的 count 值,然後我們再調用一次 Watcher 類,如果更新了,就再次更改這個 node.textContent 的值。如下:
compileText(node){
//定義正則,匹配{{}}中的count
let reg = /\{\{(.+?)\}\}/g;
let value = node.textContent;
//判斷是否含有{{}}
if(reg.test(value)){
//拿到{{}}中的count,由於我們是匹配一個捕獲組,所以我們可以根據RegExp類的$1屬性來獲取這個count
let key = RegExp.$1.trim();
node.textContent = value.replace(reg,this.vm[key]);
//如果更新了值,還要做更改
new Watcher(this.vm,key,newValue => {
node.textContent = newValue;
})
}
}
編譯文本節點到此爲止了,接下來我們來看編譯元素節點的方法。
編譯元素節點
指令
首先,讓我們想一下,我們編譯元素節點無非是想要根據元素節點上的指令來分別執行不同的操作,所以我們編譯元素節點就只需要判斷是否含有相關指令即可,這裏我們只考慮了v-text,v-model,v-on:click
這三個指令。讓我們來看看 compileElement 方法吧。
compileElement(node){
//指令不就是一堆屬性嗎,所以我們只需要獲取屬性即可
const attrs = node.attributes;
if(attrs.length){
Array.from(attrs).forEach(attr => {
//這裏由於我們拿到的attributes可能包含不是指令的屬性,所以我們需要先做一次判斷
if(this.isDirective(attr)){
//根據v-來截取一下後綴屬性名,例如v-on:click,subStr(5)即可截取到click,v-text與v-model則subStr(2)截取到text和model即可
let attrName = attr.indexOf(':') > -1 ? attr.subStr(5) : attr.subStr(2);
let key = attr.value;
//單獨定義一個update方法來區分這些
this.update(node,attrName,key,this.vm[key]);
}
})
}
}
這裏又涉及到了一個isDirective
輔助方法,我們可以使用startsWith
方法,判斷是否含有v-
值即可認定這個屬性就是一個指令。如下:
isDirective(dir){
return dir.startsWith('v-');
}
接下來,我們來看最後的update
方法。如下:
update(node,attrName,key,value){
//後續代碼
}
最後,讓我們來思考一下,我們 update 裏面需要做什麼。很顯然,我們是不是需要判斷是哪種指令來執行不同的操作?如下:
//update函數內部
if(attrName === 'text'){
//執行v-text的操作
}else if(attrName === 'model'){
//執行v-model的操作
}else if(attrName === 'click'){
//執行v-on:click的操作
}
v-text 指令
好的,我們知道,根據前面的編譯文本元素節點的方法,我們就知道這個指令的用法同前面編譯文本元素節點。所以這個判斷裏面就好寫了,如下:
//attrName === 'text'內部
node.textContent = value;
new Watcher(this.vm,key,newValue => {
node.textContent = newValue;
})
v-model 指令
v-model 指令實現的是雙向綁定,我們都知道雙向綁定是更改輸入框的 value 值,並且通過監聽 input 事件來實現。所以這個判斷,我們也很好寫了,如下:
//attrName === 'model'內部
node.value = value;
new Watcher(this.vm,key,newValue => {
node.value = newValue;
});
node.addEventListener('input',(e) => {
this.vm[key] = node.value;
})
v-on:click 指令
v-on:click 指令就是將事件綁定到 methods 內定義的函數,爲了確保 this 指向當前組件實例,我們需要通過 bind 方法改變一下 this 指向。如下:
//attrName === 'click'內部
node.addEventListener(attrName,this.methods[key].bind(this.vm));
到此爲止,我們一個 mini 版本的 vue2.x 就算是實現了。繼續下一節,學習 vue3.x 版本的 mini 實現吧。
mini 版本的 vue.js3.x 框架
模板代碼
首先我們看一下我們要實現的模板代碼:
<div id="app"></div>
邏輯代碼
然後就是我們要編寫的 javascript 代碼。
const App = {
$data:null,
setup(){
let count = ref(0);
let time = reactive({ second:0 });
let com = computed(() => `${ count.value + time.second }`);
setInterval(() => {
time.second++;
},1000);
setInterval(() => {
count.value++;
},2000);
return {
count,
time,
com
}
},
render(){
return `
<h1>How reactive?</h1>
<p>this is reactive work:${ this.$data.time.second }</p>
<p>this is ref work:${ this.$data.count.value }</p>
<p>this is computed work:${ this.$data.com.value }</p>
`
}
}
mount(App,document.querySelector("#app"));
運行效果
我們來看一下實際運行效果如下所示:
思考一下,我們要實現如上的功能應該怎麼做呢?
源碼實現 - 3.x
與 vue2.x 做比較
事實上,vue3.x 的實現思想與 vue2.x 差不多,只不過 vue3.x 的實現方式有些不同,在 vue3.x,把收集依賴的方法稱作是副作用effect
。vue3.x 更像是函數式編程了,每一個功能都是一個函數,比如定義響應式對象,那就是 reactive 方法,再比如 computed,同樣的也是 computed 方法... 廢話不多說,讓我們來看一下吧!
reactive 方法
首先,我們來看一下 vue3.x 的響應式方法,在這裏,我們仍然只考慮處理對象。如下:
function reactive(data){
if(!isObject(data))return;
//後續代碼
}
接下來我們需要使用到 es6 的 proxyAPI,我們需要熟悉這個 API 的用法,如果不熟悉,請點擊此處查看。
我們還是在 getter 中收集依賴,setter 中觸發依賴,收集依賴與觸發依賴,我們都分別定義爲 2 個方法,即 track 和 trigger 方法。如下:
function reactive(data){
if(!isObject(data))return;
return new Proxy(data,{
get(target,key,receiver){
//反射api
const ret = Reflect.get(target,key,receiver);
//收集依賴
track(target,key);
return isObject(ret) ? reactive(ret) : ret;
},
set(target,key,val,receiver){
Reflect.set(target,key,val,receiver);
//觸發依賴方法
trigger(target,key);
return true;
},
deleteProperty(target,key,receiver){
const ret = Reflect.deleteProperty(target,key,receiver);
trigger(target,key);
return ret;
}
})
}
track 方法
track 方法就是用來收集依賴的。我們用 es6 的 weakMap 數據結構來存儲依賴,然後爲了簡便化用一個全局變量來表示依賴。如下:
//全局變量表示依賴
let activeEffect;
//存儲依賴的數據結構
let targetMap = new WeakMap();
//每一個依賴又是一個map結構,每一個map存儲一個副作用函數即effect函數
function track(target,key){
//拿到依賴
let depsMap = targetMap.get(target);
// 如果依賴不存在則初始化
if(!depsMap)targetMap.set(target,(depsMap = new Map()));
//拿到具體的依賴,是一個set結構
let dep = depsMap.get(key);
if(!dep)depsMap.set(key,(dep = new Set()));
//如果沒有依賴,則存儲再set數據結構中
if(!dep.has(activeEffect))dep.add(activeEffect)
}
收集依賴就這麼簡單,需要注意的是,這裏涉及到了 es6 的三種數據結構即 WeakMap,Map,Set。下一步我們就來看如何觸發依賴。
trigger 方法
trigger 方法很明顯就是拿出所有依賴,每一個依賴就是一個副作用函數,所以直接調用即可。
function trigger(target,key){
const depsMap = targetMap.get(target);
//存儲依賴的數據結構都拿不到,則代表沒有依賴,直接返回
if(!depsMap)return;
depsMap.get(key).forEach(effect => effect && effect());
}
接下來,我們來實現一下這個副作用函數,也即 effect。
effect 方法
副作用函數的作用也很簡單,就是執行每一個回調函數。所以該方法有 2 個參數,第一個是回調函數,第二個則是一個配置對象。如下:
function effect(handler,options = {}){
const __effect = function(...args){
activeEffect = __effect;
return handler(...args);
}
//配置對象有一個lazy屬性,用於computed計算屬性的實現,因爲計算屬性是懶加載的,也就是延遲執行
//也就是說如果不是一個計算屬性的回調函數,則立即執行副作用函數
if(!options.lazy){
__effect();
}
return __effect;
}
副作用函數就是如此簡單的實現了,接下來我們來看一下 computed 的實現。
computed 的實現
既然談到了計算屬性,所以我們就定義了一個 computed 函數。我們來看一下:
function computed(handler){
// 只考慮函數的情況
// 延遲計算 const c = computed(() => `${ count.value}!`)
let _computed;
//可以看到computed就是一個添加了lazy爲true的配置對象的副作用函數
const run = effect(handler,{ lazy:true });
_computed = {
//get 訪問器
get value(){
return run();
}
}
return _computed;
}
到此爲止,vue3.x 的響應式算是基本實現了,接下來要實現 vue3.x 的 mount 以及 compile。還有一點,我們以上只是處理了引用類型的響應式,但實際上 vue3.x 還提供了一個 ref 方法用來處理基本類型的響應式。因此,我們仍然可以實現基本類型的響應式。
ref 方法
那麼,我們應該如何來實現基本類型的響應式呢? 試想一下,爲什麼 vue3.x 中定義基本類型,如果修改值,需要修改 xxx.value 來完成。如下:
const count = ref(0);
//修改
count.value = 1;
從以上代碼,我們不難得出基本類型的封裝原理,實際上就是將基本類型包裝成一個對象。因此,我們很快可以寫出如下代碼:
function ref(target){
let value = target;
const obj = {
get value(){
//收集依賴
track(obj,'value');
return value;
},
set value(newValue){
if(value === newValue)return;
value = newValue;
//觸發依賴
trigger(obj,'value');
}
}
return obj;
}
這就是基本類型的響應式實現原理,接下來我們來看一下 mount 方法的實現。
mount 方法
mount 方法實現掛載,而我們的副作用函數就是在這裏執行。它有 2 個參數,第一個參數即一個 vue 組件,第二個參數則是掛載的 DOM 根元素。所以,我們可以很快寫出以下代碼:
function mount(instance,el){
effect(function(){
instance.$data && update(instance,el);
});
//setup返回的數據就是實例上的數據
instance.$data = instance.setup();
//這裏的update實際上就是編譯函數
update(instance,el);
}
這樣就是實現了一個簡單的掛載, 接下來我們來看一下編譯函數的實現。
update 編譯函數
這裏爲了簡便化,我們實現的編譯函數就比較簡單,直接就將定義在組件上的 render 函數給賦值給根元素的innerHTML
。如下:
//這是最簡單的編譯函數
function update(instance,el){
el.innerHTML = instance.render();
}
如此一來,一個簡單的 mini-vue3.x 就這樣實現了,怎麼樣,不到 100 行代碼就搞定了,還是比較簡單的。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/gwiwKvhqzMWA4HRcnkej7A