手寫一個超簡單的Vue


基本結構

這里我根據自己的理解模仿了Vue的單文件寫法,通過給Vue.createApp傳入參數再掛載元素來實現頁面與數據的互動。

其中理解不免有錯,希望大佬輕噴。

收集數據

這里將Vue.createApp()里的參數叫做options

data可以是一個對象或者函數,在是函數的時候必須ruturn出一個對象,該對象里的數據會被vm直接調用。

可以直接先獲取options,然后將里面的data函數執行一次再把結果掛載到實例上,methods等對象也可以直接掛載:(這里忽略了data是對象的情況,只按照是函數來處理)

class Vue{
    constructor() {
        this.datas = Object.create(null);
    }
    static createApp(options){
        const vm = new Vue();
        vm.datas = options.data?.call(vm);
        for (const key in options.methouds) {
            vm.methouds[key] = options.methouds[key].bind(vm);
        }
        return vm;
    }
}

當然這樣只是會獲得一個Vue實例,上面有輸入的數據,這些數據還不會與頁面發生互動。

Vue 的響應式數據

Vue的數據雙向綁定是通過代理注入來實現的,在vue2中使用Object.defineProperty而到了vue3使用的是ProxyAPI。雖然用的方法不同,但核心思想是一樣的:截獲數據的改變,然后進行頁面更新。

這樣就可以試着寫出獲得代理數據的方法:

class Vue{
    constructor() {}
    
    static createApp(options){
        const vm = new Vue();
        const data = options.data?.call(vm);
        for (const key in data) {
            vm.datas[key] = vm.ref(data[key]);
        }
        return vm;
    }
    
    reactive(data) {
        const vm = this; //! 固定VUE實例,不然下面的notify無法使用
        return new Proxy(data, {
            //todo 修改對象屬性后修改Vnode
            set(target, p, value) {
                target._isref
                    ? Reflect.set(target, "value", value)
                    : Reflect.set(target, p, value);
                
                //todo 在這里通知,然后修改頁面
                dep.notify(vm);
                
                return true;
            },
        });
    }
    ref(data) {
        //? 基本數據類型會被包裝為對象再進行代理
        if (typeof data != "object") {
            data = {
                value: data,
                _isref: true,
                toSting() {
                    return this.value;
                },
            };
        }
        return this.reactive(data);
    }
}

現在如果data中設置的數據發生了改變,那么就會調用dep.notify來改變頁面內容。

vm代理datas等數據

因為再模板里是不會寫this.datas.xxx來調用數據的,這里也可以使用代理來把datas中的數據放到vm上:

class Vue {
    constructor() {
        //! 因為vm代理了datas 以后在vm上添加新屬性會被移動到datas中,所以如果是實例上的屬性要像el一樣占位
        this.el = "document";
        this.mountHTML = "mountHTML";
        this.datas = Object.create(null);
        this.methouds = Object.create(null);
    }
    static createApp(options) {
        //? 將data代理到vm上
        const vm = new Proxy(new Vue(), {
            get(target, p) {
                if (Reflect.get(target, p)) {
                    return Reflect.get(target, p);
                } else {
                    return target.datas[p]._isref ? target.datas[p].value : target.datas[p];
                }
            },
            set(target, p, value) {
                if (target[p]) {
                    Reflect.set(target, p, value);
                } else if (target.datas[p]?._isref) {
                    Reflect.set(target.datas[p], "value", value);
                } else {
                    Reflect.set(target.datas, p, value);
                }
                return true;
            },
        });
        //? onBeforeCreate
        options.onBeforCreate?.call(vm);
        const data = options.data?.call(vm);
        for (const key in data) {
            vm.datas[key] = vm.ref(data[key]);
        }
        for (const key in options.methouds) {
            vm.methouds[key] = options.methouds[key].bind(vm);
        }
        //? onCreated
        options.onCreated?.call(vm);
        return vm;
    }
}

這樣通過createApp獲得的Vue實例直接訪問並修改收集到的datas里的數據。

掛載

通過Vue.createApp可以獲得一個Vue實例,這樣只需要調用實例中的mount方法就可以進行掛載了,在掛載后就馬上進行數據的渲染。

vm.mount接收一個參數,可以是css選擇器的字符串,也可以直接是html節點:

class Vue{
    constructor() {}
    mount(el) {
        //todo 初始化
        this.init(el);
        //todo 渲染數據
        render(this);
        return this;
    }

    init(el) {
        this.el = this.getEl(el);
        this.mountHTML = this.el.innerHTML; //? 獲得掛載時元素的模板
    }

    getEl(el) {
        if (!(el instanceof Element)) {
            try {
                return document.querySelector(el);
            } catch {
                throw "沒有選中掛載元素";
            }
        } else return el;
    }
}

渲染頁面

Vue渲染頁面使用了VNode來記錄並按照它進行頁面的渲染,在每次更新數據時獲得數據更新的地方並通過diff算法來比較舊VNode和更新數據后VNode的不同來對頁面進行渲染。

這里不做太復雜處理,直接把掛載節點的innerHTML作為模板,通過正則進行捕獲並修改,然后渲染到頁面上,同時如果有通過@ 或 v-on綁定的事件,則按照情況進行處理:

  • 如果是原生的事件,則直接添加進去;
  • 如果是非原生的事件,則通過on來記錄,以后用emit來進行觸發。
export default function render(vm) {
    const regexp =
        /(?<tag>(?<=<)[^\/]+?(?=(>|\s)))|\{\{(\s*)(?<data>.+?)(\s*)\}\}|(?<text>(?<=>)\S+?(?=<))|(?<eName>(?<=@|(v-on:))\S+?)(=")(?<event>\S+?(?="))/g;
    const fragment = document.createDocumentFragment();
    let ele = {};
    //? 每次匹配到tag就把獲得的信息轉成標簽
    for (const result of vm.mountHTML.matchAll(regexp)) {
        if (result.groups.tag && ele.tag) {
            fragment.appendChild(createEle(vm, ele));
            ele = {};
        }
        Object.assign(ele, JSON.parse(JSON.stringify(result.groups)));
    }
    fragment.appendChild(createEle(vm, ele)); //? 最后這里再執行一次把最后的一個元素也渲染
    ele = null;

    //? 清空原來的DOM
    vm.el.innerHTML = "";
    vm.el.appendChild(fragment);
}

//? 放入原生事件,用字典儲存,這里只記錄了click
const OrangeEvents = { click: Symbol() };

/**
 * 根據解析的數據創建放入文檔碎片的元素
 */
function createEle(vm, options) {
    const { tag, text, data, eName, event } = options;
    if (tag) {
        const ele = document.createElement(tag);
        if (data) {
            ele.innerText = getByPath(vm, data);
        }
        if (text) {
            ele.innerText = text;
        }
        if (event) {
            //todo 先判斷是不是原生事件,是就直接綁定,不然用eventBinder來注冊
            if (OrangeEvents[eName]) {
                ele.addEventListener(eName, vm.methouds[event]);
            } else {
                eventBinder.off(eName); //? 因為這里render的實現是重新全部渲染,所以要清空對應的事件緩存
                eventBinder.on(eName, vm.methouds[event].bind(vm));
            }
        }
        return ele;
    }
}

/**
 * 通過字符串來訪問對象中的屬性
 */
function getByPath(obj, path) {
    const pathArr = path.split(".");
    return pathArr.reduce((result, curr) => {
        return result[curr];
    }, obj);
}

這里的正則用了具名組匹配符,可以通過我的這篇博客來了解。

這里渲染函數只是進行簡單渲染,沒有考慮到字符和數據同時出現的情況,也沒有考慮標簽嵌套的問題,只能平鋪標簽。。。

注冊事件

事件注冊就是一個標准的發布訂閱者模式的實現了,可以看看我的這篇博客(講的並不詳細)

這里對事件綁定進行了簡化,只保留了on off emit三個方法:

class Event {
    constructor() {
        this.collector = Object.create(null);
    }
    on(eName, cb) {
        this.collector[eName] ? this.collector[eName].push(cb) : (this.collector[eName] = [cb]);
    }
    off(eName, cb) {
        if (!(eName && cb)) {
            this.collector = Object.create(null);
        } else if (eName && !cb) {
            delete this.collector[eName];
        } else {
            this.collector[eName].splice(this.collector[eName].indexOf(cb), 0);
        }
        return this;
    }
    emit(eName, ...arg) {
        for (const cb of this.collector[eName]) {
            cb(...arg);
        }
    }
}

const eventBinder = new Event();

export { eventBinder };
export default eventBinder.emit.bind(eventBinder); //! emit會被注冊到vm上,讓它的this始終指向eventBinder

更新頁面

有了渲染函數就可以根據數據的變化來渲染頁面了,如果一次有多個數據進行修改,那么會觸發多次渲染函數,這是明顯的性能浪費,所以引用任務隊列的概念來保證一次操作只會重新渲染一次頁面:

// Dep.js
export default class Dep {
    constructor() {
        this.lock = true;
    }

    notify(vm) {
        //? onBeforeUpdate
        //! 把更新視圖放到微任務隊列,即使多個數據改變也只渲染一次
        if (this.lock) {
            this.lock = false;
            //! 應該在這里運用diff算法更新DOM樹 這里只是重新渲染一次頁面
            nextTick(render, vm);
            nextTick(() => (this.lock = true)); //? onUpdated
        }
    }
}
// nextTick.js
export default function nextTick(cb, ...arg) {
    Promise.resolve().then(() => {
        cb(...arg);
    });
}

結語

代碼地址

說不定還會試着加入其它功能。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM