Next.js | 青训营笔记

203 阅读8分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 15 天

Next.js 实战项目

CSR,SSR,SSG

CSR

客户端渲染(Client-Side Rendering)。常见 B 端 WEB 应用开发模式,前后端分离,服务器压力相对更轻,渲染工作在客户端进行,服务器直接返回不加工的 HTML 用户在后续访问操作。

SPA(single page application):单页面应用,它所需的资源(HTML CSS JS等),在一次请求中就加载完成,不需刷新地动态加载,首屏时间更长。

SSR

服务端渲染(Server-Side Rendering),不是什么新鲜的概念,从原先的 JSP / PHP 就已经体现了服务器端渲染。

传统的服务端渲染(例如 JSP、PHP)代码耦合度高,且模板语言中混杂编程语言,对于一些复杂的功能,维护起来相当痛苦,简直就是开发人员的噩梦。这种模式下 Java,PHP 负责渲染的逻辑,而前端只负责 UI 和交互。

同构 SSR

同构ssr.png

BFF:Backend For Frontend,服务于前端应用的后端。简单来说,就是 node 接口服务和模板页面都在同一个项目结构目录下。

前后端一体化,一套 React 代码在服务器上运行一遍,达到浏览器又运行一遍。前后端都要参与渲染,而首次渲染出的 HTML 要一样。

渲染流程:

  1. 浏览器请求 HTML
  2. 服务端运行 React 代码生成 HTML
  3. 发送 HTML 给浏览器
  4. 浏览器显示网页内容
  5. 浏览器加载 JS 文件
  6. 绑定 DOM 事件客户端渲染接管界面
  7. 再次跳转路由就是客户端渲染,无需请求后台

SSG

ssg.png

静态站点生成(Static Site Generation),在构建的时候直接把结果页面输出 html 到磁盘,每次访问直接把 html 返回给客户端,相当于一个静态资源。

CDN:建立并覆盖在 Internet 之上,由分布在不同区域的边缘节点服务器群组成的分布式网络。

优点:

  • 相比 SSR,因为不需要每次请求都由服务器端处理,所以可以大幅减轻服务器端的压力。

缺点:

  • 缺陷在于只能用于偏静态的页面,无法生成与用户相关的内容,也就是所有的用户访问的页面都是相同的。

SSR 和 SSG 的优势

  • 利于SEO
    • 浏览器的推广程度,取决于搜索引擎对站点检索的排名,搜索引擎可以理解是一种爬虫,它会爬取指定页面的 HTML,并根据用户输入的关键词对页面内容进行排序检索,最后形成我们看到的结果
  • 更短的首屏时间
    • SSR / SSG 只需要请求一个 HTML 文件就能展现出页面,虽然在服务器上会调取接口,但服务器之间的通信要远比客户端快,甚至是同一台服务器上的本地接口调取。因为不再需要请求大量 js 文件,这就使得SSR / SSG可以拥有更短的首屏时间

什么是 Next.js

需求

基于 React 提供的相关服务器端渲染 API 实现,整个过程实现比较繁琐重复,从零实现对新上手的同学很不友好,迫切需要一个封装好的集合来快速上手服务器端渲染。而这个框架最好能提供模板页面渲染同构、路由同构、Header 标签修改、脱水注水和性能优化等等多种功能的接口。

ssr-framework.png

SSR 的实现 Demo:github.com/czm12904337…

概念

Next.js 是一个构建于 Node.js 之上的开源 Web 开发框架,支持基于 React 的 Web 应用程序功能,例如服务端渲染和生成静态网站。

上手快,能力集全,而且覆盖了足够多的性能优化和生态。

对于新同学掌握前后端一体化的开发模式很友好。

Next.js 客户端开发

Next.js 初始化

创建 next 工程

npx create-next-app@latest --typescript

配置:

// 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;
  },
};

数据注入

data-injection.png

// __app.tsx
import type { AppProps, AppContext } from 'next/app';
import React from 'react';
import App from 'next/app';
import { Layout, ILayoutProps } from '@/components/layout';
import Head from 'next/head';
import axios from 'axios';
import { getIsMobile, getIsSupportWebp, LOCALDOMAIN } from '@/utils';
import { ThemeContextProvider } from '@/stores/theme';
import { UserAgentProvider } from '@/stores/userAgent';
import { LanguageContextProvider } from '@/stores/language';
import './global.scss';

export interface IComponentProps {
  isMobile?: boolean;
  isSupportWebp?: boolean;
}

const MyApp = (data: AppProps & ILayoutProps & IComponentProps): JSX.Element => {
  const { Component, pageProps, navbarData, footerData, isMobile, isSupportWebp } = data;

  return (
    <div>
      <Head>
        <title>{`A Demo for 《SSR 实战:官网开发指南》(${isMobile ? '移动端' : 'pc端'})`}</title>
        <meta name="description" content={`A Demo forSSR 实战官网开发指南》(${isMobile ? '移动端' : 'pc端'})`} />
        <meta name="viewport" content="user-scalable=no" />
        <meta name="viewport" content="initial-scale=1,maximum-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <LanguageContextProvider>
        <ThemeContextProvider>
          <UserAgentProvider>
            <Layout navbarData={navbarData} footerData={footerData}>
              <Component {...pageProps} isMobile={isMobile} isSupportWebp={isSupportWebp} />
            </Layout>
          </UserAgentProvider>
        </ThemeContextProvider>
      </LanguageContextProvider>
    </div>
  );
};

MyApp.getInitialProps = async (context: AppContext): Promise<AppProps & ILayoutProps & IComponentProps> => {
  const pageProps = await App.getInitialProps(context);
  const { data = {} } = await axios.get(`${LOCALDOMAIN}/api/layout`);

  return {
    ...pageProps,
    ...data,
    isMobile: getIsMobile(context),
    isSupportWebp: getIsSupportWebp(context),
  };
};

export default MyApp;

getInitialProps

Article.getInitialProps = async (context): Promise<IArticleProps> => {
  const { articleId } = context.query;
  const { data } = await axios.get(`${LOCALDOMAIN}/api/articleInfo`, {
    params: {
      articleId,
    },
  });
  return data;
};

在服务器端执行,只能在页面层面进行绑定,采用同构,首次渲染服务器端渲染路由跳转使用客户端路由 意味着如果使用 router 跳转当前页,会在客户端执行这部分逻辑.

getServerSideProps

export const getServerSideProps: GetServerSideProps = async context => {
  const { articleId } = context.query;
  const { data } = await axios.get(`${LOCALDOMAIN}/api/articleInfo`, {
    params: {
      articleId,
    },
  });
  return {
    props: data, // 需要拿props包裹
  };
};

SSR,与 getlnitialProps 不同的是即使使用 router 跳转当前页,也只会在服务端执行这部分逻辑。

getStaticProps

// ssg
export const getStaticPaths: GetStaticPaths = async () => ({
  paths: [{ params: { articleId: '1' } }],
  fallback: false,
});

export const getStaticProps: GetStaticProps = async context => {
  const { articleId } = context.params as any;
  const { data } = await axios.get(`${LOCALDOMAIN}/api/articleInfo`, {
    params: {
      articleId,
    },
  });
  return {
    props: data,
  };
};

在服务器端构建时执行 SSG,如果涉及动态路由 (带参数),需要使用 getStaticPaths 配置所有可能的参数情况.

CSS Modules

Next.js 支持使用文件命名约定的 CSS 模块。[name].module.css

Layout

通过在入口文件导入 layout,可以实现每个页面公共的页眉页尾

const MyApp = (data: AppProps & ILayoutProps & IComponentProps): JSX.Element => {
  const { Component, pageProps, navbarData, footerData, isMobile, isSupportWebp } = data;

  return (
    <div>
      <Head>
        <title>{`A Demo for 《SSR 实战:官网开发指南》(${isMobile ? '移动端' : 'pc端'})`}</title>
        <meta name="description" content={`A Demo forSSR 实战官网开发指南》(${isMobile ? '移动端' : 'pc端'})`} />
        <meta name="viewport" content="user-scalable=no" />
        <meta name="viewport" content="initial-scale=1,maximum-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <LanguageContextProvider>
        <ThemeContextProvider>
          <UserAgentProvider>
            <Layout navbarData={navbarData} footerData={footerData}>
              <Component {...pageProps} isMobile={isMobile} isSupportWebp={isSupportWebp} />
            </Layout>
          </UserAgentProvider>
        </ThemeContextProvider>
      </LanguageContextProvider>
    </div>
  );
};

文件式路由

Next.js 有一个基于页面概念的基于文件系统的路由器。当一个文件被添加到 pages 目录中时,它会自动作为一个路径可用。

预定义路由优先级更高,预定义路由能直接匹配的路由就不会分发给下面的动态路由。

BFF 层的文件式路由

BFF,作为服务器构建包,不影响客户端构建 bundle 体积相同的 router 生成方式,不过是作为 API 层访问,而不是 page。

路由跳转

next / link 跳转

import Link from "next/link";
// ...
<Link href={item.link} key={index}>
    <div className={styles.card}>
        <h2>{item.label} &rarr;</h2>
        <p>{item.info}</p>
    </div>
</Link>

useRouter 跳转

import { useRouter } from 'next/router';

function ActiveLink({ children, href }) {
  const router = useRouter();
  const style = {
    fmarginRight: 10,
    color: router.asPath === href ? "red" : "black",
  };

  const handleClick = (e) => {
    e.preventDefault();
    router.push(href);
  };

  return (
    <a href={href} onClick={handleClick} style={style3}>
      {children}
    </a>
  );
}

export default ActiveLink;

除了这些外,还可以使用原生方法跳转,不过原生的方法不会进行 Diff 比对渲染性能上 Nextjs 提供的路由跳转会更好。

header 的修改

<Head>
    <title>{`A Demo for 《SSR 实战:官网开发指南》(${isMobile ? '移动端' : 'pc端'})`}</title>
    <meta name="description" content={`A Demo forSSR 实战官网开发指南》(${isMobile ? '移动端' : 'pc端'})`} />
    <meta name="viewport" content="user-scalable=no" />
    <meta name="viewport" content="initial-scale=1,maximum-scale=1" />
    <link rel="icon" href="/favicon.ico" />
</Head>

可用于修改 TDK (title, description, keywords)

多媒体适配 —— CSS 适配

// 极小分辨率移动端设备
@mixin media-mini-mobile {
  @media screen and (max-width: 25.875rem) {
    @content;
  }
}

// 介于极小分辨率和正常分辨率之间的移动端设备
@mixin media-between-mini-and-normal-mobile {
  @media screen and (min-width: 25.876rem) and (max-width: 47.9375rem) {
    @content;
  }
}

// 移动端设备
@mixin media-mobile {
  @media screen and (max-width: 47.9375rem) {
    @content;
  }
}

// ipad
@mixin media-ipad {
  @media screen and (min-width: 47.9375rem) and (max-width: 75rem) {
    @content;
  }
}

多媒体适配 —— JS 适配

export const UserAgentProvider = ({ children }: IProps): JSX.Element => {
  const [userAgent, setUserAgent] = useState<Environment>(Environment.none); // 服务器渲染初始化渲染未必是预期效果,none缓冲切换视觉)

  // 监听本地缓存来同步不同页面间的主题(当前页面无法监听到,直接在顶部栏进行了类的切换)
  useEffect(() => {
    const checkUserAgent = (): void => {
      const width = document.body.offsetWidth;
      // 用宽度去判断,是为了适配不改机型,仅拉扯屏幕宽度的情况
      if (width < 768) {
        // 手机端
        setUserAgent(Environment.mobile);
      } else if (width >= 768 && width < 1200) {
        // ipad端
        setUserAgent(Environment.ipad);
      } else if (width >= 1200) {
        // pc端
        setUserAgent(Environment.pc);
      } else {
        setUserAgent(Environment.none); // 增加none类型来缓冲默认类型样式切换时的视觉突变
      }
    };
    checkUserAgent();
    window.addEventListener('resize', checkUserAgent); // 监听屏幕宽度变化,及时适配当前页面样式
    return (): void => {
      window.removeEventListener('resize', checkUserAgent);
    };
  }, [typeof document !== 'undefined' && document.body.offsetWidth]);

  return <UserAgentContext.Provider value={{ userAgent }}>{children}</UserAgentContext.Provider>;
};

Next.js 服务端

BFF 层开发

import type { NextApiRequest, NextApiResponse } from 'next';
import axios from 'axios';
import { CMSDOMAIN } from '@/utils';
import { IArticleProps } from '../article/[articleId]';

const getArticleInfoData = (req: NextApiRequest, res: NextApiResponse<IArticleProps>): void => {
  const { articleId } = req.query;
  axios.get(`${CMSDOMAIN}/api/article-infos/${articleId}`).then(result => {
    const data = result.data || {};
    res.status(200).json(data);
  });
};

export default getArticleInfoData;

和 Express 等开发类似区别是并没有参数可以直接区别请求类型

调试方式

在 package.json 配置中使用:

  "scripts": {
    "dev": "next dev",
    "debugger": "cross-env NODE_OPTIONS='--inspect' next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },

Starapi - headless CMS

仓库: github.com/strapi/stra… 初始化: npx create-strapi-app my-project --quickstart 个接口的生成有以下几个过程:

  1. content-type builder 编辑结构体
  2. content manager 配置数据源,并且发布
  3. settings roles 里选择对应角色并勾选要发布的接口类型
  4. 如果涉及嵌套,在接口后加上 populate=deep 参数 (npm install strapi-plugin-populate-deep --save),没安装加参数 populate=* ,但只能嵌套一层

核心功能

首页功能实现

  1. 页面 & 动画 & 多媒体适配
  2. BFF
  3. Strapi

文章页实现

  1. 页面 & 动画 & 多媒体适配
  2. BFF
  3. Strapi 分页 (/api/articles?paginationlpage]=1&paginationlpageSize]=10 // 按10个/页分页,返回第一页的数据)
  4. 多媒体格式的转换
    • markdown 转 html: npm install showdown --save
    • html 转 dom: dangerouslySetlnnerHTML
    • 公共样式的定义

主体化功能实现

  1. 基础样式和背景的抽离
  2. 主题化 context 全局注入
  3. 从注入数据中取出 theme 和 setTheme
  4. 多进程间的主题同步