从 0-1 搭建 SEO 项目,next ssr 实战,小白式教学,持续更新,建议收藏!

603 阅读7分钟

初言

浏览器是怎么渲染一个页面的?

img

浏览器渲染一个网页,简单来说可以分为以下几个步骤:

  • HTML 解析:在这个过程之前,浏览器会进行 DNS 解析及 TCP 握手等网络协议相关的操作,来与用户需要访问的域名服务器建议连接,域名服务器会给用户返回一个 HTML 文本用于后面的渲染 (这一点很关键,要注意)。
  • 渲染树的构建:浏览器客户端在收到服务端返回的 HTML 文本后,会对 HTML 的文本进行相关的解析,其中 DOM 会用于生成 DOM 树来决定页面的布局结构,CSS 则用于生成 CSSOM 树来决定页面元素的样式。如果在这个过程遇到脚本或是静态资源,会执行预加载对静态资源进行提前请求,最后将它们生成一个渲染树。 img
  • 布局:浏览器在拿到渲染树后,会进行布局操作,来确定页面上每个对象的大小和位置,再进行渲染。
  • 渲染:我们电脑的视图都是通过 GPU 的图像帧来显示出来的,渲染的过程其实就是将上面拿到的渲染树转化成 GPU 的图像帧来显示。首先浏览器会根据布局树的位置进行栅格化(用过组件库的同学应该不陌生,就是把页面按行列分成对应的层,比如 12 栅格,根据对应的格列来确定位置),最后得到一个合成帧,包括文本、颜色、边框等,最后将合成帧提升到 GPU 的图像帧,进而显示到页面中,就可以在电脑上看到我们的页面了。

而服务器端渲染对 C 端 网站 的优势,主要也是在于它拿到的 HTML 不同。 这样的差异,会给 Web 应用带来不同的表现。

SSR 优势

易传播性: SSR爬虫精度更高

搜索引擎可以理解是一种爬虫,它会爬取指定页面的 HTML,并根据用户输入的关键词对页面内容进行排序检索,最后形成我们看到的结果。

页面渲染过程中,HTML 解析过程中从服务器端拉取的 HTML 并不是页面最终预期的结果,对于一些高级爬虫,会待页面渲染完成后进行页面数据的拉取和关键词匹配,但是也有一些低级爬虫,它们爬取的将是服务器端拉取的 HTML,那么服务器端拉取下来的HTML中包含的实际页面关键词和数据越多,搜索引擎匹配的精度也会越高

SSR 会在服务器端完成对页面数据数据的请求,将对应数据注入 DOM 一同返回,会得到一个完整可预览的 HTML。以掘金首页举例,可以看到下图服务器端拉取的 HTML 是包含这个页面中所将展示的实际数据。 img

而对于客户端渲染,数据的拉取将会在客户端完成,请求服务器拿到的 HTML 将是一个空的包含有执行脚本的 HTML,也就是说,客户端渲染页面的服务器响应的 HTML 并不包含页面中实际数据,也可以参考下图一个 B 端管理平台的 HTML 响应。 img

服务器端渲染和客户端渲染的差异,决定了服务器端渲染在 爬虫 关键词爬取的精准度上会远胜客户端渲染,使得站点更容易获得相关关键词更高的排名

交互稳定性: SSR 更高效

交互稳定性,这个也与服务器端渲染和客户端渲染的 HTML 差异有关。对于客户端渲染,实际的数据需要在执行脚本后请求数据后才可以得到,而对于服务器端渲染,数据请求的过程在在服务器端已经完成了,这就使得服务器渲染将不再需要进行数据请求,可以拥有更短的首屏时间

项目初始化

  • 创建项目
yarn create next-app --typescript

image.png

img

配置别名

// next.config.js
const path = require("path");

module.exports = {
  reactStrictMode: true,
  swcMinify: true,
  webpack: (config) => {
    config.resolve.alias = {
      ...config.resolve.alias,
      "@": path.resolve(__dirname),
    };
    return config;
  },
};
  • tsconfig.json 中我们也需要加一下对应的别名解析识别(baseurl , paths)。
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@public/*": ["public/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

目录

image.png

项目代码地址

eslint

  • 安装依赖
yarn add -D eslint eslint-plugin-react @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-simple-import-sort
 
npx eslint --init

img

  • .eslintrc.js
// .eslintrc.js
module.exports = {
  env: {
    browser: true,
    commonjs: true, // ADD, 支持对commonjs全局变量的识别
    es2021: true,
    node: true,
  },
  extends: [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:import/recommended",
  ],
  parser: "@typescript-eslint/parser",
  parserOptions: {
    ecmaFeatures: {
      jsx: true,
    },
    ecmaVersion: "latest",
  },
  plugins: ["react", "@typescript-eslint", "eslint-plugin-simple-import-sort"],
  rules: {
    "react/jsx-uses-react": "off", // 必须增加对import React from 'react',jsx 的页面已经不再需要引入 React了,所以我们去掉这条 lint 规则
    "react/react-in-jsx-scope": "off", // 同上
    "@typescript-eslint/no-var-requires": "off", // 关闭 禁用使用 require 来定义
    "react/display-name": "off", // 关闭组件定义缺少显示名称
    "simple-import-sort/imports": "error", // import 自动排序,eslint-plugin-simple-import-sort 自动修正
    "simple-import-sort/exports": "error",
    "no-duplicate-imports": ["off", { includeExports: true }], // import不能重复重复,自动合并插件 eslint-plugin-import,添加extends:plugin:import/recommended
    "import/no-unresolved": "off",  // 关闭 eslint 无法解析的导入
  },
};

在原来的基础上,我们在 env 的配置中加上了 commonjs: true,这个是为了支持对 commonjs 全局变量的识别

  • react/jsx-uses-react:必须增加对 import React from 'react'; 的引入,在 React 17 之后,jsx 的页面已经不再需要引入 React了,所以我们去掉这条 lint 规则。
  • react/react-in-jsx-scope:同上。
  • @typescript-eslint/no-var-requires:禁用使用 require 来定义,node 很多相关的依赖没有对 es module 的定义,所以我们也去掉这条 lint 规则。
  • react/display-name:关闭组件定义缺少显示名称
  • simple-import-sort/imports:import 自动排序,安装 eslint-plugin-simple-import-sort 自动修正
  • no-duplicate-imports:import 重复导入,自动修正:安装 eslint-plugin-import,并加入extends: plugin:import/recommended
  • import/no-unresolved:关闭 eslint 无法解析的导入

commit

  • 安装依赖
yarn add -D @commitlint/config-conventional @commitlint/cli
  • 新增 commitlint.config.js
module.exports = {
  extends: ["@commitlint/config-conventional"],
  rules: {
    "type-enum": [2, "always", ["feat", "fix", "revert"]],
    "subject-max-length": [1, "always", 30],
  },
};

其中 type-enum 是指 commit 正文的前缀,通常我们会用到这三种:

  • Feat:一个新的功能;
  • Fix: 一次修复,之前已有问题的修复;
  • Revert:一次回滚,书写异常代码后的撤销。

subject-max-length 则对应实际的 commit 长度(不包括前缀),这里我们设置为30

husky

  • 安装依赖
yarn add -D husky

npx husky install 

npx husky add .husky/pre-commit
  • husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx commitlint --edit $1
npm run lint

Prettier

  • vscode 安装 Prettier - Code formatter
  • 安装依赖
yarn add -D prettier eslint-plugin-prettier
  • .prettierrc.js
module.exports = {
  singleQuote: false,
  trailingComma: "all",
  printWidth: 80,
  htmlWhitespaceSensitivity: "ignore",
};
  • .Vscode/settings.json
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true,
    "source.fixAll.stylelint": true
  },
  "stylelint.validate": ["css", "less", "scss", "vue"],
  "[scss]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  }
}

  • 添加 .editorconfig 定义编码风格
# top-most EditorConfig file
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false

stylelint

  • 安装依赖
yarn add -D stylelint stylelint-config-clean-order stylelint-config-prettier stylelint-config-standard stylelint-config-standard-scss stylelint-prettier
  • stylelint.config.js
module.exports = {
  processors: [],
  extends: [
    "stylelint-config-standard-scss",
    "stylelint-config-standard",
    "stylelint-prettier/recommended",
    "stylelint-config-prettier",
    "stylelint-config-clean-order",
  ],
  rules: {
    "prettier/prettier": true,
    "at-rule-no-unknown": null,
    "no-empty-source": null,
    "unit-no-unknown": null,
    "no-descending-specificity": null,
    "selector-pseudo-class-no-unknown": null,
    "declaration-block-no-duplicate-properties": null,
    "selector-type-no-unknown": null,
    "block-no-empty": null,
    "font-family-no-missing-generic-family-keyword": null,
    "declaration-block-no-shorthand-property-overrides": null,
    "selector-class-pattern": null,
    "no-duplicate-selectors": null,
    "selector-pseudo-class-parentheses-space-inside": null,
    "selector-combinator-space-before": null,
  },
};

仓库地址

部署打包环境

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

  • 安装 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 方式。