SSR前言
随着前端路由的兴起,spa受到越来越多人的欢迎, 下面预先介绍spa的优缺点,方便同学们认识到ssr为什么会兴起
spa优缺点
-
优点:
- 用户体验好, 不需要重新加载页面,web应用更具响应性和更令人着迷
- 减少了服务端的压力, 不需要去每个路径对应每个页面
- 基于上面一点,SPA相对对服务器压力小
- 交互性很强
-
缺点:
- 不利于seo, 由于是浏览器动态替换内容,开始请求到的页面是空节点
- 首次渲染速度变慢, 原因是服务器不需要预先下载js与css
ssr原理
其实就是一句话: 服务器接收到请求时,将对应的资源填充到HTML模板里转化为字符串形式传递给浏览器, 浏览器解析js后绑定对应事件就完成了渲染
引申几个问题
:
1. 服务器渲染完并且传递给浏览器后, 之后的链接是走浏览器还是走服务端?
2. 服务器在传递给浏览器之前会请求该页面的所需要加载的数据,那么,由于是响应式框架(数据驱动视图),浏览器必须要获取到服务器已经请求到了的数据,那么服务器是怎么传递这些数据的?
3. css文件是怎么处理?
4. 按需加载与平时有什么区别?
以上问题,现在可能刚接触的人不太理解为什么会提出这些问题,希望读者带着以上问题去阅读,看完下面,再回过头来想想这些问题会比较好
React SSR
两端需要做的事情
浏览器
:
- 挂载DOM
- 获取服务端预取的数据
服务端
:
- 数据预取
- 转换HTML字符串, 并且返回给客户端
同构
概念
什么是同构? 同构指的是一套代码能同时跑在浏览器端与服务器端,同构是SSR的核心理念,但是同构并不是没有缺点,下面列举下同构的优缺点:
优点:
1. 可复用性提高,一套代码跑两端,节省开发时间
2. 有利于seo, 服务器一开始就渲染了整个页面
缺点:
1. 服务器与浏览器的环境变量是不同的,很多npm包是针对于浏览器端的, 所以这里判断需要成本
2. 数据预取是有点同时也是缺点, 页面请求多个数据接口时,可能会花上数秒时间,导致页面无法渲染,从而资源到不了浏览器端,会留下一段空白时间
3. 内存泄露, 服务器不像浏览器刷新就可以内存重置,可能会造成服务器内存泄露
渲染
浏览器端:
了解过React的知道挂载DOM节点采用ReactDOM.render方法, 但是React为SSR提供了另一个挂载API
import ReactDOMServer from 'react-dom'
import App from './App'
ReactDOMServer.hydrate(<App/>, document.getElementById('root'))
相信大家看到了一个关键的API, hydrate
, 这个API的作用是复用已有的DOM节点,减少DOM重复渲染损耗,可提高初始化渲染速度, 该API要求服务器与浏览器渲染的内容是一致的,否则会进行警告,也可以认为是代码的bug, 大家可以想象,服务器传回的HTML已经是填充完数据后的模板,那么浏览器就没有必要重新渲染整个DOM树,直接复用即可
服务器端:
React v16以下推荐:
(这里我特地标记了版本,是因为由于版本原因, 可供用户选择的api有所不同,具体的请往下看)
renderToString: 将DOM转换为字符串形式,以便服务器发送
// entry-server.js
import { renderToString } from 'react-dom/server'
export default function render() {
return renderToString(<App />)
}
server端代码:
// server.js
const express = require('express')
const app = express()
const server = require('/path/to/entry-server.js') // 需要经过打包,因为我们采用的是es6的写法,需要转换成commonjs
app.get('/', (req, res) => {
const render = server.render()
res.end(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>ssr</title>
</head>
<body>
<div id="root">
${render}
</div>
</body>
</html>
`)
})
该方法推荐ejs作为html模板,可以动态填充数据,不了解ejs的同学可以去官网看下
GitHub地址
: github.com/Gloomysunda…
路由
路由同构指的是,路由代码同时跑在服务端与浏览器端,路由的作用就是动态显示组件, React在浏览器与服务器有着不同的Router封装
-
浏览器端:
BrowserRouter或者是HashRouter
这两个Router在此就不详细介绍了,相信用过React的同学都会知道
-
服务器端:
StaticRouter
StaticRouter官方解释: 一个永远不会改变位置。当用户实际上没有四处点击时,这在服务器端渲染方案中很有用,因此位置永远不会实际更改。因此,名称为:静态。当您只需要插入一个位置并对渲染输出进行断言时,它在简单测试中也很有用。
StaticRouter内部不会涉及到浏览器的history位置更迭,它只是一个静态路由,只做匹配路由填充数据
// entry-server.js
export default render(url) {
...
return renderToString(<StaticRouter location={url}>
<ServerRouter />
</StaticRouter>)
}
GitHub:
github.com/Gloomysunda…
路由懒加载
项目打包通常不会把所有的内容打包到一个文件里面,那样会造成文件过大导致请求过慢从而影响了浏览器渲染速度, 通常我们会在请求某个页面时去请求相对应的资源,SSR也不例外。
浏览器端:
webpack结合import(path)即是按需加载(webpack2.x的require.ensure被import()替代了): import返回的是一个promise对象, 在then方法返回的参数即是内容
const AsyncDetail = () => import('/path/to/AsyncDetail')
const router = [
{
path: '/asyncdetail',
component: AsyncDetail
}
]
export default function createRouter() {
return <Switch>
{router.map(route => (<Route {...route}/>))}
</Switch>
}
以上的代码显然还是会报错, 原因就在于AsyncDetail是一个promise对象并不是ReactElement
, 我们需要在浏览器还未加载之前先显示一段加载内容提示用户, 可以直接采用第三方的包, react-loadable
, 返回的是一个组件这样就可以直接使用了, 或者是React v16以上的Suspense与lazy
但此时服务端的路由因为一开始就要显示出所有的内容,如果在内容还未加载完成之前服务器就已经将html发给客户端,那么还是会出现空白页面,此时entry-server.js需要更改下
// 此时ServerRouter已经不能与客户端的Router共用一个router config了
async function getServerRouter(router) {
const serverRouter = []
for (let route of router) {
serverRouter.push({
...route,
component: await (route.component())
})
}
return serverRouter
}
// entry-server.js
import createRouter from '/path/to/Router'
export default async render(url) {
// async函数返还的是一个promise对象
const serverRouterConfig = await getServerRouter(router)
const ServerRouter = createRouter(serverRouterConfig)
return renderToString(<StaticRouter location={url}>
<ServerRouter />
</StaticRouter>)
}
数据预取
React是通过数据响应来驱动视图的变化,在服务端返回模板之前需要进行数据请求,将请求好的数据填充进React模板最后形成一个字符串返回给客户端
react-router官方推荐在router里定义一个loadData属性用来识别加载数据方法
// router.js
const router = [{
path: '/component',
loadData: Component.loadData,
component: Component,
}]
// entry-server.js
import { matchPath } from 'react-router-dom'
import createStore from './store'
export default render(url) {
const store = createStore()
const promise = []
router.forEach(route => {
const match = matchPath(url, route)
if (match) {
promise.push(route.loadData(store))
}
})
Promise.all(promise).then(...)
}
function AsyncLoadData() {
return <div>{props.data}</div>
}
AsyncLoadData.loadData = (store) => {
return store.dispatch(action.getData)
}
export default connect(mapStateToProps)(AsyncLoadData)
首先介绍下以上涉及到的几个变量:
-
matchPath
实质上是对route配置的path进行正则分析,通过分析的结果对比url.pathname来判断是否有对应的配置,若有则返回该配置,没有则返回Null
-
store
由于同构原因,必须创建一个仓库可以适配于两端的,redux是用的比较多的一个公共仓库,所以这里采用redux作为数据存储仓库
-
不知道大家有没有发现这里不管是router还是store都是使用createXX方法来创建的,原因有以下两点:
-
服务器并不是客户端, 所有的客户端请求的是同一个服务器, 服务器内存始终保存着同一个变量,多个客户端请求同一个变量,会造成交叉污染,导致不必要的麻烦,所以采用函数创建,返回不同的副本
-
服务端与客户端配置会有些许不同, 采用传参方式可以复用代码,减少冗余
-
发生了问题
通过以上的写法,服务端会在请求完数据后填充数据并返回模板给到客户端,但是此时会发现客户端发生了闪屏, 原有的数据在一开始展示了但是瞬间又会消失掉, 打开NetWork,会发现服务端返回了完整的HTML,但是Element里又没有显示,原因出在哪里?
原因
原因在于服务端请求完数据后,但是客户端是不清楚返回数据的, 所以在服务端返回完整html后,一开始展示了有数据的页面,后来经过浏览器解析js后,又展示了空白页面
解决方案
我们需要在服务器请求完数据后,将其填充到window下,并且一起返回给客户端,客户端在挂载数据之前,先去取到window下的数据并且填充到视图里
// entry-client.js
...
if (window.initialData) {
store.repalceState(window.initialData)
}
ReactDOM.hydrate(...)
// entry-server.js
Promise.all(promise).then(() => {
resolove({
template: ...,
initialData: store.getState()
})
})
// server.js
...
<script type="text/javascript">
window.initialData = initialData
</script>
这样基本就完成了一个SSR全过程,下面来介绍下一些遇到的问题与webpoack的配置
webpack配置(基本配置不进行介绍了)
client端:
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css'
})
]
客户端的目的就只是挂载DOM节点,所以它需要处理下css样式的问题
server端:
output: {
libraryTarget: 'commonjs2', // module.exports
libraryExport: 'default', // 若是所有的到处都是用default来导出,则可以使用
},
externals: [nodeExternals()],
target: 'node', // 可以在打包的时候不注入node环境下变量
服务端的目的是为了将html解析成字符串,它不管渲染等方便,所以对css没有什么要求
遇到的问题
css modules
1. React无法和Vue一样拥有独自的css局域块,所以通常采用css-loader里的module隔离开各个样式,但是此时就会发生一个问题:
import styles from './xxx.less'
class xxx extends Component {
render() {
return <div className={styles.yyyy}>xxx</div>
}
}
在解析这段代码时,会报错,因为没有css-loader解析导致styles无法被获取,className自然无法被赋值,所以React在配置webpack时,还需要在server端也打包进css-loader,与此同时,又爆发出一个问题:
2.Css-loader在两端获取yyy属性时,表现出了不同的获取方式,server端还需要在locals下才能获取类名
原因:
css-loader只有在style-loader下才会直接在styles能够获取到类名
但是style-loader由于会添加style标签引用到浏览器特有属性才可以使用, 所以只能会使用isomorphic-style-loader
来代替style-loader
renderToNodeStream
React v16版本以上提供了renderToNodeStream, 该方法为服务端提供了以流的方式传输HTML模板, 输出的内容等同于renderToString
好处: 以流的方式输出,可以有效缓解服务器压力,流不是在一开始就全部可以用,所以不会一开始全部塞入内存导致服务器堵塞。
Vue SSR
Vue SSR原理基本与React SSR类似,但是Vue比React更为简单一些,下面开始介绍下Vue SSR
Webpack配置
// client端
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
module.exports = merge(baseConfig, {
entry: './src/entry-client.js',
plugins: [
// 重要信息:这将 webpack 运行时分离到一个引导 chunk 中,
// 以便可以在之后正确注入异步 chunk。
// 这也为你的 应用程序/vendor 代码提供了更好的缓存。
new webpack.optimize.CommonsChunkPlugin({
name: "manifest",
minChunks: Infinity
}),
// 此插件在输出目录中
// 生成 `vue-ssr-client-manifest.json`。
new VueSSRClientPlugin()
]
})
// server端
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(baseConfig, {
// 将 entry 指向应用程序的 server entry 文件
entry: './src/entry-server.js',
// 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
// 并且还会在编译 Vue 组件时,
// 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
target: 'node',
// 对 bundle renderer 提供 source map 支持
devtool: 'source-map',
output: {
libraryTarget: 'commonjs2'
},
externals: nodeExternals({
// 不要外置化 webpack 需要处理的依赖模块。
// 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
// 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
whitelist: /\.css$/
}),
// 这是将服务器的整个输出
// 构建为单个 JSON 文件的插件。
// 默认文件名为 `vue-ssr-server-bundle.json`
plugins: [
new VueSSRServerPlugin()
]
})
经过以上两步,出口文件夹在正常输出文件以外多出两个json文件
- vue-ssr-client-manifest.json
- vue-ssr-server-bundle.json
同构
同构同样是核心理念,在两端书写一套共有的代码
实例化
实例化Vue, 这里要主要的是上面讲到的是交叉渲染污染,所以设计成函数形式, 每次请求都会实例化一个新的app出来
// app.js
import { sync } from 'vuex-router-sync'
...
export function createApp() {
const router = createRouter()
const store = createStore()
sync(store, router) // 将vuex与vue-router状态共享
const app = new Vue({
store,
router,
render: h => h(App)
})
return {
app,
store,
router
}
}
路由同构
// router.js
export default function createRouter() {
return new Router({
... // 正常配置路由
})
}
浏览器正常使用, 在app.js实例化的时候就已经同步塞入Vue里了
服务端由于设计到一些数据预期等等额外的操作, 需要进行简单处理下
根据官方定义的一个调用方法asyncData
, 在服务端获取到客户端请求的时候,会先检测组件配置里是否定义了asyncData, 若有的话, 则进行调用, 在这里声明下asyncData是在Vue实例化之前调用的, 也就是说此时是无法获取到执行上下文的, 所以需要用到公共库Vuex来存储数据(当然自己写一个类似的也可以), asyncData会传入store与router的参数给到组件
import { createApp } from './app'
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp(context)
// 服务端与客户端路径确保一致
router.push(context.url)
// 保证路由全部加载完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute
})
}
})).then(() => {
// 在所有预取钩子(preFetch hook) resolve 后,
// 我们的 store 现在已经填充入渲染应用程序所需的状态。
// 当我们将状态附加到上下文,
// 并且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state
resolve(app)
}).catch(reject)
// 对所有匹配的路由组件调用 `asyncData()`
}, reject)
}).catch(err => {
})
}
以上有个特别要指明的地方: Vue有两种形式,一个是vue文件, 另一个是直接在Vue实例上进行视图驱动, 两种数据填充是不同的
- Vue文件 与React一样,会填充到window下的__INITIAL_STATE__属性, 并且在entry-client.js里,挂载DOM之前, 会取到这个数据
import { createApp } from './app'
// 客户端特定引导逻辑……
const { app, store } = createApp()
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
// 这里假定 App.vue 模板中根元素具有 `id="app"`
app.$mount('#app')
- 实例 Vue会直接在data上定义一个相应数据, 可以直接使用
// store.js
mutations: {
setItem (state, { id, item }) {
Vue.set(state.items, id, item) // 直接定义一个响应数据
}
}
启动服务
Vue提供了一个api, createBundleRenderer
, 用于处理官方介绍的问题
每次编辑过应用程序源代码之后,都必须停止并重启服务。这在开发过程中会影响开发效率。此外,Node.js 本身不支持 source map
好处:
-
内置的 source map 支持(在 webpack 配置中使用 devtool: 'source-map')
-
在开发环境甚至部署过程中热重载(通过读取更新后的 bundle,然后重新创建 renderer 实例)
-
关键 CSS(critical CSS) 注入(在使用 *.vue 文件时):自动内联在渲染过程中用到的组件所需的CSS。更多细节请查看 CSS 章节。
-
使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk。
const express = require('express')
const server = express()
const path = require('path')
const { createBundleRenderer } = require('vue-server-renderer')
const serverMainfest = path.resolve(__dirname, './dist/vue-ssr-server-bundle.json')
const renderer = createBundleRenderer(serverMainfest, {
runInNewContext: false, // true的时候 每次请求Vue会自己去实例化一个App, 这个主要就是避免交叉污染,但是其实我们已经避免了这个问题,所以可以设置为false, 毕竟这是一个性能消耗的过程
template: require('fs').readFileSync(path.join(__dirname, './index.html'), 'utf-8'), // html模板,
// <div id="app">
// <!--vue-ssr-outlet--> Vue会将内容自动填充到此处
// </div>
clientManifest: require(path.resolve(__dirname, './dist/vue-ssr-client-manifest.json')) // 这里就是之前webpack打包好的
})
server.use(express.static(path.resolve(__dirname, './dist')))
server.get('*', (req, res) => {
const context = {
url: req.url
}
renderer.renderToString(context, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error')
return
}
res.end(`
${html}
`)
})
})
server.listen(8080)
GitHub
: github.com/Gloomysunda…
遇到的问题
- 在app.js实例化之前之前会有一些依赖于浏览器环境的第三方包, 例如FastClick, 或者是在组件引入第三方组件时也有依赖浏览器环境的包, 此时会报错window is not defined
解决: 由于服务端只会执行beforeCreate与created生命周期(因为服务端不会去挂载DOM), 所以可以在beforeMount方法里去动态加载模块
beforeMount() {
if (typeof window !== 'undefined') {
const { swiper, swiperSlide } = require('vue-awesome-swiper').default
this.$options.components.swiper = swiper
this.$options.components.swiperSlide = swiperSlide
}
},
mounted() {
if (typeof window !== "undefined") {
const Fastclick = require('fastclick')
Fastclick.attach(document.body)
}
}
- 由于数据预取完全是取决于数据请求回来的时间,可能数据加载过慢导致页面出现白屏, 此时需要做下loading状态
后言
不知道我说的够不够清楚,总之,SSR核心理念就是同构, 写一套代码通用于两端
服务端职责
- 数据预取
- 转换HTML字符串模板返回给客户端
浏览器职责
- 获取服务器预取的数据
- 挂载DOM