Appearance
部署打包环境
在开发中,为了减少上线后遇到的并发问题或者在开发中并没发现的奇葩问题,我们可以 时不时打包出静态文件,在本地看一下效果。在引入组件库之前,这一步其实尤为重要。
- 安装 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 目录
首页还是 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. 就会有代码提示
响应式布局
- 安装 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,
    },
  },
};
媒体查询
在 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>
- antdV5 打包 test 环境样式丢失,修改 _document.page.tsx,问题解决参考地址
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
- 安装依赖
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>
封装 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 文件夹,新增如下两个文件
// 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 },
  };
}
我们在终端控制台可以看到 mock 数据已被打印出来,之后我们就能在页面组件中通过 props 拿到它返回的数据,并可以传递给组件使用。
封装通用 Layout
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 },
  };
}
图片优化 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>
数据渲染
- next 会根据导出的函数来区分这个页面是哪种渲染,这两个函数 (getStaticProps、getServerSideProps)只能存在一个
- 调用时机都是在浏览器渲染之前,也就是说没有 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}` } };
}
总结
服务器部署自动上传可以参考我的文章
至此初步的项目结构已经完成,本文将持续更新,后面将加入埋点、监控系统、后台管理系 统等,同时也会将尝试将自己的博客换成 ssr 方式。