基於React的SSG靜態站點渲染方案

WindrunnerMax發表於2024-06-04

基於React的SSG靜態站點渲染方案

靜態站點生成SSG - Static Site Generation是一種在構建時生成靜態HTML等檔案資源的方法,其可以完全不需要服務端的執行,透過預先生成靜態檔案,實現快速的內容載入和高度的安全性。由於其生成的是純靜態資源,便可以利用CDN等方案以更低的成本和更高的效率來構建和釋出網站,在部落格、知識庫、API文件等場景有著廣泛應用。

描述

在前段時間遇到了一個比較麻煩的問題,我們是主要做文件業務的團隊,而由於對外的產品文件涉及到全球很多地域的使用者,因此在CN以外地域的網站訪問速度就成了比較大的問題。雖然我們有多區域部署的機房,但是每個地域機房的資料都是相互隔離的,而實際上很多產品並不會做很多特異化的定製,因此文件實際上是可以通用的,特別是提供了多語言文件支援的情況下,各地域共用一份文件也變得合理了起來。而即使對於CN和海外地區有著特異化的定製,但在海外本身的訪問也會有比較大的侷限,例如假設機房部署在US,那麼在SG的訪問速度同樣也會成為一件棘手的事情。

那麼問題來了,如果我們需要做到各地域訪問的高效性,那麼就必須要在各個地域的主要機房部署服務,而各個地域又存在資料隔離的要求,那麼在這種情況下我們可能需要手動將文件複製到各個機房部署的服務上去,這必然就是一件很低效的事情,即使某個產品的文件不會經常更新,但是這種人工處理的方式依然是會耗費大量精力的,顯然是不可取的。而且由於我們的業務是管理各個產品的文件,在加上在海外業務不斷擴充套件的情況下,這類的反饋需求必然也會越來越多,那麼解決這個問題就變成了比較重要的事情。

那麼在這種情況下,我就忽然想到了我的部落格站點的構建方式,為了方便我會將部落格直接透過gh-pages分支部署在GitHub Pages上,而GitHub Pages本身是不支援服務端部署的,也就是說我的部落格站全部都是靜態資源。由此可以想到在業務中我們的文件站也可以用類似的方式來實現,也就是在釋出文件的時候透過SSG編譯的方式來生成靜態資源,那麼在全部的內容都是靜態資源的情況下,我們就可以很輕鬆地基於CDN來實現跨地域訪問的高效性。此外除了排程CDN的分發方式,我們還可以透過將靜態資源釋出到業務方申請的程式碼倉庫中,然後業務方就可以自行部署服務與資源了,透過多機房部署同樣可以解決跨地域訪問的問題。

當然,因為要考慮到各種問題以及現有部署方式的相容,在我們的業務中透過SSG來單獨部署實現跨地域的高效訪問並不太現實,最終大機率還是要走合規的各地域資料同步方案來保證資料的一致性與高效訪問。但是在思考透過SSG來作為這個問題的解決方案時,我還是很好奇如何在React的基礎上來實現SSG渲染的,畢竟我的部落格就可以算是基於MdxSSG渲染。最開始我把這個問題想的特別複雜,但是在實現的時候發現只是實現基本原理的話還是很粗暴的解決方案,在渲染的時候並沒有想象中要處理得那麼精細,當然實際上要做完整的方案特別是要實現一個框架也不是那麼容易的事情,對於資料的處理與渲染要做很多方面的考量。

在我們正式開始聊SSG的基本原理前,我們可以先來看一下透過SSG實現靜態站點的特點:

  • 訪問速度快: 靜態網站只是一組預先生成的HTMLCSSJavaScriptImage等靜態檔案,沒有執行在伺服器上的動態語言程式,在部署於CDN的情況下,使用者可以直接透過邊緣節點高效獲取資源,可以減少載入時間並增強使用者體驗。
  • 部署簡單: 靜態網站可以在任何託管服務上執行,例如GitHub PagesVercel等,我們只需要傳輸檔案即可,無需處理伺服器配置和資料庫管理等,如果藉助Git版本控制和CI/CD工具等,還可以比較輕鬆地實現自動化部署。
  • 資源佔用低: 靜態網站只需要非常少的伺服器資源,這使得其可以在低配置的環境中執行,我們可以在較低配置的伺服器上藉助Nginx輕鬆支撐10k+QPS網站訪問。
  • SEO優勢: 靜態網站通常對搜尋引擎最佳化SEO更加友好,預渲染的頁面可以擁有完整的HTML標籤結構,並且透過編譯可以使其儘可能符合語義化結構,這樣使得搜尋引擎的機器人更容易抓取和索引。

那麼同樣的,透過SSG生成的靜態資源站點也有一些侷限性:

  • 實時性不強: 由於靜態站點需要提前生成,因此就無法像動態網站一樣根據實時的請求生成對應的內容,例如當我們釋出了新文件之後,就必須要重新進行增量編譯甚至是全站全量編譯,那麼在編譯期間就無法訪問到最新的內容。
  • 不支援動態互動: 靜態站點通常只是靜態資源的集合,因此在一些動態互動的場景下就無法實現,例如使用者登入、評論等功能,當然這些功能可以透過客戶端渲染時動態支援,那麼這種情況就不再是純粹的靜態站點,通常是藉助SSG來實現更好的首屏和SEO效果。

綜上所述,SSG更適用於生成內容較為固定、不需要頻繁更新、且對於資料延遲敏感較低的的專案,並且實際上我們可能也只是選取部分能力來最佳化首屏等場景,最終還是會落到CSR來實現服務能力。因此當我們要選擇渲染方式的時候,還是要充分考慮到業務場景,由此來確定究竟是CSR - Client Side RenderSSR - Server Side RenderSSG - Static Site Generation更適合我們的業務場景,甚至在一些需要額外最佳化的場景下,ISR - Incremental Static RegenerationDPR - Distributed Persistent RenderingESR - Edge Side Rendering等也可以考慮作為業務上的選擇。

當然,回到最初我們提到的問題上,假如我們只是為了靜態資源的同步,透過CDN來解決全球跨地域訪問的問題,那麼實際上並不是一定需要完全的SSG來解決問題。將CSR完全轉變為SSR畢竟是一件改造範圍比較大的事情,而我們的目標僅僅是一處生產、多處消費,因此我們可以轉過來想一想實際上JSON檔案也是屬於靜態資源的一種型別,我們可以直接在前端發起請求將JSON檔案作為靜態資源請求到瀏覽器並且藉助SDK渲染即可,至於一些互動行為例如點贊等功能的速度問題我們也是可以接受的,文件站最的主要行為還是閱讀文件。此外對於md檔案我們同樣可以如此處理,例如docsify就是透過動態請求,但是同樣的對於搜尋引擎來說這些需要執行Js來動態請求的內容並沒有那麼容易抓取,所以如果想比較好地實現這部分能力還是需要不斷最佳化迭代。

那麼接下來我們就從基本原理開始,最佳化元件編譯的方式,進而基於模版渲染生成SSG,文中相關API的呼叫基於React17.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結構。在ReactDOMServer API中存在存在兩個相關的API,分別是renderToStaticMarkuprenderToString,這兩個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預覽靜態資源地址。實際上當前很多開源的靜態站點搭建框架例如VitePressRsPress等等都是採用類似的原理,都是在服務端生成HTMLJsCSS等等靜態檔案,然後在客戶端由各自的框架重新接管DOM的行為,當然這些框架的整合度很高,對於相關庫的複用程度也更高。而針對於更復雜的應用場景,還可以考慮NextGatsby等框架實現,這些框架在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-pluginHTML檔案輸出,這部分排程我們會在最後統一處理。

// 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);
})();

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://www.sanity.io/ssr-vs-ssg-guide
https://react.docschina.org/reference/react-dom
https://www.theanshuman.dev/articles/what-the-heck-is-ssg-static-site-generation-explained-with-nextjs-5cja

相關文章