React 同构应用 思考与实践

484 阅读7分钟

最近在看同构应用相关的知识,于是记录下自己的思考,然后写下动手实践的过程;

首先是对于几个问题的思考;然后是实践中的应用;

如有错误,欢迎大家在评论区指正:

问题:

1.什么是同构应用

个人认为同构就是应用同一套技术,做到一套代码双端运行的解决方案;

下面chatgpt的回答:

同构应用是指可以同时运行在服务端和客户端的应用程序,通常用于提高应用的性能和SEO(搜索引擎优化)。在同构应用中,应用的一部分代码运行在服务端,另一部分运行在客户端,服务端渲染的内容会在页面初始加载时就显示出来,而客户端渲染的内容则会在页面交互中动态地更新。这种方式可以使应用具备更好的首屏加载速度和SEO效果,同时也可以提高应用的用户体验。

2. 为什么需要同构应用;

随着AJAX技术的出现,前端代码可以控制页面的局部更新,给了用户更好的交互体验,同时也减轻了服务端的压力,只需要提供数据给前端即可,页面渲染放到浏览器中来执行(早期jQuery+前端模版,后面出现的vue,react,angular);

但是这种方式有两个问题:1.不太利于SEO(静态有内容的HTML页面对于爬虫来说更友好);2.首屏资源需要加载js和请求数据后才能得到页面最终内容;

我们既想要SPA的交互体验,也想拥有较好的SEO和较短的首屏渲染时间,就需要将服务端渲染和客户端渲染结合起来,同时为了代码的重用,就需要引入同构的方式来解决了;

那么同构应用是不是就是SSR呢?

个人感觉不是的,这里的同构可以在用户导航的时候完全接管页面,保留了SPA的交互体验,只有在刷新的时候请求后端直出,并且这两种场景我们只需要写一份代码,跟JSP和PHP感觉还是有区别的;

3. 同构应用需要解决的问题,主要从React来看;
1. 服务端直出页面到客户端后,客户端的交互事件绑定与虚拟DOM构建
2. 服务端与客户端的路由同构,服务端匹配组件与客户端匹配应该是一套逻辑
3. 数据同构,服务端已填充数据的情况,客户端不再重复发送请求
4. 服务端的CSS处理

下面动手写一个demo来看这些问题的解决方式;详细代码已上传github,地址;

项目配置

客户端和服务端都需要编译代码,先安一下依赖, 由于只是demo使用,服务端和客户端依赖放在了一起,实际中需要分开

  "devDependencies": {
    "@babel/core": "^7.21.8",
    "@babel/preset-env": "^7.21.5",
    "@babel/preset-react": "^7.18.6",
    "@types/express": "^4.17.17",
    "@types/node-fetch": "^2.6.3",
    "@types/react": "^18.2.5",
    "@types/react-dom": "^18.2.4",
    "@types/webpack": "^5.28.1",
    "css-loader": "^6.7.3",
    "css-loader4": "npm:css-loader@^4.3.0",
    "isomorphic-style-loader": "^5.3.2", // 这是我是本地的软连接,后面下载地址有说明
    "less": "^4.1.3",
    "less-loader": "^11.1.0",
    "nodemon": "^2.0.22",
    "npm-run-all": "^4.1.5",
    "ts-loader": "^9.4.2",
    "typescript": "^5.0.4",
    "webpack": "^5.82.0",
    "webpack-cli": "^5.0.2",
    "webpack-merge": "^5.8.0",
    "webpack-node-externals": "^3.0.0"
  },
  "dependencies": {
    "express": "^4.18.2",
    "node-fetch": "^2.6.9",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router": "^6.11.1",
    "react-router-dom": "^6.11.1"
  }

使用npx tsc --init 创建 tsconfig.js,个别配置如下:

lib: ["DOM"], // 依赖的全局库的类型声明;
"jsx": "react-jsx", // 开启JSX的编译, 17以上不要用react ts(2686)   
"module": "ESNext",
"moduleResolution": "node",
"target": "ES5",

webpack配置主要罗列一下loader的配置,后面有用到再详细说明: 需要注意的是node端的打包需要引入nodeExternals, 用于打包的时候Node的全局变量的解决; 详细配置与demo代码已上传github,地址;

module.exports = /** @type {import('webpack').Configuration}*/ ({
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: {
          loader: "ts-loader",
          options: {
            compilerOptions: {},
          },
        },
      },
      {
        test: /\.less$/,
        use: [
          {
            loader: "isomorphic-style-loader",
            options: {
              getCss: (css) => {
                return css.default;
              },
            },
          },
          {
            loader: "css-loader4",
            options: {
              importLoaders: 1,
              modules: true,
            },
          },
          {
            loader: "less-loader",
          },
        ],
      },
    ],
  },
  devtool: "cheap-module-source-map",
  resolve: {
    extensions: [".ts", ".tsx", ".js"],
  }
});

打包脚本:

"scripts": {
    "build:server": "webpack --config ./webpack.server.js",
    "build:client": "webpack --config ./webpack.client.js",
    "build": "npm-run-all --parallel build:*",
    "server": "nodemon ./dist/server.js --watch dist",
    "start": "npm-run-all build server"
}

最简单的同构应用:

编写APP组件

import React, {useState} from 'react';

const App = () => {
  const [count, setCount] = useState(0);
  return <div onClick={() => setCount(count + 1)}>Hello world! {count}</div>
}

export {App};

为其编写客户端和服务端运行的代码:

// 客户端入口代码
import { App } from "./App";
import { hydrateRoot } from "react-dom/client";
import React from 'react'

hydrateRoot(document.getElementById('root') as Element, <App />);


// 服务端入口代码
import express from 'express';
import {pageRouter} from './pageRouter';
const path = require('path');
const app = express();

app.use(express.static(path.join(__dirname, './static')))
app.use(pageRouter);

app.listen(3000, () => {
  console.log('server start');
});


// 服务端组件路由 pageRouter
import { Router } from "express";
import {renderToString} from 'react-dom/server';
import {App} from '../src/App';
import React from "react";
const pageRouter = Router();

pageRouter.get("*", (req, res) => {
  const str = renderToString(<App/>);
  res.send(`
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
      <script src="/client.js" defer></script>
    </head>
    <body>
      <div id="root">${str}</div>
    </body>
    </html>
  `);
});

export {pageRouter};

执行npm start, 启动服务,访问3000端口,可以发现页面返回了带内容的HTML,并且点击交互正常;那么客户端是如何激活服务端返回的字符串并绑定事件呢?

hydrateRoot

我们没有使用Render,是因为hydrateRoot会在Render阶段尽可能重用服务端返回的DOM,只构建fiber树,不用再commit中重新生成DOM,同时会在root上绑定代理事件,当触发事件后再沿着fiber树查找对应的节点触发回调;

数据同构

需要考虑服务端跟客户端的初始请求代码是一份,都放在组件中,我们约定组件的getInitialData为数据请求的方法;

当刷新页面的时候,初始化请求走服务端,客户端不发送该API请求;

当用户操作页面跳转的时候,初始化请求需要走客户端;

改造APP代码如下:

import React, { useEffect, useState, useRef } from "react";
import { getList } from "../mock/list";
interface Item {
  name: string;
  age: number;
}
declare let __SSR: boolean;
const useData: <T>(initData: T) => [T, React.Dispatch<T>] = (initialData) => {
  const hasServerData = useRef(false);
  const [data, setData] = useState(() => {
    if (__SSR) {
      // @ts-ignore
      return global.__innerData;
    } else {
      let el = document.getElementById("ssrInnerData"); // 此处需提取常量
      let data = JSON.parse(el?.innerText || JSON.stringify(""));
      if (data) {
        hasServerData.current = true;
        el && el.parentNode?.removeChild(el);
        return data;
      }
    }
    return initialData;
  });
  useEffect(() => {
    if (hasServerData.current) {
      return;
    }
    getList().then((res) => setData(res));
  }, []);
  return [data, setData];
};

const App = () => {
  const [list, setList] = useData<Item[]>([]);
  return (
    <div>
      <span>Hello world!</span>

      {list.map((item) => {
        return <div key={item.name}>{item.name}</div>;
      })}
    </div>
  );
};
App.getInitialData = async () => {
  let data = await getList();
  return data;
};

export { App };

server端代码如下:

pageRouter.get("*", async (req, res) => {
  const data = await App.getInitialData();
  // @ts-ignore
  global.__innerData = data;
  const str = renderToString(<App />);
  // @ts-ignore
  global.__innerData = null;
  res.send(`
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
      <script src="/client.js" defer></script>
      <script src="/observer.js" defer></script>
    </head>
    <body>
      <div id="root">${str}</div>
      <textarea id="ssrInnerData" style="display:none">${JSON.stringify(
        data
      )}</textarea>
    </body>
    </html>
  `);
});

需要注意的是,在服务端运行的变量在客户端是拿不到的,例如我们在服务端渲染时请求的列表数据 const data = await App.getInitialData();,客户端中运行的JS代码是无法拿到的,本例中使用的是服务端在HTML中写入序列化的数据,然后客户端从DOM节点中获取的方式来解决的请求复用问题,如果从DOM中拿到了数据,客户端就不会再useEffect中发送请求了,至于为啥拿完要删除掉是因为如果有用户点击跳转的情况,那么在这种情况下是需要客户端发请求的,删掉后客户端拿不到数据就会在useEffect中发请求了;

路由同构

主要实现客户端和服务端使用一套路由规则;我们使用react-router-dom6来实现;(5之前使用的context 向页面组件注入staticContext来实现的,需要区分下);

react-router6引入了loader的概念,帮助在路由组件渲染前加载数据,必须在data-router中使用;并且它提供了useLoaderData可以在组件中获取请求到的数据;这里获取的数据不带类型信息,所以封装了一个useDataFromLoader来帮助获取类型,减少重复的as;

export function useDataFromLoader<T extends LoaderFunction>(loaderFn: T) {
  return useLoaderData() as Awaited<ReturnType<typeof loaderFn>>;
}

客户端改造成两个路由页面:HOME,USER, 它们都属于Layout布局组件的一部分:

// Home.tsx
const Home = () => {
  const list = useDataFromLoader(Home.loader);
  return (
    <div>
      <span>Hello world!</span>

      {list.map((item) => {
        return <div key={item.name}>{item.name}</div>;
      })}
    </div>
  );
};

Home.loader = async () => {
  const data = await getList();
  return data;
};
// User.tsx
const User = () => {
  const userInfo = useDataFromLoader(User.loader);
  return <div>user: {JSON.stringify(userInfo)}</div>;
};
User.loader = async () => {
  let data = await getUser();
  return data;
};
// Layout.tsx
export const Layout = () => {
  const news = useDataFromLoader(Layout.loader);
  return (
    <div>
      Hello world;
      {news.map((item) => {
        return <div key={item.id}>{item.desc}</div>;
      })}
      <Outlet />
    </div>
  );
};

Layout.loader = getNews;
// route 配置
export const routes: RouteObject[] = [
  {
    element: <Layout />,
    path: "/",
    loader: Layout.loader,
    children: [
      {
        element: <Home />,
        loader: Home.loader,
        index: true, // 默认
      },
      {
        path: "user",
        element: <User />,
        loader: User.loader,
      },
    ],
  },
];

// 客户端入口
const router = createBrowserRouter(routes);

hydrateRoot(
  document.getElementById("root") as Element,
  <RouterProvider router={router} />
);

服务端代码改造如下:

// 构造一个Request对象,React-Router根据它的信息来匹配路由
const createRequest = (req: ExpressRequest) => {
  const origin = `${req.protocol}://${req.get("host")}`;
  const url = new URL(req.url, origin);
  const controller = new AbortController();
  req.on("close", () => controller.abort());
  const init = {
    method: req.method,
    signal: controller.signal,
  };
  // @ts-ignore
  return new Request(url.href, init);
};

const pageRouter = Router();

type QueryReturnType = Awaited<ReturnType<StaticHandler["query"]>>;

function isResponse(
  context: QueryReturnType
): context is Extract<QueryReturnType, { status: number }> {
  return context instanceof Response;
}

pageRouter.get("*", async (req, res) => {
  const { query, dataRoutes } = createStaticHandler(routes);
  const context = await query(
    createRequest(req) as unknown as globalThis.Request
  );

  if (isResponse(context)) {
    throw context;
  }

  const router = createStaticRouter(dataRoutes, context);
  const str = renderToString(
    <StaticRouterProvider context={context} router={router} />
  );
  res.send(`
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
      <script src="/client.js" defer></script>
      <script src="/observer.js" defer></script>
    </head>
    <body>
      <div id="root">${str}</div>
    </body>
    </html>
  `);
});

服务端主要就是将路由请求托管到了React-Router,它会根据不同的路径和路由配置的path进行匹配和获取数据;

CSS处理

为组件增加一些样式,使用less; 为了避免TS报错,增加externals.d.ts声明文件,内容如下:

declare module "*.less" {
  const resource: { [k: string]: string };
  export = resource;
}

为了处理CSS,需要使用三个loader: isomorphic-style-loader, css-loader, less-loader;

less-loader将less转换为css, css-loader 做 css-module 相关的事情和包裹字符串代码为可导入的模块;isomorphic-style-loader用于将CSS插入到文档中;

我们使用的react18的版本和css module的方式,isomorphic-style-loader对于这种方式的使用有一些问题,大家可以安装我修改的这个版本;

css处理相关的配置如下:

{
    loader: "isomorphic-style-loader",
    options: {
      getCss: (css) => {
        return css.default;
      },
    },
},

先看使用方式,然后简单的介绍下原理;

客户端的使用方式:

// Home.tsx
import styles from "./index.less";
import useStyles from "isomorphic-style-loader/useStyles";
const Home = () => {
  useStyles(styles);
  const list = useDataFromLoader(Home.loader);
  return (
    <div>
      <span>Hello world!</span>
      {list.map((item) => {
        return (
          <div className="line" key={item.name}>
            {item.name}
          </div>
        );
      })}
    </div>
  );
};
// 文件入口 index.ts
const insertCss = (...styles: any[]) => {
  const removeCss = styles.map((style) => style._insertCss());
  return () => {
    removeCss.forEach((dispose) => dispose());
  };
};
hydrateRoot(
  document.getElementById("root") as Element,
  <StyleContext value={{ insertCss }}>
    <RouterProvider router={router} />
  </StyleContext>
);

服务端使用方式:

const styleEls: string[] = [];
const insertCss = (...styles: StyleItem[]) => {
    styles.forEach((style, i) => {
      const [[id, content]] = style._getContent();
      styleEls.push(`<style id="s${id}-${i}">${content}</style>`);
    });
};

const router = createStaticRouter(dataRoutes, context);
const str = renderToString(
    <StyleContext.Provider value={{ insertCss }}>
      <StaticRouterProvider context={context} router={router} />
    </StyleContext.Provider>
);
res.send(`
<!DOCTYPE html>
<html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
      ${styleEls.join("")}
      <script src="/client.js" defer></script>
      <script src="/observer.js" defer></script>
    </head>
    <body>
      <div id="root">${str}</div>
    </body>
</html>
`);

简单的介绍一下这几个API,useStyles 接收 loader 处理完的js模块记作A,最终isomorphic-style-loader在A中添加上_insertCss,_getContent,_getCss属性,然后useStyles会获取Context传下来的insertCss;该loader内部useStyle代码如下

function useStyles(...styles) {
  const { insertCss } = useContext(StyleContext)
  if (!insertCss) throw new Error('Please provide "insertCss" function by StyleContext.Provider')
  const runEffect = () => {
    const removeCss = insertCss(...styles)
    return () => {
      setTimeout(removeCss, 0)
    }
  }
  if (isBrowser) {
    useEffect(runEffect, [])
  } else {
    runEffect()
  }
}

在客户端insertCss里面调用_insertCss, 执行动态的插入Style标签的逻辑;

在服务端insertCss会生成Style数组,转为字符串直接插入到HTML的字符串中;这里主要注意的点是insertCss里要拿到css模块的ID,给Style标签设置上,客户端insertCSS执行的时候会查看是否有该ID对应的Style,有的话就不再重复执行了;

后面打算单开一篇webpack中的CSS处理相关的文章,这里只简单说明;

按需加载

结合react-router-dom的配置,只需要将路由配置更改一下,结合webpack import组件导入, 配置修改如下,以Home页为例:

{
    index: true, // 默认
    async lazy() {
      let { Home } = await import(/* webpackChunkName: 'Home'*/ "./Home");
      return { Component: Home, loader: Home.loader };
    },
},

还有一个注意的点是刷新进入页面(或者首次进入页面的时候,需要将当前页面的路由文件都加载完后再进行客户端的激活,为的是React hydrate 过程的正确,防止双端对比造成的渲染错误);入口代码如下:

const render = () =>
  hydrateRoot(
    document.getElementById("root") as Element,
    <StyleContext.Provider value={{ insertCss }}>
      <RouterProvider router={router} />
    </StyleContext.Provider>
  );
const router = createBrowserRouter(routes);

let lazyMatched = matchRoutes(routes, window.location)?.filter((module) => {
  return module.route.lazy;
});

if (lazyMatched && lazyMatched.length) {
  Promise.all(
    lazyMatched.map(async (module) => {
      const routeModule = await module.route.lazy?.();
      Object.assign(module.route, {
        ...routeModule,
        lazy: undefined,
      });
    })
  ).then(render);
} else {
  render();
}

但是打包会发现还是只打包出了一个bundle,其实是ts-loader的转换与webpack的import冲突了,如果大家也遇到了同样的问题,可以参考这两篇文章

## Code-splitting with TypeScript and Webpack

# Code-Splitting a TypeScript Application with import() and webpack;

嗯,就暂且写到这里吧,后面有啥感悟再添加进来;