关于 SSR(全称 Server-side-render),每个前端同窗必定都很熟悉,咱们知道 SSR 能够减小白屏等待时间,对 SEO 友好,容易被搜索引擎抓取到,可是咱们该怎么写好一个 SSR 项目呢?下面这篇文章由一道著名的面试题为起点,带你一步一步揭开 SSR 的奥秘。css
这个过程简单归纳为几大步:html
做为一个前端工程师咱们应该关注 三、四、5。前端
浏览器发送 HTTP 请求前,会首先检查该资源是否存在缓存,有如下请求头、响应头做为缓存标识:Expires、Cache-Control、Last-Modified、if-Modified-Since、Etag、if-None-Match,下面来给他们分个类。vue
当浏览器准备发送 Http 请求请求一条资源时,它检查以前曾经发过这条资源,并且这条资源当时的响应结果带了 Expires 这个响应头并设置了一个绝对的时间 Expires: Wed, 21 Oct 2021 00:00:00 GMT
,这个时候浏览器一看,这条资源到 2021 年才过时呢,就不会发送请求了,而是直接取以前的返回结果。
Expires 是 http1.0 时代的强缓存依据,在 http1.1 又补充了 Cache-Control 这个响应头做为强缓存依据,Cache-Control 的一般用法是 Cache-Control: max-age=31600
,它表示资源有效时间,是一个相对的时间。Cache-Control 的存在解决了当服务器时间和客户端时间(浏览器的时间其实是依赖系统时间的,而咱们是可以随意修改系统时间的)不一致引起的问题,咱们发出的 http 资源的强缓存依然有效,不会时间变长也不会变短。node
经过浏览器缓存机制咱们能够极大的减小浏览器请求的资源量,加快页面的展示。在现代前端项目中,浏览器缓存机制每每是配合 Webpack 来实现的,咱们通常经过 Webpack 对项目进行打包。在 Webpack 中核心配置主要有 entry、output、module、plugin,经过如下最基础的配置来对 Webpack 配置有一个基础的印象。webpack
const path = require('path') const { ProgressPlugin } = require('webpack') const { CleanWebpackPlugin } = require('clean-webpack-plugin') const VueLoaderPlugin = require('vue-loader/lib/plugin-webpack4') const HtmlWebpackPlugin = require('html-webpack-plugin') const MiniCssExtractPlugin = require('mini-css-extract-plugin') const handler = (percentage, message, ...args) => { console.info('构建进度:', percentage) } module.exports = { mode: 'production', // 可选值有 'node' || 'development' || 'production' production 会设置 process.env.NODE_ENV = 'production' 并开启一些优化插件 entry: './main.js', // Webpack 打包开始的入口文件 output: { // 完成打包后的输出规则 path: path.resolve(__dirname, 'dist/'), // 输出到当前目录的 dist 目录下 filename: '[name].[hash].js' // 文件会按照 [name].[hash].js 的命名规则进行命名 }, /** * Webpack 只可以解析 Js 模块,当遇到非 Js 的模块、文件时,须要经过 loader 将其转换成 Js */ module: { rules: [ { test: /\.vue$/, use: 'vue-loader' }, // 将 Vue 文件转换为 html、css、js 三部分 { test: /\.(css|less)$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'] // Less => Css => Js => 最后利用 MiniCssExtractPlugin.loader 抽离出 css 文件。 }, { test: /\.js$/, use: ['babel-loader'] // 利用 babel 将 ES 高版本代码编译为 ES5 } ] }, /** * Loader 的存在可以让 Webpack 识别并转换任何的代码,可是缺乏在打包过程当中对资源进行操做的方式,Plugin 经过 Webpack 内置的钩子函 * 数给咱们提供了强大的扩展性,咱们能够利用 Plugin 作不少事情。 */ plugins: [ new CleanWebpackPlugin(), // 在打包前清空 output 的目标目录 new VueLoaderPlugin(), // 配合 Vue-loader 使用,将 Vue-loader 转换出 Js 代码从新根据 rules 配置的 loader 进行转换 new HtmlWebpackPlugin({ // 利用指定的 html 模版在构建结束后生成一个 html 文件并引入构建后资源。 template: 'index.html' }), new MiniCssExtractPlugin({ // 将原本内置在 Js 的样式代码抽离成单独的 css 文件 filename: '[name].[hash].css', chunkFilename: '[id].[hash].css' }), new ProgressPlugin(handler) // 打印构建进度 ] }
经过以上 Webpack 配置构建后构建的结果以下所示
在构建结果中咱们可以看到,咱们的输出的文件都按照根据本次构建的 hash 生成了一个文件名称中带有 hash 的文件,利用这个 hash 咱们可使用浏览器的强缓存,经过配置 Cache-Control: max-age={很大的数字}
来是咱们的静态资源可以保留在浏览器中,当下一次构建时会生成新的 hash,不会由于缓存而致使 Web 应用没法更新。git
想要学习 Webpack,能够看一看 Webpack 文档,若是想要深刻的学习 编写一个插件 是不可错过的。
CDN 全称是 Content Delivery Network(内容分发网络),它的做用是减小传播时延,找最近的节点。经过以上缓存的方式咱们解决了重复请求资源的效率问题,可是当第一次请求资源时,这好几 Mb 的内容够用户加载好一下子了,若是都是从服务器中发出,可想而知服务器的贷款压力有多大。
CDN 的存在帮咱们解决了这个问题,CDN 的主要做用就是减轻源站(服务器)负载,经过部署在全球各地的节点返回数据。真正的 CDN 可能在某个地区的运营商都会有一个专门的节点。github
咱们将内容上传至 CDN 源站中,当第一次访问该资源的时候会进行 DNS 查询得到该域名的 CNAME 记录,而后对新的 CNAME 进行 DNS 查询会获得一个离用户访问最近的边缘服务器的 IP 地址,用户浏览器与边缘服务器创建 TCP 连接,将 HTTP 请求发送到边缘服务器,边缘服务器检查是否有该资源,若是没有该资源会进行回源,向上一级 CDN 服务器请求该资源,直至找到该资源并返回给边缘服务器,边缘服务器会缓存该资源,并返回给用户。当另外一个用户访问到同一个边缘服务器时,就能很快的获取该资源。web
在解释为什么 Vue-Spa 使首屏加载变慢前咱们首先须要了解当浏览器请求到资源后是如何渲染资源的。面试
<head> display:none
等不可见的标签若是咱们直接请求一个 html 文件就是上面的过程,这个过程很是快,在几毫秒就能够完成。
可是得力与前端技术的发展,咱们开发的大型 WEB 应用没法经过一个 Html 就能传给用户使用,咱们在 Html 中引入了不少不少 Javascript 文件,并经过 Javascript 来渲染咱们的应用。以 Vue-Spa 为例咱们从新讲解这个渲染过程。
<head> display:none
等不可见的标签浏览器依然会走以上这四步过程,可是由于咱们的 html 中除了一些 <script> <link>
之外几乎是空的,因此是白屏状态。
浏览器启动 V8 引擎执行咱们的 Js 代码,以 Vue 为例:
能够看到 Vue-Spa 比直接渲染 Html 的方式多出了 五、六、7 步骤,而且多出了几倍的耗时。
因此就有了骨架屏的优化思路,在第一次返回的 Html 中不反悔白屏的空内容了,而是返回一个骨架屏或者 Loading 的图标,提示用户耐心等待,但这不是用户想要看到的,用户但愿看到内容。
Vue.js 是构建客户端应用程序的框架。默认状况下,能够在浏览器中输出 Vue 组件,进行生成 DOM 和操做 DOM。然而,也能够将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上彻底可交互的应用程序。这样咱们首屏就可以看到一部份内容,而不是空白、或者 loading 提示了。
Vue-ssr 经过 createRender() 方法生成一个 renderer 实例,利用 renderer 对象咱们能够将 vue 实例转换为 html。
const app = new Vue({ template: `<div>Hello World</div>` }) const renderer = require('vue-server-renderer').createRenderer() renderer.renderToString(app, (err, html) => {})
咱们依然须要知足一套代码能在 Server 端和浏览器端同时运行,官方给出了以下的流程图。
根据上图,咱们能够看到,咱们编写通用的 Web 代码,使用 Webpack
经过 entry-server
和 entry-client
两个入口打包出 server-bundle
和 Client-bundle
,服务端使用 server-bundle
渲染出的 Html 与 client-bundle
进行混合最终共同运行在浏览器上。
在生产环境中咱们不会调用 createRenderer
这个方法来进行服务端渲染,由于 Server 端的代码会依赖 Client 端代码,使得 Server 端会随着 Client 端的代码更新频繁重启。在生产环境中咱们使用 createBundleRenderer
来进行服务端渲染,也就是上图所用的流程。
用户与客户端的关系是一对一,而与服务器的关系是多对一,因此不能像 Spa 那样使用一个单例的 Vue 实例,会形成不一样用户之间的数据共享,咱们首先要将以前的单例模式更改成工厂函数,动态生成Vue
Vue-router
Vuex
实例。
import Vue from 'vue' import App from './App.vue' import { createRouter } from './router' // 背后是 new Router() import { createStore } from './store' // 背后是 new Vuex.store() export const createApp = () => { const router = createRouter() const store = createStore() const app = new Vue({ router, store, render: h => h(App) }) return { app, router, store } }
两个入口文件对应打包出得 bundle 文件分别执行不一样的职责:
// entry-server.js import { createApp } from './app' export default context => { // 由于有可能会是异步路由钩子函数或组件,因此咱们将返回一个 Promise, // 以便服务器可以等待全部的内容在渲染前, // 就已经准备就绪。 return new Promise((resolve, reject) => { const { app, router, store } = createApp() // 建立新的 Vue、Vue-router、Vuex 实例 const { url } = context // context 是 Server 端传过来请求上下文,咱们经过这个对象取出请求的 url router.push(url).catch(err => { // 将服务端的 Vue-router 的路径修改成 url reject(err) }) // 等到 router 将可能的异步组件和钩子函数解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents() // 获取当前路由匹配到的 Vue 组件实例 if (!matchedComponents.length) { // 没有匹配到则抛出错误 return reject({ code: 404 }) } Promise.all( matchedComponents.map( // 运行匹配组件的 asyncData 钩子函数进行数据预取,并将预取的数据放在 Vuex 中。 ({ asyncData }) => asyncData && asyncData({ store, route: router.currentRoute }) ) ) .then(() => { context.state = store.state // 将 vuex 的 state 赋值给 context.state ,最终将自动序列化为 window.__INITIAL_STATE__,并注入 HTML。 resolve(app) // 返回 数据预取后的 Vue 实例 }) .catch(reject) }) }) }
// entry-client.js import { createApp } from './app' const { app, router, store } = createApp() // 建立新的 Vue、Vue-router、Vuex 实例 if (window.__INITIAL_STATE__) { // 将服务端预取的数据赋值给客户端的 vuex。 store.replaceState(window.__INITIAL_STATE__) } router.onReady(() => { // 添加路由钩子函数,用于处理 asyncData. // 在初始路由 resolve 后执行, // 以便咱们不会二次预取(double-fetch)已有的数据。 // 使用 `router.beforeResolve()`,以便确保全部异步组件都 resolve。 router.beforeResolve((to, from, next) => { // 咱们只关心以前没有渲染的组件,因此咱们对比它们,找出两个匹配列表的差别组件 const matched = router.getMatchedComponents(to) const prevMatched = router.getMatchedComponents(from) let diffed = false const activated = matched.filter((c, i) => { return diffed || (diffed = prevMatched[i] !== c) }) // 由于此时客户端 bundle 接管服务端渲染的 html,已经变成了一个单页应用,咱们能够在代码中进行 router.push 来实现虚拟路由跳转,可是代码中不会执行 asyncData 数据预取这部分逻辑,因此这里咱们要将新的组件中的 asyncData 拿出来执行。 const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _) if (!asyncDataHooks.length) { return next() } Promise.all(asyncDataHooks.map(hook => hook({ store, route: to }))) .then(() => { next() }) .catch(next) }) // 服务端渲染出得 html 在浏览器渲染后,会有一个 `data-server-rendered="true"` 的标记,标明这部分 Dom 是服务端渲染的,浏览器端的代码准备好后就会接管这部分 Dom,使其从新变为一个单页应用。 app.$mount('#container') })
咱们须要在配置文件中生成两份配置文件分别为 webpack.server.conf.js
webpack.client.conf.js
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') // webpack.server.conf.js 主要是和客户端构建不一样的地方 module.exports = { // 这容许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),而且还会在编译 Vue 组件时,告知 `vue-loader` 输送面向服务器代码(server-oriented code)。 target: 'node', // 入口文件为 entry-server.js entry: path.resolve(__dirname, '../code/client/src/entry-server.js'), // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports) output: { filename: 'server-bundle.js', libraryTarget: 'commonjs2' }, // 由于 Node 能够依赖 node_modules 运行,因此不须要打包 node_modules 中的依赖,外置化应用程序依赖模块,可使服务器构建速度更快,并生成较小的 bundle 文件。 externals: nodeExternals({ // 不要外置化 webpack 须要处理的依赖模块。你能够在这里添加更多的文件类型。例如,未处理 *.css 文件, whitelist: /\.css$/ }), plugins: [ // 这是将服务器的整个输出,构建为单个 JSON 文件的插件,默认文件名为 `vue-ssr-server-bundle.json` new VueSSRServerPlugin() ] }
// webpack.client.conf.js const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') module.exports = { entry: { app: path.resolve(__dirname, '../code/client/src/entry-client.js') }, plugins: [ // 这是将客户端的整个输出,构建为单个 JSON 文件的插件,默认文件名为 `vue-ssr-client-manifest.json` new VueSSRClientPlugin() ] }
经过 Vue 官方提供的 vue-server-render/server-plugin
vue-server-render/client-plugin
两个插件咱们在构建完成后生成了 vue-ssr-server-bundle.json
vue-ssr-client-manifest.json
。
// 这里的entry和files参数是vue-ssr-server-bundle.json中的entry和files字段,分别是应用的入口文件名和打包的文件内容集合。 { "entry": "server-bundle.js", "files": { "server-bundle.js": "module.exports=xxx..." } }
{ "publicPath": "/client/", "all": [ // 客户端打包生成的所有资源文件 "index.html", "static/js/app.7825d6691cb956e176c7.js", "static/js/manifest.ec516eefca3b4e60fa2e.min.js", "static/js/vendor.5c495484f630d50d4de0.js" ], "initial": [ // 会以 preload 的形式插入到服务端生成的 html 中的资源文件 "static/js/manifest.ec516eefca3b4e60fa2e.min.js", "static/js/vendor.5c495484f630d50d4de0.js", ], "async": [ // 会以 prefetch 的形式插入到服务端生成的 html 中的资源文件 "static/js/app.7825d6691cb956e176c7.js" ], "modules": { // 项目的各个模块包含的文件的序号,对应all中文件的顺序 "25965440": [ 3 ], ... } }
咱们会在这里调用这两个文件,来生成服务端渲染的 html。
const { createBundleRenderer } = require('vue-server-renderer') const serverBundle = require('path-to-vue-ssr-server-bundle.json/vue-ssr-server-bundle.json') const clientManifest = require('path-to-vue-ssr-client-manifest.json/vue-ssr-client-manifest.json') const renderer = createBundleRenderer(serverBundle, { clientManifest })
监听 Http 请求并调用 renderer.renderToString 生成 html 返回给客户端
const Koa = require('koa') const koaRouter = require('koa-router') const { createBundleRenderer } = require('vue-server-renderer') const serverBundle = require('path-to-vue-ssr-server-bundle.json/vue-ssr-server-bundle.json') const clientManifest = require('path-to-vue-ssr-client-manifest.json/vue-ssr-client-manifest.json') const app = new Koa() const router = koaRouter() const renderer = createBundleRenderer(serverBundle, { // 利用 serverBundle 和 clientManifest 生成 renderer clientManifest }) const renderData = function(context) { // 包装 renderToString 方法 return new Promise((resolve, reject) => { renderer.renderToString(context, (err, html) => { if (err) { return reject(err) } resolve(html) }) }) } router.get('*', async (ctx, next) => { let html try { html = await renderData(ctx) } catch (e) { if (e.code === 404) { // 处理渲染的异常状况 status = 404 html = '404 | Not Found' } else { status = 500 html = '500 | Internal Server Error' } } ctx.body = html // 返回构建的 html }) app.use(router.routes()).use(router.allowedMethods()) app.listen(3000)
在服务端渲染中,Vue 实例的生命周期只会执行 beforeCreate
created
两个生命周期,在这两个生命周期要注意区分是在 server 环境仍是在 浏览器环境,会占用全局内存的逻辑,如定时器、全局变量、闭包等,尽可能不要放在 beforeCreate、created 钩子中,不然在 beforeDestory 方法中将没法注销,致使内存泄漏。
SSR 项目比 SPA 项目要占用更多的服务器资源用于数据预取
与html 渲染
,比较耗费 CPU 资源和网络资源,
Node.js 虽然是单线程模型,可是其基于事件驱动、异步非阻塞模式,能够应用于高并发场景,避免了线程建立、线程之间上下文切换所产生的资源开销。可是却遇到大量计算,CPU 耗时的操做,则没法经过开启线程利用 CPU 多核资源,可是能够经过开启进程的方式,来利用服务器的多核资源。
const cluster = require('cluster') const http = require('http') let cupsLength = require('os').cpus().length if (cluster.isMaster) { while (cupsLength--) { cluster.fork() // 复制出其余的 worker 进程 } } else { // 执行端口监听的逻辑。 }
pm2 start index.js -i max
缓存能够利用 vue-ssr 提供的页面级缓存和组件缓存两种
const LRU = require('lru-cache') const renderer = createRenderer({ cache: LRU({ max: 10000, maxAge: ... }) })
export default { name: 'item', // 必填选项 props: ['item'], serverCacheKey: props => props.item.id, render(h) { return h('div', this.item.id) } }s
当咱们遇到大量的请求时,服务器压力过大或渲染出错时咱们须要抛弃服务端渲染该用客户端渲染。
当某次请求的服务端渲染出错时,中止服务端渲染,并将 SPA 应用的 HTML 模板返回给用户。
能够经过 Node 的 os.loadavg()
来获取最近 1 分钟的 cpu 占用率,当发现 Cpu 使用率太高时能够降级为 Spa 应用。
最后,双手奉上开箱即用的 Demo 吧: Vue-ssr 模版