重新手寫一個Vue

影依賢者發表於2021-07-28

該版把上一次的資料修改就更新全部頁面改為了區域性更新,相比於上一版的在資料繫結上不是簡單的一個監聽set再全部更新,具體見下文。

總體流程

仍然是根據自己理解來實現的繫結,相較於上一版的資料更新就全部重新整理,這次改成了部分頁面更改,總體流程大致如圖:(字本來就醜,那個筆芯寫更醜了,希望能看懂吧)

QQ圖片20210728143535.jpg

這裡就從頭介紹下怎樣實現整個流程的

createApp

這裡是整個Vue的入口,通過傳入options引數會將裡面的data,methods等掛載到Vue例項上,再通過代理,讓對vm的屬性訪問轉換為對vm.$data中屬性的訪問:

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.$data[p]._isref ? target.$data[p].value : target.$data[p];
                }
            },
            set(target, p, value) {
                if (target[p]) {
                    Reflect.set(target, p, value);
                } else if (target.$data[p]?._isref) {
                    Reflect.set(target.$data[p], "value", value);
                } else {
                    Reflect.set(target.$data, p, value);
                }
                return true;
            },
        });

        options.onBeforCreate?.call(vm);

        vm.$data = options.data.call(vm);
        new Observer(vm).observeData(); //! 將data的資料轉為響應式

        for (const key in options.methouds) {
            vm.$methouds[key] = options.methouds[key].bind(vm);
        }

        options.onCreated?.call(vm);
        return vm;
    }

將data中的資料轉換為響應式

這個步驟通過Observer例項中的observeData來進行,我這裡通過Proxy來實現(Vue2.x中使用Object.defineProperty)。

import Dep from "./dep.js";

const dep = new Dep();

export default class Observer {
    constructor(vm) {
        this.vm = vm;
    }
    observeData() {
        const data = this.vm.$data;
        for (const key in data) {
            data[key] = this.ref(data[key]);
        }
    }
    // *===============↓ 將資料轉換為響應式資料的方法 ↓===============* //
    reactive(data) {
        //? 如果物件裡還有物件,遞迴實現響應式
        for (const key in data) {
            if (typeof data[key] === "object") {
                data[key] = this.reactive(data[key]);
            }
        }
        return new Proxy(data, {
            get(target, p) {
                window.target && dep.add(window.target);
                window.target = null; //? 將watch例項儲存後刪除
                return Reflect.get(target, p);
            },
            //todo 修改物件屬性後修改Vnode
            set(target, p, value) {
                target._isref
                    ? Reflect.set(target, "value", value)
                    : Reflect.set(target, p, value);

                dep.notify();

                return true;
            },
        });
    }
    ref(data) {
        //? 基本資料型別會被包裝為物件再進行代理
        if (typeof data != "object") {
            data = {
                value: data,
                _isref: true,
                toSting() {
                    return this.value;
                },
            };
        }
        return this.reactive(data);
    }
}

這裡在get上設定了dep.add,在第一次渲染頁面的時候會讀取到對應的$data中的屬性,在這個時候將這個屬性的位置和一個用來更新檢視的回撥函式打包進Watcher的例項再放入dep中儲存起來,在以後資料更新時會觸發set,通知dep呼叫儲存的所有watcher例項上的update方法,update方法會比較儲存的舊值來決定是否觸發回撥函式來更新檢視。

Dep:

import { nextTick } from "./util.js";

export default class Dep {
    constructor() {
        this.watchers = [];
        this.lock = true;
    }
    add(watcher) {
        this.watchers.push(watcher);
    }
    notify() {
        //? 放入微任務佇列,只要觸發一次notify就不再觸發,在微任務裡更新檢視,這樣所有資料都更新後再觸發更新
        if (this.lock) {
            this.lock = false;
            nextTick(() => {
                this.watchers.forEach((watcher) => {
                    watcher.update(); //? 用watcher例項的update更新檢視
                });
                this.lock = true;
            });
        }
    }
}

Watcher:

import { getByPath } from "./util.js";

export default class Watcher {
    constructor(vm, key, cb) {
        this.vm = vm;
        this.key = key; //? 代表該資料在$data哪裡的字串
        this.cb = cb; //? 更新頁面的回撥函式
        window.target = this;
        //! 獲得舊資料,同時觸發vm[key]的get把上面一行設定watcher例項push進dep 見observer.js
        this.oldValue = getByPath(vm, key);
    }

    //? dep呼叫notify來呼叫所有的update更新檢視
    update() {
        let newValue = getByPath(this.vm, this.key);
        if (newValue === this.oldValue) return;
        this.oldValue = newValue;
        this.cb(newValue);
    }
}

為了使用方便,這裡把Watcher的例項化過程掛載到vm上,例項化Watcher並推入dep的過程全由vm.$watche完成:

class Vue {
    constructor() {
       this.$watch = function (key, cb) {
            new Watcher(this, key, cb);
        }; 
    }
}

頁面渲染

通過修改原來的第一版渲染函式,這裡改為了挨個讀取節點來轉換,通過讀取每個節點的字串形式來把資料替換或把方法掛載:

export default function render($el, vm) {
    const nodes = $el.children;
    Array.prototype.forEach.call(nodes, (el) => {
        if (el.children.length > 0) {
            render(el, vm); //? 遞迴渲染子節點
        } else {
            renderTemplate(vm, el);
        }
    });
}

function renderTemplate(vm, el) {
    renderData(vm, el);
    renderEvent(vm, el);
    renderVModel(vm, el);
}

//? 將{{}}裡的資料渲染
function renderData(vm, el) {
    const nodeText = el.textContent;
    const regexp = /\{\{(\s*)(?<data>.+?)(\s*)\}\}/g;
    if (regexp.test(nodeText)) {
        return nodeText.replace(regexp, (...arg) => {
            const groups = JSON.parse(JSON.stringify(arg.pop()));
            //! 將這個資料相對於vm的位置儲存進dep,每次dep收到更新時觸發回撥
            vm.$watch(groups.data, (newValue) => {
                el.textContent = newValue;
            });
            el.textContent = getByPath(vm, groups.data);
        });
    }
}

... ...

再說明一下,現在的渲染操作只在進行mount的時候會執行,當以後$data屬性改變時會觸發在這裡設定的回撥函式,通過它來修改頁面。

一些其它細節的地方

在頁面渲染時讀取$data屬性只能通過寫在模板上的字串,這裡用了reduce方法來獲取字串對應的值:

export function getByPath(obj, path) {
    const pathArr = path.split(".");
    return pathArr.reduce((result, curr) => {
        return result[curr];
    }, obj);
}

nextTick函式在這裡只是用了開啟微任務佇列的方式實現:

export function nextTick(cb, ...arg) {
    Promise.resolve().then(() => {
        cb(...arg);
    });
}

測試

最後簡單寫個計數器來看看實現的所有功能,可以看到和預期的一樣

GIF 2021-7-28 18-06-38.gif

程式碼倉庫

相關文章