在 Next.js 有一個很棒的優點是在 /pages
中的頁面預設 next build
時都會各自打包成獨立的檔案,SSG 的頁面會產生靜態的 HTML 檔案,而 SSR 與 CSR 則是會產生的 JS 檔案,使用者在載入每個頁面時不用載入其他用不到的資源。
談到各自打包成獨立的檔案就會讓人聯想到 code splitting 這項技巧, Next.js 預設已經有調教過 webpack 的設定檔,如果沒有想要更近一步的優化網頁的 bundle size,預設的設定就已經足夠。
但是當想要優化網頁的 bundle size,就不得不提到靜態的 webpack.spiltChunk
與動態的 dynamic import,在這篇文章中我們會專注在討論 dynamic import 上。
首先我們先來了解 ES2020 的 dynamic import,如各位所知它是 ES2020 後才出現的 feature,對比的是 ES6 的 static import,static import 一定會寫在檔案的最頂端,反之,dynamic import 則是可以不用把 import
的陳述式 (statement) 寫在檔案的最頂端,可以在程式碼中以 function 動態的方式 import()
需要的模組。
而 dynamic import 會回傳 promise,因此就可以用 .then
或是 async/await
的方式載入模組:
// 程式碼來源 [https://v8.dev/features/dynamic-import](https://v8.dev/features/dynamic-import)
<script type="module">
const moduleSpecifier = './utils.mjs';
import(moduleSpecifier)
.then((module) => {
module.default();
// → logs 'Hi from the default export!'
module.doStuff();
// → logs 'Doing stuff…'
});
</script>
<script type="module">
(async () => {
const moduleSpecifier = './utils.mjs';
const module = await import(moduleSpecifier)
module.default();
// → logs 'Hi from the default export!'
module.doStuff();
// → logs 'Doing stuff…'
})();
</script>
以 moment 這個套件來說,我們經常會用它來處理關於 date 的問題,是一個很完整的套件,美中不足的是 moment 的檔案體積非常大,從 bundlephobia 會發現它在 minified 後還有 289 kB,對於使用者來說載入如此大的套件是不太友善的。
在還沒使用 webpack bundle analyzer 看 bundle 的組成之前,按照一般在寫 React 的經驗, webpack 在沒有額外設定 splitChunk
之下,moment 跟其他所有的檔案都會被打包在一起,在客戶端在瀏覽網頁時基本上就是要載入一整包的網站,儘管其他沒有被用到的資源也是如此。
在 Next.js 11.1.2 版本的 [webpack.config.js](https://github.com/vercel/next.js/blob/9343b67c110c129b769af7fbdfe752ac9786fdd8/packages/next/build/webpack-config.ts)
設定包含了只要 bundle size > 160 kB 就會單獨打包成一包,而大於 20kB 的套件則會被組合在一起變成一包。moment 因為其檔案大小已經超過 160 kB,它會被單獨打包,因此就不用擔心在沒有使用到 moment 的頁面也會載入這個資源。
筆者不太確定哪一版以後有這個設定,這邊說的 11.1.2 版本並不是說在這個版本之後才有這個設定。
既然大於 160 kB 的第三方套件都會被打包成一包,基本上可以考慮不用針對第三方處理 bundle size,除非有些檔案較小套件因為很少被用到,覺得跟其他套件打包在一起有可能沒有使用到時也會被載入不是一件好事,這時就可以考慮使用 dynamic import。
可是必須說在 React 中使用 dynamic import 不太方便,因為 dynamic import 回傳的是 Promise,不能像以下的方式使用,因為 get
這個變數實際上接的是 pending Promise,所以只能用 .then
或 .catch
的方式使用它:
const get = import("lodash/get").then((module) => module.default);
在這種情況下有兩種解法,一種是將 module.default
儲存在 state 裡面,另一種是在 .then
裡面直接使用 module.default
,最後再將結果儲存在 state。但是這種方式使用起來會特別地彆扭,等於是要渲染兩次 component 才能得到正確的結果,這樣不如在 webpack.config.js
中事先設定 splitChunk
。
實際上有另一種用法是在 getServerSideProps
中使用,因為使用 dynamic import 的目標是讓使用者不用載入用不到的資源,最終目的是可以提升使用者載入頁面的速度,所以在 getServerSideProps
使用 dynamic import 是一種選項,甚至因為這是個 async function,所以還可以在裡面使用 async/await
,讓程式碼看起來更容易閱讀:
import posts from "../../posts.json";
const Post = (props) => {
return (
<div>
<h1>{props.post.title}</h1>
<p>Published on {props.date}</p>
<p>{props.post.content}</p>
</div>
);
};
export const getServerSideProps = async () => {
const moment = (await import("moment")).default();
return {
date: moment.format("dddd D MMMM YYYY"),
post: posts[query.id],
};
};
export default Post;
這樣做實際上不會有什麼問題,因為 moment 是在 getServerSideProps
中使用,甚至 moment 不會被打包到客戶端的 bundle 中。然而,在 getServerSideProps
使用 dynamic import 可以等價於 static import,因為 moment 最後都會被打包進 .next/server/webpack-runtime.js
,在伺服器端直接從這個檔案中使用相對應的 module,所以其實可以使用 static import 就好。以上面的例子來說也可以這樣改寫:
import moment from "moment";
export const getServerSideProps = async () => {
return {
props: {
post: {
title: "my post",
content: "post content",
date: moment().format("dddd D MMMM YYYY"),
},
},
};
};
next/dynamic
從前面的例子我們發現使用 ES2020 的 dynamic import 效用並不高,如果想要針對有些套件 code splitting,可以直接考慮在 webpack.config.js
中設定 splitChunk
,這樣的程式碼可維護性還會相對較高。
但是對於 component 不太一樣,在 Next.js 中要使用 next/dynamic
,它類似於 React.lazy
,可以延遲載入特定的 component,以下就讓我們依序來看看例子。
首先,我們先來看一個沒有使用 dynamic import 的例子,以下是一個簡易的頁面,在這個頁面中只有一個按鈕,使用者點選這個按鈕之後,會載入 <Meme />
這個元件:
// pages/index.js
import { useState } from "react";
import Meme from "../components/Meme";
export default function Home() {
const [visible, setVisible] = useState();
return (
<div>
{visible && <Meme />}
<button onClick={() => setVisible(true)}>click me</button>
</div>
);
}
前面提到 Next.js 會根據每個路由 code splitting,同時會把 import
的元件都會打包進 bundle 中,這意味著實際上 <Meme />
這個元件不論是否顯示在畫面上都會載入期內容。
接著我們可以使用兩種方式證實 <Meme />
是否真的會被打包進去,先在 Meme.js
檔案中放入一段獨特的字串,然後到 .next/static/pages/index.js
這個檔案中搜尋該字串,應該能夠搜尋到該字串,因此我們可以說 Meme
的檔案內容實際上會被打包進入一個頁面的 bundle 中。
另一種方式其實概念一樣,也是放入一段獨特的字串,然後再透過 Chrome 的 devtool → Network,在裡面可以找到 index.js
這個檔案,在裡面同樣能夠搜尋該字串。
雖然 <Meme />
這個元件現在看起來很小,但是在真實的世界中一個元件可能都不小,甚至是用到許多套件的元件,此時儘管使用者沒有點擊按鈕,瀏覽 <Meme />
這個元件,但是仍然花費網路流量載入不必要的資料。
因此,為了解決載入不必要的資料這個問題,可以使用到 next/dynamic
這個模組幫我們在 Next.js 中實現 dynamic import:
import { useState } from "react";
import dynamic from "next/dynamic";
const Meme = dynamic(import("../components/Meme"));
export default function Home() {
const [visible, setVisible] = useState();
return (
<div>
{visible && <Meme />}
<button onClick={() => setVisible(true)}>click me</button>
</div>
);
}
然後開啟 Chrome 的 devtool → Network 後會發現 index.js
的檔案變小了,而且更近一步確認在該檔案中應該會沒辦法搜尋到「特殊字串」:
由於 <Meme />
這個元件使用 dynamic import,所以會動態地被 code splitting,在使用者點擊按鈕後才會載入這個元件的內容:
import()
一定要寫在 dynamic()
裡面,因為 Next.js 才能夠匹配 webpack 打包後的 hash id,讓元件在載入的時候可以知道有還有哪些元件會需要被延遲載入
dynamic()
要跟 static import 一樣放在檔案最頂層,不能寫在 component 裡面
dynamic()
裡面不能使用 template string,例如以下的例子中將會造成 /components
這個資料夾裡面的所有的檔案都拆成獨立的 bundle
const Meme = dynamic(import(`../components/${id}`));