iT邦幫忙

2021 iThome 鐵人賽

DAY 29
0
Modern Web

從零開始學習 Next.js系列 第 29

Day29 - 當 Next.js 遇見了 Typescript

Typescript

Next.js 目前已經支援 TypeScript,而且從 GitHub 中可以看到 TypeScript 占整體 codebase 的比例逐漸變高,所以不用擔心在 Next.js 不能使用 TypeScript 的問題。

在這篇文章中將紀錄在 Next.js 中常見的 TypeScript 寫法,看文這篇文章後你將會學到:

  • Next.js 如何定義型別
  • 在 SSR、SSG、CSR 頁面中如何使用 Typescript
  • App 與 Document 兩個特殊的文件怎麼定義 TypeScript
  • 在 API routes 中如何使用 TypeScript
  • 如何讓 TypeScript 也可以檢測 next.config.js 的型別

Next.js 如何定義型別

Next.js 跟許多從 JavaScript 轉移到 TypeScript 的套件不太一樣,沒有另外安裝 @types/next 的套件,而是直接在根目錄使用 next-env.d.ts 這個檔案引用型別

/// <reference types="next" />
/// <reference types="next/types/global" />

這個檔案中使用 typescript 的 triple-slash directives,引用了 Next.js 所定義的型別。再看到 tsconfig.json 中的 include 包含了這個檔案,所以你現在知道,這個檔案會參與 TypeScript 的編譯過程。

第一行引用的是 next/types/index.d.ts ,在這個檔案中在額外引用了一些型別,基本上都是在撰寫 React 使用得到的:

/// <reference types="node" />
/// <reference types="react" />
/// <reference types="react-dom" />
/// <reference types="styled-jsx" />

此外,還包含了許多在建構 Next.js 應用時會到的型別定義,例如 GetServerSidePropsGetStaticProps 等等。以及讓我們可以使用以下三種語法:

  • <html amp="">
  • <link nonce="">
  • <style jsx>

第二行引用的是 node_modules/next/types/global.d.ts ,在這個檔案中宣告了:

  • declare module '*.module.css' { ... }
  • declare module '*.module.sass' { ... }
  • declare module '*.module.scss' { ... }

所以我們不用額外設定,TypeScript 就可以支援 CSS 檔案跟 CSS Modules,像是 *.module.css 這種形式的檔案。如果把 next-env-d.ts 的第二行刪掉後,你會看到 typescript 無法解析像是 Home.module.css 這種檔案。

在 SSR、SSG、CSR 頁面中如何使用 Typescript

在官方文件中有簡略提到如何針對 getStaticPropsgetStaticPathsgetServerSideProps 三者設定型別:

import { GetStaticProps, GetStaticPaths, GetServerSideProps } from "next";

export const getStaticProps: GetStaticProps = async (context) => {
  // ...
};

export const getStaticPaths: GetStaticPaths = async () => {
  // ...
};

export const getServerSideProps: GetServerSideProps = async (context) => {
  // ...
};

但是實際上這樣的型別定義不是很嚴謹,以 getServerSideProps 違例,我們進一步看到它的原始型別定義。:

type ParsedUrlQuery = {
	[key: string]: T | undefined;
}

type GetServerSidePropsResult<P> =
  | { props: P }
  | { redirect: Redirect }
  | { notFound: true }

export type GetServerSideProps<
  P extends { [key: string]: any } = { [key: string]: any },
  Q extends ParsedUrlQuery = ParsedUrlQuery
> = (
  context: GetServerSidePropsContext<Q>
) => Promise<GetServerSidePropsResult<P>>

getServerSideProps 回傳的型別是 GetServerSidePropsResult ,這個回傳值可以是 propsredirectnotFound 三者其一,看到 props 的型別實際上預設的是一個很簡易的物件定義:

 { [key: string]: any }

當你看到 any 時就會知道實際上 props 不論回傳什麼都是合法的,getServerSideProps 的實作就會有些脆弱,很容易就會發生改錯卻沒發現的情況,儘管在 Next.js 預設的 TypeScript 就有開啟 strict mode,但是由於這是在 Next.js 內部的型別定義,所以 strict mode 並無法影響。

我們使用 VS Code 開啟 Next.js 專案,打開一個 SSR 的頁面,將滑鼠移動到 props 上方,此時如同上面看到的型別定義,現在不論 props 裡面的物件帶得對不對,都可以通過 TypeScript 的型別檢驗,如此一來程式碼就顯得不嚴謹。

更嚴謹的型別定義

比較好的型別定義是在使用 GetServerSideProps 時也同時傳入兩個範型,第一個範型 P 決定的是 props 的回傳型別,如下方的例子中可以看到回傳型別定義為 { post: PostData } ,假設沒有在 props 中回傳符合 PostData 的物件就會無法通過 TypeScript 的型別檢驗。

第二個範型則是可以指定目前網址上 query string 的型別,從上方的範例中可以看到 query string 原始的型別定義與 props 差不多,可以用來匹配任何的 query string。但是如此一來我們就無法精準的使用 params 物件,可能會在取值的時候發生錯誤。

從下方的範例中可以看到另外定義的 Params 型別包含了 { id: string } ,此時在 getServerSideProsp 中就可以使用 [params.id](http://params.id) 取值,傳入到 getPost 的參數也可以被正確地指定型別。

import { ParsedUrlQuery } from "querystring";

type Props = {
  post: PostData;
};

interface Params extends ParsedUrlQuery {
  id: string;
}

export const getServerSideProps: GetServerSideProps<Props, Params> = async (
  context
) => {
  // ! is a non-null/non-undefined assertion
  const params = context.params!;
  const post = await getPost(params.id);
  return {
    props: { post },
  };
};

有一個地方可以注意的是在取得 params 時使用的 non-null/non-undefined assertion,這個是 TypeScript 的一個 feature,可以被用來指定一個屬性絕對不會是 null | undefined 。在 Next.js 中使用的是 file-based routing,在 [id].tsx 的頁面中,我們知道 context.params 絕對帶有 id 這個屬性,但是因為原始的型別定義讓 params 可能是 undefined ,因此便無法順利從 context 拿到 params 這個屬性,會發生 Object is possibly 'undefined'.ts(2532) 這個錯誤。

在 Custom App 中使用 TypeScript

在一般的 custom App 使用 TypeScript 非常簡單,只需要從 next/app 中取出 AppProps 即可:

import { AppProps } from "next/app";

function MyApp({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}

export default MyApp;

使用共用 layout 的 custom App

這是一個在 Next.js 中很好用的 pattern,可以被用來抽象 layout 的程式碼,在頁面中設定 getLayout 後,在 pages/_app.tsx 中使用該 getLayout 渲染 layout,這樣才可以避免在切換頁面時造成用共 layout 的狀態消失,在前面幾天我們有討論過這個議題。

由於我們需要從 Component 中取得 getLayout 這個 function,但是原始的型別定義中並沒有包含這個 getLayout ,這是我們自己額外定義的。同時在頁面上也許要加入 getLayout 這個 function,因此才能在 _app.tsx 中拿到它。

而同樣地在頁面中或是 _app.tsx 都沒有 getLayout 這個型別,所以我們必須要幫兩者自定義新的型別,分別為 AppPropsWithLayoutNextPageWithLayout

// _app.tsx
import { AppPropsWithLayout } from "next/app";

function MyApp({ Component, pageProps }: AppPropsWithLayout) {
  const getLayout = Component.getLayout || ((page) => page);

  return getLayout(<Component {...pageProps}></Component>);
}
export default MyApp;

// pages/index.tsx
import { NextPageWithLayout } from "next";
import { getLayout } from "@/components/Layout";

const Home: NextPageWithLayout = () => {
  return <div>You are in /</div>;
};

Home.getLayout = getLayout;

export default Home;

我們可以在跟目錄創建一個檔案 next.d.ts ,這個檔案將會被用來在 next 中新增兩個新的型別,分別為 NextPageWithLayoutAppPropsWithLayout

// next.d.ts
import { NextPageWithLayout } from "next";
import { AppProps } from "next/app";

declare module "next" {
  type NextPageWithLayout<P> = NextPage<P> & {
    getLayout?: (page: ReactElement) => ReactNode;
  };
}

declare module "next/app" {
  type AppPropsWithLayout = AppProps & {
    Component: NextPageWithLayout;
  };
}

NextPageWithLayout 主要就是讓 NextPage 這個型別加上 getLayout 這個 function,可以傳入一個類似 HOC 的 function。而在 AppPropsWithLayout 可以直接拿 NextPageWithLayout 來用,在這個檔案的最上面能夠看到我們在 module 'next' 中定義的 NextPageWithLayout 被 import 進來使用。

在 Custom Document 中使用 TypeScript

目前在使用 custom Document 還是建議使用 class component 的形式,而在 class component 中需要定義型別的部分很少,從官方文件中只有提到 getInitialProps 中的 ctx 需要定義其型別為 DocumentContext

import Document, { DocumentContext } from "next/document";

class MyDocument extends Document {
  static async getInitialProps(ctx: DocumentContext) {
    const initialProps = await Document.getInitialProps(ctx);

    return initialProps;
  }
}

export default MyDocument;

各位讀者可能會問, custom Document 可以是 functional component 的形式嗎?答案是可以的,從 PR#28515 可以發現 function component 的使用案例已經被 merge 到 11.1.1 版本中,但是目前支援度不是很好,許多 React hooks 都不能使用,而且型別定義也還有改善的空間,等之後官方文件中有特別寫道 functional component 的使用個案時再採取這個方案可能會比較好。

在 API routes 中如何使用 TypeScript

在 API routes 使用 TypeScript 的方式也是開箱即可使用,從 next 中 import NextApiRequestNextApiResponse 分別用來定義 API routes 的兩個參數:

import { NextApiRequest, NextApiResponse } from "next";

type Data = {
  name: string;
};

export default (req: NextApiRequest, res: NextApiResponse<Data>) => {
  res.status(200).json({ name: "John Doe" });
};

在定義 res 的型別時可以傳入一個範型,它會被用來定義回傳值的物件型別,從上方的範例中就可以看到回傳物件會是 { name: string } ,透過定義回傳值的型別可以讓 API routes 的程式碼更為嚴謹,比較不怕會改壞它。

Type checking next.config.js

根據官方說明, next.config.js 必須是 .js 檔案,目前無法原生支援 TypeScript,如果要讓這個檔案也有型別檢查可以透過以下方式,讓 IDE 幫我們指出哪個屬性寫錯了:

// @ts-check

/**
 * @type {import('next').NextConfig}
 */
const config = {
  // your config here
};

module.exports = config;

舉一個例子,假設我們傳入 env: true ,但是與原始型別定義不符,就可以讓 IDE 幫我們指出 env 型別有誤:

Reference


上一篇
Day28 - Next.js 如何優化圖片在網頁上的體驗?
下一篇
Day30 - 導入 Next.js 的雜談與系列文總結
系列文
從零開始學習 Next.js30

尚未有邦友留言

立即登入留言