实现ssr服务端渲染

前言

前段时间寻思做个个人网站,然后就立马行动了。  个人网站如何实现选择什么技术方案,自己可以自由决定。  刚好之前有大致想过服务端渲染,加载速度快,还有 SEO 挺适合个人网站的。  所以就自己造了个轮子用 koa+react 来实现 SSR 服务端渲染。

什么是SSR

最初听说有单页面的服务端渲染的时候,就理解为类似传统的服务端路由+模板渲染,只是需要用单页面应用的框架写。后面寻思这样好像有点傻,再一了解,原来只是在首次加载的时候,后端进行当前路径页面的组件渲染和数据请求,组装成HTML返回给前端,用户就能很快看到看到页面,当 HTML 中的 JS 资源加载完成后,剩下执行和运行的就是一般的单页面应用。  

所以SSR是后端模板渲染和单页面的组合。  SSR有两种模式,单页面和非单页面模式,第一种是后端首次渲染的单页面应用,第二种是完全使用后端路由的后端模版渲染模式。他们区别在于使用后端路由的程度。

优势

SSR的两个明显的优势:首次加载快和 SEO。  为什么说首次加载快呢。  一个普通的单页面应用,首次加载的时候需要把所有相关的静态资源加载完毕,然后核心JS才会开始执行,这个过程就会消耗一定的时间,接着还会请求网络接口,最终才能完全渲染完成。

SSR模式下,后端拦截到路由,找到对应组件,准备渲染组件,所有的 JS 资源在本地,排除了JS资源的网络加载时间,接着只需要对当前路由的组件进行渲染,而页面的 ajax 请求,可能在同一台服务器上,如果是的话速度也会快很多。最后后端把渲染好的页面反回给前端。 

注意:页面能很快的展示出来,但是由于当前返回的只是单纯展示的 DOM、CSS,其中的 JS 相关的事件等在客户端其实并没有绑定,所以最终还是需要 JS 加载完以后,对当前的页面再进行一次渲染,称为同构。  所以SSR就是更快的先展示出页面的内容,先让用户能够看到。  

为什么SEO友好呢,因为搜索引擎爬虫在爬取页面信息的时候,会发送 HTTP 请求来获取网页内容,而我们服务端渲染首次的数据是后端返回的,返回的时候已经是渲染好了 title,内容等信息,便于爬虫抓取内容。


如何实现

大致对SSR有了一个了解,我们现在需要对实现整理一下大致实现思路和流程。 

1.选择一个单页面框架(我目前选择的是react)

2.选择node服务端框架(我目前选择的是koa2)

3.实现核心逻辑,让node服务端能够路由和渲染单页面组件(这一点分为很多小实现点,后面说)

4.优化开发和发布环境自动化构建工具(webpack)


开始实现之前创建一个 react-ssr 项目,项目下创建 client 和 server 目录用于写客户端和服务端代码,webpack 目录用于 weppack 文件配置。

1.react应用

安装 react 依赖,在 client 中创建好一个基础的 react 文件夹结构,并写好一个可以运行的有路由配置的应用,client 文件目录如下:

 

2.server应用

安装 koa 和相关依赖,在 server 中创建好一个基础的服务端文件夹结构,并写好一个简单的可运行的后端应用服务。 server 文件夹如下:

 

3.核心实现

因为有仓库代码就不对基础代码做解释,现在我们有一个可以单独运行的 react 单页面应用和一个后端应用,他们都有各自的路由。接下来我们做改造,实现SSR的单页面模式(非单页面模式仅仅是做部分调整,因此这里只讲实现单页面模式)。

核心实现分为以下几步:
  • 1) 后端拦截路由,根据路径找到需要渲染的react页面组件X
  • 2)调用组件X初始化时需要请求的接口,同步获取到数据后,使用 react 的 renderToString 方法对组件进行渲染,使其渲染出节点字符串。
  • 3)后端获取基础 HTML 文件,把渲染出的节点字符串插入到 body 之中,同时也可以操作其中的 title,script 等节点。返回完整的 HTML 给客户端。
  • 4)客户端获取后端返回的 HTML,展示并加载其中的 JS,最后完成 react 同构。
 

1)我们在客户端写 react 的时候,router 常规的会定义一个数组,存放组件和对应的 path,然后注册路由,如下:
 

 

上面说过,实现SSR就是实现单页面应用+首次服务端渲染,所以我们本身就是做的一个单页面应用。  现在实现了单页面应用,需要实现首次服务端渲染。 

服务端的应用启动以后,接受到url请求,比如访问 http://localhost:9999/ ,后端服务获取到当前的 path 为  ‘/’ ,这个时候我们就希望后端找到配置 path 为 ‘/’ 的上图的Index组件,对其进行渲染。  我们在 client 的 router 文件夹中建立两个 JS 文件 index 和pages:

 

pages 里配置路由路径和组件的映射,代码大致如下,使其能被客户端路由和服务端路由同时使用。

 

 

在 server 路由中代码大致是这样的,在服务端获取到 get 请求以后,匹配路径,如果路径 path 是有映射页面组件的,获取到此组件并渲染,这就是我们的第一步:后端拦截路由,根据路径找到需要渲染的 react 页面组件。  

 

2)如上图,匹配到组件以后,执行了组件的 getInitialProps 方法(和 nextjs 的命名保持一致),此方法是一个封装的静态方法,主要用于获取初始化所需要的 ajax 数据,在服务端会同步获取,而后通过 ssrData 参数传入组件 prorps 并执行组件渲染。  此方法在客户端依然是异步请求。 

这一步比较重要,为什么我们需要一个静态方法,而不是直接把请求写在 willmount 中呢。  因为在服务端使用 renderToString 渲染组件时,生命周期只会执行到 willmount 之后的第一次 render,在 willmount 内部,请求是异步的,第一次 render 完成的时候,异步的数据都没有获取到,这个时候 renderToString 就已经返回了。  那我们页面的初始化数据就没有了,返回的 HTML 不是我们所期望的。 

因此定义了一个静态方法,在组件实例化之前获取到这个方法,同步执行,数据获取完成后,通过 props 把数据传入给组件进行渲染。  那么这个方法是如何实现的呢?  我们根据代码截图来看 base.js:

 

首先在 client 的 pages 里新建一个 base 组件,base 继承 React.Component,所有 pages 里的页面组件都需要继承这个 base,base 有一个静态方法 getInitialProps,此方法主要是返回组件初始化需要的异步数据。  如果有初始化的 ajax 请求,就应该重写在此方法里,并且 return 数据对象。 

constructor 判断了页面组件是否有初始化定义的 state 静态属性,有的话传递给组件实例化的 state 对象,如果 props 有传入 ssrData,把 ssrData 传递值给组件 state 对象。 

base 中的 componentWillMount 会判断是否还需要去执行 getInitialProps 方法,如果在服务端渲染的时候,数据已经在组件实例化之前同步获取并传入了 props,所以忽略。 

如果在客户端环境,分两种情况

第一种:用户第一次进到页面,这时候是服务端去请求的数据,服务端获取到数据后在服务端渲染组件,同时也会把数据存放在 HTML 的 script 代码中,定义一个全局变量 ssrData,如下图,react 在注册单页面应用并且同构的时候会把全局 ssrData 传递给页面组件,这个时候页面组件在客户端同构渲染的时候,就可以延续使用服务端之前的数据,这样也保持了同构的一致性,也避免了一次重复请求。 

第二种情况:就是当前用户在单页面之中切换路由,这样就没有服务端渲染,那么就执行 getInitialProps 方法,把数据直接返回给 state,几乎等同于在 willmount 中执行请求。  这样封装我们就可以用一套代码兼容服务端渲染和单页面渲染。

 

 

client/app.js 

 

再看看如何写页面组件,下面是页面组件 Index 的截图,Index 继承 Base,定义了静态 state,组件 constructor 方法会把此对象传递给组件实例化的 state 对象中,之所以用静态方法来写默认数据,是想保证定义的默认 state 先传递给实例对象的 state,接口请求传递的 props 数据后传递给实例对象的 state。  

为什么不直接写 state 属性而要加 static,因为 state 属性会执行在 constructor 之后,这样会覆盖 constructor 定义的 state,也就是会覆盖我们 getInitialProps 返回的数据。

 

注意:在服务端渲染环境下,执行 renderToString 的时候,组件会被实例化,并且返回字符串形式的 DOM,这个过程react组件的生命周期只会执行到 willmount 之后的 render。

3)我们写好一个 HTML 文件,大致如下。  当前已经渲染出了相应的节点字符串,后端需要返回 HTML 文本,内容应该包含标题,节点和最后需要加载的打包好的 JS,依次去替换 HTML 占位部分。  

 
index.html

 

server/router.js

  

4)最后客户端 JS 加载完成后,会运行 react,并且执行同构方法 ReactDOM.hydrate,而不是平时用的 ReactDOM.render。
 

 

以下是首次渲染过程大致流程图,点击查看大图

 

CSS处理

现在我们已经完成了最核心的逻辑,但是有一个问题。  我发现在后端渲染组件的时候,style-loader 会报错,style-loader 会找到组件依赖的 CSS,并在组件加载时,把 style 载入到 html header 中,但是我们在服务端渲染的时候,没有 window 对象,因此 style-loader 内部代码会报错。   

服务端 webpack 需要移除 style-loader,用其他方法代替,后来我把样式赋值给组件静态变量,然后通过服务端渲染一并返回给前端,但是有个问题,我只能拿到当前组件的样式,子组件的样式没办法拿到,如果要给子组件再添加静态方法,再想办法去取,那就太麻烦了。 

后来我找到了一个库 isomorphic-style-loader 可以支持我们想要的功能,看了下它的源码和使用方法,通过高阶函数把样式赋值给组件,然后利用 react 的 Context,拿到当前需要渲染的所有组件的样式,最后把 style 插入到 HTML 中,这样解决了子组件样式无法导入的问题。 但是我觉得有点麻烦,首先需要定义所有组件的高阶函数和引入这个库,然后在 router 之中需要写相关代码收集 style,最后插入到 HTML 中。  

之后我定义了一个 ProcessSsrStyle 方法,入参是 style 文件,逻辑是判断环境,如果是服务端把 style 加载到当前组件的 DOM 中,如果是客户端就不处理(因为客户端有 style-loader)。  实现和使用非常简单,如下:

ProcessSsrStyle.js

 

使用:

 

服务端返回 HTML 的内容如下,用户马上能够看到完整的页面样式,而当客户端 react 同构完成后,DOM 会被替换为纯 DOM,因为 ProcessSsrStyle 方法在客户端不会输出 style,最终 style-loader 执行后 header 中也会有样式,,页面不会出现不一致的变化,对于用户来说这一切都是无感的。

 

 

 

至此,最核心的功能已经实现,但是在后来的开发中,我发现事情还并没有那么简单,因为开发环境似乎太不友好了,开发效率低,需要手动重启。 

 

开发环境

先说说最初的开发环境如何工作:

  • npm run dev 启动开发环境
  • webpack.client-dev.js 打包服务端代码,代码会被打包到 dist/server中
  • webpack.server-dev.js 打包客户端代码,代码会被打包到 dist/client中
  • 启动服务端应用,端口 9999
  • 启动 webpack-dev-server, 端口 8888

webpack 打包后,启动了两个服务,一个是服务端的app应用、端口为 9999,一个是客户端的 dev-server、端口为 8888,dev-server 会监听和打包 client 代码,可以在客户端代码更新的时候,实时热更新前端代码。  当访问 localhost:9999 时,server 会返回 HTML,我们的 server 返回的 HTML 中的 JS 脚本路径是指向的 dev-serve 端口的地址,如下图。  也就是说,客户端的程序和服务端的程序被分别打包,并且运行两个不同的端口服务。   

在生产环境下,因为不需要 dev-server 去监听和热更新,因此只一个服务就足够, 如下图,服务端注册静态资源文件夹:

server/app.js

 

目前的构建系统,区分了生产环境和开发环境,现在的开发环境构建是没有什么问题的。  但是开发环境问题就比较明显,存在的最大问题是服务端没有热更新或者重新打包重启。  这样会导致很多问题,最严重的就是前端已经更新了组件,但是服务端并没有更新,所以在同构的时候会出现不一致,就会导致报错,有些报错会影响运行,解决办法只有重启。  这样的开发体验是无法忍受的。  后来我开始考虑做服务端的热更新。

 

监听、打包、重启

最初我的方法是监听修改,打包然后重启应用。  还记得我们的 client/router/pages.js 文件吗,客户端和服务端的路由都引入了这个文件,所以服务端和客户端的打包依赖都有 pages.js,因此所有 pages 的组件相关的依赖都可以被客户端和服务端监听,当一个组件更新了,dev-server 已经帮助我们监听和热更新了客户端代码,现在我们要自己来处理以下如何更新和重启服务端代码。 

其实方法很简单,就是在服务端打包配置里开启监听,然后在插件配置中,写一个重启的插件,插件代码如下:

 

 

当 webpack 首次运行之后,插件会启动一个子进程,运行 app.js,当文件发生变动后,再次编译,判断是否有子进程,如果有杀掉子进程,然后重启子进程,这样就实现了自动重启。  因为客户端和服务端是两个不同的打包服务和配置,当文件被修改,他们同时会重新编译,为了保证编译后运行符合预期,要保证服务端先编译完成,客户端后编译完成,所以在客户端的 watch 配置里,增加一点延迟,如下图,默认是300毫秒,所以服务端是300毫秒后执行编译,而客户端是1000毫秒后执行编译。

 

现在解决了重启问题,但是我觉得还不够,因为在开发的大部分时间里 pages.js 中组件,也就是展示端的代码更新频率会很高,如果老是去重启编译后端的代码,我觉得效率太低。  因此我觉得再做一次优化。

 

抽离client/router/pages单独打包

流程应该是这样的,增加一个 webpack.server-dev-pages.js 配置文件,单独监听和打包出 dist/pages,服务端代码判断如果是开发环境,在路由监听方法中每次执行都重新获取 dist/pages 包,服务端监听配置忽略 client 文件夹。 

看起来有点懵逼,其实最终的效果就是当 pages 中依赖的组件发生了更新,webpack.server-dev-pages.js 重新编译并打包到 dist/pages 中,服务端 app 不编译和重启,只需要在服务端 app 路由中重新获取最新的 dist/pages 包,就保证了服务应用更新了所有客户端组件,而服务端应用并不会编译和重启。  当服务端本身的代码发生了修改,还是会自动编译和重启。  所以最终我们的开发环境需要启动3个打包配置

  • webpack.server-dev-pages
  • webpack.server-dev
  • webpack.client-dev

 

server/router,如何清除和更新 pages 包

 

至此,比较满意的开发环境基本实现了。  后来又觉得每次更新 CSS 都需要去重新打包后端的 pages 也没有必要,加上同构的时候 CSS 不一致,仅仅只有警告,没有实质影响,因此我在 server-dev-pages 中忽略了 less 文件(因为我用的less)。  这样会导致一个问题,因为没有更新 pages,所以页面会刷新时会先展示旧的样式,然后同构完成又立马变成新样式,在开发环境中这一瞬间是可以接受的,也不影响什么。  但是避免了无谓的编译。

 

没有做的事情

  • 封装成一个更有包裹性的三方脚手架
  • CSS 作用域控制
  • 封装性更强的 webpack 配置
  • 开发环境下,图片路径会出现不一致

    最初做自己小站的目的是学习,加上自己使用,因此有太多个性的东西。  从自己的小站中抽离了出来,已经删去了很多包和代码,只为了让他人更能快速理解其中的核心代码。  代码中有很多注释都能帮助他人理解,如果大家想使用当前库开发一个自己的小站,是完全可以的,也可以帮助大家更好的理解它。  如果是用于商业项目,推荐 nextjs。 

    CSS 没有做作用域控制,因此如果想隔离作用域,手动添加上层css隔离,比如.index{ ..... }包裹一层,或者尝试自己引入三方包。  

    webpack 通用的配置可以封装成一个文件,然后在每个文件里引入,再个性修改。  但是之前看其他代码的时候发现,这种方法,会增加阅读难度,加上本身配置内容不多,所以不做封装,看起来更直观。 

    开发环境下,图片路径会出现不一致,比如客户端地址请求地址是 localhost...assets/xx.jpg,而服务端是 assets/xx.jpg,可能会有警告,但是不影响。  因为只是一个是绝对路径,一个是相对路径。  

 

最后

对于这次的SSR服务端渲染的实现还是挺满意的,也花费了挺多时间。  感受下加载速度吧,欢迎访问大诗人小站,https://dashiren.cn/ 。  部分页面有接口请求,比如https://dashiren.cn/space,加载速度依然很快。

 

 

仓库已经准备好,下载下来试试吧,安装依赖后,运行命令即可。https://github.com/zimv/react-ssr

 

码字不易,点个赞吧~

 

posted @ 2019-03-18 08:56  子慕大诗人  阅读(3385)  评论(1编辑  收藏  举报