最近在看同构应用相关的知识,于是记录下自己的思考,然后写下动手实践的过程;
首先是对于几个问题的思考;然后是实践中的应用;
如有错误,欢迎大家在评论区指正:
问题:
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;
嗯,就暂且写到这里吧,后面有啥感悟再添加进来;