Skip to content

仓库地址

部署打包环境

在开发中,为了减少上线后遇到的并发问题或者在开发中并没发现的奇葩问题,我们可以 时不时打包出静态文件,在本地看一下效果。在引入组件库之前,这一步其实尤为重要。

  • 安装 cross-env,区分环境变量,cross-env:运行跨平台设置和使用环境变量的脚本
  • 安装依赖
yarn add -D cross-env
  • 常规添加 .env.development、.env.test、.env.production,并写入配置。

注意:nextjs 如果想在浏览器环境访问变量,意思就是除了构建时调用,还想在平时调 用接口啥的使用,就必须添加前缀 NEXT_PUBLIC_,否则打包后将无法访问该变量

NEXT_PUBLIC_HOST = https://junfeng530.xyz
  • 添加打包脚本
"build": "cross-env NODE_ENV=test next build",
"export:test": "cross-env NODE_ENV=test next build && next export",
"export:prod": "cross-env NODE_ENV=production next build && next export",
  • 安装 http-server

全局安装 http-server,npm 管理安装依赖教程地址

npm install -g http-server
  • 查看静态文件
yarn export:test
cd out
http-server

样式、模块化代码提示

  • 安装依赖 Nextjs 已经提供了对 css 和 sass 的支持,只需要安装一下 sass 的依赖 即可。

这里 next 有个坑,如果版本超过 13.1.1 ,将会报错 unhandledRejection: Error: Cannot find module 'D:\nextjs\node_modules\next\dist\compiled\sass-loader/fibers.js' 因此将 next 版本锁住降级 yarn add next@13.1.1问题导航

yarn add -D sass
  • next.config.js 配置,自定义页面扩展名,项目将会打包指定后缀的文件为页面
const path = require('path')

module.exports = {
  pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],  // 指定项目扩展名
  reactStrictMode: true,
  swcMinify: true,
  webpack: config => {
    config.resolve.alias = {
      ...config.resolve.alias,
      '@': path.resolve(__dirname),
    }
    return config
  },
}
  • index.ts 文件名换成 index.page.tsx ,_app.tsx 改成 _app.page.tsx

修改 index.page.tsx

// @/pages/index.page.tsx
import styles from './home/index.module.scss'

export default function () {
  return <div className={styles.home}>官网实战</div>
}

此处应该 eslint 应该会报错:组件缺少DisplayName,.eslintrc.js 增加规则关闭此限 制

'react/display-name': 'off'
  • 修改一下 pages 目录

image.png

首页还是 index.page.tsx ,使用的是 home 目录下的文件,每个页面都有类似 :api.ts、index.module.scss、index.page.tsx、components 等文件

  • 添加样式代码提示页面中只能使用 cssModule 的方式,全局样式放到 @/styles 文件 目录下,并在 _app.tsx 中引入

  • 安装 vscode 插件添加代码提示 CSS Modules

  • 修改配置 next.config.js兼容驼峰风格

const path = require("path");

/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ["page.tsx", "page.ts", "page.jsx", "page.js"],
  reactStrictMode: true,
  images: {
    loader: "akamai",
    path: "/",
  },
  webpack: (config) => {
    config.resolve.alias = {
      ...config.resolve.alias,
      "@": path.resolve(__dirname),
    };
    const rules = config.module.rules
      .find((rule) => typeof rule.oneOf === "object")
      .oneOf.filter((rule) => Array.isArray(rule.use));
    rules.forEach((rule) => {
      rule.use.forEach((moduleLoader) => {
        if (
          moduleLoader.loader !== undefined &&
          moduleLoader.loader.includes("css-loader") &&
          typeof moduleLoader.options.modules === "object"
        ) {
          moduleLoader.options = {
            ...moduleLoader.options,
            modules: {
              ...moduleLoader.options.modules,
              // This is where we allow camelCase class names
              exportLocalsConvention: "camelCase",
            },
          };
        }
      });
    });

    return config;
  },
};

module.exports = nextConfig;

在页面中引入样式.module.scss 后,使用 styls. 就会有代码提示

image.png

响应式布局

  • 安装 postcss-px-to-viewport
yarn add -D postcss-px-to-viewport
  • postcss.config.js
module.exports = {
  plugins: {
    "postcss-px-to-viewport": {
      unitToConvert: "px", // 要转化的单位
      viewportWidth: 1920, // 设置成设计稿宽度
      unitPrecision: 5, // 转换后的精度,即小数点位数
      propList: ["*"], // 指定转换的css属性的单位,*代表全部css属性的单位都进行转换
      viewportUnit: "vw", // 指定需要转换成的视窗单位,默认vw
      fontViewportUnit: "vw", // 指定字体需要转换成的视窗单位,默认vw
      selectorBlackList: [], // 指定不转换为视窗单位的类名,
      minPixelValue: 1, // 默认值1,小于或等于1px则不进行转换
      mediaQuery: false, // 是否在媒体查询的css代码中也进行转换,默认false,这点可以用来写固定弹窗类的样式
      replace: true, // 是否转换后直接更换属性值
      exclude: undefined,  // 设置忽略文件,用正则做目录名匹配
      include: undefined,
      landscape: false, // 是否处理横屏情况
      landscapeUnit: "vw",
      landscapeWidth: 568,
    },
  },
};

image.png

媒体查询

在 px 端的适配,我们有一些弹窗它本身就很小,并不需要响应式布局,我们可以通过 postcss 的 mediaQuery 特性,我们给样式添加一个媒体查询即可避开 vw 的转换

比如:

@media (min-width: 1px) {
}

其它适配移动端媒体查询就是常规用法~

设备判断

如果根据服务器请求的 user-agent 请求头去判断设备,如果我们打开客户端没有请求那 么打包后将无法正确判断设备,推荐使用以下方式:

  • 安装 react-use
yarn add react-use
  • 封装 hooks 方法,@/components/useDevice.ts
import { useEffect, useState } from "react";
import { useWindowSize } from "react-use";

export const useDevice = () => {
  const [isMobile, setMobile] = useState(true);
  const size = useWindowSize();

  useEffect(() => {
    const userAgent =
      typeof window.navigator === "undefined" ? "" : navigator.userAgent;
    const mobile =
      /Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i.test(
        userAgent,
      );
    setMobile(size.width <= 750 || mobile);
  }, [size.width]);

  return {
    isMobile,
  };
};
  • index.page.tsx
import { useDevice } from "@/hooks/useDevice";

const { isMobile } = useDevice();

{!isMobile && <div>pc端布局</div>}
{isMobile && <div>移动端布局</div>}

引入 antd

最新版 antd5.0,采用 CSS-in-JS,CSS-in-JS 本身具有按需加载的能力,不再需要插件 支持,不再支持 babel-plugin-import, 因此只需下载依赖,引入使用即可 。antd 引入官网文档

  • 安装依赖
yarn add antd
  • 为了兼容旧浏览器,比如在安卓微信中打开某些样式会失效,可以通过 @ant-design/cssinjs 的 StyleProvider 去除降权操作。

_app.page.tsx

import type { AppProps } from "next/app";
import "@/styles/globals.scss";
import { StyleProvider } from "@ant-design/cssinjs";
import { ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <StyleProvider hashPriority="high">
      <ConfigProvider locale={zhCN}>
        <Component {...pageProps} />
      </ConfigProvider>
    </StyleProvider>
  );
}
  • @/_app.page.tsx 引入 antd 默认样式文件(可选)
import 'antd/dist/reset.css';
  • @/index.page.tsx
import { Button } from "antd";
<Button type="primary">antd 按钮</Button>

image.png

import { createCache, extractStyle, StyleProvider } from "@ant-design/cssinjs";
import Document, {
  DocumentContext,
  Head,
  Html,
  Main,
  NextScript,
} from "next/document";

export default class MyDocument extends Document {
  static async getInitialProps(ctx: DocumentContext) {
    const cache = createCache();
    const originalRenderPage = ctx.renderPage;

    ctx.renderPage = () =>
      originalRenderPage({
        enhanceApp: (App) => (props) =>
          (
            <StyleProvider cache={cache}>
              <App {...props} />
            </StyleProvider>
          ),
      });

    const initialProps = await Document.getInitialProps(ctx);
    return {
      ...initialProps,
      styles: (
        <>
          {initialProps.styles}
          <style
            data-test="extract"
            dangerouslySetInnerHTML={{ __html: extractStyle(cache) }}
          />
        </>
      ),
    };
  }

  render() {
    return (
      <Html lang="en">
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

引入 antd-mobile

antd-mobile ssr 引入文档地址

  • 安装依赖
yarn add antd-mobile
  • next.config.js

方式一:此方式会有一堆警告

experimental: {
  transpilePackages: ["antd-mobile"],
},

方式二:建议用这个

yarn add -D next-transpile-modules

// 修改 next.config.js
const withTM = require('next-transpile-modules')([
  'antd-mobile',
]);
module.exports = withTM({
  // 你项目中其他的 Next.js 配置
});
  • antd-mobile 去除 postcss-px-to-viewport 的转换

postcss.config.js

exclude: [/antd-mobile/]
  • antd-mobile 会自动按需加载,只需引入使用即可

@/index.page.tsx

import { Button as ButtonMobile } from "antd-mobile";
<ButtonMobile size="large" color="primary">
        antd-mobile 按钮
</ButtonMobile>

image.png

封装 axios

  • 安装依赖
yarn add axios
  • @/utils/request.ts
import { notification } from "antd";
import type { AxiosError, AxiosRequestConfig } from "axios";
import axios from "axios";

const instance = axios.create({
  timeout: 30 * 1000,
})

// 请求拦截
instance.interceptors.request.use(
  config => config,
  error => Promise.reject(error)
)

// 响应拦截
instance.interceptors.response.use(
  res => {
    if (
      res.data.code !== undefined &&
      res.data.code !== 0 &&
      res.data.code !== 200 &&
      !(res.config as AxiosRequestConfig & { skipErrorHandler?: boolean }).skipErrorHandler
    ) {
      notification.error({
        message: '异常',
        description: res.data.msg || res.data.message,
      })
      return Promise.reject(res.data)
    }
    return Promise.resolve(res.data)
  },
  (error: AxiosError<{ code: number; message?: string; msg?: string }>) => {
    const { skipErrorHandler } = error.config as AxiosRequestConfig & {
      skipErrorHandler?: boolean
    }
    if (error.response?.status === 401 && !skipErrorHandler) {
      return
    }
    if (!skipErrorHandler) {
      notification.error({
        message: '异常',
        description: error.response?.data?.message || error.response?.data?.msg || error.message,
      })
    }
    return Promise.reject(error)
  }
)

type Request = <T = unknown>(
  config: AxiosRequestConfig & { skipErrorHandler?: boolean }
) => Promise<T>

export const request = instance.request as Request

搭建 mock 环境

  • 根目录下新增 mock 文件夹,新增如下两个文件

image.png

// mock/data.json
{
    "indexStore": {
        "store": {
            "深圳": [
                {
                    "name": "坂田店",
                    "address": "福田街道xxx",
                    "marker": [
                        114.294773,
                        22.587251
                    ]
                },
                {
                    "name": "坂田店",
                    "address": "福田街道xxx",
                    "marker": [
                        114.294773,
                        22.587251
                    ]
                }
            ],
            "广州": [
                {
                    "name": "天河店",
                    "address": "天河街道xxx",
                    "marker": [
                        114.294773,
                        22.587251
                    ]
                },
                {
                    "name": "天河店",
                    "address": "天河街道xxx",
                    "marker": [
                        114.294773,
                        22.587251
                    ]
                }
            ],
            "佛山": [
                {
                    "name": "好地方店",
                    "address": "而得到街道xxx",
                    "marker": [
                        114.294773,
                        22.587251
                    ]
                },
                {
                    "name": "好地方店",
                    "address": "而得到街道xxx",
                    "marker": [
                        114.294773,
                        22.587251
                    ]
                }
            ]
        },
        "seo": {
            "content": "坂田店、福田街道xxx、天河店、天河街道xxx、好地方店、而得到街道xxx"
        }
    }
}
// mock/routes.json
{
    "/api/*": "/$1"
}
  • 安装 json-server
yarn add -D json-server
  • 同时运行 mock 以及 next dev 两个终端,安装 concurrently
yarn add -D concurrently
  • 添加命令
dev:mock": "concurrently  \"yarn mock\" \"next dev\"",
"mock": "cd ./mock && json-server --watch data.json --routes routes.json --port 4000"

服务端获取接口数据

nextjs 提供 getStaticProps 方法让我们在项目构建时获取服务器的静态数据,注意 该方法只在 build 时执行一次,数据必须是发布时更新的才使用这个,且必须是在页面 级别上使用。

mock 数据只能在本地调试使用,打包构建时记得切换

  • @/home/api.ts
import { request } from "@/utils/request";

export interface IMockData {
  store: {
    [key: string]: {
      name: string;
      address: string;
      marker: number[];
    }[];
  };
  seo: string;
}

// 获取mock数据
export function fetchMockData() {
  return request<IMockData>({
    url: `${process.env.NEXT_PUBLIC_HOST}/api/indexStore`,
    method: "GET",
  });
}

// export function fetchMockData() {
//   return new Promise<IMockData>((resolve) => {
//     resolve({
//       store: {
//         深圳: [
//           {
//             name: "111",
//             address: "222",
//             marker: [11, 22],
//           },
//         ],
//       },
//       seo: "333",
//     });
//   });
// }
  • index.page.tsx
import { Button } from "antd";
import { Button as ButtonMobile } from "antd-mobile";

import { fetchMockData, IMockData } from "./home/api";
import styles from "./home/index.module.scss";

export default function (props: { mockData: IMockData }) {
  console.log("mockData", props.mockData);
  return (
    <div>
      <Button type="primary">antd 按钮</Button>
      <ButtonMobile color="primary">antd-mobile 按钮</ButtonMobile>
      <div className={styles["home-container"]}>官网实战</div>;
    </div>
  );
}

// 静态生成 SSG ,往下会介绍  getStaticProps
export async function getStaticProps() {
  // 获取门店列表
  const res = await fetchMockData();
  const mockData = res;

  return {
    props: { mockData },
  };
}

image.png

我们在终端控制台可以看到 mock 数据已被打印出来,之后我们就能在页面组件中通过 props 拿到它返回的数据,并可以传递给组件使用。

封装通用 Layout

  • 组件封装 image.png

  • @/components/footer/index.tsx

import styles from "./index.module.scss";

export default function () {
  return (
    <div id="footer" className={styles["footer-container"]}>
      底部
    </div>
  );
}
  • @/components/headSeo/index.tsx
import Head from "next/head";

export default function (seo: {
  content: {
    keywords: string;
    description: string;
    title: string;
  };
}) {
  return (
    <Head>
      <meta charSet="UTF-8" />
      <meta
        name="viewport"
        content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0;"
      />
      <meta name="keywords" content={seo.content.keywords} />
      <meta name="description" content={seo.content.description} />
      <meta name="robots" content="index, follow" />
      <meta name="applicable-device" content="pc,mobile" />
      <meta name="format-detection" content="telephone=no" />
      <title>{seo.content.title}</title>
    </Head>
  );
}
  • @/components/layout/index.tsx
import Footer from "../footer";
import HeadSeo from "../headSeo";
import Navbar from "../navbar";

export default function Layout(props: {
  children: React.ReactNode;
  seo: {
    keywords: string;
    description: string;
    title: string;
  };
}) {
  return (
    <>
      <HeadSeo content={props.seo} />
      <Navbar />
      <main>{props.children}</main>
      <Footer />
    </>
  );
}
  • @/components/navbar/index.tsx
import styles from "./index.module.scss";

export default function () {
  return (
    <div id="footer" className={styles["navbar-container"]}>
      头部
    </div>
  );
}
  • 我们可以为每个页面都传入不同的 headseo,加到 description 标签中,这样搜索 引擎就可以爬取到我们这些信息。

`@/index.page.tsx

import { Button } from "antd";
import { Button as ButtonMobile } from "antd-mobile";

import Layout from "../components/layout";
import type { IMockData } from "./home/api";
import { fetchMockData } from "./home/api";
import styles from "./home/index.module.scss";

export default function (props: { mockData: IMockData }) {
  console.log("mockData", props.mockData);
  const headSeo = {
    keywords: "sso、nextjs、antd、jiang",
    description: `seo实践 ${props.mockData.seo}`,
    title: "nextJs 官网 SSR 实战",
  };
  return (
    <Layout seo={headSeo}>
      <div>
        <Button type="primary">antd 按钮</Button>
        <ButtonMobile color="primary">antd-mobile 按钮</ButtonMobile>
        <div className={styles["home-container"]}>官网实战</div>;
      </div>
    </Layout>
  );
}

export async function getStaticProps() {
  // 获取mock数据
  const res = await fetchMockData();
  const mockData = res;

  return {
    props: { mockData },
  };
}

image.png

图片优化 webp + cdn

  • 封装 useWebp hooks,@/hooks/useWebp.ts
import { useEffect, useState } from "react";

export const useWebp = () => {
  const [isSupportWebp, setIsSupportWebp] = useState(true);
  useEffect(() => {
    if (typeof window !== "undefined") {
      const supportWebp =
        window.document
          .createElement("canvas")
          .toDataURL("image/webp")
          .indexOf("data:image/webp") > -1;
      setIsSupportWebp(supportWebp);
    }
  }, []);

  return {
    isSupportWebp,
  };
};
  • 封装 useOss hooks ,@/hooks/useOss.ts
import { useCallback } from "react";

import { useWebp } from "./useWebp";

export const useOSS = () => {
  const { isSupportWebp } = useWebp();
  const getOssImage = useCallback(
    (option: {
      originUrl: string;
      /**
       * @description 不支持 webp,降级处理宽度
       * @type {number}
       */
      notSupportWebpWidth?: number;
      /**
       * @description 不支持 webp,降级处理高度
       * @type {number}
       */
      notSupportWebpHeight?: number;
      width?: number; // 不使用 oss,正常传即可
      height?: number;
    }) => {
      let process = "";
      if ((option.notSupportWebpWidth && !isSupportWebp) || option.width) {
        process = `w_${option.notSupportWebpWidth || option.width},`;
      }
      if ((option.notSupportWebpHeight && !isSupportWebp) || option.height) {
        process = `${process}h_${
          option.notSupportWebpHeight || option.height
        },`;
      }
      if (process) {
        process = `x-oss-process=image/resize,m_fill,limit_0,${process},`;
      }

      if (isSupportWebp && process) {
        process = `${process}/format,webp`;
      }
      if (isSupportWebp && !process) {
        process = `x-oss-process=image/format,webp`;
      }
      return `${option.originUrl}?${process}`;
    },
    [isSupportWebp],
  );

  return { getOssImage };
};
  • 封装 ossImage 组件,@/components/OssImage/index.tsx
/* eslint-disable react/require-default-props */
import { useOSS } from "@/hooks/useOss";

type Props = React.DetailedHTMLProps<
  React.ImgHTMLAttributes<HTMLImageElement>,
  HTMLImageElement
> & {
  notSupportWebpWidth?: number;
  notSupportWebpHeight?: number;
  ossWidth?: number;
  ossHeight?: number;
};

export default function (props: Props) {
  const { getOssImage } = useOSS();
  return (
    <img
      {...props}
      src={getOssImage({
        originUrl: props.src || "",
        notSupportWebpWidth: props.notSupportWebpWidth,
        notSupportWebpHeight: props.notSupportWebpHeight,
        width: props.ossWidth,
        height: props.ossHeight,
      })}
      loading="lazy"
    />
  );
}
  • 在页面中使用 index.page.tsx
import OssImage from "@/components/OssImage";

 {/* 使用 oss,自动判断是否支持 webp*/}
<OssImage
  style={{
    background: "beige",
  }}
  src="https://img.alicdn.com/tfs/TB11B9iM7voK1RjSZPfXXXPKFXa-338-80.png"
  notSupportWebpWidth={338}
  notSupportWebpHeight={80}
></OssImage>
{/* 不使用 oss,正常传宽高*/}
<OssImage
  style={{
    background: "beige",
  }}
  src="https://img.alicdn.com/tfs/TB11B9iM7voK1RjSZPfXXXPKFXa-338-80.png"
  width={338}
  height={80}
></OssImage>

image.png

数据渲染

  • next 会根据导出的函数来区分这个页面是哪种渲染,这两个函数 (getStaticPropsgetServerSideProps只能存在一个
  • 调用时机都是在浏览器渲染之前,也就是说没有 document、window 之类的对象,开发 时,请在终端查看数据

getStaticProps SSG (静态生成)

  • 项目构建打包时调用,并生成 html(开发时是每次请求都更新),理解为写死了传到服 务器上,想要更新请重新打包。
  • 适用于不变的数据,能够做 seo
  • 在页面中使用,index.page.tsx
// 静态 SSG
export async function getStaticProps() {
  // 获取mock数据
  const res = await fetchMockData();
  const mockData = res;

  return {
    props: { mockData },
  };
}

getServerSideProps SSR (服务端渲染)

  • 每次在服务器接收到请求时更新
  • 适用于经常改变的数据,无法做 seo
  • getServerSideProps 返回值除了可以设置 props 外还可以使用 notFound 来强制页面 跳转到 404,或者是使用 redirect 来将页面重定向。
export async function getServerSideProps() {
  const data = await fetchMockData();
  console.log("data", data);

  if (!data) {
    return {
      redirect: {
        destination: "/",
        permanent: false,
      },
    // return {
    //   notFound: true
    // };
    };
  }

  return {
    props: { data },
  };
}

getStaticPaths 生成多页面

比如我们的项目有一个新闻页面,它需要做 seo,这样一个页面肯定无法满足,我们可以 通过 getStaticPaths 去生成多个页面,搭配 getStaticProps 去构造每个页面不同的页 面数据,文件名只需要使用 [变量名].page.tsx

  • @/static-path/[id].page.tsx
export default function ({ post }: { post: string }) {
  return (
    <div>
      <h1>Post: {post}</h1>
    </div>
  );
}

export async function getStaticPaths() {
  const paths = new Array(10).fill(0).map((_, i) => ({
    params: { id: i + 1 + "" },
  }));

  console.log("paths", paths);
  return { paths, fallback: false };
}

export async function getStaticProps({ params }: { params: { id: string } }) {
  // 在这里我们可以获取需要的数据,然后根据不同的 id 去返回到页面上
  console.log("params", params);
  return { props: { post: `post ${params.id}` } };
}

image.png

总结

服务器部署自动上传可以参考我的文章

至此初步的项目结构已经完成,本文将持续更新,后面将加入埋点、监控系统、后台管理系 统等,同时也会将尝试将自己的博客换成 ssr 方式。