Day27 - 在 Next.js 如何正确地使用 dynamic import

前言

在 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 的 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>

在 Next.js 中使用 ES2020 dynamic import

以 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.js 中使用 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 的档案变小了,而且更近一步确认在该档案中应该会没办法搜寻到「特殊字串」:

截图 2021-09-23 下午9.41.56.png

由於 <Meme /> 这个元件使用 dynamic import,所以会动态地被 code splitting,在使用者点击按钮後才会载入这个元件的内容:

截图 2021-09-23 下午9.45.44.png

使用 dynamic import 时要注意的事

  1. import() 一定要写在 dynamic() 里面,因为 Next.js 才能够匹配 webpack 打包後的 hash id,让元件在载入的时候可以知道有还有哪些元件会需要被延迟载入

  2. dynamic() 要跟 static import 一样放在档案最顶层,不能写在 component 里面

  3. dynamic() 里面不能使用 template string,例如以下的例子中将会造成 /components 这个资料夹里面的所有的档案都拆成独立的 bundle

    const Meme = dynamic(import(`../components/${id}`));
    

适合 dynamic import 的 component

  • 像是 ModalDrawer 等等会等待跟使用者互动後才显示的 component,换句话说,并不是每位使用者都会用到的 component

Reference


<<:  【Day 27】指标的范例讲解

>>:  D32 - 用 Swift 和公开资讯,打造投资理财的 Apps { 台股申购功能扩充,算出价差.3 }

【Day16】React Router

一页式网站 SPA SPA 全名 Single Page Applications 只有一个 HTM...

不只懂 Vue 语法:什麽是单向资料流和双向绑定?

问题回答 双向绑定(two-way data bindings)是指把画面上的 DOM 与资料透过 ...

前端工程师也能开发全端网页:挑战 30 天用 React 加上 Firebase 打造社群网站|Day5 注册登入页面

连续 30 天不中断每天上传一支教学影片,教你如何用 React 加上 Firebase 打造社群...

Day4: [资料结构] Array —  阵列

Array是资料结构的一种,概念就像置物柜一样,每个柜子都可以存放资料并且都有自己的编号称为索引值...

全职打工族在日本打造百万下载 App 心得

在日本下班时间运营的 app,在两个人都全职打工的情况下,超过十万每月活跃用户後持续稳定成长。 有兴...