Vue 是如何將一份模板轉換為真實的 DOM 節點的,又是如何高效地更新這些節點的呢?我們接下來就將嘗試通過深入研究 Vue 的內部渲染機制來解釋這些問題。
你可能已經聽說過“虛擬 DOM”的概念了,Vue 的渲染系統正是基于這個概念構建的。
虛擬 DOM (Virtual DOM,簡稱 VDOM) 是一種編程概念,意為將目標所需的 UI 通過數據結構“虛擬”地表示出來,保存在內存中,然后將真實的 DOM 與之保持同步。這個概念是由 React 率先開拓,隨后被許多不同的框架采用,當然也包括 Vue。
與其說虛擬 DOM 是一種具體的技術,不如說是一種模式,所以并沒有一個標準的實現。我們可以用一個簡單的例子來說明:
const vnode = {
type: 'div',
props: {
id: 'hello'
},
children: [
/* 更多 vnode */
]
}
這里所說的 vnode 即一個純 JavaScript 的對象 (一個“虛擬節點”),它代表著一個 <div> 元素。它包含我們創建實際元素所需的所有信息。它還包含更多的子節點,這使它成為虛擬 DOM 樹的根節點。
一個運行時渲染器將會遍歷整個虛擬 DOM 樹,并據此構建真實的 DOM 樹。這個過程被稱為掛載 (mount)。
如果我們有兩份虛擬 DOM 樹,渲染器將會有比較地遍歷它們,找出它們之間的區別,并應用這其中的變化到真實的 DOM 上。這個過程被稱為更新 (patch),又被稱為“比對”(diffing) 或“協調”(reconciliation)。
虛擬 DOM 帶來的主要收益是它讓開發者能夠靈活、聲明式地創建、檢查和組合所需 UI 的結構,同時只需把具體的 DOM 操作留給渲染器去處理。
從高層面的視角看,Vue 組件掛載時會發生如下幾件事:
Vue 模板會被預編譯成虛擬 DOM 渲染函數。Vue 也提供了 API 使我們可以不使用模板編譯,直接手寫渲染函數。在處理高度動態的邏輯時,渲染函數相比于模板更加靈活,因為你可以完全地使用 JavaScript 來構造你想要的 vnode。
那么為什么 Vue 默認推薦使用模板呢?有以下幾點原因:
模板更貼近實際的 HTML。這使得我們能夠更方便地重用一些已有的 HTML 代碼片段,能夠帶來更好的可訪問性體驗、能更方便地使用 CSS 應用樣式,并且更容易使設計師理解和修改。
由于其確定的語法,更容易對模板做靜態分析。這使得 Vue 的模板編譯器能夠應用許多編譯時優化來提升虛擬 DOM 的性能表現 (下面我們將展開討論)。
在實踐中,模板對大多數的應用場景都是夠用且高效的。渲染函數一般只會在需要處理高度動態渲染邏輯的可重用組件中使用。想了解渲染函數的更多使用細節可以去到渲染函數 & JSX 章節繼續閱讀。
虛擬 DOM 在 React 和大多數其他實現中都是純運行時的:更新算法無法預知新的虛擬 DOM 樹會是怎樣,因此它總是需要遍歷整棵樹、比較每個 vnode 上 props 的區別來確保正確性。另外,即使一棵樹的某個部分從未改變,還是會在每次重渲染時創建新的 vnode,帶來了大量不必要的內存壓力。這也是虛擬 DOM 最受詬病的地方之一:這種有點暴力的更新過程通過犧牲效率來換取聲明式的寫法和最終的正確性。
但實際上我們并不需要這樣。在 Vue 中,框架同時控制著編譯器和運行時。這使得我們可以為緊密耦合的模板渲染器應用許多編譯時優化。編譯器可以靜態分析模板并在生成的代碼中留下標記,使得運行時盡可能地走捷徑。與此同時,我們仍舊保留了邊界情況時用戶想要使用底層渲染函數的能力。我們稱這種混合解決方案為帶編譯時信息的虛擬 DOM。
下面,我們將討論一些 Vue 編譯器用來提高虛擬 DOM 運行時性能的主要優化:
在模板中常常有部分內容是不帶任何動態綁定的:
<div>
<div>foo</div> <!-- 需提升 -->
<div>bar</div> <!-- 需提升 -->
<div>{{ dynamic }}</div>
</div>
foo 和 bar 這兩個 div 是完全靜態的,沒有必要在重新渲染時再次創建和比對它們。Vue 編譯器自動地會提升這部分 vnode 創建函數到這個模板的渲染函數之外,并在每次渲染時都使用這份相同的 vnode,渲染器知道新舊 vnode 在這部分是完全相同的,所以會完全跳過對它們的差異比對。
此外,當有足夠多連續的靜態元素時,它們還會再被壓縮為一個“靜態 vnode”,其中包含的是這些節點相應的純 HTML 字符串。(示例)。這些靜態節點會直接通過 innerHTML 來掛載。同時還會在初次掛載后緩存相應的 DOM 節點。如果這部分內容在應用中其他地方被重用,那么將會使用原生的 cloneNode() 方法來克隆新的 DOM 節點,這會非常高效。
對于單個有動態綁定的元素來說,我們可以在編譯時推斷出大量信息:
<!-- 僅含 class 綁定 -->
<div :class="{ active }"></div>
<!-- 僅含 id 和 value 綁定 -->
<input :id="id" :value="value">
<!-- 僅含文本子節點 -->
<div>{{ dynamic }}</div>
在為這些元素生成渲染函數時,Vue 在 vnode 創建調用中直接編碼了每個元素所需的更新類型:
createElementVNode("div", {
class: _normalizeClass({ active: _ctx.active })
}, null, 2 /* CLASS */)
最后這個參數 2 就是一個更新類型標記 (patch flag)。一個元素可以有多個更新類型標記,會被合并成一個數字。運行時渲染器也將會使用位運算來檢查這些標記,確定相應的更新操作:
if (vnode.patchFlag & PatchFlags.CLASS /* 2 */) {
// 更新節點的 CSS class
}
位運算檢查是非常快的。通過這樣的更新類型標記,Vue 能夠在更新帶有動態綁定的元素時做最少的操作。
Vue 也為 vnode 的子節點標記了類型。舉例來說,包含多個根節點的模板被表示為一個片段 (fragment),大多數情況下,我們可以確定其順序是永遠不變的,所以這部分信息就可以提供給運行時作為一個更新類型標記。
export function render() {
return (_openBlock(), _createElementBlock(_Fragment, null, [
/* children */
], 64 /* STABLE_FRAGMENT */))
}
運行時會完全跳過對這個根片段中子元素順序的重新協調過程。
再來看看上面這個例子中生成的代碼,你會發現所返回的虛擬 DOM 樹是經一個特殊的 createElementBlock() 調用創建的:
export function render() {
return (_openBlock(), _createElementBlock(_Fragment, null, [
/* children */
], 64 /* STABLE_FRAGMENT */))
}
這里我們引入一個概念“區塊”,內部結構是穩定的一個部分可被稱之為一個區塊。在這個用例中,整個模板只有一個區塊,因為這里沒有用到任何結構性指令 (比如 v-if 或者 v-for)。
每一個塊都會追蹤其所有帶更新類型標記的后代節點 (不只是直接子節點),舉例來說:
<div> <!-- root block -->
<div>...</div> <!-- 不會追蹤 -->
<div :id="id"></div> <!-- 要追蹤 -->
<div> <!-- 不會追蹤 -->
<div>{{ bar }}</div> <!-- 要追蹤 -->
</div>
</div>
編譯的結果會被打平為一個數組,僅包含所有動態的后代節點:
div (block root)
- div 帶有 :id 綁定
- div 帶有 {{ bar }} 綁定
當這個組件需要重渲染時,只需要遍歷這個打平的樹而非整棵樹。這也就是我們所說的樹結構打平,這大大減少了我們在虛擬 DOM 協調時需要遍歷的節點數量。模板中任何的靜態部分都會被高效地略過。
v-if 和 v-for 指令會創建新的區塊節點:
<div> <!-- 根區塊 -->
<div>
<div v-if> <!-- if 區塊 -->
...
<div>
</div>
</div>
一個子區塊會在父區塊的動態子節點數組中被追蹤,這為他們的父區塊保留了一個穩定的結構。
更新類型標記和樹結構打平都大大提升了 Vue SSR 激活的性能表現:
靜態站點生成SSG - Static Site Generation是一種在構建時生成靜態HTML等文件資源的方法,其可以完全不需要服務端的運行,通過預先生成靜態文件,實現快速的內容加載和高度的安全性。由于其生成的是純靜態資源,便可以利用CDN等方案以更低的成本和更高的效率來構建和發布網站,在博客、知識庫、API文檔等場景有著廣泛應用。
在前段時間遇到了一個比較麻煩的問題,我們是主要做文檔業務的團隊,而由于對外的產品文檔涉及到全球很多地域的用戶,因此在CN以外地域的網站訪問速度就成了比較大的問題。雖然我們有多區域部署的機房,但是每個地域機房的數據都是相互隔離的,而實際上很多產品并不會做很多特異化的定制,因此文檔實際上是可以通用的,特別是提供了多語言文檔支持的情況下,各地域共用一份文檔也變得合理了起來。而即使對于CN和海外地區有著特異化的定制,但在海外本身的訪問也會有比較大的局限,例如假設機房部署在US,那么在SG的訪問速度同樣也會成為一件棘手的事情。
那么問題來了,如果我們需要做到各地域訪問的高效性,那么就必須要在各個地域的主要機房部署服務,而各個地域又存在數據隔離的要求,那么在這種情況下我們可能需要手動將文檔復制到各個機房部署的服務上去,這必然就是一件很低效的事情,即使某個產品的文檔不會經常更新,但是這種人工處理的方式依然是會耗費大量精力的,顯然是不可取的。而且由于我們的業務是管理各個產品的文檔,在加上在海外業務不斷擴展的情況下,這類的反饋需求必然也會越來越多,那么解決這個問題就變成了比較重要的事情。
那么在這種情況下,我就忽然想到了我的博客站點的構建方式,為了方便我會將博客直接通過gh-pages分支部署在GitHub Pages上,而GitHub Pages本身是不支持服務端部署的,也就是說我的博客站全部都是靜態資源。由此可以想到在業務中我們的文檔站也可以用類似的方式來實現,也就是在發布文檔的時候通過SSG編譯的方式來生成靜態資源,那么在全部的內容都是靜態資源的情況下,我們就可以很輕松地基于CDN來實現跨地域訪問的高效性。此外除了調度CDN的分發方式,我們還可以通過將靜態資源發布到業務方申請的代碼倉庫中,然后業務方就可以自行部署服務與資源了,通過多機房部署同樣可以解決跨地域訪問的問題。
當然,因為要考慮到各種問題以及現有部署方式的兼容,在我們的業務中通過SSG來單獨部署實現跨地域的高效訪問并不太現實,最終大概率還是要走合規的各地域數據同步方案來保證數據的一致性與高效訪問。但是在思考通過SSG來作為這個問題的解決方案時,我還是很好奇如何在React的基礎上來實現SSG渲染的,畢竟我的博客就可以算是基于Mdx的SSG渲染。最開始我把這個問題想的特別復雜,但是在實現的時候發現只是實現基本原理的話還是很粗暴的解決方案,在渲染的時候并沒有想象中要處理得那么精細,當然實際上要做完整的方案特別是要實現一個框架也不是那么容易的事情,對于數據的處理與渲染要做很多方面的考量。
在我們正式開始聊SSG的基本原理前,我們可以先來看一下通過SSG實現靜態站點的特點:
那么同樣的,通過SSG生成的靜態資源站點也有一些局限性:
綜上所述,SSG更適用于生成內容較為固定、不需要頻繁更新、且對于數據延遲敏感較低的的項目,并且實際上我們可能也只是選取部分能力來優化首屏等場景,最終還是會落到CSR來實現服務能力。因此當我們要選擇渲染方式的時候,還是要充分考慮到業務場景,由此來確定究竟是CSR - Client Side Render、SSR - Server Side Render、SSG - Static Site Generation更適合我們的業務場景,甚至在一些需要額外優化的場景下,ISR - Incremental Static Regeneration、DPR - Distributed Persistent Rendering、ESR - Edge Side Rendering等也可以考慮作為業務上的選擇。
當然,回到最初我們提到的問題上,假如我們只是為了靜態資源的同步,通過CDN來解決全球跨地域訪問的問題,那么實際上并不是一定需要完全的SSG來解決問題。將CSR完全轉變為SSR畢竟是一件改造范圍比較大的事情,而我們的目標僅僅是一處生產、多處消費,因此我們可以轉過來想一想實際上JSON文件也是屬于靜態資源的一種類型,我們可以直接在前端發起請求將JSON文件作為靜態資源請求到瀏覽器并且借助SDK渲染即可,至于一些交互行為例如點贊等功能的速度問題我們也是可以接受的,文檔站最的主要行為還是閱讀文檔。此外對于md文件我們同樣可以如此處理,例如docsify就是通過動態請求,但是同樣的對于搜索引擎來說這些需要執行Js來動態請求的內容并沒有那么容易抓取,所以如果想比較好地實現這部分能力還是需要不斷優化迭代。
那么接下來我們就從基本原理開始,優化組件編譯的方式,進而基于模版渲染生成SSG,文中相關API的調用基于React的17.0.2版本實現,內容相關的DEMO地址為https://github.com/WindrunnerMax/webpack-simple-environment/tree/master/packages/react-render-ssg。
通常當我們使用React進行客戶端渲染CSR時,只需要在入口的index.html文件中置入<div id="root"></div>的獨立DOM節點,然后在引入的xxx.js文件中通過ReactDOM.render方法將React組件渲染到這個DOM節點上即可。將內容渲染完成之后,我們就會在某些生命周期或者Hooks中發起請求,用以動態請求數據并且渲染到頁面上,此時便完成了組件的渲染流程。
那么在前邊我們已經聊了比較多的SSG內容,那么可以明確對于渲染的主要內容而言我們需要將其離線化,因此在這里就需要先解決第一個問題,如何將數據離線化,而不是在瀏覽器渲染頁面之后再動態獲取。很明顯在前邊我們提到的將數據從數據庫請求出來之后寫入json文件就是個可選的方式,我們可以在代碼構建的時候請求數據,在此時將其寫入文件,在最后一并上傳到CDN即可。
在我們的離線數據請求問題解決后,我們就需要來看渲染問題了,前邊也提到了類似的問題,如果依舊按照之前的渲染思路,而僅僅是將數據請求的地址從服務端接口替換成了靜態資源地址,那么我們就無法做到SEO以及更快的首屏體驗。其實說到這里還有一個比較有趣的事情,當我們用SSR的時候,假如我們的組件是dynamic引用的,那么Next在輸出HTML的時候會將數據打到HTML的<script />標簽里,在這種情況下實際上首屏的效率還是不錯的,并且Google進行索引的時候是能夠正常將動態執行Js渲染后的數據抓取,對于我們來說也可以算作一種離線化的渲染方案。
那么這種方式雖然可行但是并不是很好的方案,我們依然需要繼續解決問題,那么接下來我們需要正常地來渲染完整的HTML結構。在ReactDOM的Server API中存在存在兩個相關的API,分別是renderToStaticMarkup與renderToString,這兩個API都可以將React組件輸出HTML標簽的結構,只是區別是renderToStaticMarkup渲染的是不帶data-reactid的純HTML結構,當客戶端進行React渲染時會完全重建DOM結構,因此可能會存在閃爍的情況,renderToString則渲染了帶標記的HTML結構,React在客戶端不會重新渲染DOM結構,那么在我們的場景下時需要通過renderToString來輸出HTML結構的。
// packages/react-render-ssg/src/basic/index.ts
import ReactDOMServer from "react-dom/server";
const App = React.createElement(
React.Fragment,
null,
React.createElement("div", null, "React HTML Render"),
React.createElement(
"button",
{
onClick: () => alert("On Click"),
},
"Button"
)
);
const HTML = ReactDOMServer.renderToString(App);
// <div data-reactroot="">React HTML Render</div><button data-reactroot="">Button</button>
當前我們已經得到組件渲染過后的完整HTML結構,緊接著從輸出的內容我們可以看出來一個問題,我們定義的onClick函數并沒有在渲染過后的HTML結構中體現出來,此時在我們的HTML結構中只是一些完整的標簽,并沒有任何事件的處理。當然這也是很合理的情況,我們是用React框架實現的事件處理,其并不太可能直接完整地映射到輸出的HTML中,特別是在復雜應用中我們還是需要通過React來做后續事件交互處理的,那么很顯然我們依舊需要在客戶端處理相關的事件。
那么在React中我們常用的處理客戶端渲染函數就是ReactDOM.render,那么當前我們實際上已經處理好了HTML結構,而并不需要再次將內容完整地渲染出來,或者換句話說我們現在需要的是將事件掛在相關DOM上來處理交互行為,將React附加到在服務端環境中已經由React渲染的現有HTML上,由React來接管有關的DOM的處理。那么對于我們來說,我們需要將同樣的React組件在客戶端一并定義,然后將其輸出到頁面的Js中,也就是說這部分內容是需要在客戶端中執行的。
// packages/react-render-ssg/src/basic/index.ts
const PRESET = `
const App = React.createElement(
React.Fragment,
null,
React.createElement("div", null, "React HTML Render"),
React.createElement(
"button",
{
onClick: () => alert("On Click"),
},
"Button"
)
);
const _default = App;
ReactDOM.hydrate(_default, document.getElementById("root"));
`;
await fs.writeFile(`dist/${jsPathName}`, PRESET);
實際上這部分代碼都是在服務端生成的,我們此時并沒有在客戶端運行的內容,或者說這是我們的編譯過程,還沒有到達運行時,所以我們生成的一系列內容都是在服務端執行的,那么很明顯我們是需要拼裝HTML等靜態資源文件的。因此在這里我們可以通過預先定義一個HTML文件的模版,然后將構建過程中產生的內容放到模版以及新生成的文件里,產生的所有內容都將隨著構建一并上傳到CDN上并分發。
<!-- packages/react-render-ssg/public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<!-- ... Meta -->
<title>Template</title>
<!-- INJECT STYLE -->
</head>
<body>
<div id="root">
<!-- INJECT HTML -->
</div>
<!-- ... React Library -->
<!-- INJECT SCRIPT -->
</body>
</html>
// packages/react-render-ssg/src/basic/index.ts
const template = await fs.readFile("./public/index.html", "utf-8");
await fs.mkdir("dist", { recursive: true });
const random = Math.random().toString(16).substring(7);
const jsPathName = `${random}.js`;
const html = template
.replace(/<!-- INJECT HTML -->/, HTML)
.replace(/<!-- INJECT SCRIPT -->/, `<script src="${jsPathName}"></script>`);
await fs.writeFile(`dist/${jsPathName}`, PRESET);
await fs.writeFile(`dist/index.html`, html);
至此我們完成了最基本的SSG構建流程,接下來就可以通過靜態服務器訪問資源了,在這部分DEMO可以直接通過ts-node構建以及anywhere預覽靜態資源地址。實際上當前很多開源的靜態站點搭建框架例如VitePress、RsPress等等都是采用類似的原理,都是在服務端生成HTML、Js、CSS等等靜態文件,然后在客戶端由各自的框架重新接管DOM的行為,當然這些框架的集成度很高,對于相關庫的復用程度也更高。而針對于更復雜的應用場景,還可以考慮Next、Gatsby等框架實現,這些框架在SSG的基礎上還提供了更多的能力,對于更復雜的應用場景也有著更好的支持。
雖然在前邊我們已經實現了最基本的SSG原理,但是很明顯我們為了最簡化地實現原理人工處理了很多方面的內容,例如在上述我們輸出到Js文件的代碼中是通過PRESET變量定義的純字符串實現的代碼,而且我們對于同一個組件定義了兩遍,相當于在服務端和客戶端分開定義了運行的代碼,那么很明顯這樣的方式并不太合理,接下來我們就需要解決這個問題。
那么我們首先需要定義一個公共的App組件,在該組件的代碼實現中與前邊的基本原理中一致,這個組件會共享在服務端的HTML生成和客戶端的React Hydrate,而且為了方便外部的模塊導入組件,我們通常都是通過export default的方式默認導出整個組件。
// packages/react-render-ssg/src/rollup/app.tsx
import React from "react";
const App = () => (
<React.Fragment>
<div>React Render SSG</div>
<button onClick={() => alert("On Click")}>Button</button>
</React.Fragment>
);
export default App;
緊接著我們先來處理客戶端的React Hydrate,在先前我們是通過人工維護的編輯的字符串來定義的,而實際上我們同樣可以打包工具在Node端將組建編譯出來,以此來輸出Js代碼文件。在這里我們選擇使用Rollup來打包Hydrate內容,我們以app.tsx作為入口,將整個組件作為iife打包,然后將輸出的內容寫入APP_NAME,然后將實際的hydrate置入footer,就可以完成在客戶端的React接管DOM執行了。
// packages/react-render-ssg/rollup.config.js
const APP_NAME = "ReactSSG";
const random = Math.random().toString(16).substring(7);
export default async () => {
return {
input: "./src/rollup/app.tsx",
output: {
name: APP_NAME,
file: `./dist/${random}.js`,
format: "iife",
globals: {
"react": "React",
"react-dom": "ReactDOM",
},
footer: `ReactDOM.hydrate(React.createElement(${APP_NAME}), document.getElementById("root"));`,
},
plugins: [
// ...
],
external: ["react", "react-dom"],
};
};
接下來我們來處理服務端的HTML文件生成與資源的引用,這里的邏輯與先前的基本原理中服務端生成邏輯差別并不大,只是多了通過終端調用Rollup打包的邏輯,同樣也是將HTML輸出,并且將Js文件引入到HTML中,這里需要特殊關注的是我們的Rollup打包時的輸出文件路徑是在這里由--file參數覆蓋原本的rollup.config.js內置的配置。
// packages/react-render-ssg/src/rollup/index.ts
const exec = promisify(child.exec);
(async () => {
const HTML = ReactDOMServer.renderToString(React.createElement(App));
const template = await fs.readFile("./public/index.html", "utf-8");
const random = Math.random().toString(16).substring(7);
const path = "./dist/";
const { stdout } = await exec(`npx rollup -c --file=${path + random}.js`);
console.log("Client Compile Complete", stdout);
const jsFileName = `${random}.js`;
const html = template
.replace(/<!-- INJECT HTML -->/, HTML)
.replace(/<!-- INJECT SCRIPT -->/, `<script src="${jsFileName}"></script>`);
await fs.writeFile(`${path}index.html`, html);
})();
當前我們已經復用了組件的定義,并且通過Rollup打包了需要在客戶端運行的Js文件,不需要再人工維護輸出到客戶端的內容。那么場景再復雜一些,假如此時我們的組件有著更加復雜的內容,例如引用了組件庫來構建視圖,以及引用了一些CSS樣式預處理器來構建樣式,那么我們的服務端輸出HTML的程序就會變得更加復雜。
繼續沿著前邊的處理思路,我們在服務端的處理程序僅僅是需要將App組件的HTML內容渲染出來,那么假設此時我們的組件引用了@arco-design組件庫,并且通常我們還需要引用其中的less文件或者css文件。
import "@arco-design/web-react/dist/css/arco.css";
import { Button } from "@arco-design/web-react";
// OR
import "@arco-design/web-react/es/Button/style/index";
import { Button } from "@arco-design/web-react/es/Button";
那么需要關注的是,當前我們運行組件的時候是在服務端環境中,那么在Node環境中顯然我們是不認識.less文件以及.css文件的,實際上先不說這些樣式文件,import語法本身在Node環境中也是不支持的,只不過我們通常是使用ts-node來執行整個運行程序,暫時這點不需要關注,那么對于樣式文件我們在這里實際上是不需要的,所以我們就需要配置Node環境來處理這些樣式文件的引用。
require.extensions[".css"] = () => undefined;
require.extensions[".less"] = () => undefined;
但是即使這樣問題顯然沒有結束,熟悉arco-design的打包同學可能會清楚,當我們引入的樣式文件是Button/style/index時,實際上是引入了一個js文件而不是.less文件,如果需要明確引入.less文件的話是需要明確Button/style/index.less文件指向的。那么此時如果我們是引入的.less文件,那么并不會出現什么問題,但是此時我們引用的是.js文件,而這個.js文件中內部的引用方式是import,因為此時我們是通過es而不是lib部分明確引用的,即使在tsconfig中配置了相關解析方式為commonjs也是沒有用的。
{
"ts-node": {
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true
}
}
}
因此我們可以看到,如果僅僅用ts-node來解析或者說執行服務端的數據生成是不夠的,會導致我們平時實現組件的時候有著諸多限制,例如我們不能隨便引用es的實現而需要借助包本身的package.json聲明的內容來引入內容,如果包不能處理commonjs的引用那么還會束手無策。那么在這種情況下我們還是需要引入打包工具來打包commonjs的代碼,然后再通過Node來執行輸出HTML。通過打包工具,我們能夠做的事情就很多了,在這里我們將資源文件例如.less、.svg都通過null-loader加載,且相關的配置輸出都以commonjs為基準,此時我們輸出的文件為node-side-entry.js。
// packages/react-render-ssg/rspack.server.ts
const config: Configuration = {
context: __dirname,
entry: {
index: "./src/rspack/app.tsx",
},
externals: externals,
externalsType: "commonjs",
externalsPresets: {
node: true,
},
// ...
module: {
rules: [
{ test: /\.svg$/, use: "null-loader" },
{ test: /\.less$/, use: "null-loader" },
],
},
devtool: false,
output: {
iife: false,
libraryTarget: "commonjs",
publicPath: isDev ? "" : "./",
path: path.resolve(__dirname, ".temp"),
filename: "node-side-entry.js",
},
};
當前我們已經得到了可以在Node環境中運行的組件,那么緊接著,考慮到輸出SSG時我們通常都需要預置靜態數據,例如我們要渲染文檔的話就需要首先在數據庫中將相關數據表達查詢出來,然后作為靜態數據傳入到組件中,然后在預輸出的HTML中將內容直接渲染出來,那么此時我們的App組件的定義就需要多一個getStaticProps函數聲明,并且我們還引用了一些樣式文件。
// packages/react-render-ssg/src/rspack/app.tsx
import "./app.less";
import { Button } from "@arco-design/web-react";
import React from "react";
const App: React.FC<{ name: string }> = props => (
<React.Fragment>
<div>React Render SSG With {props.name}</div>
<Button style={{ marginTop: 10 }} type="primary" onClick={() => alert("On Click")}>
Button
</Button>
</React.Fragment>
);
export const getStaticProps = () => {
return Promise.resolve({
name: "Static Props",
});
};
export default App;
/* packages/react-render-ssg/src/rspack/app.less */
body {
padding: 20px;
}
同樣的,我們也需要為客戶端運行的Js文件打包,只不過在這里由于我們需要處理預置的靜態數據,我們在打包的時候同樣就需要預先生成模版代碼,當我們在服務端執行打包功能的時候,就需要將從數據庫查詢或者從文件讀取的數據放置于生成的模版文件中,然后以該文件為入口去再打包客戶端執行的React Hydrate能力。在這里因為希望將模版文件看起來更加清晰,我們使用了JSON.parse來處理預置數據,實際上這里只需要將占位預留好,數據在編譯的時候經過stringify直接寫入到模版文件中即可。
// packages/react-render-ssg/src/rspack/entry.tsx
/* eslint-disable @typescript-eslint/no-var-requires */
const Index = require(`<index placeholder>`);
const props = JSON.parse(`<props placeholder>`);
ReactDOM.hydrate(React.createElement(Index.default, { ...props }), document.getElementById("root"));
在模版文件生成好之后,我們就需要以這個文件作為入口調度客戶端資源文件的打包了,這里由于我們還引用了組件庫,輸出的內容自然不光是Js文件,還需要將CSS文件一并輸出,并且我們還需要配置一些通過參數名可以控制的文件名生成、externals等等。這里需要注意的是,此處我們不需要使用html-plugin將HTML文件輸出,這部分調度我們會在最后統一處理。
// packages/react-render-ssg/rspack.config.ts
const args = process.argv.slice(2);
const map = args.reduce((acc, arg) => {
const [key, value] = arg.split("=");
acc[key] = value || "";
return acc;
}, {} as Record<string, string>);
const outputFileName = map["--output-filename"];
const config: Configuration = {
context: __dirname,
entry: {
index: "./.temp/client-side-entry.tsx",
},
externals: {
"react": "React",
"react-dom": "ReactDOM",
},
// ...
builtins: {
// ...
pluginImport: [
{
libraryName: "@arco-design/web-react",
customName: "@arco-design/web-react/es/{{ member }}",
style: true,
},
{
libraryName: "@arco-design/web-react/icon",
customName: "@arco-design/web-react/icon/react-icon/{{ member }}",
style: false,
},
],
},
// ...
output: {
chunkLoading: "jsonp",
chunkFormat: "array-push",
publicPath: isDev ? "" : "./",
path: path.resolve(__dirname, "dist"),
filename: isDev
? "[name].bundle.js"
: outputFileName
? outputFileName + ".js"
: "[name].[contenthash].js",
// ...
},
};
那么此時我們就需要調度所有文件的打包過程了,首先我們需要創建需要的輸出和臨時文件夾,然后啟動服務端commonjs打包的流程,輸出node-side-entry.js文件,并且讀取其中定義的App組件以及預設數據讀取方法,緊接著我們需要創建客戶端入口的模版文件,并且通過調度預設數據讀取方法將數據寫入到入口模版文件中,此時我們就可以通過打包的commonjs組件執行并且輸出HTML了,并且客戶端運行的React Hydrate代碼也可以在這里一并打包出來,最后將各類資源文件的引入一并在HTML中替換并且寫入到輸出文件中就可以了。至此當我們打包完成輸出文件后,就可以使用靜態資源服務器啟動SSG的頁面預覽了。
const appPath = path.resolve(__dirname, "./app.tsx");
const entryPath = path.resolve(__dirname, "./entry.tsx");
require.extensions[".less"] = () => undefined;
(async () => {
const distPath = path.resolve("./dist");
const tempPath = path.resolve("./.temp");
await fs.mkdir(distPath, { recursive: true });
await fs.mkdir(tempPath, { recursive: true });
const { stdout: serverStdout } = await exec(`npx rspack -c ./rspack.server.ts`);
console.log("Server Compile", serverStdout);
const nodeSideAppPath = path.resolve(tempPath, "node-side-entry.js");
const nodeSideApp = require(nodeSideAppPath);
const App = nodeSideApp.default;
const getStaticProps = nodeSideApp.getStaticProps;
let defaultProps = {};
if (getStaticProps) {
defaultProps = await getStaticProps();
}
const entry = await fs.readFile(entryPath, "utf-8");
const tempEntry = entry
.replace("<props placeholder>", JSON.stringify(defaultProps))
.replace("<index placeholder>", appPath);
await fs.writeFile(path.resolve(tempPath, "client-side-entry.tsx"), tempEntry);
const HTML = ReactDOMServer.renderToString(React.createElement(App, defaultProps));
const template = await fs.readFile("./public/index.html", "utf-8");
const random = Math.random().toString(16).substring(7);
const { stdout: clientStdout } = await exec(`npx rspack build -- --output-filename=${random}`);
console.log("Client Compile", clientStdout);
const jsFileName = `${random}.js`;
const html = template
.replace(/<!-- INJECT HTML -->/, HTML)
.replace(/<!-- INJECT STYLE -->/, `<link rel="stylesheet" href="${random}.css">`)
.replace(/<!-- INJECT SCRIPT -->/, `<script src="${jsFileName}"></script>`);
await fs.writeFile(path.resolve(distPath, "index.html"), html);
})();
作者:WindrunnerMax
鏈接:https://juejin.cn/post/7375426024705769482
來源:稀土掘金
近一位同學在學習 vue3 源碼的時候,把 vue 3 的大部分核心邏輯都整理到了腦圖之中:
整理的內容非常詳細。應該會對所有還在學習 vue3 源碼
在了解 Vue3 框架設計之前,我們需要做兩件事情,而這兩件事情也是今天的主要內容。
那么準備好了?
我們開始吧!
針對于目前的前端開發而言,主要存在兩種 編程范式:
這兩種 范式 一般是相對來去說的。
那么首先我們先來說什么叫做 命令式。
具體例子:
張三的媽媽讓張三去買醬油。
那么張三怎么做的呢?
張三拿起錢
打開門
下了樓
到商店
拿錢買醬油
回到家
以上的流程詳細的描述了,張三在買醬油的過程中,每一步都做了什么。那么這樣一種:詳細描述做事過程 的方式就可以被叫做 命令式。
那么如果把這樣的方式放到具體的代碼實現之中,又應該怎么做呢?
我們來看以下這樣的一個事情:
在指定的 div 中展示 “hello world”
那么如果想要完成這樣的事情,通過命令式的方式我們如何實現呢?
我們知道命令式的核心在于:關注過程。
所以,以上事情通過命令式實現則可得出以下邏輯與代碼:
// 1. 獲取到指定的 div
const divEle = document.querySelector('#app')
// 2. 為該 div 設置 innerHTML 為 hello world
divEle.innerHTML = 'hello world'
該代碼雖然只有兩步,但是它清楚的描述了:完成這件事情,所需要經歷的過程
那么假如我們所做的事情,變得更加復雜了,則整個過程也會變得更加復雜。
比如:
為指定的 div 的子元素 div 的子元素 p 標簽,展示變量 msg
那么通過命令式完成以上功能,則會得出如下邏輯與代碼:
// 1. 獲取到第一層的 div
const divEle = document.querySelector('#app')
// 2. 獲取到它的子 div
const subDivEle = divEle.querySelector('div')
// 3. 獲取第三層的 p
const subPEle = subDivEle.querySelector('p')
// 4. 定義變量 msg
const msg = 'hello world'
// 5. 為該 p 元素設置 innerHTML 為 hello world
subPEle.innerHTML = msg
那么通過以上例子,相信大家可以對命令式的概念有了一個基礎的認識。
最后做一個總結,什么叫做命令式呢?
命令式是:關注過程 的一種編程范式,他描述了完成一個功能的 詳細邏輯與步驟。
當了解完命令式之后,那么接下來我們就來看 聲明式 編程。
針對于聲明式而言,大家其實都是非常熟悉的了。
比如以下代碼,就是一個典型的 聲明式 :
<div>{{ msg }}</div>
對于這個代碼,大家是不是感覺有些熟悉?
沒錯,這就是 Vue 中非常常見的雙大括號語法。所以當我們在寫 Vue 模板語法 的時候,其實一直寫的就是 聲明式 編程。
那么聲明式編程具體指的是什么意思呢?
還是以剛才的例子為例:
張三的媽媽讓張三去買醬油。
那么張三怎么做的呢?
張三拿起錢
打開門
下了樓
到商店
拿錢買醬油
回到家
在這個例子中,我們說:張三所做的事情就是命令式。那么張三媽媽所做的事情就是 聲明式。
在這樣一個事情中,張三媽媽只是發布了一個聲明,她并不關心張三如何去買的醬油,只關心最后的結果。
所以說,所謂聲明式指的是:不關注過程,只關注結果 的范式。
同樣,如果我們通過代碼來進行表示的話,以下例子:
為指定的 div 的子元素 div 的子元素 p 標簽,展示變量 msg
將會得出如下代碼:
<div id="app">
<div>
<p>{{ msg }}</p>
</div>
</div>
在這樣的代碼中,我們完全不關心 msg 是怎么被渲染到 p 標簽中的,我們所關心的只是:在 p 標簽中,渲染指定文本而已。
最后做一個總結,什么叫做聲明式呢?
聲明式是:關注結果 的一種編程范式,他 并不關心 完成一個功能的 詳細邏輯與步驟。(注意:這并不意味著聲明式不需要過程!聲明式只是把過程進行了隱藏而已!)
那么在我們講解完成 命令式 和 聲明式 之后,很多同學肯定會對這兩種編程范式進行一個對比。
是命令式好呢?還是聲明式好呢?
那么想要弄清楚這個問題,那么我們首先就需要先搞清楚,評價一種編程范式好還是不好的標準是什么?
通常情況下,我們評價一個編程范式通常會從兩個方面入手:
那么接下來我們就通過這兩個方面,來分析一下命令式和聲明式。
性能一直是我們在進行項目開發時特別關注的方向,那么我們通常如何來表述一個功能的性能好壞呢?
我們來看一個例子:
為指定 div 設置文本為 “hello world”
那么針對于這個需求而言,最簡單的代碼就是:
div.innerText = "hello world" // 耗時為:1
你應該找不到比這個更簡單的代碼實現了。
那么此時我們把這個操作的 耗時 比作 :1 。(PS:耗時越少,性能越強)
然后我們來看聲明式,聲明式的代碼為:
<div>{{ msg }}</div> <!-- 耗時為:1 + n -->
<!-- 將 msg 修改為 hello world -->
那么:已知修改text最簡單的方式是innerText ,所以說無論聲明式的代碼是如何實現的文本切換,那么它的耗時一定是 > 1 的,我們把它比作 1 + n(對比的性能消耗)。
所以,由以上舉例可知:命令式的性能 > 聲明式的性能
可維護性代表的維度非常多,但是通常情況下,所謂的可維護性指的是:對代碼可以方便的 閱讀、修改、刪除、增加 。
那么想要達到這個目的,說白了就是:代碼的邏輯要足夠簡單,讓人一看就懂。
那么明確了這個概念,我們來看下命令式和聲明式在同一段業務下的代碼邏輯:
// 命令式
// 1. 獲取到第一層的 div
const divEle = document.querySelector('#app')
// 2. 獲取到它的子 div
const subDivEle = divEle.querySelector('div')
// 3. 獲取第三層的 p
const subPEle = subDivEle.querySelector('p')
// 4. 定義變量 msg
const msg = 'hello world'
// 5. 為該 p 元素設置 innerHTML 為 hello world
subPEle.innerHTML = msg
// 聲明式
<div id="app">
<div>
<p>{{ msg }}</p>
</div>
</div>
對于以上代碼而言,聲明式 的代碼明顯更加利于閱讀,所以也更加利于維護。
所以,由以上舉例可知:**命令式的可維護性 < 聲明式的可維護性 **
由以上分析可知兩點內容:
那么雙方各有優劣,我們在日常開發中應該使用哪種范式呢?
想要搞明白這點,那么我們還需要搞明白更多的知識。
企業應用的設計原則,想要描述起來比較復雜,為什么呢?
因為對于 不同的企業類型(大廠、中小廠、人員外包、項目外包),不同的項目類型(前臺、中臺、后臺)來說,對應的企業應用設計原則上可能會存在一些差異。
所以我們這里所做的描述,會拋棄一些細微的差異,僅抓住核心的重點來進行闡述。
無論什么類型的企業,也無論它們在開發什么類型的項目,那么最關注的點無非就是兩個:
項目成本非常好理解,它決定了一個公司完成“這件事”所付出的代價,從而直接決定了這個項目是否是可以盈利的(大廠的燒錢項目例外)。
那么既然項目成本如此重要,大家可以思考一下,決定項目成本的又是什么?
沒錯!就是你的 開發周期。
開發周期越長,所付出的人員成本就會越高,從而導致項目成本變得越高。
通過我們前面的分析可知,聲明式的開發范式在 可維護性 上,是 大于 命令式的。
而可維護性從一定程度上就決定了,它會使項目的:開發周期變短、升級變得更容易 從而大量節約開發成本。
所以這也是為什么 Vue 會變得越來越受歡迎的原因。
決定開發者開發體驗的核心要素,主要是在開發時和閱讀時的難度,這個被叫做:心智負擔。
心智負擔可以作為衡量開發難易度的一個標準,心智負擔高則證明開發的難度較高,心智負擔低則表示開發的難度較低,開發更加舒服。
那么根據我們之前所說,聲明式的開發難度明顯低于命令式的開發難度。
所以對于開發體驗而言,聲明式的開發體驗更好,也就是 心智負擔更低。
Vue 作者尤雨溪在一次演講中說道:框架的設計過程其實是一個不斷取舍的過程 。
這代表的是什么意思呢?
想要搞明白這個,那么再來明確一下之前說過的概念:
當我們明確好了這樣的一個問題之后,那么我們接下來來思考一個問題:框架的開發與設計原則是什么呢?
我們知道對于 Vue 而言,當我們使用它的是通過 聲明式 的方式進行使用,但是對于 Vue 內部而言,是通過 命令式 來進行的實現。
所以我們可以理解為:Vue 封裝了命令式的邏輯,而對外暴露出了聲明式的接口
那么既然如此,我們明知 命令式的性能 > 聲明式的性能 。那么 Vue 為什么還要選擇聲明式的方案呢?
其實原因非常的簡單,那就是因為:命令式的可維護性 < 聲明式的可維護性 。
為指定的 div 的子元素 div 的子元素 p 標簽,展示變量 msg
以這個例子為例。
對于開發者而言,不需要關注實現過程,只需要關注最終的結果即可。
而對于 Vue 而言,他所需要做的就是:封裝命令式邏輯,同時 盡可能的減少性能的損耗!它需要在 性能 與 可維護性 之間,找到一個平衡。從而找到一個 可維護性更好,性能相對更優 的一個點。
所以對于 Vue 而言,它的設計原則就是:在保證可維護性的基礎上,盡可能的減少性能的損耗。
那么回到我們的標題:為什么說框架的設計過程其實是一個不斷取舍的過程?
答案也就呼之欲出了,因為:
我們需要在可維護性和性能之間,找到一個平衡點。在保證可維護性的基礎上,盡可能的減少性能的損耗。
所以框架的設計過程其實是一個不斷在 可維護性和性能 之間進行取舍的過程
在 Vue 3 的 源代碼 中存在一個 runtime-core 的文件夾,該文件夾內存放的就是 運行時 的核心代碼邏輯。
runtime-core 中對外暴露了一個函數,叫做 渲染函數render
我們可以通過 render 代替 template 來完成 DOM 的渲染:
有些同學可能看不懂當前代碼是什么意思,沒有關系,這不重要,后面我們會詳細去講。
<head>
<meta charset="UTF-8">
<title>Document</title>
<script src="https://unpkg.com/vue@3.2.36/dist/vue.global.js"></script>
</head>
<body>
<div id="app"></div>
</body>
<script>
const { render, h } = Vue
// 生成 VNode
const vnode = h('div', {
class: 'test'
}, 'hello render')
// 承載的容器
const container = document.querySelector('#app')
// 渲染函數
render(vnode, container)
</script>
我們知道,在 Vue 的項目中,我們可以通過 tempalte 渲染 DOM 節點,如下:
<template>
<div class="test">hello render</div>
</template>
但是對于 render 的例子而言,我們并沒有使用 tempalte,而是通過了一個名字叫做 render 的函數,返回了一個不知道是什么的東西,為什么也可以渲染出 DOM 呢?
帶著這樣的問題,我們來看:
我們知道在上面的代碼中,存在一個核心函數:渲染函數 render,那么這個 render 在這里到底做了什么事情呢?
我們通過一段代碼實例來去看下:
假設有一天你們領導跟你說:
我希望根據如下數據:
渲染出這樣一個 div:
{
type: 'div',
props: {
class: test
},
children: 'hello render'
}
<div class="test">hello render</div>
那么針對這樣的一個需求你會如何進行實現呢?大家可以在這里先思考一下,嘗試進行一下實現,然后我們再繼續往下看..........
那么接下來我們根據這個需求來實現以下代碼:
<script>
const VNode = {
type: 'div',
props: {
class: 'test'
},
children: 'hello render'
}
// 創建 render 渲染函數
function render(vnode) {
// 根據 type 生成 element
const ele = document.createElement(vnode.type)
// 把 props 中的 class 賦值給 ele 的 className
ele.className = vnode.props.class
// 把 children 賦值給 ele 的 innerText
ele.innerText = vnode.children
// 把 ele 作為子節點插入 body 中
document.body.appendChild(ele)
}
render(VNode)
</script>
在這樣的一個代碼中,我們成功的通過一個 render 函數渲染出了對應的 DOM,和前面的 render 示例 類似,它們都是渲染了一個 vnode,你覺得這樣的代碼真是 妙極了!
但是你的領導用了一段時間你的 render 之后,卻說:天天這樣寫也太麻煩了,每次都得寫一個復雜的 vnode,能不能讓我直接寫 HTML 標簽結構的方式 你來進行渲染呢?
你想了想之后,說:如果是這樣的話,那就不是以上 運行時 的代碼可以解決的了!
沒錯!我們剛剛所編寫的這樣的一個“框架”,就是 運行時 的代碼框架。
那么最后,我們做一個總結:運行時可以利用render 把 vnode 渲染成真實 dom 節點。
在剛才,我們明確了,如果只靠 運行時,那么是沒有辦法通過 HTML 標簽結構的方式 的方式來進行渲染解析的。
那么想要實現這一點,我們就需要借助另外一個東西,也就是 編譯時。
Vue 中的編譯時,更準確的說法應該是 編譯器 的意思。它的代碼主要存在于 compiler-core 模塊下。
我們來看如下代碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<script src="https://unpkg.com/vue@3.2.36/dist/vue.global.js"></script>
</head>
<body>
<div id="app"></div>
</body>
<script>
const { compile, createApp } = Vue
// 創建一個 html 結構
const html = `
<div class="test">hello compiler</div>
`
// 利用 compile 函數,生成 render 函數
const renderFn = compile(html)
// 創建實例
const app = createApp({
// 利用 render 函數進行渲染
render: renderFn
})
// 掛載
app.mount('#app')
</script>
</html>
對于編譯器而言,它的主要作用就是:把 template 中的 html 編譯成 render 函數。然后再利用 運行時 通過 render 掛載對應的 DOM。
那么最后,我們做一個總結:編譯時可以把html 的節點,編譯成 render函數
前面兩小節我們已經分別了解了 運行時 和 編譯時,同時我們也知道了:vue 是一個 運行時+編譯時 的框架!
vue 通過 compiler 解析 html 模板,生成 render 函數,然后通過 runtime 解析 render,從而掛載真實 dom。
那么看到這里可能有些同學就會有疑惑了,既然 compiler 可以直接解析 html 模板,那么為什么還要生成 render 函數,然后再去進行渲染呢?為什么不直接利用 compiler 進行渲染呢?
即:為什么 vue 要設計成一個 運行時+編譯時的框架呢?
那么想要理清楚這個問題,我們就需要知道 dom 渲染是如何進行的。
對于 dom 渲染而言,可以被分為兩部分:
那么什么是初次渲染呢?
當初始 div 的 innerHTML 為空時,
<div id="app"></div>
我們在該 div 中渲染如下節點:
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
那么這樣的一次渲染,就是 初始渲染。在這樣的一次渲染中,我們會生成一個 ul 標簽,同時生成三個 li 標簽,并且把他們掛載到 div 中。
那么此時如果 ul 標簽的內容發生了變化:
<ul>
<li>3</li>
<li>1</li>
<li>2</li>
</ul>
li - 3 上升到了第一位,那么此時大家可以想一下:我們期望瀏覽器如何來更新這次渲染呢?
瀏覽器更新這次渲染無非有兩種方式:
那么大家覺得這兩種方式哪一種方式更好呢?那么我們來分析一下:
那么根據以上分析,我們知道了:
那么這兩種方式,哪一種更快呢?我們來實驗一下:
const length = 10000
// 增加一萬個dom節點,耗時 3.992919921875 ms
console.time('element')
for (let i = 0; i < length; i++) {
const newEle = document.createElement('div')
document.body.appendChild(newEle)
}
console.timeEnd('element')
// 增加一萬個 js 對象,耗時 0.402099609375 ms
console.time('js')
const divList = []
for (let i = 0; i < length; i++) {
const newEle = {
type: 'div'
}
divList.push(newEle)
}
console.timeEnd('js')
從結果可以看出,dom 的操作要比 js 的操作耗時多得多,即:dom** 操作比 js 更加耗費性能**。
那么根據這樣的一個結論,回到我們剛才所說的場景中:
首先對于第一種方式而言:它的好處在于不需要進行任何的比對,僅需要執行 6 次(刪除 3 次,重新渲染 3 次)dom 處理即可。
對于第二種方式而言:在邏輯上相對比較復雜。他需要分成兩步來做:
對比 舊節點 和 新節點 之間的差異
根據差異,刪除一個 舊節點,增加一個 新節點
根據結論可知:方式一會比方式二更加消耗性能(即:性能更差)。
那么得出這樣的結論之后,我們回過頭去再來看最初的問題:為什么 vue 要設計成一個 運行時+編譯時的框架呢?
答:
在 vue 的源碼中,會大量的涉及到一個概念,那就 副作用。
所以我們需要先了解一下副作用代表的是什么意思。
副作用指的是:當我們 對數據進行 setter 或 getter 操作時,所產生的一系列后果。
那么具體是什么意思呢?我們分別來說一下:
setter 所表示的是 賦值 操作,比如說,當我們執行如下代碼時 :
msg = '你好,世界'
這時 msg 就觸發了一次 setter 的行為。
那么假如說,msg 是一個響應性數據,那么這樣的一次數據改變,就會影響到對應的視圖改變。
那么我們就可以說:msg 的 setter 行為,觸發了一次副作用,導致視圖跟隨發生了變化。
getter 所表示的是 取值 操作,比如說,當我們執行如下代碼時:
element.innerText = msg
此時對于變量 msg 而言,就觸發了一次 getter 操作,那么這樣的一次取值操作,同樣會導致 element 的 innerText 發生改變。
所以我們可以說:msg 的 getter 行為觸發了一次副作用,導致 element 的 innterText 發生了變化。
那么明確好了副作用的基本概念之后,那么大家想一想:副作用可能會有多個嗎?
答案是:可以的。
舉個簡單的例子:
<template>
<div>
<p>姓名:{{ obj.name }}</p>
<p>年齡:{{ obj.age }}</p>
</div>
</template>
<script>
const obj = ref({
name: '張三',
age: 30
})
obj.value = {
name: '李四',
age: 18
}
</script>
在這樣的一個代碼中 obj.value 觸發了一次 setter 行為,但是會導致兩個 p 標簽的內容發生改變,也就是產生了兩次副作用。
根據本小節我們知道了:
根據前面的學習我們已經知道了:
那么了解了這些內容之后,下來 vue3 的一個基本框架設計:
對于 vue3 而言,核心大致可以分為三大模塊:
我們以以下基本結構來描述一下三者之間的基本關系:
<template>
<div>{{ proxyTarget.name }}</div>
</template>
<script>
import { reactive } from 'vue'
export default {
setup() {
const target = {
name: '張三'
}
const proxyTarget = reactive(target)
return {
proxyTarget
}
}
}
</script>
在以上代碼中:
以上就是 reactivity、runtime、compiler 三者之間的運行關系。
*請認真填寫需求信息,我們會在24小時內與您取得聯系。