React+TypeScript+Nest+Webpack5 手写 React SSR

以下是个人对 React SSR 的理解,纯手工打造,可扩展性高(业务依赖低),以及在手写 React SSR 中遇到的问题,感谢阅读

前言

随着前端的发展,前端从页面仔再到现在大前端时代的到来,从浏览器到跨端,从客户端到服务器,前端能做的事越来越多,也越来越强稳,包括跨平台的桌面软件、跨平台的手机 APP、跨浏览器和手机端的页面及小程序、Html5 游戏、还有一些偏门的技术,比如小机器人(Ruff)等等。

主要使用技术栈:

  • React 适合开发大型项目的 UI 库,写法舒适,扩展性强,函数式编程,组件式开发,Diff 性能偏优
  • Typescript JavaScript 的泛型,静态类型限制,更加规范严谨的开发的语言
  • Nest 一款先进的 Node 库,基于经典 MVC 模式以及成熟的 DI(依赖注入),使用 TypeScript 开发的库
  • Webpack5 最新 Webpack 版本,成熟的模块化编译工具,体积更小(prepack)、速度更快(多进程打包)等优势

什么是 SSR

  • SSR 即 Server Side Render,就是由 server 端进行渲染,比如很多后端语言所具有的 jsp、htm、ejs 等 Mpa 应用,但随着发展,这些多页并不能良好的发挥用户体验,多页应用重定且页面切换速度慢,针对多页应用的优化,也是基于人造模板,比如返回部分所需 html,通过 js 来切换展示,流渲染等,但是用户体验并不是那么丝滑。
  • 为了更加友好的用户体验,Spa 迎用而生,用户操作也无需重定向,通过 js 来控制所需展示,但是 Spa 缺乏了 SEO,爬虫抓取不到静态化 html,而且 Spa 需要依赖静态资源,用户刷新页面,都需要等待静态返回才能加载页面,之后有了预渲染,更快的展示页面内容,但是预渲染解决不了 SEO 缺乏的问题,只是针对站内切换,提前渲染了 html,用户刷新还是要等待静态资源的加载。
  • 随后,就出了基于 Spa 的 SSR,相结合纯 SSR 以及 Spa 的优点,刷新即 SSR,站内切换即 Spa,既保证了 SEO,更加保证了速度及用户体验,当然,SSR 也有缺点,比如增加了服务器压力,对开发要求高一点

SSR 原理

  • 我们知道,SSR 之所以能实现,都是基于 Virtual DOM,有人说 Virtual DOM 的出现是因为它更快,其实并不是更快,而是减少了真实 DOM 重排重绘的的次数,我们都知道,操作 DOM 很快,而在通过 Js 内部去操作,有时候反而会更慢,我们知道 DOM 很昂贵,任何一个小节点,内存都不小,Virtual DOM 主要是为了解决频繁操作真实 DOM,以最小的代价来渲染真实 DOM,况且操作真实 DOM 还会阻塞主线程,Virtual DOM 不会立马渲染真实 DOM,也不会大面积的重排与重绘,当然,Virtual DOM 抽象了真实 DOM,带来了跨平台的能力,还有现在比较火的多端统一的趋势。

  • 既然有了 Virtual DOM,React 和 Node 都可以解析以及执行,最后渲染成真实 HTML,刷新进行 server 端渲染,站内切换进行 JS 控制视图,而且都支持 JavaScript,但是,紧接着面临如何挂载的问题,我们知道 React.render()可以进行 DOM 的挂载,那么 Node 呢,React 提供了 renderToString(),返回 HTML 字符串,在浏览器调用 ReactDOM.hydrate()来复用 Html,挂载事件。

  • 接下来,我们会想到,那么前端路由和服务端路由一样,怎么区分是服务端渲染还是客户端渲染,我们知道,如果是刷新,首先会进入 serwer 端的真路由,然后再到浏览器,才会再到浏览器路由。

  • 服务端路由要和客户端路由一样的写出一份吗,答案是的,只是我们不需要准备两份,维护起来也麻烦,只需要一份路由,双端都可以执行,业内出了一个词,叫路由同构。

  • 服务端渲染时,如何拿到数据,并返回有数据且渲染好的 html,涉及到了数据同构

  • 服务端返回的 html 样式怎么办,还要等待静态资源吗,答案是不需要,我们可以直接返回 style,更快的展示友好的视图,也就是样式同构。

下面,拿我的博客来举例,手写 React SSR

项目介绍

  • 首先,区分目录,一个明确的项目结构,能让你之后的开发更加顺畅,思路更加清晰,开发更加便捷
    -img
    • webpack.config.js 基础 webpack 配置,按需 merge 其他 webpack.config 实现多环境开发以及打包
    • webpack.server.js 将客户端代码打包成 Node 可以解析的 commonjs 代码
    • pm2.json 部署 node
    • src 代码所在目录
    • scripts 利用 scripty 包实执行 shell,更明确可扩展的命令
    • configs 开发上线环境的 webpack 配置
    • server 服务端目录
      • img
      • middlewares 中间件
      • entry 客户端 commonjs 包
      • assets 静态资源目录
      • algorithms 算法目录
      • server-source 相关网络资源
      • app.ts node 入口
      • mds 文章
      • api 接口
      • views 视图目录
      • services 服务目录
      • controller 控制器
      • modules 数据模块
    • redux 状态管理链接
      • img
      • loadDats.ts 数据注水
      • index.ts redux store
      • server.tsx 服务端路由
      • matches.ts 路由同构
      • client.tsx 客户端路由
    • client 客户端代码
      • img
      • server.html server 端 html 入口,模板注水
      • index.html client 基础模板,可做骨架屏
      • utils common functions
      • assets client 静态目录
      • server.tsx server 打包入口
      • pages client 路由页
      • index.tsx client 入口
      • components common 组件

手写 webpack 配置

首先,我们要知道我们的目标技术栈的打包技术,结合我们的预想及脑补,打包项目,当然,也要了解上面的原理以及那些 api 可以实现你想要的

  • 结合自己的思路,我们会想到一些项目配置,比如开发环境启动、开发环境打包、上线环境打包、server 端打包、server 端启动、一键自动化上线等,在这里,我没有选择打包 node,先看我的基础 webpack 配置,公共的打包配置,先看 client 打包配置,细节不聊,只看想法。。。。
// webpack.config.js
/*
 * @Author: your name
 * @Date: 2020-11-13 12:25:36
 * @LastEditTime: 2020-11-27 18:07:33
 * @LastEditors: your name
 * @Description: In User Settings Edit
 * @FilePath: /duanxl.com/webpack.config.js
 */
// yargs-parser可以取到shell参数
const argv = require("yargs-parser")(process.argv.slice(2));
const _mode = argv.mode;
// merge其他webpack配置
const { merge } = require("webpack-merge");
const { resolve } = require("path");
const _mergeConfig = require(`./config/webpack.${_mode}.js`);
const HtmlWebpackPlugin = require("html-webpack-plugin");
// script嵌入属性
const ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin')
// 通过命令区分上线开发环境,使用不同模板
const _htmlSrc =
  _mode === "production"
    ? "./src/client/server.html"
    : "./src/client/index.html";

// webpack基础配置
const basicConfig = {
  entry: resolve("/src/client/index.tsx"),
  output: {
    path: _mode === "production" ? resolve(__dirname, "src/server/assets") : resolve(__dirname, "dist"),
    filename: _mode === "production" ? "[name].[hash].js" : "[name].js",
    publicPath: '/'
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".jsx"],
    alias: {
      '@': resolve(__dirname, 'src')
    },
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: "index.html",
      template: _htmlSrc,
    }),
    new ScriptExtHtmlWebpackPlugin({
      defaultAttribute: _mode === "production" ? 'defer' : ''
    })
  ],
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: "babel-loader",
        },
      },
      {
        test: /\.(less|css)$/,
        use: [
          {
            loader: 'style-loader', // creates style nodes from JS strings
          },
          {
            loader: 'css-loader', // translates CSS into CommonJS
          },
          {
            loader: 'less-loader', // compiles Less to CSS
          },
        ],
      }
    ],
  },
};

module.exports = merge(basicConfig, _mergeConfig)

这里的 babel 配置也很简单,使用了@babel/preset-react、@babel/preset-typescript、@babel/plugin-syntax-dynamic-import,针对 react、typescript、动态导入的 babel 配置,开发环境和上线环境的 webpack 配置这里就不说了,比如我们上线环境要打包 less 等,静态资源的打包、optimization 等

// .babelrc
{
    "presets": [
        "@babel/preset-react",
        "@babel/preset-typescript",
    ],
    "plugins": [
        "@babel/plugin-syntax-dynamic-import"
    ]
}
  • server 端打包,我们知道,要打包成 commonjs 代码,只需要指定 target:node 就可以,但是这里也要注意哪些需要打包,哪些不要,我们可以使用 webpack-node-externals ,排除掉开发环境及其他的包,缩小入口体积,剔除没必要的打包
// webpack.server.js

const { resolve, join } = require("path");
// 独立css
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
    entry: join(__dirname, "./src/client/pages/App/server.tsx"),
    target: 'node',
    mode: 'development',
    output: {
        path: join(__dirname, "src/server/entry"),
        filename: "index.js",
        libraryTarget: "commonjs2"   //这里注意,如果我们想打包出module.exports规范的包,使用commonjs2规范
    },
    resolve: {
        extensions: [".ts", ".tsx", ".js", ".jsx"],
        alias: {
            '@': resolve(__dirname, 'src')
        },
    },
    plugins: [
        new MiniCssExtractPlugin({
            filename: "index.css",
            chunkFilename: "index.css",
            ignoreOrder: false,
        }),
    ],
    module: {
        rules: [
            {
                test: /\.(js|jsx|ts|tsx)$/,
                include: [resolve('src')],
                exclude: /node-modules/,
                use: {
                    loader: "babel-loader",
                },
            },
            {
                test: /\.(less|css)$/,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader,
                        options: {
                            publicPath: './',
                        },
                    },
                    {
                        loader: 'css-loader', // translates CSS into CommonJS
                        options: {
                            importLoaders: 1,
                        }
                    },
                    {
                        loader: 'less-loader', // compiles Less to CSS
                    },
                ],
            },
            {
                test: /\.(png|jpe?g|gif)$/i,
                use: [
                    {
                        loader: 'file-loader',
                        options: {
                            name: './images/[contenthash].[ext]'
                        }
                    },
                ],
            },
        ],
    },
    externals: Object.keys(require('./package.json').dependencies),   //剔除开发环境引用
};

客户端路由懒加载

使用官方文档里的 lazy API,实现动态路由打包分割以及懒加载

import React, { lazy, Suspense } from "react";
import { Route, RouteProps, Switch } from "react-router-dom";
import Loading from "../client/components/Loading";

interface RouterType extends RouteProps {
  loadData?: Function;
}
const Home = lazy(() => import("../client/pages/Home"));
const Blogs = lazy(() => import("../client/pages/Blogs"));
const Detail = lazy(() => import("../client/pages/Details"));
const About = lazy(() => import("../client/pages/About"));
const ClientRouters: RouterType[] = [
  {
    path: "/",
    component: Home,
    exact: true,
  },
  {
    path: "/blogs",
    component: Blogs,
    exact: true,
  },
  {
    path: "/detail/:id",
    component: Detail,
    exact: true,
  },
  {
    path: "/about",
    component: About,
    exact: true,
  },
];

const RouterPages = () => {
  return (
    <>
      <Suspense fallback={<Loading />}>
        <Switch>
          {ClientRouters.map((t: RouterType, index: number) => {
            const LazyComponent: any = t.component;
            return (
              <Route
                path={t.path}
                exact={t.exact}
                key={`page${index}`}
                forceRefresh
                component={LazyComponent}
              ></Route>
            );
          })}
        </Switch>
      </Suspense>
    </>
  );
};

export default RouterPages;

export { ClientRouters };

搭建 Nest

Node 我们使用了 Nest,Nest 相比于 koa 等技术更先进,无论是 IOC、MVC、typescript 等,当然,也有缺点,在打包成 Es5 时,因为 typescript 版本配置难以逾越。这里我们不依托于 Nest CLI,直接去 github 看 Nest 主要构成以及主要依赖

  • @nestjs/common IOC 以及主要 api 的输出内容
  • @nestjs/core 内部封装依赖
  • @nestjs/platform-express 渲染模板

安装完主要模块,开始搬砖(看 API),以下是我简单的主文件入口

// app.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./modules/app.module";
import { join } from "path";
// import { createProxyMiddleware } from 'http-proxy-middleware'
import { NestExpressApplication } from "@nestjs/platform-express";
async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  app.useStaticAssets(join(__dirname, "/", "assets"));
  app.setBaseViewsDir(join(__dirname, "/", "views"));
  app.setViewEngine("hbs");
  // app.use('/', createProxyMiddleware({ target: 'http://127.0.0.1:3000/', changeOrigin: true }))
  app.enableCors(); //开发客户端暂时打开cors
  await app.listen(3000);
}
bootstrap();

SSR 客户端开发注意点(自己遇到的坑)

  • 1.因为我们要将客户端打包一份 Commonjs,要避免操作 Bom、Dom,如果要操作,就要在 Server Render 之后操作,即生命周期内,浏览器接手才会执行 UseEffect
  • 2.路由同构要注意 CDN 等 head 请求、Option 请求等、如果经过 Node 没有处理,会造成 TCP 断开(在公司项目遇到过)
  • 3.本着单一开发原则等,不要让一个 Controller 做太多事,不易维护
  • 4.状态同构时,Node 执行客户端代码请求数据时,如果按照将 loadData 捆绑在组件上,会造成组件内部预执行
  • 5.数据注水与脱水后,站内切要注意注水数据复用的 dom 的问题

Server Render

紧接着,我们可以将上面打包好的两套代码(生产环境)开始进行 render,我们使用官网的 renderToString()

/*
 * @Author: your name
 * @Date: 2020-11-07 17:42:34
 * @LastEditTime: 2020-11-29 19:07:51
 * @LastEditors: Please set LastEditors
 * @Description: In User Settings Edit
 * @FilePath: /duanxl.com/src/server/controllers/app.controller.ts
 */
import { Controller, Get, Next, Req, Res } from "@nestjs/common";
import { Response, Request } from "express";
import { AppService } from "../services/app.service";
import { renderToString } from "react-dom/server";
import { createServerStore } from "../../redux/index";
import App from "../entry/index.js";
import React from "react";
import { StaticRouter } from "react-router";
import { createReadStream } from "fs";
import { join } from "path";
import { matchRoutes } from "react-router-config";
import { Provider } from "react-redux";
import routes from "../../redux/matches";
import util from "util";
import { getTitle } from "../middlewares/getTitle";

@Controller("*")
export class AppController {
  constructor(private readonly appService: AppService) {}
  @Get()
  async helloData(
    @Req() req: Request,
    @Res() res: Response,
    @Next() next: Function
  ) {
    if (req.url.includes("api") && !req.url.includes("api-about")) {
      next();
    } else {
      const readCss = function () {
        return new Promise((resolve: any) => {
          const stream = createReadStream(
            join(__dirname, "../entry/index.css")
          );
          stream.on("data", function (data) {
            resolve(`<style>${String(data)}</style>`);
          });
        });
      };
      let urlParam = "";
      if (req.url.includes("/detail/")) {
        urlParam = req.url.replace("/detail", "");
      }
      const matchedRoutes = matchRoutes(routes, req.url);
      const promises: any[] = [];
      const store = createServerStore();
      matchedRoutes.forEach((item) => {
        if (item.route.loadData) {
          promises.push(item.route.loadData(store, urlParam));
        }
      });
      const cssString = await readCss();
      const title = getTitle(req.url.replace("/detail/", ""));

      const metaString = `
      <title>${title} | 段鑫磊</title>
      <meta name="description" content={段鑫磊 | ${title}} />
      <meta name="keywords" content={段鑫磊 | ${title}} />
      `;
      Promise.all(promises).then(() => {
        const renderString: any = renderToString(
          <Provider store={store}>
            <div className="config">
              <StaticRouter location={req.url}>
                <App data={store.getState()} url={req.url} />
              </StaticRouter>
            </div>
          </Provider>
        );
        res.render("index", {
          content: renderString,
          cssString,
          metaString,
          SERVER_DATA: `<script>window.DUANXL_CN = ${util.inspect(
            store.getState() || { data: "" }
          )}</script>`,
        });
      });
    }
  }
}

这里我的思路是首先代理所有路由,然后分级处理,readCss 方法是流读取打包好的 css,可以理解为我的样式同构

样式同构

  • 1.使用 isomorphic-style-loader 提取用到的 css
  • 2.可以自己手动分析用到的 css,需要在打包时做
  • 3.JSS(即 CSS In Js)

从上面可以看到,在 node 中,我们解析了组件中的 loadData 方法,其实就是获取数据,然后注入 state 的过程,以及将数据注水道 window,下面看一下我的 loadDatas,没什么可说的,就是请求接口

// /src/redux/loadDatas.ts
import { Store } from "redux";
import { request } from "../client/utils/request";

const BlogsLoadData = async (store: Store, ..._args: any[]) => {
  const data = await request("https://duanxl.com/api/lists");
  store.dispatch({
    type: "DUANXL_CN",
    payload: {
      type: "list",
      data,
    },
  });
};

const DetailLoadData = async (store: Store, url: string) => {
  const data = await request(`https://duanxl.com/api/detail/${url}`);
  store.dispatch({
    type: "DUANXL_CN",
    payload: {
      type: "detail",
      data,
    },
  });
};
const AboutLoadData = async (store: Store, url: string) => {
  const data = await request(`https://duanxl.com/api/about`);
  store.dispatch({
    type: "DUANXL_CN",
    payload: {
      type: "about",
      data,
    },
  });
};
export { BlogsLoadData, DetailLoadData, AboutLoadData };

上面的 type 是我区分路由用的,因为自己手写 ssr,避免将业务组件执行,分离了数据请求。。。当然,还有很多没做,比如 404、ts-docs、jest 等等,后面会一步步优化,现有的博客业务很简单

最后,我们看一下,Server Render 后,客户端怎么脱水,还有客户端判断是否需要请求等

// /redux/index.ts
import { createStore } from "redux";

export interface StateType {
  data: unknown;
  title: string;
}

export interface ActionType {
  type: string;
  payload: unknown;
}

const state: StateType = {
  data: null,
  title: "段鑫磊 | 段鑫磊的博客 | 前端技术",
};

const reducer = (initState = state, action: any) => {
  switch (action.type) {
    case "DUANXL_CN":
      return {
        ...initState,
        data: action.payload.data,
        type: action.payload.type,
        title: action.payload.title ?? initState.title,
      };
    default:
      return { ...initState };
  }
};

const createClientStore = () => {
  return createStore(reducer, (window as any).DUANXL_CN);
};

const createServerStore = () => {
  return createStore(reducer);
};

export { createClientStore, createServerStore };

我们使用经典的 Redux 进行状态管理,可以看到,服务端状态管理 Commonjs,数据请求则被注水到 window 上,浏览器接手,就会 createStore(),简称脱水,在组件里,我们使用 connect 将 dispatch 以及 state 注入到 components 中,这样就可以得到 state

拿简单的一个 page 来举例

import React, { useEffect } from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { request } from "@/client/utils/request";
import Skeleton from "antd/es/skeleton";
import marked from "marked";
import hljs from "highlight.js";
import "../Details/index.less";
import "highlight.js/styles/agate.css";
import javascript from "highlight.js/lib/languages/javascript";
import { AboutLoadData } from "@/redux/loadDatas";
import Header from "@/client/components/Header";
import { Helmet } from "react-helmet";
marked.setOptions({
  renderer: new marked.Renderer(),
  gfm: true,
  pedantic: false,
  sanitize: false,
  breaks: true,
  smartLists: true,
  smartypants: true,
  highlight: function (code) {
    hljs.registerLanguage("javascript", javascript);
    return hljs.highlightAuto(code, ["javascript", "typescript", "css"]).value;
  },
});
const About = (props: any) => {
  useEffect(() => {
    if (props.data && props.type === "about") {
    } else {
      request(`https://duanxl.com/api/about`).then((data) => {
        props.getData(data);
      });
    }
  }, [props]);
  return (
    <>
      <Header active="about" />
      <Helmet>
        <title>段鑫磊 | 前端博客 | 关于博客</title>
        <meta name="description" content={`段鑫磊 | 段鑫磊的博客 | 前端`} />
        <meta name="keywords" content={`段鑫磊 | 段鑫磊的博客 | 前端`} />
      </Helmet>
      {props.data && props.type === "about" ? (
        <>
          <div
            className="detail"
            dangerouslySetInnerHTML={{ __html: marked(props.data) }}
          ></div>
        </>
      ) : (
        <Skeleton active />
      )}
    </>
  );
};
function mapStateToProps(state: any) {
  return state;
}

function mapDispatchToProps(dispatch: Dispatch) {
  return {
    getData(data: any) {
      return dispatch({
        type: "DUANXL_CN",
        payload: {
          type: "about",
          data,
        },
      });
    },
  };
}
About.loadData = AboutLoadData;
export default connect(mapStateToProps, mapDispatchToProps)(About);

上面也说了。这个 type 是我单独传递的,目的就是判断站内切是否请求数据,这里有瑕疵,可以改造成一个 isServerRender 的变量,然后判断直刷还是站内切

结语:博客很简单,是我手写 React SSR 简单的认识以及实践,Node 端使用 Ts-node+pm2 部署,因为静态编译,并不会有运行性能的问题,文章做得是流直出 Md,CI&CD 使用 jenkins+github+sonar。

0