目录
简介(本博文访问我个人博客查看效果更佳)
MVVM框架做SSR(Server Side Rendering)前端圈提的很多了,是为了减少首屏渲染时间(First Contentful Paint),减少用户等待时间提升体验。但是国内有几家真正的做了SSR?做SSR难度大不大,到底要怎么做,整个流程是怎么样的?Vue官方的说明比较抽象,网上一搜都是各种简易的demo,没有触及真正的深度的使用,大部分web前端开发也是对其一知半解。基于此,本文对Vue做了一次SSR的工程化实践,可用于生产。
随着迭代,此博文有了多个续集,从初次尝试、性能压测、封装完善、经验总结,到上线运行的整个过程全面分享SSR的经验积累。
《不是非黑即白 – Vue SSR 在NodeJS环境的性能测试》《 Vue SSR工程实践续-高级封装与经验之谈》《Vue CSR & SSR 3G网络性能对比》《SSR缓存-使用Redis实现LRU-cache》(敬请期待)本文假定你已熟练掌握webpack、nodejs、vue等前端技能。
本文目录结构:
理论分析CSR与SSR差异CSR与SSR耗时决定因素工程搭建使用webpack区分两端构建源码CSS的处理特定平台API的解决办法SSR CSR的互切与降级容错SSR与CSR的首屏渲染时间实际对比NodeJS的性能测试单一页面SSR的Render耗时高并发情况下的NodeJS性能测试缓存策略理论分析CSR与SSR差异
web前端开发者知道,网页要在浏览器内将内容呈现在用户面前需要HTML与CSS即可。
CSR(客户端渲染)与SSR(服务端渲染)的区别是:
1、CSR的HTML由JavaScript代码将VDOM 转换成real DOM插入到DOM中,即可以认为客户端JS同过运行生成的HTML。
2、SSR则是由服务器(MVVM框架的SSR一般为NodeJS服务器)运行JavaScript生成HTML,发送到客户端。
由于网页的HTML、CSS、JS等需要同过网络下载,因此二者的加载耗时(这里指请求url到页面渲染完成需要的时间)表现根据环境会有所不同。那么二者究竟哪个网页加载更快呢?接下来从理论角度分析下;
加载耗时的决定因素有哪些?来做一道推理题
上面提到加载耗时通常指的是请求url到页面渲染完成需要的时间,用户点击网页链接(表明用户请求)到页面完整呈现(提供者给予的反馈)。
加载耗时 = 网络请求用时 + 数据服务API用时 + 服务端JS代码运行用时 + 返回document传输用时 + 解析document用时 + 下载stylesheet用时 + 浏览器渲染HTML与CSS用时 + 浏览器JS代码运行用时;
实际上网络请求用时、解析document用时、浏览器渲染HTML与CSS的用时可以忽略不计,进而精简得出:
加载耗时 = 数据服务API用时 +服务端JS代码运行用时 + 返回document传输用时 +下载stylesheet用时 + 浏览器JS代码运行用时;
在CSR与SSR情况下,CSR无服务器JS代码运行耗时,SSR 无浏览器JS代码运行耗时,此外CSR额外多了一个JS下载耗时,我们针对性的提炼下他们的用时公式;
CSR加载耗时 =数据服务API用时 + 返回document传输用时 +下载stylesheet用时 + 下载JS耗时 +浏览器JS代码运行用时
SSR加载耗时 =数据服务API用时 + 服务端JS代码运行用时 + 返回document传输用时 +下载stylesheet用时
排除次要因素,抓重点
实际上下载stylesheet用时CSR与SSR表现相同,CSR有的浏览器JS代码运行用时 = SSR才有的服务端JS代码运行用时,故而还可以精简得出以下公式:
CSR加载耗时 =数据服务API用时 + 返回document传输用时 +下载JS耗时
SSR加载耗时 =数据服务API用时 + 返回document传输用时
实际中
数据服务API用时: SSR < CSR(内网访问)返回document 传输用时: SSR > CSR (SSR HTML已经生成,内容较多)此二因素在极端情况下也决定作用,影响加载耗时。但在一般情况下,也可以忽略不计。此时下载JS的耗时,成了最为关键的因素,并且通常是不可忽略的。
其他因素综合起来作为一个常量值A,下载耗时 = JS体积(JSs) * 网速(Ns)
那么 CSR(t)= A + JSs * Ns; SSR(t)= A;
用图来表示
SSR的加载耗时较为恒定。客户端渲染的时间主要由网速、JS体积大小共同决定。网速是开发这不可控的因素,当网速越小则加载耗时越长、JS体积越大此特征越明显。因此可以看出SSR在JS体积较大时有优势,当JS体积不大的情况下,二者差距不是特别明显。
现在网速越来越快,移动端5G的到来也可以减弱这个差距。CSR可以通过“骨架屏”,“loading图”过度此加载时间、code splitting、lazyload非首屏代码等优化减弱这个差距。但无论怎么样,理论上CSR只能趋近于SSR,耗时不可能小于SSR。SSR对于首屏渲染时间非常关键的产品有意义(还有SEO),对于后台管理类等没有价值。
理解了SSR与CSR的区别,是本文的基石。如果到此觉得SSR意义不是很大的,就可以止步了(没有价值的东西就是垃圾,不看也罢)。如果你的产品需要做SSR,接下来可以跟随本文一起动手搭建工程了。
工程搭建
如果你想直接运行demo工程,可以参考我的github源码vue-ssr-starter,抑或是用multipages-generator 工具直接生成,vue-ssr-stater就是用此工具生成的。
从零开始
SSR需要用在nodejs中运行vue-server-render模块将应用代码生成html返回给浏览器,于是我们需要两套webpack,分别构建生成两份代码,一份用于客户端运行,一份用于NodeJS端运行。
目录搭建
基于express、webpack创建好上图中的目录,内容较多不做展开,简易读者自行搭建工程。
一个Vue应用的基本代码结构如下图:
编写通用app.js
import ./index.css import Vue from vue; import App from ./components/App; export function createApp(store) { let app = new Vue({ data: store, render: h => h(App) }); return { store, app, App} }createApp的参数store是一个外部传入的对象(简易版的Vuex,就是个普通对象);
App.vue
<template> <div id="app"> <div class="header__banner"></div> <h1>Vue SSR </h1> <bar></bar> <foo v-for="(hero, index) in heros" :key="index" :mdata="hero"></foo> </div> </template> <script> import Bar from ./Bar; import Foo from ./Foo; export default { name: "App", data(){ return { } }, // 暂时先忽略(静态方法,用于外部访问进行控制请求) asyncData(store){ return store.setHerosAction(); }, components: { Bar, Foo }, computed: { heros(){ return this.$root.$data.state.heros; } }, mounted(){ console.log(这是日志, window); }, methods: { } } </script> <style> </style>Bar.vue,Foo.vue 是简单的Vue组件,这里不做展开。
store.js如下所示:
import * as service from ./service var store ={ state: { message: hello, heros: [], }, setHerosAction() { return service.getHeros() .then( resp => { this.state.heros = resp.data; }) }, addHerosAction(newValue){ this.state.heros = newValue; }, clearHerosAction(){ this.state.heros = []; } } export function createStore() { return store; }store.js中依赖的service.js是一个promise对象;
service.js
import axios from axios; export function getHeros(params){ returnPromise.resolve({ data: [雷神, 美队, 黑寡妇, 钢铁侠] }) }client、server webpack打包入口拆分;
entry-client.js
import {createStore} from "./app/store"; import { deepExtend } from "../../common/js/commonUtils"; import {createApp} from "./app/app"; let store = createStore(); // 服务端构建后返回的html script中带有数据挂在window对象中,此处将数据放到全局状态中 store.state = deepExtend(store.state, window.__INITIAL_STATE__); let { app } = createApp(store); // 第二个参数为true来做客户端激活 app.$mount(#app, true);有同学问服务端渲染完成后,客户端是重新render,还是接着继续做呢?服务端渲染完成了,客户端再来一遍就浪费了,当然是没有必要的了。客户端只需做一个“激活”,在dom上加事件就可以了。vue会判断SSR与CSR的是否内容一致,如果不一致CSR会再来一遍覆盖的。
entry-server.js
import { createApp } from "./app/app"; import { createStore } from "./app/store"; // 导出为function,webpack构建后的代码由nodejs调用 export default context => { const store = createStore(); const { app, App } = createApp(store); // 调用App组件中的静态方法,获取数据 return App.asyncData(store) .then( resp => { context.state = store.state; return app; }) }上面为什么App组件要做一个静态方法暴露给外部调用呢?因为服务端渲染需要先获取数据, 然后才能根据数据进行VDOM转换成HTML。否则数据还没获取完,html就返回给浏览器了。来看看nodejs端的调用者
routes/demo.js
const fs = require(fs); const path = require(path); const { createBundleRenderer } = require(vue-server-renderer); const env = process.env.ENV !== prod ? dev : prod; // bundle即entry-server.js入口webpack打包后的文件 const bundle = fs.readFileSync(path.resolve(server/ssr_code/demo/index-server.js), utf8); // 调用createBundleRenderer,根据template生成renderer对象 const renderer = createBundleRenderer(bundle, { template: fs.readFileSync(path.resolve(server/views/ + env + /demo/index.html), utf8) }); module.exports.index = function(req, res){ const context = { url: req.url } // renderer对象根据context参数生成html renderer.renderToString(context, (err, html) => { if(err){ console.log(err); } res.end(html) }) };上面这就是最基本的源码了。完了吗?还没,这上面的代码用vue 官网的SSR说明与网络上搜的demo没什么差别,好戏刚刚开始。
使用webpack区分两端构建源码
这里起了个express服务用于热编译entry-client、用webpack.watch热编译entry-server、起了一个服务专门用于跑服务端。这样可以一个命令起3个服务,高效开发。
// ... 忽略 const webpackClient = require(../webpack/webpack.client.config); const webpackSever = require(../webpack/webpack.server.config); // 步骤一、热编译client端项目 const compiler = webpack(webpackClient); compiler.plugin(done, function(){ setTimeout(() => { spinner.stop(); console.log(chalk.bgGreen(\n √ Build done ) + \n); console.log(chalk.magenta(`[Tips] visit: :${port}/${projectName}/`)); console.log(chalk.magenta(`: http://${ip()}:${port}/${projectName}/`) + \n); }, 0); }); const middleware = webpackDevMiddleware(compiler, { publicPath: webpackClient.output.publicPath, // html only writeToDisk: filePath => /\.html$/.test(filePath), }); app.use(middleware); app.use(webpackHotMiddleware(compiler)); app.use(proxy(`:${mgConfig.server.port}`)); app.listen(8080); // 步骤二、服务端监听构建Vue const serverCompiler = webpack(webpackSever()); const watching = serverCompiler.watch({ // watchOptions 示例 aggregateTimeout: 300, poll: }, (err, stats) => { // 在这里打印 watch/build 结果... console.log(chalk.bgGreen(\n √ Server compile done ) + \n); if (err) { console.error(err); return; } if(stats.hasErrors()){ console.log(chalk.red(\n ✖️ Server compile Error ) + \n); console.log(stats.toString({ chunks: false,// 使构建过程更静默无输出 colors: true// 在控制台展示颜色 })); } }); // 步骤三、4秒后启动服务端服务 setTimeout(function () { // 启动代理服务端 const processServer = exec(`npm run server`); processServer.stdout.on(data, stats => { process.stdout.write(stats.toString({ colors: true, modules: false, children: false, chunks: false, chunkModules: false }) + \n); }); }, 6000)webpack的配置,client端没有什么特殊的,正常的配置。server端的需要注意一些环节,只需生成js与css,其他一概不需要,并且注意以下内容:
var config = { mode: isDev ? development : production, entry: readServerEntrys(moduleBS, pageBS), target: node,// 注意1: 需要指定target 为node,webpack会按node环境来build output: { path: resolve(`server/ssr_code/${moduleBS}`), // 说明1: 输出位置与服务端使用的位置需要匹配 publicPath: publicPath, filename: [name].js, libraryTarget: commonjs2// 注意2: libraryTarget需用commonjs2 }, module: { rules: [ ... 省略 { test: /\.(le|c)ss$/, use: [ MiniCssExtractPlugin.loader, // 注意3: 需要提取CSS,这里是webpack4 css-loader, postcss-loader, less-loader ] }, ... 省略 ] }, plugins: [ new VueLoaderPlugin(), new MiniCssExtractPlugin({ filename: ../../../dist/[name].[contenthash:8].css, // 说明2: css输出特别注意,见CSS的处理章节 chunkFilename: [id].[contenthash:8].css }) ] }CSS的处理
由于client、server端分别打包,由于Vue loader在处理CSS Module的时候加上了moduleID 即 data-v-xxxx,如下图所示
<div id="app" data-server-rendered="true"> <div class="header__banner"></div> <h1>Vue SSR </h1> <div class="blockBar" data-v-2013d139> // 这里加了data-v-xxxxx,为CSS module id <div data-v-2013d139> <div class="swiper-wrapper" data-v-2013d139> <div class="swiper-slide" data-v-2013d139> <div class="banner" data-v-2013d139> <img src="300.jpg" alt data-v-2013d139> </div> </div> <div class="swiper-slide" data-v-2013d139> <div class="banner" data-v-2013d139> <img src="360.png" alt data-v-2013d139> </div> </div> </div> <div class="swiper-pagination" data-v-2013d139></div> </div> </div> <div class="block card" data-v-03cb93d8> <div class="card__img" data-v-03cb93d8> <img src="60_360.png" alt data-v-03cb93d8> </div> <div class="card__text" data-v-03cb93d8> <div class="card__title" data-v-03cb93d8>孕期饮食很重要,怎么吃是关键</div> <div content="card__subtitle" data-v-03cb93d8>妇幼专家联合母婴达人共同出品</div> </div> </div> </div>由于两份编译,两份css,服务端render生成的html用的data-v-xxxxx必须用sever端打包生成的css,这样样式才对的上。所以实际上client打包生成的css是无用的。(当然,不用Vue的CSS module也就没这个问题了);
我工程中为了顺滑的解决此问题,在发布build的时候用server端的css覆盖client的css,如下所示:
... 略 // 获取命令携带的参数 const webpackClient = require(path.resolve(build/webpack/webpack.client.config.js)); const webpackServer = require(path.resolve(build/webpack/webpack.server.config.js)); const spinner = new Spinner(Building...\n); const clientPack = function(){ return new Promise((resolve, reject) => { webpack(webpackClient, (err, stats) => { spinner.stop(); if (err) throw err; process.stdout.write(stats.toString({ colors: true, modules: false, children: false, chunks: false, chunkModules: false }) + \n); resolve(true); }); }) } const serverPack = function(){ return new Promise((resolve, reject) => { webpack(webpackServer(), (err, stats) => { spinner.stop(); if (err) throw err; process.stdout.write(stats.toString({ colors: true, modules: false, children: false, chunks: false, chunkModules: false }) + \n); resolve(true); }); }) } serverPack() .then( () => clientPack()) .then( () => { replaceCssContent(); if(mgConfig.upload.autoUpload === true){ doUpload(); } }) .catch(err => { console.log(build 出错, err); }); /** * client 与 server 构建的CSS module id 不同导致同构的css代码不同,目前使用server构建的css来临时解决;即: * 用server构建的的css覆盖client构建的css */ function replaceCssContent(){ let files = fs.readdirSync(path.resolve(dist)); let serverPackedCSS = []; // 遍历找出所有server pack的css文件 files.forEach( file => { if(file.endsWith(.css) && file.indexOf(-server.) !== -1){ serverPackedCSS.push(file); } }) // 根据文件命名规则 page-server.xxxxxx.css,找出相应的css文件名,做覆盖 serverPackedCSS.forEach( serverCssFile => { let page = serverCssFile.split(-server)[0]; files.forEach( item => { if(item.endsWith(.css) && item.indexOf( page + .) !== -1){ fs.writeFileSync(path.resolve(dist, item), fs.readFileSync(path.resolve(dist, serverCssFile), utf8)) } }) }) }特定平台API的解决办法
通常我们会在前端代码中调用某些平台的api,如浏览器的window对、jssdk、自家公司app的JSbridge方法等等,这些在node中运行是会报错的。
解决方法:
一、在Vue组件的其他生命周期中调用,SSR只会调用beforeCreate,created两个生命周期的代码,其他的生命周期如beforedmouted、mouted、beforeupdate,updated等等不会运行。
mounted(){ window.addEventListener(scroll, function (e) { console.log(这是日志) }) }二、通过环境判断是否运行。
有些代码不在Vue组件内运行的,通过此方法在任意位置可运行
export function isBrowser(){ return global.toString() === [object Window] || global.toString() === [object DOMWindow]; } if(isBrowser()){ // 特定平台api调用 }三、惰性加载代码来运行
通过codesplitting,lazyload等方法在浏览器中惰性加载运行。
SSR CSR的互切与降级容错
互切
有时候业务场景需要CSR与SSR可以临时切换,比如某些大的平台的客户端做了静态模板,不需要做SSR就要CSR,离开了这个平台又需要做SSR比如分享到中。所以有此需求。很简单,做个参数判断来决定是走SSR还是CSR。下面的代码还比较简单,需要依据业务场景做下封装
module.exports.index = function(req, res){ // 互切 if(req.query.ssr === true){ const context = { url: req.url } renderer.renderToString(context, (err, html) => { if(err){ // 降级容错 res.render(demo/index) console.log(err); } res.end(html) }) } else{ res.render(demo/index) } };降级容错
SSR有可能导致报错,此时不应该是直接出错,此处应该做个容错,降级为CSR。
entry-client如何做兼容
import {createStore} from "./app/store"; import { deepExtend } from "../../common/js/commonUtils"; import {createApp} from "./app/app"; let store = createStore(); store.state = deepExtend(store.state, window.__INITIAL_STATE__); let { app, App } = createApp(store); // 降级方案,判断是否要做CSR if(isCSR()){ createAppEl(); // 降级后数据也要先预取再挂载 App.asyncData(store) .then( resp => { app.$mount(#app, true); }) } else { app.$mount(#app, true); } // 检查有没有appid,没有则表示是CSR,创建一个div,设置id为app function isCSR(){ let appEl = document.getElementById(app); return !appEl; } function createAppEl(){ let appEl = document.createElement(div); appEl.setAttribute(id, app) document.querySelector(body).insertBefore(appEl, document.querySelector(body).firstChild); }判断是否有id为app的div(注意这里是约定的),如果无则表明应该为切换为CSR、或者降级为CSR。需要做CSR了。
API的代理与跨域
我们知道Nodejs经常做中间层做API代理解决跨域问题,此情况下兼容情况下如果即可以让ajax在CSR情况下走代理,SSR情况下正常请求呢?
//express中的app.js, 代理api app.use(/gravidity-api, proxy({ target:changeOrigin: true })) //client service.js 中的请求 export function getHeros(params){ let host = isBrowser() ? "" : ""; return axios.get(host + /gravidity-api/v2/knowledge_column_list_v2, { params }); }其实也就是做个环境的判断,经常这么写也不是个办法,需要基于业务做封装。
SSR与CSR的首屏渲染时间实际对比
SSR做完了,究竟首屏加载耗时提升了多少呢,根据之前的理论分析,js体积越大SSR优势越明显。于是做了两次测试;
测试工具:用google的chrome的插件lighthouse分析业务场景:请求一个真实阿里云服务器上的api,渲染一个数据列表代码环境:prod,即代码都经过构建、压缩上传到CDN上测试一:代码量 gizp 后33kb;
33kb代码量的CSR与SSR对比出乎意料,竟然差不多。查看了下返回的response,确实已经有区分了
测试二:为了增加代码体积,加入了2个比较大的组件,eruda、vue-swiper,体积gizp后达到将近200kb
200kb代码量下的CSR与SSR对比增加了js体积后,效果就明显了。SSR的首屏渲染时间更优;这也印证了前面推理的CSR、SSR耗时的公式是正确的。
感想,对于业务量较大的业务SSR使用可能较佳。对于很简单的业务SSR必要性不是很强。随着未来5G的普及,差距会更小。这可能也前端圈SSR比较少做的原因之一。
NodeJS的性能测试
SSR的渲染都放在了服务端,性能的压力都集中在了服务器,服务器在高并发的情况下,性能究竟如何?服务器能够承受什么样的业务量?如何检测服务器性能?如何使用缓存?这个环节单独用一章去说明。请看《并不是非黑即白-Vue SSR NodeJS侧的性能测试》,感谢阅读。