2021年,讓咱們手寫一個mini版本的vue2.x和vue3.x框架

mini版本的vue.js2.X版本框架

模板代碼

首先咱們看一下咱們要實現的模板代碼:javascript

<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代碼。html

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";
        }
    }
});

運行效果

咱們來看一下實際運行效果以下所示:vue

思考一下,咱們要實現如上的功能應該怎麼作呢?你也能夠單獨打開以上示例:java

點擊此處node

源碼實現-2.x

miniVue類

首先,無論三七二十一,既然是實例化一個mini-vue,那麼咱們先定義一個類,而且它的參數必定是一個屬性配置對象。以下:react

class miniVue {
     constructor(options = {}){
         //後續要作的事情
     }
 }

如今,讓咱們先初始化一些屬性,好比data,methods,options等等。es6

//在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,經過這個方法來完成這個代理方法。以下:api

//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方法,事實上這裏還會有刪除依賴的方法,可是這裏爲了最簡便,咱們只須要一個addnotify方法便可。以下:

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行代碼就搞定了,仍是比較簡單的。

詳細文檔收錄網站

相關文章
相關標籤/搜索