0 前言
本文主要介绍ssr(server side rendering)的基本原理及 mvvm框架搭建ssr项目时会面临哪些问题、如何解决这些问题,需要框架提供哪些功能以支持ssr
这里mvvm框架我们定义为数据响应式的、用组件形式组织交互单元的前端框架,一个组件包含模板、样式、生命周期钩子这三个要素。
本文主要参考 vue ssr 指南
1 什么是ssr
ssr,服务端渲染,是指用node作中间层,将首屏数据从api server获取并填充到给定模板中,生成完整的首屏页面返回给客户端的技术
一次包含服务端渲染的页面加载过程如下
- 客户端向node server发起请求,请求页面
- node server接到请求后,向api server发起请求,请求首屏数据
- api server返回首屏数据
- node server用首屏数据渲染指定模板,得到首屏页面html
- node server将首屏页面html返回给客户端
- 客户端拿到首屏页面html后,直接渲染到浏览器上
所以,构建最简单的ssr项目,前端只需要一个node server(用来响应client http request),一个模板(node server用来生成首屏html),和一个数据api接口
2 为什么需要ssr
与传统的spa相比,ssr主要有以下优势
- 更好的 SEO,因为搜索引擎爬虫抓取工具可以直接查看完全渲染的页面
- 更快的内容到达时间,因为客户端拿到的是渲染好的html,服务端做请求和数据填充的操作比客户端更快
3 mvvm框架的ssr
通常我们使用mvvm框架实现ssr技术时,会面临一些问题,下面详述这些问题及解决办法
3.1 前端路由
假定我们有一个用mvvm框架构建的单页应用 myspa
,项目使用前端路由控制页面
前端路由的基本原理是操作地址栏里的url并监听url改变以变化UI
在一般的mvvm框架中,前端路由地址栏url变化时,通过替换组件来实现“页面跳转”。服务器会将所有路由对应到同一个前端项目,由前端根据路由确定渲染哪个页面。
通常情况下,为了实现按需加载,代码会按路由进行切割,所以不同路由对应的组件都是异步的组件
这样,在 myspa
中做ssr的话,需要服务端的渲染逻辑保持和前端相同的路由。node server接到一个页面请求时,会解析url中的路由,和 myspa
中的路由进行匹配,如果匹配不上,就返回404页,匹配上的话,则将相应的异步组件页面渲染并返回给客户端
3.2 mvvm
smarty是用来进行服务端渲染的一种php模板语言,当页面请求到来时,php会根据url参数读取数据并根据指定模板渲染出html返回给客户端
node server ssr和smarty有两个主要区别,理论上讲,smarty的响应速度更快,因为node server ssr读取数据是从api接口里获取,肯定不如smarty从数据库里查更快。第二个主要区别是smarty的前后端分离程度不如node server ssr
还是以myspa
为例,我们先来回顾一下ssr的基本加载过程,然后就可以看出实现ssr需要mvvm框架提供哪些支持
当请求到达node server时,node server先从api server获取数据,然后匹配url中的路由找到对应的异步组件进行渲染
从上面的过程中可以看出,实现ssr需要mvvm框架的组件提供一个预加载数据的钩子,在这个钩子里进行数据请求并异步返回结果。只有这样,node server才能在加载组件之前执行这个钩子从而渲染模板。另外还需要提供一个将组件转为html的方法。
vue中,组件预加载数据的生命周期钩子为asyncData
,而要使用 将组件渲染为html的方法需要引入vue ssr配套模块vue vue-server-renderer
,基本用法如下
const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer()
server.get('*', (req, res) => {
const app = new Vue({
data: {
url: req.url
},
template: `<div>访问的 URL 是: {{ url }}</div>`
})
renderer.renderToString(app, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error')
return
}
res.end(`
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>${html}</body>
</html>
`)
})
})
server.listen(8080)
mvvm框架实现ssr的另一个问题是,挂载应用程序之前,需要保证客户端数据和服务端渲染数据的一致。也就是说,服务端渲染后返回给客户端的不仅是渲染好的html,还要包括渲染所用的数据,否则客户端应用在执行时就会出现数据不一致的问题。这可以通过服务端在渲染html时,将序列化的数据挂到window下来实现。
还有一个问题。客户端应用执行时,其实是有两种方式的,一种是以服务端渲染方式,另一种是客户端渲染方式。区别是什么呢?如果客户端应用以客户端渲染方式执行,那就会创建dom并添加数据响应,建立一个组件树,以控制整个应用的业务逻辑;而以服务端渲染执行的话,由于已经有渲染好的html,所以不会再创建dom,而是为已经创建好的html添加数据响应,使其变为由 Vue 管理的动态 DOM ,这个过程也成为“客户端激活”。
那么,如何区分需要客户端渲染还是服务端渲染呢?vue提供的方法是服务端渲染时,在html中的根节点dom上添加一个属性 data-server-rendered="true"
。这样就可以区分是客户端渲染还是服务端渲染。
3.3 同构
由于mvvm使用组件的方式构建单页应用,组件本身就有模板的属性,因此通常情况下,mvvm框架实现ssr时,服务端渲染和客户端运行的应用是基于同一套业务网代码的。这称为 同构。但是服务端和客户端是不同的平台,运行同一套代码时有些需要注意的问题:
- 有些服务端的api在客户端不能用,因此写业务代码时要保证服务端的代码只在服务端环境中运行
- 有些客户端api在服务端不能用,因此要保证客户端代码只运行在客户端环境中
- 因为服务端只有渲染而没有动态更新,所以组件的某些生命周期钩子只在客户端调用。因此服务端逻辑不能写在这些生命周期钩子中,另外还需要注意避免在服务端渲染时产生有全局副作用的代码(比如在服务端和客户端都有的生命周期钩子中开启一个定时器,而只在客户端特有的生命周期钩子中停掉这个定时器,那么服务端执行时,永远不会停掉这个定时器)
4 mvvm框架ssr的基本过程
综上,mvvm框架ssr的基本过程如下:
- 客户端向node server发起请求,请求页面
- node server接到请求后,匹配路由,并执行路由对应的组件的数据预取钩子来请求预取数据
- api server 返回预取数据
- node server接收到数据后,用数据渲染相应的路由组件,并将数据序列化后挂载到window下
- node server将渲染好的html返回给客户端
- 客户端加载html并加载相应的js执行应用程序,从window上取出首屏数据同步到应用中,并激活客户端,使页面变为vue控制的数据响应式dom
5 React SSR工程搭建
0. 前言
react框架本身提供了SSR的node层api支持,即一个将react组件渲染成html字符串的方法。那么我们如何搭建一个SSR的工程呢?
我们先来看一下SSR的整个流程
- 首先浏览器向node server发出请求,请求页面
- node server获取到模板和app
- node server拿到app后,访问每个组件中需要获取的数据(如果有必要),发起异步请求并用返回的结果渲染app为html字符串,然后将字符串填充到模板上形成完整的页面html字符串,将之返回给浏览器
- 浏览器拿到html字符串后,渲染形成首屏页面。然后浏览器向node server请求静态资源(如果静态资源和入口html在同一域名下而不放在cdn上的话),node server返给浏览器响应的静态资源
- 浏览器拿到js资源,开始执行,给页面中元素添加事件并执行一系列业务逻辑,使整个app变成可响应的应用
- 如果我们的web app是单页应用并且有多个路由,那么还需要考虑请求是不同路由的时候,返回相应路由的html
根据上面说明的SSR的流程,我们实现SSR功能需要解决的问题,即我们搭建一个SSR工程或脚手架需要实现的功能如下
- node server如何获取模板和app?这里模板指的是浏览器要访问的html中除了app外的其他部分,包括html整体框架和各种静态资源的引用标签
- node server如何根据app实现数据预取,并渲染出正确的html?
- node server如何serve静态资源?
- 如何让我们的应用执行以后,页面变成可响应的?(因为渲染出html后只是各个元素可见,但是没有执行js代码,所以这时候页面中元素还不是可以响应事件的)
- node server如何根据路由渲染出正确的html?
当然,我们还需要考虑的一个重要的问题,是本地开发环境如何搭建。我们希望本地开发环境满足
- 可以实现模块热重载
- 可以实现当server代码修改时,自动重启服务器
这样我们就可以方便地调试客户端代码和服务器代码了
下面详细说明上面提到的问题如何解决,功能如何实现
1. node server如何获取模板和app
我们都知道,node server返回给浏览器的是根据数据渲染好的页面html字符串。那么node server是如何把页面html渲染出来的呢?
首先node server需要拿到模板,然后把app渲染成html字符串填充到模板中,就得到了最终的页面html
这里说的模板,指的是包含静态资源引用标签和和入口页面整体框架的html文件,它有个根节点用来挂载我们的web app(比如一个id为root的div标签)。如果是客户端渲染,会拿到这个元素后执行ReactDom.render方法,将我们的web app挂载到该节点下。如果是服务端渲染,拿到这个模板后,会将app渲染成html字符串,然后插到这个标签下面,最终生成完整的首屏html。
app指的是react app的组件树。react框架提供一个api来将组件树转换成对应的html。
那么node server如何拿到这个模板和app呢?
服务端渲染模板其实就是客户端渲染时候浏览器请求到的html文件,这个文件是通过webpack打包时候,将引用的静态资源链接注入到写死的html原始模板中得到的。
app是以我们的react根组件文件为入口打包,得到的js模块。
由上面描述可以看出来,进行服务端渲染,还需要入口html和app。这说明我们不仅需要进行客户端渲染的打包,还需要额外提供一个react组件树模块打包结果,这些打包结果都存放在构建输出目录中。
node server获取模板和app,可以通过访问输出目录得到,具体方式是node server通过node的文件api读取相应的文件,获取模板和app。
2. node server如何根据app实现数据预取
node server获取模板和app之后,就会将app渲染成html字符串。这个过程是如何实现的呢?
我们先来了解一下一个工具react-async-bootstrapper
这个模块导出一个方法,它接收react组件作为参数,返回一个promise。它会执行以参数的组件为根组件的组件树中所有组件的asyncBootrap
方法后resolve。
我们可以用这个工具实现数据预取功能。首先给组件加上asyncBootstrap
的成员方法,然后执行react-async-bootstrapper
方法,然后在返回的promise的resolve回调里执行服务端渲染逻辑。因为在resolve时候数据已经预取完毕,这时候进行服务端渲染就可以得到预期的结果了。
3. node server如何serve静态资源
如果我们的静态资源不部署到cdn上,而是和node server部署到我们自己的域名下,当浏览器加载完html后,会发起静态资源的请求。这些静态资源的请求是会直接打到我们的node server上的。那么node server应该怎么处理才能让浏览器得到想要的静态资源呢?
我们可以在客户端渲染打包时候,将静态资源请求的publicPath固定(比如asset
),然后node server接收到带有asset
路由的请求时,转发到静态资源输出目录。
4. 如何让我们的应用执行以后,页面变成可响应的
客户端渲染的react入口文件会调用ReactDOM.render()
方法将组件挂载到根节点上去。在服务端渲染时候,浏览器拿到的是已经渲染后的html。这时候需要调用ReactDOM.hydrate()
方法,hydrate()
与 render()
相同,但用于混合容器,该容器的HTML内容是由 ReactDOMServer 渲染的。 React 将尝试将事件监听器附加到现有的标记。
需要注意的一个问题是如何保证服务端渲染的html结果和客户端渲染执行的结果保持一致。因为服务端渲染为了预取数据,会先执行异步方法asyncBootrap
。但是客户端渲染不会执行之,所以可能出现服务端渲染的html结果和客户端渲染的组件state
不一致的情况,解决的思路是,将asyncBootrap
异步执行后的数据挂在window下一个变量(__INITIAL__STATE__
)上面,客户端渲染时候读取这个变量的值,用来渲染组件。
5. node server如何根据路由渲染出正确的html
前端路由是由hash、history等浏览器api支持的,服务端渲染时候,没有这些api,所以需要一个“假的”Router
组件来提供给ReactDOM.renderToString()
渲染,这个“假的Router
组件”暴露给子组件中的Route
组件的location
等的属性不是浏览器环境下js的属性。react-router
提供了一个这种组件StaticRouter
,这个组件接受两个参数:location
和context
。location
是当前的url,context
是路由的上下文,在渲染过程中,如果子组件改变了location,会反映到context上。
6. 如何搭建我们的本地开发环境
搭建本地开发环境需要实现以下几个功能
- 客户端渲染代码调试
- 服务端渲染代码调试,这个需要实现服务端渲染代码更新后重启服务的功能,并且node server运行时候,也需要实现客户端渲染代码调试的一系列功能
客户端渲染代码调试需要启动一个本地服务器serve静态资源,并提供模块热重载、historyApiFallback等功能,可以使用webpack-dev-server实现
服务端渲染代码调试需要实现服务端渲染代码更新后重启服务的功能,并且node server运行时候,也需要实现客户端渲染代码调试的一系列功能
server代码更新后重启服务器可以通过一些工具(nodemon)实现,本地调试时候,node server获取的模板和app可以直接通过访问webpack-dev-server的资源(html、js、css等)实现,这样就实现了客户端渲染的所有功能
7. 总结
生产环境
实现服务端渲染我们主要关注两点,一个是打包构建,另一个是node server逻辑
构建:客户端渲染打包 + app模块打包
node server:读取html和app模块文件,渲染首屏html;重定向静态资源访问
开发环境
客户端渲染使用webpack-dev-server;node server使用express,读取webpack-dev-server中的模板和app模块,并执行渲染逻辑;静态资源重定向到webpack-dev-server
6 next
0 简介
next.js是一个基于react的服务端渲染框架
所谓框架即一个程序库,对开发者屏蔽底层实现细节,要求开发者按照其支持的范式编写程序,即可实现开发者想要的效果
react框架对外屏蔽了dom操作细节及数据响应式的具体实现,要求开发者按照其提供的组件式、数据驱动式的程序编写方式,即可开发出各式各样的spa
next.js则在react的基础上增加了服务端渲染的功能,这个功能实现对开发者透明,开发者只要按照next要求的开发范式(比如实现组件的预取数据的钩子),即可让你的spa拥有服务端渲染的功能
除服务端渲染,next还提供了诸如静态化、自动生成路由、自动代码分割等功能和特性,并提供很多特性的定制化的支持
下面介绍下,如何使用next.js构建一款服务端渲染应用
1 next.js的能力
1.1 spa的要素
通常我们构建一个spa,需要搭建一系列环境,考虑一系列要素,如
开发
- 本地开发环境(本地server、live reload、HMR等)
- 入口html内容编辑(rm自适应方案设置html的font-size、第三方script引用等)
- 模块化(amd、cmd、commonjs、es6 module)
- css方案(内联、预编译等)
- static文件方案
- 路由方案(前端路由、link导航、api导航、error page)
- ajax方案
打包构建及部署
- 构建打包(js转义压缩、css压缩、图片压缩)
- 按需加载
- 覆盖式部署、非覆盖式部署
服务端渲染
如果spa使用ssr,还要考虑实现相关的技术
- node server渲染实现
- 同构代码支持
- 打包相关
- node server路由支持
其他
- layout方案
1.2 next.js的能力
对于上述的和未提到的一系列要素,next.js提供了相关支持,它在底层做了很多工作,对外屏蔽了大量细节,使开发者很大程度上只需要关心业务逻辑的实现
服务端渲染
next.js核心能力之一就是服务端渲染,它实现了代码同构支持、node server服务端渲染支持、ssr路由支持等
对于开发者,只需要编写业务组件,实现数据预取的钩子,返回需要的数据即可,其余的工作都由next.js完成
路由
使用next.js构建单页应用,路由是通过文件目录配置的,next.js规定开发者要在项目根目录下的pages文件夹下编写路由组件,然后next.js会自动生成路由页面
本地开发环境及构建打包
next.js提供了命令来启动本地服务器和构建打包
启动本地服务器
next
在服务器上构建并启动项目
next build
next start
css方案
next.js支持内置样式和预编译等方式供开发者写样式
内置样式写法
export default () =>
<div>
Hello world
<p>scoped!</p>
<style jsx>{`
p {
color: blue;
}
div {
background: red;
}
@media (max-width: 600px) {
div {
background: blue;
}
}
`}</style>
<style global jsx>{`
body {
background: black;
}
`}</style>
</div>
预编译css支持less、stylus等,需要设置next.js配置以提供编译支持
自定义
next.js允许开发者自定义一些选项
- node server:开发者可以通过在项目根目录下添加server.js文件,并在项目package.json中修改script key实现
{
"scripts": {
"dev": "node server.js",
"build": "next build",
"start": "NODE_ENV=production node server.js"
}
}
- 入口html标签:开发者可以通过在pages目录下添加_document.js来覆盖默认的html
- app:开着可以通过在pages目录下添加_app.js来覆盖默认的应用元素设置
- 自定义配置:开发者可以通过在项目根目录下添加next.config.js来覆盖默认配置(如webpack配置、构建目录、是否使用服务端渲染等)
- babel配置:开发者可以通过在项目根目录下添加.babelrc来覆盖默认babel配置
静态化
next.js提供方法将项目导出为静态项目,静态项目运行时,不依赖node server。
layout
在单页应用的开发中,可能会有这样的需求,在不同路由之间跳转时,界面某些元素是一直不变的(如一些logo、标题等)。next.js提供的实现这种需求的方法是使用组件实现不变的部分并引用之来包裹变化的部分。这中方式是react天然支持的的。
2 开发者工作
pages
开发者在pages目录下创建文件,实现页面,nextjs会自动根据目录生成路由
getInitialProps
在这个钩子中实现数据预取,用于首屏页面的服务端渲染和非首屏路由的数据获取
server
通常情况下开发者不需要自己实现node server,如果有特殊需求,如路由遮盖等,可以自己实现node server,利用nextjs提供的api完成服务端渲染,再加入一些中间件来完成定制化需求