在早期 Web 时代,网站信息多以静态内容为主,其功能只能满足人们的阅读需求,网站页面几乎不与后台进行动态的数据交互;随着时代的发展,人们对于互联网的需求越来越丰富,静态内容的网站已经很难满足人们的冲浪需求,随之孕育而生的就是借助PHP、JSP、ASP.NET为代表的动态页面技术。
动态页面技术
在这个时期的前后端架构中,前端开发所承担的工作,大多以静态页面开发为主,很少涉及数据交互;而后端开发所承担的工作内容就相对较多了,除了后端服务的开发以外,还要在用户请求页面的时候,负责输出完整的静态页面,后端开发在前端提供静态页面的基础之上,利用模板引擎等技术渲染完整的页面并返回给用户,我们称这种技术为服务端渲染(Serve Side Render, SSR)。
Warning: render(): Calling ReactDOM.render() to hydrate server-rendered markup will stop working in React v17. Replace the ReactDOM.render() call with ReactDOM.hydrate() if you want React to attach to the server HTML.
本篇文章将介绍如何在 React 技术生态之上构建同构应用。也许在实际工作场景之中,我们并不需要开发同构应用,但是了解什么是同构应用并清楚其中的运行原理对我们的工作成长也是有很大帮助的。
什么是同构应用
每一项技术在被推向广大开发者面前的背后,都是为了解决实际业务场景中碰到的问题,在明白什么是同构应用之前,我们不妨先来了解一下前后端架构的演进过程。
前后端架构的演进
在早期 Web 时代,网站信息多以静态内容为主,其功能只能满足人们的阅读需求,网站页面几乎不与后台进行动态的数据交互;随着时代的发展,人们对于互联网的需求越来越丰富,静态内容的网站已经很难满足人们的冲浪需求,随之孕育而生的就是借助PHP、JSP、ASP.NET为代表的动态页面技术。
动态页面技术
在这个时期的前后端架构中,前端开发所承担的工作,大多以静态页面开发为主,很少涉及数据交互;而后端开发所承担的工作内容就相对较多了,除了后端服务的开发以外,还要在用户请求页面的时候,负责输出完整的静态页面,后端开发在前端提供静态页面的基础之上,利用模板引擎等技术渲染完整的页面并返回给用户,我们称这种技术为服务端渲染(Serve Side Render, SSR)。
在这种轻前端、重后端的技术背景之下,前后端工作耦合非常严重。前端高度依赖后端开发环境,后端同时又依赖前端开发的页面模板,显而易见的不好配合、效率低下。以至于很多公司甚至没有单独的前端开发岗位,后端开发即是全栈开发,效率反而更高效。
前后端分离
在上述前后端配合低效的问题之下,时间来到了 2008年9月2日,随着谷歌发布第一个版本的 Chrome,V8 Javascript 引擎的第一个版本随之一起发布。V8 引擎的发布引起了Ryan Dahl 的注意,Ryan 利用 Chrome 的 V8 引擎打造了基于事件循环的异步 I/O 框架 —— Node.js 诞生。2010 年 1 月,NPM 作为 Node.js 的包管理系统首次发布。随着 Node.js 的到来,给开发人员带来的无限想象,随之而来的是带动前后端分离的快速发展。
在前后端分离的技术架构下,单页面应用(Single Page Application, SPA) 得以流行,当用户请求页面的时候,服务端返回了一个空白的页面,然后通过加载 JavaScript 资源,在客户端完成页面的渲染 (Client Side Render, CSR)。这也是我们当前阶段最常见的一种开发方式。
同构应用
单页面应用虽然极大的提升了前后端的开发效率,但于此同时也会带来一些问题,比如:
为了解决上述问题,服务端渲染又再一次粉墨登场,但
同构!==服务端渲染
,简单的讲,同构是利用服务端渲染技术,在用户请求页面,服务端将依据渲染好的页面进行返回,之后仍然由客户端渲染完成用户在这个页面上完成的其他路由导航的一种技术,使同构应用既能解决单页应用的问题又同时能够带来单页应用的体验。同构的含义所在为前后端共用一部分代码,和我们现在所接触的react-native
、taro.js
、uni-app
有异曲同工之处。基于 React 构建同构应用
上面简单的介绍了下同构技术出现的背景,接下来我们将基于 React 来创建一个同构应用,并在此过程逐步了解 React 同构的实现原理。
1. 最简单的服务端渲染
我们先来看一下如何通过 node.js 和 ejs 模板引擎如何实现一个最简单的服务端渲染:
创建一个模板
借助 ejs 模板引擎进行渲染
http .createServer((req, res) => { if (req.url === '/') { res.writeHead(200, { 'Content-Type': 'text/html', });
}) .listen(9981);
我们先通过 babel 将
Home
组件编译成以下内容:React 通过
createElement
将组件转换成一个对象(虚拟DOM),在 SPA 应用中,如果我们想将Home
组件渲染到页面上,就得借助react-dom
:render 方法会创建所需要的标签完成渲染,但在服务端,我们无法访问到
document
这个浏览器特有的对象,就自然无法调用document.createElement
,所以其实跟 ejs 这类模板引擎做的事一样,我们需要将组件渲染成 html 字符串,服务端才能直出页面,所以我们借助renderToString
这个方法:除了
renderToString
之外,ReactDomServer 还提供了renderToStaticMarkup
、renderToNodeStream
、renderToStaticNodeStream
另外三个 API,详细介绍请查看官网说明。以上是 React 服务端渲染的简单介绍,接着我们稍微改造一下示例代码,来演示一下 React 同构中客户端渲染的这一部分。
打开页面,会在控制台看到这样一处警告:
我们先忽略这个问题接着看。
在这段代码里边我们不再通过
ReactDOM.render
而是通过ReactDOM.hydrate
方法来渲染页面,接着我们再来改造一下示例(将客户端渲染的部分注释掉):这时候重新运行服务并查看页面,会发现,我们绑定在
button
上边的点击事件根本不生效。以上发现三点问题:
第一个问题我们放到下面的章节在进行回答,这里先回答 2,3 两个问题。
其实第二个问题上面已经解释过了,服务端无法访问浏览器的特定对象,自然而然就无法进行事件绑定。
再来看看
React.hydrate
这个 API:通俗地讲
hydrate
就是注水的意思(给干巴巴的海绵加点水,又变成活力十足的海绵宝宝了)。而相对应注水的另外一个意思就是脱水,很显然renderToString
就是脱水的过程,所以从官网的文档我们也可以看到:所以
hydrate
和render
的区别就在于hydrate
在客户端执行的时候并不会再进行一次渲染,只是进行事件的处理绑定,而render
会在客户端再进行一次渲染,进而导致性能的浪费,如果我们在同构应用中使用render
方法,会看到以下警告:现在让我们带着 服务端和客户端渲染的节点内容不一样怎么解决? 问题进入下一章节。
3. 基于 webpack 构建同构应用
webpack 是前端工程化中的重要一环,在这里我们也将借助 webpack 来构建我们的同构应用。在开始之前,先简单介绍一下这其中的流程:
3.1 目录结构
先来了解示例中使用到的目录结构如下:
3.2 package.json
(dev)Dependencies
我们利用以下技术栈来实现一个同构应用:
scripts
示例代码通过执行
npm run dev
启动我们的服务器。3.3 webpack 的配置
我这边精简掉了很多配置,因为这篇文章的主要目的是了解 React 同构的原理。
webpack 提供几种可选方式,帮助你在代码发生变化后自动编译代码:
这边为了精简配置使用 watch 观察模式来自动编译我们的代码,同时热更新也不在本篇文章讨论范围之内。
webpack.client.js
客户端的 webpack 配置,同我们平常开发的 SPA 应用没什么大不同,但有以下几点区别:
express.static
的地址webpack.server.js
服务端的 webpack 配置注意以下几点:
node
3.4 同构实现
我们先来看看服务端的入口文件:
这边使用到两个中间件:
/static
路径时,使用 express.static 中间件,上边有提到,因为在直出 html 文件的时候需要注入客户端编译后的资源文件,需要配置静态服务才能正确访问到资源我们在回顾一下前边提到的同构的实现流程:
1. 如何维护双端路由?
实际项目中不可能只有一个页面,那如何维护双端路由,使我们在访问页面的时候,服务端知道我们是访问哪个页面输出对应 html,之后客户端的路由也能正确跳转呢?
在这里我们借助
react-router-dom
来维护我们双端的路由。 首先我们在声明一份路由配置:并在同构入口
App.js
中使用它:接着再来看两端如何维护路由,先看服务端,在
reactSSR
的代码中,可以看到以下这段代码对于服务端来说,路由永远只有首屏这个页面,也就是说服务端打包的只会是一个页面的代码,所以这里我们通过
react-router-dom
的StaticRouter
并配合请求地址req.url
来渲染正确的页面。在访问不存在页面的时候,应该设置正确的 http-code,这里我们通过
staticContext
我们解决 404 的问题。客户端方面就和 SPA 应用没有任何区别了:
如何解决页面的 SEO 问题
解决页面的 SEO 问题,就是在服务端直出 html 的时候,在 html 的 header 标签中添加
title
、description
、keywords
(简称tdk
)。这边我们借助
react-helmet-async
,使其可以在书写页面代码的时候声明tdk
。在看两端的实现:
利用
React Context
的 API,在服务端渲染的时候,可以往helmetContext
中写入我们在页面设置的tdk
,最后从helmetContext
中渲染相关的内容:讲到 HTML 组件,顺便讲下如何利用 manifest.json 文件正确插入客户端资源文件:
其中 initialChunks 的值由 webpack 配置所决定,以下配置所对应的 chunk 都是 initialChunks:
chunks: all
的所有 chunk解决数据预期问题,保证双端渲染的节点一致
在第二个示例的时候演示两端节点不一致的问题,涉及到数据预取,有两个以下点要考虑:
两个问题说开了其实也是一个问题,和上面解决 SEO 问题的思路一样,我们尽量在页面代码中把这个数据预取的逻辑给写了,避免双端再各自维护,我们期望使用以下的方式进行数据预取:
并且 getInitialProps 应该在:
先看服务端如何处理组件的
getInitialProps
方法:使用 textarea 在避免被 XSS 的风险的同时,讲服务端预取到的数据设置到客户端中,客户端代码实现如下:
最后来看下 withSSR.js 的实现:
withSSR.js
是一个高阶组件,只能作用于页面组件,因为服务端无法获取到页面组件下的子组件,所以getInitialProps
只能在页面组件中声明。至此,我们就完成了一个 React 同构应用。
参考文章