vue3原始碼難學,先從petite-vue開始吧

福祿網路研發團隊發表於2021-07-30

如今這個世道,作為一個有幾年工作經驗的前端,不學點框架原始碼都感覺要被拋棄了,react或vue要能吹吹牛吧,最好能造個輪子,聽說vue3原始碼好學點,那麼學學vue3,但是學起來還是那麼費勁,感覺快放棄了,就在這個時候出現了petite-vue,害,這傢伙比vue簡單啊,拿它來重拾學習原始碼的信心豈不更好,能自己寫一個petite-vue再學習vue3豈不是事半功倍。說了這麼多,今天就開始邁出第一步吧。注意,本文是學習petite-vue原始碼系列的第一篇文章,先打個廣告,github專案地址,歡迎點個星星喔,現在進入正題吧。
petite-vue還算是比較新的一個框架,尤雨溪2021年6月30號才初始化專案,經過幾天密集的程式碼提交後,有二十多天已經沒有更新了,看得出已經比較穩定了,本文不打算詳細介紹petite-vue是幹嘛的,有啥優勢,關於這些可以檢視官方介紹,首先來看看怎麼跑一個hello world吧。

<div v-scope>{{msg}}</div>
<script src="https://unpkg.com/petite-vue"></script>
<script>
    PetiteVue.createApp({ msg: 'hello world!' }).mount()
</script>

如果你熟悉vue,那麼對petite-vue的用法就很熟悉了,畢竟師出同門,當然還有一些個性化的語法,如上面的v-scope;對petite-vue有了簡單的認識後,我們就模仿上面的示例,來實現一個看起來一樣的程式碼吧,其中我們要實現如下幾個關鍵部分:

PetiteVue

PetiteVue是一個全域性物件,包含createApp這個重要的API,因此可以像下面這樣宣告:

const PetiteVue = {
    createApp(scope) {
        ...
    }
};

createApp

createApp是一個函式,入參可以接收一個表示元件資料值的物件,同時需要返回一個包含mount函式的物件,我們在上一步的基礎上接著豐富createApp函式吧:

const PetiteVue = {
    createApp(scope) {
        const appContent = {
            scope: scope,
        };
        const app = {
            context: appContent,
            mount() {
                ...
            }
        };
        return app;
    }
};

mount

mount根據字面意思,就是掛載我們的元件了,這裡我們只是簡單的將msg渲染到頁面上,要實現這一目標,我們要遍歷div的DOM結構,找到{{插值}}的地方,然後用scope的值去填充文字,說完了思路,接下來就實現吧,這裡我們新增兩個遍歷DOM的函式walk和walkChildren:

function walk(node, context) {
    const { nodeType } = node;
    if (nodeType === 1) { // Element
        return walkChildren(node, context);
    }
    if (nodeType === 3) { // Text
        ...
    }
}
function walkChildren(node) {
    let child = node.firstChild;
    while(!child) {
        walk(child);
        child = child.nextSibling;
    }
}
const PetiteVue = {
    createApp(scope) {
        const appContent = {
            scope: scope,
        };
        const app = {
            context: appContent,
            mount() {
                const root = document.querySelector('[v-scope]');
                if (!root) {
                    console.warn('請提供有v-scope屬性的html標籤');
                    return;
                }
                walk(root, appContent);
                root.removeAttribute('v-scope');
            }
        };
        return app;
    }
};

通過walk和walkChildren遞迴,可以遍歷所有DOM節點,這裡我們只關心Text節點,上面的程式碼還沒實現具體邏輯,先不急,把架子搭起來,後面再實現。

v-scope

v-scope是標記根元件的自定義屬性,petite-vue支援多個根元件節點,在本篇實現中就先實現一個吧,儘量保持簡單些;通過document.querySelector獲取到根節點引用,它就作為遍歷DOM的起點,當然最後要把v-scope屬性刪除,上面的程式碼已經實現了,這裡多廢話幾句。

{{}}

{{}}是我們自定義的插值語法,因此需要在walk遍歷過程中去識別和解析出來,識別還是很簡單的,就判斷文字是不是{{xx}}格式的,通過一個簡單的正則/{{([^]+?)}}/就可以判斷,這裡簡單說一下正規表示式吧,[^]+?表示匹配任意字元,但是儘量少匹配,外面的括號是一個分組,會提取出{{}}裡面的表示式,最後前後需要有{{}}包裹住,還是比較好理解的,現在動手實現具體的邏輯吧:

const RE = /{{([^]+?)}}/;
function walk(node, context) {
    const { nodeType } = node;
    if (nodeType === 1) { // Element
        return walkChildren(node, context);
    }
    if (nodeType === 3) { // Text
        const text = node.textContent;
        const match = text.match(RE);
        if (match) {
            const exp = match[1].trim(); // 刪除表示式前後的空白字元
            node.textContent = context.scope[exp];
        }
    }
}
function walkChildren(node) {
    let child = node.firstChild;
    while(!child) {
        walk(child);
        child = child.nextSibling;
    }
}
const PetiteVue = {
    createApp(scope) {
        const appContent = {
            scope: scope,
        };
        const app = {
            context: appContent,
            mount() {
                const root = document.querySelector('[v-scope]');
                if (!root) {
                    console.warn('請提供有v-scope屬性的html標籤');
                    return;
                }
                walk(root, appContent);
                root.removeAttribute('v-scope');
            }
        };
        return app;
    }
};

現在可以在瀏覽器裡面跑起來了,看下效果吧,嗯,跟petite-vue的例子看起來差不多了,到這裡我們就基本達成了最初的目標了,實現了一版很簡陋的看起來差不多的框架。

繼續完善

從實現來看當匹配到插值語法的時候,我們直接把文字節點的內容全部替換了,如果我們的文字是這樣的格式呢:"this is content: {{msg}} is't over",那麼最終渲染的還是隻有msg的狀態值,其他都丟失了,這樣顯得有點糟糕,我們就乘勝追擊,再完善一下吧。首先分析一下為了實現文字完整的渲染,我們要將靜態的文字和插值文字提取出來,然後再拼接起來才是最終符合預期的結果,從左到右依次解析文字,"this is content: {{msg}} is't over"需要分成三部分,分別是["this is content: ", "{{msg}}", " is't over"],msg經過轉換後變成["this is content: ", "{hello world!", "is't over"],最後拼接起來回填到文字節點就可以了:

const RE = /{{([^]+?)}}/g;
 function walk(node, context) {
    const { nodeType } = node;
    if (nodeType === 1) { // Element
        return walkChildren(node, context);
    }
    if (nodeType === 3) { // Text
        const text = node.textContent;
        let i = 0; // 儲存上一個匹配{{}}格式的字元結束索引
        if (text.includes('{{')) { // 先判斷是否有"{{"字元,有才進行下面的判斷
            let match = null;
            const segments = []; // 儲存所有截斷的文字
            while ((match = RE.exec(text))) {
                segments.push(text.slice(i, match.index)); // {{之前的字元
                i = match.index + match[0].length;
                const exp = match[1].trim(); // 刪除表示式前後的空白字元
                segments.push(context.scope[exp]); // msg的值求得之後,放入陣列中便於後面拼接
            }
            segments.push(text.slice(i)); // 最後一個}}後面的字元
            node.textContent = segments.join('');
        }
    }
}

支援表示式

通過拼接字串的方式我們完成了渲染的基本要求,但是熟悉vue語法的同學會說,雙花括號內部是支援js表示式的,既然實現到這裡了,我們就支援一下表示式吧,首先分析一下,表示式裡面的識別符號指向scope物件的屬性值,一個還好說,那麼兩個怎麼通過簡單的方式去實現呢,挨個挨個去把識別符號提取出來,然後計算再合併麼,想想都麻煩,那有沒有簡單的方式呢,我都這麼說了,當然是有的,先看下實現原理吧:

function createFunc(exp) {
    return new Function(`scope`, `with(scope) { return (${exp}) }`)
}
const f = foo('a + b');
f({ a: 1, b: 2 });

通過createFunc建立一個新的函式,with將exp表示式的作用域限定在scope中,這樣當執行a+b的時候,相當於scope.a + scope.b,最後將結果返回,最終執行的函式如下所示:

(function(scope) {
    with(scope) {
        return (a + b);
    }
})({a: 1, b: 2})

知曉了原理之後,我們就補齊表示式的計算吧:

function createFunc(exp) {
    return new Function(`scope`, `with(scope) { return (${exp}) }`);
}
...
function walk(node, context) {
    const { nodeType } = node;
    if (nodeType === 1) { // Element
        return walkChildren(node, context);
    }
    if (nodeType === 3) { // Text
        const text = node.textContent;
        let i = 0;
        if (text.includes('{{')) {
            let match = null;
            const segments = []; // 儲存所有截斷的文字
            while ((match = RE.exec(text))) {
                segments.push(text.slice(i, match.index));
                i = match.index + match[0].length;
                const exp = match[1].trim(); // 刪除表示式前後的空白字元
                segments.push(createFunc(exp)(context.scope)); // createFunc(exp)生成函式,再將scope傳入執行
            }
            segments.push(text.slice(i));
            node.textContent = segments.join('');
        }
    }
}
...

現在我們寫的第一版框架就完成啦,完整的v1版本程式碼可點選這裡,當然現在功能十分有限,沒有其他指令集,沒有響應式,不過作為學習petite-vue的第一步,已經邁出去啦,給自己一個贊吧,持之以恆,終會有收穫的。這裡預告一下第二篇的內容,我們將分析和實現響應式方面的內容。

福祿·研發中心 福袋

相關文章