前言
React应用同构可以达到更好的用户体验
以及SEO
,但是过程也较为繁杂,代码难以解耦,至于项目是否需要同构我认为就看对应的场景及投入和产出了,本文不对此做赘述
关于同构(Isomorphic),网上说法颇多,我个人认为是一份代码,服务端进行渲染,拼接html及初始化数据,浏览器端拿到html及js后在浏览器插入初始化的数据和挂载相关监听事件
关于isomorphic,也有人认为应该为Universal Rendering(看自知乎),本文将均使用"同构"一词。
此文将记录本人React应用———一个个人博客
进行同构的实践、SSR效果,以及过程中所遇到的问题及解决方案,此链接为同构后上线的个人博客周立涵的博客
在参考大量的文章之后做出了此次改写,React的生态确实繁荣,方案颇多,此次实践采用的方案是同构
,同时写了两套webpack,即一套React代码,两套webpack配置(分别针对浏览器端与服务端),服务端使用express进行渲染,夹带浏览器端js代码返回前端
,本质上还是一套代码
由于是首次改写同构,有建议之处还请大佬指出
此文将按以下顺序进展:
- 同构效果对比
- React同构原理
- 同构过程遇到的问题
- 实践中的步骤或问题
- 服务端webpack配置
- server/index.js结构编写
- 服务端renderToString,浏览器端hydrate
- 定制主题的Ant Design处理
- react-router处理
- Ajax数据请求处理
- DOM处理
同构前后的效果对比
为单纯对比性能,排除网络环境影响,测试在本地开发环境下进行
同构前
FMP指标截图:
具体指标:
FP | FCP | FMP | DCL | L |
---|---|---|---|---|
1766.5ms | 1766.5ms | 1950.1ms | 1706.1ms | 4400.2.5ms |
同构后
FMP指标截图:
具体指标:
FP | FCP | FMP | DCL | L |
---|---|---|---|---|
403.8ms | 403.8ms | 403.8ms | 1343.5ms | 2442.5ms |
同构前后FMP降低了1546.3ms!!!
同时也肉眼可见的首屏速度有显著的提升
在不同环境下测试的结果会有差异,但基本每次测试,同构后都比同构前的FMP降低了1000+ms!
React 同构原理
React 同构简单地说即是在服务端renderToString拼接HTML字符串,请求初始化数据,返回浏览器HTML及相关静态文件(如js、css等等),在浏览器端执行React代码,插入初始化数据及挂载相关的监听事件(hydrate)
此处强烈推荐此文,非常清晰地阐述了SSRServer-Side Rendering with React, Redux, and React-Router,下图也摘自文中
应用目录结构
本博客应用使用了React + React Router + Ant Design + CSS Module
同构前的React应用结构如下
│ .babelrc
│ package-lock.json
│ package.json
│ postcss.config.js
│ webpack.common.js
│ webpack.dev.js //开发环境webpack
│ webpack.prod.js //生产环境webpack
│
├─public
│ favicon.ico
│ index.html
└─src
│ App.css
│ App.jsx
│ App.module.css
│ index.css
│ index.js //浏览器端入口文件
│
├─components //组件目录
│
├─container //应用视图目录
│
└─source //项目静态资源
同构前通过src/index.js
进行浏览器端的挂载
配合express
进行同构后目录如下
│ .babelrc
│ package-lock.json
│ package.json
│ postcss.config.js
│ server.js //编译后的服务端代码
│ webpack.common.js
│ webpack.dev.js
│ webpack.prod.js //浏览器端webpack
│ webpack.server.js //服务端webpack
│
├─dist //编译后的客户端代码
│
│
└─src //项目源码
├─browser //浏览器端入口文件
│ index.js
│
├─server //服务端入口文件
│ index.js
│
└─shared //共享目录
│ App.css
│ App.jsx
│ App.module.css
│ index.css
│ routesConfig.js
│
├─components
│
├─container
│
└─source
同构后,浏览器访问express
接口,express返回html字符串及编译后的客户端代码(dist目录下的静态资源),浏览器端挂载事件及初始化数据
目录结构的文件关系可以如下表示:
因为每次访问端口都依赖于静态资源,所以每次修改代码查看效果都需要webpack重新编译,开发过程中相对麻烦。
目前的想法是通过配置webpackDevServe在本地服务器上打通资源访问,以支持热更新,后续再研究
本应用中将dist作为静态资源目录,其中包含webpack打包后的js、css,lessc编译后的css文件等等
同构过程遇到的问题
综上,本项目使用了express + React + React Router + Ant Design + CSS Module,再加上一些简单的逻辑代码(涉及DOM的懒加载、数据请求)
我同构的步骤及解决的问题如下:
- 服务端webpack配置——ESM、JSX、CSS Module
- server/index.js结构编写
- 服务端renderTostring,浏览器端hydrate
- 抽取自定义样式的Ant Design为单独的CSS文件
- react-router处理——使前后端均可路由
- Ajax数据请求处理——使后端返回已初始化数据的html
- DOM处理——Node端运行js无DOM(document)、Window(window, navigator等)、Storage(localStorage, sessionStorage等)对象
接下来将按照顺序阐述步骤缘由,及采用了什么方案
1.服务端webpack配置
js可以跑在浏览器和node上,但是也存在些许的环境差异
(比如是否有DOM),所以需要针对服务端(即node)进行webpack的配置
本项目主要针对了以下内容进行配置
webpack target属性——说明打包后的js运行环境为node而不是浏览器
ESM——node(v10.15.3)原生不支持import及export
JSX
CSS Module——使得在node端完成对className的hash
前三项内容可以说是必须项,最后一项得看对应项目技术栈
对于说明打包后的js运行环境为node,直接在webpack配置中设置target
属性即可:
...
entry: "./src/server/index.js",
target: "node",
...
对于ESM、JSX及CSS Module,node端同浏览器端一样根据需要配置webpack module
2.server/index.js结构编写
我们通过访问express的API,返回对应的html,在此写出server/index.js的基本结构
import express from "express";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import React from "react";
import App from "../shared/App";
const app = express();
app.use(
express.static("dist", {
index: false
})
);//指定端口使用的静态资源目录,dist目录下放的是打包后的客户端静态资源;index为false表示关闭index.**文件索引
app.get("路径", (req,res)=>{
/**
* 客户端访问接口,接口返回html
**/
const reactComhtml = ...;//react元素获取html字符串,reactComhtml变量值见下文
const theHtml = ...;//拼接成完整的html,theHtml变量值见下文
res.setHeader("Content-Type", "text/html");//设置响应头
res.send(theHtml); //返回html
});
3.服务端renderToString,浏览器端hydrate
这里介绍两个API,renderToString与hydrate
renderToString在服务端将React元素渲染为初始的HTML
// server/index.js
const reactComHtml = ReactDOMServer.renderToString(
<StaticRouter>
<App reqPathname={req.path} />
</StaticRouter>
);
const theHtml = `
<html>
<head>
...
</head>
<body>
<div id="App">${reactComHtml}</div> //拼接上html字符串
<script src="{...静态资源目录}/main.js" charset="utf-8"></script> //携带webpack打包后的浏览器端静态资源,比如js文件
</body>
</html>
`;
hydrate在浏览器端对存在元素挂载监听事件
。React希望服务端渲染的内容与浏览器渲染的内容一致。同时,React可以对文字内容
的不一致进行修补,但我们最好不要人为地引入不一致,而且这一修补机制并不能保证完全正确。在开发环境下,若发现内容不一致,React会发出警告,比如Warning: Expected server HTML to contain a matching <div> in <div>.
。当然,造成这一警告的原因也有很多,比如本应用中使用了react-markdown
组件,对markdown文本进行了处理,使得服务端与客户端部分内容不一致,开发环境下就抛出了警告,关于hydrate更多可查看官网说明hydrate
// brpwser/index.js
const app = document.getElementById("App");
app? ReactDOM.hydrate(
<BrowserRouter>
<App />
</BrowserRouter>,app) : false;
由此,服务端可以输出初始化的HTML(此处还未填充初始化数据),浏览器挂载事件供用户进行交互操作,以此`提升了SEO`和`极大地缩短了首屏白屏时间`
4.定制主题的Ant Design处理
本项目使用了Ant Design,并且配置了babel-plugin-import进行按需加载,所以若进行定制主题,则需要将babel-plugin-import的style
配置改为true,但是这就导致了antd样式没有作为单独的CSS文件抽取出来,而是通过js运行,在DOM中插入<style>
标签形成样式。
这在CSR中是没有问题的,因为在插入<style>标签之前用户看不到网页内容,但是现在用户打开网页第一眼就能看到完整的HTML内容,js运行后才插入<style>样式,就会造成页面样式“突变”的效果。
于是我将babel-plugin-import中的style
的值设置为false
,样式将不会通过js生成style标签插入。
对于css样式,antd使用less
作为开发语言,其中定义了一些样式变量,可以使用lessc生成.css文件
,作为静态资源使用
//比如此处修改了两个样式变量@primary-color与@link-hover-color
lessc --js --modify-var=@primary-color=#ffc34e --modify-var=@link-hover-color=#ffc34e ./node_modules/antd/dist/antd.less > dist/ant.css
其中dist为静态资源目录
至此,自定义的样式文件在返回的html中引用即可
const theHtml = `
<html>
<head>
<title>${title}</title>
<link rel="shortcut icon" href="${...静态资源目录}/favicon.ico">
<link rel="stylesheet" type="text/css" href="${...静态资源目录}/ant.css"> //引用antd样式文件
<link rel="stylesheet" type="text/css" href="${...静态资源目录}/main.css">
...
</head>
<body>
<div id="App">${reactComHtml}</div>
<script src="${...静态资源目录}/main.js" charset="utf-8"></script>
</body>
</html>
`;
5.react-router
在CSR下,路由交给前端控制,后端没有对应的路由,所以如果前端进入了某个路径,刷新了网页,后台会返回404。举个例子:
某项目为CSR,使用了react-router,主页为www.somebody.com,我们浏览器进入了www.somebody.com/test,此时刷新,或者将此链接分享给好友,返回的都会是404,因为后台对应路径下并不存在html文件或者服务。StackOverflow上对此有多种解决方案
同构也能解决此问题。
在服务端,我们使用进行路由,浏览器同样是使用
// server/index.js
const reactComHtml = renderToString(
<StaticRouter location={req.url}>
<App />
</StaticRouter>
);
const theHtml = `
<html>
<head>
<title>Home</title>
<link rel="shortcut icon" href="${...静态资源目录}/favicon.ico">
<link rel="stylesheet" type="text/css" href="${...静态资源目录}/ant.css">
<link rel="stylesheet" type="text/css" href="${...静态资源目录}/main.css">
...
</head>
<body>
<div id="App">${reactComHtml}</div>
<script src="/main.js" charset="utf-8"></script>
</body>
</html>
`;
// browser/index.js
const app = document.getElementById("App");
app ? ReactDOM.hydrate(
<BrowserRouter>
<App />
</BrowserRouter>,app): false;
通过StaticRouter的location
属性填入路径,我们能获得对应的路由html,拼接成html返回给浏览器
6.Ajax数据请求处理
前后端分离下前端基本通过ajax请求向后端拉取数据,而SSR下我们希望返回的html包含初始数据。
那这里有两个问题:a.如何在服务端请求数据?b.如何将数据进行呈现?
a.如何在服务端请求数据
同样是通过Ajax向端口请求数据,但是要注意异步与环境问题。
对于异步,当需要请求多个接口
时,需要做好对异步的处理,保证能将请求的数据传给html,比如可以使用Promise进行处理
对于环境问题,要注意使用的Ajax库必须能跑在node上,而不是只能跑在浏览器环境上。我在这里使用的是axios
app.get("路径",(req, res)=>{
axios.get(url)//ajax请求接口
.then((response)=>{//获取数据
const data = response.data;
const context = data;
//如何呈现见下文
const reactComHtml = ...;
const theHtml = ...;
res.setHeader("Content-Type", "text/html");
res.send(theHtml);
})
})
在通过Ajax获取数据后,我们就要进行如何将数据呈现的步骤了
b.如何将数据进行呈现
如何将数据进行呈现有非常多的方案,此处本人的方案是StaticRouter的context属性 + 自定义window.__ROUTE_DATE__属性
进行(这里的__ROUTE_DATA__属性是自定义的,你可以使用任何与window对象不冲突的属性)
补充上文的代码
// server/index.js
const reactComHtml = renderToString(
<StaticRouter location={req.url} context={context}>
<App reqPathname={req.path} />
</StaticRouter>
);
const theHtml = `
<html>
<head>
<title>${title}</title>
<link rel="shortcut icon" href="${...静态资源目录}/favicon.ico">
<link rel="stylesheet" type="text/css" href="${...静态资源目录}/ant.css">
<link rel="stylesheet" type="text/css" href="${...静态资源目录}/main.css">
<script>window.__ROUTE_DATA__ = ${JSON.stringify(context)}</script> //以字符串的形式传递给html,在浏览器端使用此数据
</head>
<body>
<div id="App">${reactComHtml}</div>
<script src="${...静态资源目录}/main.js" charset="utf-8"></script>
</body>
</html>
`;
// 举例:shared/container/Article/index.jsx /article/:articleId路由的对应组件
class Article extends Component {
constructor(props) {
super(props);
const staticContext = props.staticContext;
if (staticContext) {
//S端运行
const { title, content, time, tag } = staticContext;
this.state = {
title: title,
content: content,
time: time,
tag: tag
};
} else {
//B端运行
const data = window.__ROUTE_DATA__;
this.state = {
content: data.content,
title: data.title,
time: data.time,
tag: data.tag
};
delete window.__ROUTE_DATA__;
}
}
...
...
运行于服务端,props.staticContext也仅存于服务端
服务端上通过staticContext取值,renderToString输出HTML字符串
浏览器端上通过window.__ROUTE_DATA__取值,hydrate对已存在的元素挂载监听事件
7.对DOM
由于node上不存在DOM、Window、Storage对象,所以需要进行判断处理,本应用举例对DOM的处理
封装函数判断是否在浏览器环境
const isBrowser = ()=>{
return typeof window !== "undefined";
}
export default isBrowser;
在对应的涉及到DOM的代码中,通过isBrowser判断当前是否为浏览器环境,是则执行相应操作,不是则不执行
constructor(props) {
super(props);
//举例
if (isBrowser() === true) {
this.handleDom = this.handleDom.bind(this);
this.handleDom();
}
}
结语
经过此次实践后,对同构、服务端渲染、node、React、react router等有了更清楚的认识和了解,也着实体会到了SSR带来的首屏速度提升。但是同构过程也相对繁琐,本次改写仅仅是一个非常简单的博客应用,也暂时不存在性能问题,若是复杂应用则必定会是大工程。
SSR带来的好处就是SEO
及首屏速度的提升
,对原应用进行同构的过程也着实繁琐。现阶段,需不需要将CSR改为SSR私以为得看具体场景,得看投入和产出是否值得。
未来,随着浏览器的升级、搜索引擎爬虫的进步、网络的提升,是否能直接解决CSR在SEO和首屏速度的问题呢?可是这未来会不会特别远呢?会不会又有新的方案解决呢?未来可真是令人好奇
以上有不当之处请大佬指出。
参考资料
本次学习参考了比较多的文章,很多实现方法各有不同,也存在着对应的可以优化的问题。
什么是前端同构——知乎
Server-Side Rendering with React, Redux, and React-Router(强烈推荐此文)
User-centric Performance Metrics(包含性能指标阐述)
Ant Design定制主题
react-router官网——Server Rendering
Stack Overflow React-router urls don't work when refreshing or writing manually
An Introduction to React Server-Side Rendering
Using React Router 4 with Server-Side Rendering(浏览器端上,数据初始化放在了不是特别恰当的生命周期)
YouTube——ReactCasts #12 - Server Side Rendering
React Server Side Rendering with Express(比较推荐的入门实践文章,但是中间多了不必要的大胡子语法)
Tips for server-side rendering with React