如今這個世道,作為一個有幾年工作經驗的前端,不學點框架原始碼都感覺要被拋棄了,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的第一步,已經邁出去啦,給自己一個贊吧,持之以恆,終會有收穫的。這裡預告一下第二篇的內容,我們將分析和實現響應式方面的內容。