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


<<:  【Day29】Git 版本控制 - GitBook 使用教学

>>:  Day 30 -资料库应用小程序 订单显示(内涵程序码)

k8s prometheus 监控多个MySql -盖完後的新增

【YC的寻路青春】 上一篇已经有点太多了 容许我分两篇 不然有点爆炸 如果盖完之後 要增新的话 1....

全端入门Day30_结尾

昨天介绍了Golang的http,今天是这30天的结尾。 这30天,我收获良多因为我觉得这是一个毅力...

Day 30 - 相关资源分享

本文将於赛後同步刊登於笔者部落格 有兴趣学习更多 Kubernetes/DevOps/Linux 相...

【Side Project】 程序码整理 -Model运用

之前为方便快速了解我们程序完整的功能, 所以把所有的资料都放在Controller里面。 今天这篇我...

使用者帐号在Mendix要怎麽管理?

铁人赛来到第29天了! 既然应用程序都开发得差不多了,是时候来给Admin更多的权限了! 创建帐号连...