本人有幸开发过2个官网:一个是前公司的官网用的是vue技术栈开发的,百度搜索不到用jquery重构将静态页面交给后端。另一个是接的私单要求能百度到1天交货但是好在是静态页面,于是用了next.js从开发到部署。虽然已经开发过2个官网了但是我对一些SSR、SEO等概念还是有一点模糊,于是决定买本掘金小册系统学习一下。
为什么官网要用SSR开发/或者说C端网站要用SSR开发?
相比于 B 端网站,C 端 Web 应用更要求易传播性和交互稳定性。B端网页一般都是后端管理之后的内部系统,即使在浏览器上搜不到、不好用、卡顿,甚至死机,用户可能会因为公司要求或是选择有限而强制使用。但是没有人可以强迫你使用一个 C 端网站 (掘金、CSDN、博客园,或是字节官网、淘系官网都是C端产品)。
1、易传播性:浏览器的推广程度,取决于搜索引擎对站点检索的排名,搜索引擎可以理解是一种爬虫,它会爬取指定页面的 HTML,并根据用户输入的关键词对页面内容进行排序检索,最后形成我们看到的结果。服务端渲染可以有效提高搜索引擎爬取的精度,进而提高网站的易传播性。在页面的渲染过程种,高级爬虫会等到页面渲染完成后进行页面数据拉取和关键词匹配,低级爬虫会爬取服务端拉取的HTML,服务端拉取下来的HTML种包含的实际页面关键词和数据越多,搜索引擎匹配的精度也会越高。
2、稳定性:对于客户端渲染,实际的数据需要在执行脚本后请求数据后才可以得到,而对于服务器端渲染,数据请求的过程在在服务器端已经完成了,这就使得服务器渲染将不再需要进行数据请求,可以拥有更短的首屏时间。
如何实现 SSR 的静态页面渲染?
npm install react react-dom express --save
// ./src/pages/Home/index.tsx
const Home = () => {
return (
<div>
<h1>hello-ssr</h1>
<button
onClick={(): void => {
alert("hello-ssr");
}}
>
alert
</button>
</div>
);
};
export default Home;
上面的代码是react客户端静态模板,我们要生成的是服务端的静态模板,咋弄?可以使用react提供的renderToString,这个方法可以把模板元素转换成 HTML 字符串返回。它的底层和客户端模板编译其实是一样的,都是根据 AST (也就是虚拟 DOM )来转化成真实 DOM 的过程,React 在它的基础上,提供了更多流相关的能力,返回了一套 server 相关的 api。
// ./src/server/index.tsx
import express from "express";
import childProcess from "child_process";
import { renderToString } from "react-dom/server";
import Home from "@/pages/Home";
const app = express();
const content = renderToString(<Home />);
app.get("*", (req, res) => {
res.send(`
<html
<body>
<div>${content}</div>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log("ssr-server listen on 3000");
});
childProcess.exec("start http://127.0.0.1:3000");
O(∩_∩)O,服务端的静态模块已经生成了,但是点击事件没有任何反应?事件失效了?不,这是因为rendertoString 只是渲染页面,而事件相关的绑定是没办法在服务器端中进行的,那我们怎么才能把事件绑定到静态页面呢?
掘金服务端返回的 HTML 文本中包括一组打包过后的 JS,这个其实就是这个页面所对应的相关事件和脚本,我们只需要打包过后将 JS 绑定在 HTML 中就可以。这个就是“同构”,是服务器端渲染的核心概念,同一套 React 代码在服务器端渲染一遍,然后在客户端再执行一遍。服务端负责静态 dom 的拼接,而客户端负责事件的绑定,不仅是模板页面渲染,路由,数据的请求都涉及到同构的概念,服务器端渲染都是基于同构去展开的(以前一直不懂什么是同构,一直以为是用node服务端静态渲染页面就是同构了,O(∩_∩)O,菜狗)。那么我们如果将打包引入到HTML中呢?可以使用ReactDom.hydrateRoot,在已经提供了服务器端静态渲染节点的情况下使用,它只会对模板中的事件进行处理,这样就可以满足我们的需求了。
// src/client/index.tsx
import { hydrateRoot } from "react-dom/client";
import Home from "@/pages/Home";
hydrateRoot(document.getElementById("root") as Document | Element, <Home />);
ReactDom.hydrateRoot 需要指定一个绑定的真实 dom,我们给 server 入口 send 的页面加一个id
// src/server/index.tsx
import express from "express";
import childProcess from "child_process";
import { renderToString } from "react-dom/server";
import Home from "@/pages/Home";
const app = express();
const content = renderToString(<Home />);
app.get("*", (req, res) => {
res.send(`
<html
<body>
<div id="root">${content}</div>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log("ssr-server listen on 3000");
});
childProcess.exec("start http://127.0.0.1:3000");
现在打包后运行试试
完美,你就是未来的前端大神。
路由的匹配
我们的页面怎么可能只有一个页面呢?所以就需要使用路由了。
npm install react-router-dom --save
再创建一个简单的 demo 模板页面
// ./src/pages/Demo/index.tsx
import { FC } from "react";
const Demo: FC = (data) => {
return (
<div>这是一个demo页面</div>
);
};
export default Demo;
然后我们创建一个 router.tsx 来存放路由相关的配置
// ./src/router.tsx
import Home from "@/pages/Home";
import Demo from "@/pages/Demo";
interface IRouter {
path: string;
element: JSX.Element;
}
const router: Array<IRouter> = [
{
path: "/",
element: <Home />,
},
{
path: "/demo",
element: <Demo />,
},
];
export default router;
改造一下客户端
// ./src/client/index.tsx
import { hydrateRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import router from "@/router";
const Client = (): JSX.Element => {
return (
<BrowserRouter>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />;
})}
</Routes>
</BrowserRouter>
);
};
hydrateRoot(document.getElementById("root") as Document | Element, <Client />);
服务端也相同,我们用路由的部分来替换上面固定的<Home />
,我们可以使用
StaticRouter 无状态的路由,因为服务器端不同于客户端,客户端中,浏览器历史记录会改变状态,同时将屏幕更新,但是服务器端是不能改动到应用状态的,所以我们这里采用无状态路由。
// ./src/server/index.tsx
import express from "express";
import childProcess from "child_process";
import { renderToString } from "react-dom/server";
import path from "path";
import router from "@/router";
import { Route, Routes } from "react-router-dom";
import { StaticRouter } from "react-router-dom/server";
const app = express();
app.use(express.static(path.resolve(process.cwd(), "client_build")));
app.get("*", (req, res) => {
const content = renderToString(
<StaticRouter location={req.path}>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />;
})}
</Routes>
</StaticRouter>
);
res.send(`
<html
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log("ssr-server listen on 3000");
});
childProcess.exec("start http://127.0.0.1:3000");
现在存在客户端路由和服务端路由,所以服务器端渲染通过不同的方式跳转也会采用不同的渲染方式,当使用 React 内置的路由跳转的时候,会进行客户端路由的跳转,采用客户端渲染;而通过 a 标签,或者原生方式打开一个新页面的时候,才会进行服务器端路由的跳转,使用服务器端渲染。继续改造一下home.
// ./src/pages/Home/index.tsx
import { useNavigate } from "react-router-dom";
const Home = () => {
const navigate = useNavigate();
return (
<div>
<h1>hello-ssr</h1>
<button
onClick={(): void => {
alert("hello-ssr");
}}
>
alert
</button>
<a href="http://127.0.0.1:3000/demo">链接跳转</a>
<span
onClick={(): void => {
navigate("/demo");
}}
>
路由跳转
</span>
</div>
);
};
export default Home;
点击a标签跳转,可以看到 network 中会有对服务器端的请求,所以是通过服务器端渲染的页面。
点击“路由跳转 ” 的时候,走的是客户端路由,这时候打开的页面将不是服务器端渲染,而是会走客户端渲染兜底,可以看到 network 中是没有对服务器端的 HTML 请求的。
Header 标签的修改
SSR 的模板页面的渲染和路由匹配,不能满足我们所有静态页面的需求,因为模板页面只是影响到 body 的部分,修改不同路由下对应的标题,多媒体适配或是 SEO 时加入的相关 meta 关键字,都需要加入相关的 header。
务器端渲染怎么才能修改到对应的 header 呢?可以使用 react-helmet 来实现我们的需求,这个依赖支持对文档头进行调整。
npm install react-helmet --save
同样,这个调整是需要同构的,客户端和服务端都要针对性调整,我们先对客户端进行改造,以 Home页举例:
// ./src/pages/Home/index.tsx
import { useNavigate } from "react-router-dom";
import { Fragment } from "react";
import { Helmet } from "react-helmet";
const Home = () => {
const navigate = useNavigate();
return (
<Fragment>
<Helmet>
<title>简易的服务器端渲染 - HOME</title>
<meta name="description" content="服务器端渲染"></meta>
</Helmet>
<div>
<h1>hello-ssr</h1>
<button
onClick={(): void => {
alert("hello-ssr");
}}
>
alert
</button>
<a href="http://127.0.0.1:3000/demo">链接跳转</a>
<span
onClick={(): void => {
navigate("/demo");
}}
>
路由跳转
</span>
</div>
</Fragment>
);
};
export default Home;
然后我们对服务器端也进行相同的同构,保证服务器端的返回也是相同的 header,这一点很重要,因为大部分搜索引擎的爬虫关键词爬取都是根据服务器返回内容进行关键词检索的。
import express from "express";
import childProcess from "child_process";
import { renderToString } from "react-dom/server";
import path from "path";
import router from "@/router";
import { Route, Routes } from "react-router-dom";
import { StaticRouter } from "react-router-dom/server";
import { Helmet } from "react-helmet";
const app = express();
app.use(express.static(path.resolve(process.cwd(), "client_build")));
app.get("*", (req, res) => {
const content = renderToString(
<StaticRouter location={req.path}>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />;
})}
</Routes>
</StaticRouter>
);
const helmet = Helmet.renderStatic();
res.send(`
<html
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log("ssr-server listen on 3000");
});
childProcess.exec("start http://127.0.0.1:3000");
Helmet.renderStatic()可以提供渲染时添加的所有内容,这样我们就已经完成 header 标签的修改了,可以刷新一下页面看一下效果:
SSR如何进行数据请求
我们先创建一个接口,express 没办法直接读取请求的 body,所以我们需要用 body-parser 对请求进行一个解析
npm install body-parser --save
// ./src/server/index.tsx
// ./src/server/index.tsx
import express from "express";
import childProcess from "child_process";
import { renderToString } from "react-dom/server";
import path from "path";
import router from "@/router";
import { Route, Routes } from "react-router-dom";
import { StaticRouter } from "react-router-dom/server";
import { Helmet } from "react-helmet";
const app = express();
const bodyParser = require("body-parser");
app.use(express.static(path.resolve(process.cwd(), "client_build")));
// 请求body解析
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// 启一个post服务
app.post("/api/getDemoData", (req, res) => {
res.send({
data: req.body,
status_code: 0,
});
});
app.get("*", (req, res) => {
const content = renderToString(
<StaticRouter location={req.path}>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />;
})}
</Routes>
</StaticRouter>
);
const helmet = Helmet.renderStatic();
res.send(`
<html
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log("ssr-server listen on 3000");
});
childProcess.exec("start http://127.0.0.1:3000");
我们再客户端的静态页面测试一下接口
npm install axios --save
// ./src/pages/Demo/index.tsx
import { FC, useState, useEffect } from "react";
import axios from "axios";
const Demo: FC = (data) => {
const [content, setContent] = useState("");
useEffect(() => {
axios
.post("/api/getDemoData", {
content: "这是一个demo页面",
})
.then((res: any) => {
setContent(res.data?.data?.content);
});
}, []);
return <div>{content}</div>;
};
export default Demo;
数据请求成功了,不过,不对的是,我们可以在 network 中看到对应的请求,数据也没在服务器端请求的时候塞入 HTML,也就是说走的是客户端渲染,而不是服务端渲染,和我们预期的不一样,看来是不能直接用 hook 来常规请求的。
回忆之前静态页面的思路,是在服务器端拼凑好 HTML 并返回,所以请求的话,咱们应该也是获取到每个模板页面初始化的请求,并在服务器端请求好,进行 HTML 拼凑,在这之前我们需要建立一个全局的 store,使得服务端请求的数据可以提供到模板页面来进行操作。
npm install @reduxjs/toolkit redux-thunk react-redux --save
// ./src/pages/Demo/store/demoReducer.ts
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import axios from "axios";
const getDemoData = createAsyncThunk(
"demo/getData",
async (initData: string) => {
const res = await axios.post("http://127.0.0.1:3000/api/getDemoData", {
content: initData,
});
return res.data?.data?.content;
}
);
const demoReducer = createSlice({
name: "demo",
initialState: {
content: "默认数据",
},
// 同步reducer
reducers: {},
// 异步reducer
extraReducers(build) {
build
.addCase(getDemoData.pending, (state, action) => {
state.content = "pending";
})
.addCase(getDemoData.fulfilled, (state, action) => {
state.content = action.payload;
})
.addCase(getDemoData.rejected, (state, action) => {
state.content = "rejected";
});
},
});
export { demoReducer, getDemoData };
我们创建一个 index.ts 来作为统一导出,因为一个页面可能不只有一个 reducer,这样引用的时候就不用每一个都写一个 import 了,都从 index.ts 中统一导出就可以
// ./src/pages/Demo/store/index.ts
import { demoReducer } from "./demoReducer";
export { demoReducer };
然后我们分别创建一下客户端和服务器端的 store,将 reducer 导入一下,并且接入一下 thunk 的中间件,使得 dispatch 相关的函数支持异步函数的入参
// ./src/store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import thunk from "redux-thunk";
import { demoReducer } from "@/pages/Demo/store";
const clientStore = configureStore({
reducer: { demo: demoReducer.reducer },
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(thunk),
});
const serverStore = configureStore({
reducer: { demo: demoReducer.reducer },
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(thunk),
});
export { clientStore, serverStore };
接下来我们将创建好的 store 分别在客户端和服务器端的路由处注入一下就可以
// ./src/client/index.tsx
import { hydrateRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import router from "@/router";
import { clientStore } from "@/store";
import { Provider } from "react-redux";
const Client = (): JSX.Element => {
return (
<Provider store={clientStore}>
<BrowserRouter>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />;
})}
</Routes>
</BrowserRouter>
</Provider>
);
};
// 将事件处理加到ID为root的dom下
hydrateRoot(document.getElementById("root") as Document | Element, <Client />);
// ./src/server/index.tsx
import express from "express";
import childProcess from "child_process";
import { renderToString } from "react-dom/server";
import path from "path";
import router from "@/router";
import { Route, Routes } from "react-router-dom";
import { StaticRouter } from "react-router-dom/server";
import { Helmet } from "react-helmet";
import { serverStore } from "@/store";
import { Provider } from "react-redux";
const app = express();
const bodyParser = require("body-parser");
app.use(express.static(path.resolve(process.cwd(), "client_build")));
// 请求body解析
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// 启一个post服务
app.post("/api/getDemoData", (req, res) => {
res.send({
data: req.body,
status_code: 0,
});
});
app.get("*", (req, res) => {
const content = renderToString(
<Provider store={serverStore}>
<StaticRouter location={req.path}>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />;
})}
</Routes>
</StaticRouter>
</Provider>
);
const helmet = Helmet.renderStatic();
res.send(`
<html
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log("ssr-server listen on 3000");
});
childProcess.exec("start http://127.0.0.1:3000");
到这里 store 就已经注入好了,我们只需要在 Demo 中与 store 连接就行。connect 暴露了两个参数,一个 state,一个 dispatch,它会根据你的需要拼接成指定的参数,以装饰器的形式包装你定义的函数,这样我们的 Demo 就可以接收到我们定义的 content 和 getDemoData 参数了。
// ./src/pages/Demo/index.tsx
import { FC, useState, useEffect, Fragment } from "react";
import axios from "axios";
import { connect } from "react-redux";
import { getDemoData } from "./store/demoReducer";
import { Helmet } from "react-helmet";
interface IProps {
content?: string;
getDemoData?: (data: string) => void;
}
const Demo: FC<IProps> = (data) => {
// const [content, setContent] = useState("");
// // 客户端异步请求
// useEffect(() => {
// axios
// .post("/api/getDemoData", {
// content: "这是一个demo",
// })
// .then((res) => {
// setContent(res.data?.data?.content);
// });
// }, []);
return (
<Fragment>
<Helmet>
<title>简易的服务器端渲染框架 - DEMO</title>
<meta name="description" content="服务器端渲染框架"></meta>
</Helmet>
<div>
<h1>{data.content}</h1>
<button
onClick={(): void => {
data.getDemoData && data.getDemoData("刷新过后的数据");
}}
>
刷新
</button>
</div>
</Fragment>
);
};
const mapStateToProps = (state: any) => {
// 将对应reducer的内容透传回dom
return {
content: state?.demo?.content,
};
};
const mapDispatchToProps = (dispatch: any) => {
return {
getDemoData: (data: string) => {
dispatch(getDemoData(data));
},
};
};
const storeDemo: any = connect(mapStateToProps, mapDispatchToProps)(Demo);
export default storeDemo;
可以看到新增了对应的请求,对应展示的内容也切换为了刷新过后的数据,那这就意味着咱们 store的部分已经走通了,接下来咱们只需要考虑,应该怎样在服务器端进行请求,使得在 html 拼接的时候就可以拿到初始化的数据呢?
先来捋捋思路,首先我们肯定得先在服务器端拿到所有需要请求的函数,怎么透传过去呢?我们应该可以使用路由,因为客户端和服务端咱们都有配置路由,如果加一个参数通过路由把参数透传,然后在服务器端遍历,最后把结果对应分发是不是就可以了。不过这里有个小细节大家要注意一下,服务器端不同于客户端,它是拿不到请求的域名的,所以服务器端下的axios请求应该是包含域名的绝对路径,而不是使用相对路径,很多SSR的初学者在开发过程中很容易遇到类似问题 。
// ./src/pages/Demo/index.tsx
import { FC, useState, useEffect, Fragment } from "react";
import axios from "axios";
import { connect } from "react-redux";
import { getDemoData } from "./store/demoReducer";
import { Helmet } from "react-helmet";
interface IProps {
content?: string;
getDemoData?: (data: string) => void;
}
const Demo: FC<IProps> = (data) => {
// const [content, setContent] = useState("");
// // 客户端异步请求
// useEffect(() => {
// axios
// .post("/api/getDemoData", {
// content: "这是一个demo",
// })
// .then((res) => {
// setContent(res.data?.data?.content);
// });
// }, []);
return (
<Fragment>
<Helmet>
<title>简易的服务器端渲染框架 - DEMO</title>
<meta name="description" content="服务器端渲染框架"></meta>
</Helmet>
<div>
<h1>{data.content}</h1>
<button
onClick={(): void => {
data.getDemoData && data.getDemoData("刷新过后的数据");
}}
>
刷新
</button>
</div>
</Fragment>
);
};
const mapStateToProps = (state: any) => {
// 将对应reducer的内容透传回dom
return {
content: state?.demo?.content,
};
};
const mapDispatchToProps = (dispatch: any) => {
return {
getDemoData: (data: string) => {
dispatch(getDemoData(data));
},
};
};
const storeDemo: any = connect(mapStateToProps, mapDispatchToProps)(Demo);
storeDemo.getInitProps = (store: any, data?: string) => {
return store.dispatch(getDemoData(data || "这是初始化的demo"));
};
export default storeDemo;
对路由进行一下改造,将初始化的方法给路由带上
// ./src/router/index.tsx
import Home from "@/pages/Home";
import Demo from "@/pages/Demo";
interface IRouter {
path: string;
element: JSX.Element;
loadData?: (store: any) => any;
}
const router: Array<IRouter> = [
{
path: "/",
element: <Home />,
},
{
path: "/demo",
element: <Demo />,
loadData: Demo.getInitProps,
},
];
export default router;
接下来咱们就该在服务器端拉取对应的初始化方法,并统一请求注入它们了,这个过程很简单,我们只需要改造 get 方法就可以,遍历所有的初始化方法,然后统一请求塞进 store 里。
// ./src/server/index.tsx
import express from "express";
import childProcess from "child_process";
import path from "path";
import { Route, Routes } from "react-router-dom";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import { matchRoutes, RouteObject } from "react-router-dom";
import router from "@/router";
import { serverStore } from "@/store";
import { Provider } from "react-redux";
import { Helmet } from "react-helmet";
const app = express();
const bodyParser = require("body-parser");
// 请求body解析
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// 注入事件处理的脚本
app.use(express.static(path.resolve(process.cwd(), "client_build")));
// demo api
app.post("/api/getDemoData", (req, res) => {
res.send({
data: req.body,
status_code: 0,
});
});
app.get("*", (req, res) => {
const routeMap = new Map<string, () => Promise<any>>(); // path - loaddata 的map
router.forEach((item) => {
if (item.path && item.loadData) {
routeMap.set(item.path, item.loadData(serverStore));
}
});
// 匹配当前路由的routes
const matchedRoutes = matchRoutes(router as RouteObject[], req.path);
const promises: Array<() => Promise<any>> = [];
matchedRoutes?.forEach((item) => {
if (routeMap.has(item.pathname)) {
promises.push(routeMap.get(item.pathname) as () => Promise<any>);
}
});
Promise.all(promises).then((data) => {
// 统一放到state里
// 编译需要渲染的JSX, 转成对应的HTML STRING
const content = renderToString(
<Provider store={serverStore}>
<StaticRouter location={req.path}>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />;
})}
</Routes>
</StaticRouter>
</Provider>
);
const helmet = Helmet.renderStatic();
res.send(`
<html
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
</head>
<body>
<div id="root">${content}</div>
<script>
window.context = {
state: ${JSON.stringify(serverStore.getState())}
}
</script>
<script src="/index.js"></script>
</body>
</html>
`);
});
});
app.listen(3000, () => {
console.log("ssr-server listen on 3000");
});
childProcess.exec("start http://127.0.0.1:3000");
O(∩_∩)O,睡觉睡觉!