服务端渲染(SSR)和浏览器端渲染 (CSR)

转载服务端渲染(SSR) - 知乎 (zhihu.com)

一、什么是浏览器端渲染 (CSR)?

CSR是Client Side Render简称;页面上的内容是我们加载的js文件渲染出来的,js文件运行在浏览器上面,服务端只返回一个html模板。


CSR加载图

二、什么是服务器端渲染 (SSR)?

SSR是Server Side Render简称;页面上的内容是通过服务端渲染生成的,浏览器直接显示服务端返回的html就可以了。


SSR加载图

本文以Vue.js 做为演示框架来区分SSR和CSR。默认情况下,Vue.js可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。

服务器渲染的 Vue.js 应用程序也可以被认为是"同构"或"通用",因为应用程序的大部分代码都可以在服务器和客户端上运行。

附:vue-ssr官方文档

基本用法 | Vue SSR 指南ssr.vuejs.org/zh/guide/

三、不同渲染方式在浏览器解析情况

从输入页面URL到页面渲染完成大致流程为:

解析URL

浏览器本地缓存

DNS解析

建立TCP/IP连接

发送HTTP请求

服务器处理请求并返回HTTP报文

浏览器根据深度遍历的方式把html节点遍历构建DOM树

遇到CSS外链,异步加载解析CSS,构建CSS规则树

遇到script标签,如果是普通JS标签则同步加载并执行,阻塞页面渲染,如果标签上有defer / async属性则异步加载JS资源

将dom树和CSS DOM树构造成render树

渲染render树


performance.timing


CSR-浏览器performance情况


SSR-浏览器performance情况

FP:首次绘制。用于标记导航之后浏览器在屏幕上渲染像素的时间点。这个不难理解,就是浏览器开始请求网页到网页首帧绘制的时间点。这个指标表明了网页请求是否成功。

FCP:首次内容绘制。FCP 标记的是浏览器渲染来自 DOM 第一位内容的时间点,该内容可能是文本、图像、SVG 甚至 <canvas> 元素。

FMP:首次有效绘制。这是一个很主观的指标。根据业务的不同,每一个网站的有效内容都是不相同的,有效内容就是网页中"主角元素"。对于视频网站而言,主角元素就是视频。对于搜索引擎而言,主角元素就是搜索框。

TTI:可交互时间。用于标记应用已进行视觉渲染并能可靠响应用户输入的时间点。应用可能会因为多种原因而无法响应用户输入:①页面组件运行所需的JavaScript尚未加载完成。②耗时较长的任务阻塞主线程

根据上图devtool时间轴的结果,虽然CSR配合预渲染方式(loading、骨架图)可以提前FP、FCP从而减少白屏问题,但无法提前FMP;SSR将FMP提前至js加载前触发,提前显示网页中的"主角元素"。SSR不仅可以减少白屏时间还可以大幅减少首屏加载时间。

附:首屏时间获取方法

前端 白屏时间如何获取?18 赞同 · 9 评论回答

四、node服务(server.js)

第一步 利用express框架写一个简单node服务

Express是基于Node.js平台,快速、开放、极简的 Web 开发框架

/*第一步 利用express框架写一个简单node服务*/letexpress=require('express');letapp=express();app.get('*',function(req,res){res.send('hello world');});constport=process.env.PORT||8080app.listen(port,()=>{console.log(`server started at localhost:${port}`)})

附:express文档

Express - 基于 Node.js 平台的 web 应用开发框架www.expressjs.com.cn/

第二步 利用vue-server-renderer提供的createRenderer将vue与node结合

renderer.renderToString(vm, context?, callback?): ?Promise<string>

将 Vue 实例渲染为字符串。上下文对象 (context object) 可选。回调函数是典型的 Node.js 风格回调,其中第一个参数是可能抛出的错误,第二个参数是渲染完毕的字符串。

/*第一步 利用express框架写一个简单node服务第二步 利用vue-server-renderer提供的createRenderer将vue与node结合*/

let express=require('express');

let app=express();

constVue=require('vue')constrenderer=require('vue-server-renderer').createRenderer()app.get('*',function(req,res){render(req,res)});functionrender(req,res){constapp=newVue({data:{url:req.url},template:`<div>req.url:{{ url }}</div>`})renderer.renderToString(app,(err,html)=>{if(err){res.status(500).end('Internal Server Error')return}else{res.end(`${html}`)}})}constport=process.env.PORT||8080app.listen(port,()=>{console.log(`server started at localhost:${port}`)})

第三步 读入index.template.html文件

创建 renderer 时提供一个页面模板。多数时候,我们会将页面模板放在特有的文件中,例如index.template.html

<!DOCTYPE html><htmllang="en"><head><title>Hello</title></head><body><!--vue-ssr-outlet--></body></html>

<!--vue-ssr-outlet-->注释 -- 这里将是应用程序 HTML 标记注入的地方。

/*第一步 利用express框架写一个简单node服务第二步 利用vue-server-renderer提供的createRenderer将vue与node结合第三步 读入index.template.html文件*/letexpress=require('express');letapp=express();constVue=require('vue')constpath=require('path')constresolve=file=>path.resolve(__dirname,file)constrenderer=require('vue-server-renderer').createRenderer({template:require('fs').readFileSync(resolve('./src/index.template.html'),'utf-8')})app.get('*',function(req,res){render(req,res)});functionrender(req,res){constapp=newVue({data:{url:req.url},template:`<div>req.url:{{ url }}</div>`})constcontext={title:'ssr测试',}renderer.renderToString(app,context,(err,html)=>{if(err){res.status(500).end('Internal Server Error')return}else{res.end(`${html}`)}})}constport=process.env.PORT||8080app.listen(port,()=>{console.log(`server started at localhost:${port}`)})

第四步 引入已经打包好的vue-ssr-server-bundle.json

vue-server-renderer 提供一个名为 createBundleRenderer 的 API,用于处理此问题,通过使用 webpack 的自定义插件,server bundle 将生成为可传递到 bundle renderer 的特殊 JSON 文件。所创建的 bundle renderer,用法和普通 renderer 相同,但是 bundle renderer 提供以下优点:

const renderer = createBundleRenderer(serverBundle, {

  runInNewContext: false, // 推荐,bundle 代码将与服务器进程在同一个 global 上下文中运行

  template, // (可选)页面模板

  clientManifest // (可选)客户端构建 manifest

})

内置的 source map 支持(在 webpack 配置中使用 devtool: 'source-map')

在开发环境甚至部署过程中热重载(通过读取更新后的 bundle,然后重新创建 renderer 实例)

关键 CSS(critical CSS) 注入(在使用 *.vue 文件时):自动内联在渲染过程中用到的组件所需的CSS。

使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk。

/*第一步 利用express框架写一个简单node服务第二步 利用vue-server-renderer提供的createRenderer将vue与node结合第三步 读入index.template.html文件第四步 引入已经打包好的vue-ssr-server-bundle.json*/letexpress=require('express');letapp=express();constpath=require('path')constresolve=file=>path.resolve(__dirname,file)consttemplatePath=resolve('./src/index.template.html')constserverBundle=require('./dist/vue-ssr-server-bundle.json')const{createBundleRenderer}=require('vue-server-renderer')letrenderer=createBundleRenderer(serverBundle,{template:require('fs').readFileSync(templatePath,'utf-8'),//clientManifest 客户端构建 manifest 暂不演示})app.get('*',function(req,res){render(req,res)});functionrender(req,res){constcontext={title:'ssr测试',url:req.url// 传递path,这个参数很重要}renderer.renderToString(context,(err,html)=>{if(err){res.status(500).end('Internal Server Error')return}else{res.end(`${html}`)}})}constport=process.env.PORT||8080app.listen(port,()=>{console.log(`server started at localhost:${port}`)})

第五步 将bundle换成webpack实时输入的内存的bundle(非生产环境)

webpack 默认使用普通文件系统来读取文件并将文件写入磁盘。但是,还可以使用不同类型的文件系统(内存(memory), webDAV 等)来更改输入或输出行为。为了实现这一点,可以改变inputFileSystem或outputFileSystem

调用watch方法会触发 webpack 执行器,但之后会监听变更(很像 CLI 命令:webpack --watch),一旦 webpack 检测到文件变更,就会重新执行编译。该方法返回一个Watching实例。

/*第一步 利用express框架写一个简单node服务第二步 利用vue-server-renderer提供的createRenderer将vue与node结合第三步 读入index.template.html文件第四步 引入已经打包好的vue-ssr-server-bundle.json第五步 将bundle换成webpack实时输入的内存的bundle*/letexpress=require('express');letapp=express();constpath=require('path')constresolve=file=>path.resolve(__dirname,file)consttemplatePath=resolve('./src/index.template.html')//const bundle = require('./dist/vue-ssr-server-bundle.json')constwebpack=require('webpack')constserverConfig=require('./build/webpack.server.config')constMFS=require('memory-fs')constreadFile=(fs,file)=>{try{returnfs.readFileSync(path.join(serverConfig.output.path,file),'utf-8')}catch(e){}}const{createBundleRenderer}=require('vue-server-renderer')letrenderer;app.get('*',function(req,res){render(req,res)});functionrender(req,res){constcontext={title:'ssr测试',url:req.url// 传递path,这个参数很重要}renderer.renderToString(context,(err,html)=>{if(err){res.status(500).end('Internal Server Error')return}else{res.end(`${html}`)}})}constserverCompiler=webpack(serverConfig)constmfs=newMFS()serverCompiler.outputFileSystem=mfs//打包至内存中serverCompiler.watch({},(err,stats)=>{if(err)throwerrletbundle=JSON.parse(readFile(mfs,'vue-ssr-server-bundle.json'))renderer=createBundleRenderer(bundle,{template:require('fs').readFileSync(templatePath,'utf-8'),})})constport=process.env.PORT||8080app.listen(port,()=>{console.log(`server started at localhost:${port}`)})

附:webpack在Node.js 中的API

Node.js API | webpack 中文网www.webpackjs.com/api/node/

五、剖析构建流程

构建流程

通用配置(Base Config)

服务器配置 (Server Config)

服务器配置,是用于生成传递给 createBundleRenderer 的 server bundle。它应该是这样的:

constmerge=require('webpack-merge')constnodeExternals=require('webpack-node-externals')constbaseConfig=require('./webpack.base.config.js')constVueSSRServerPlugin=require('vue-server-renderer/server-plugin')module.exports=merge(baseConfig,{// 将 entry 指向应用程序的 server entry 文件entry:'/path/to/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',// 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)output:{libraryTarget:'commonjs2'},// https://webpack.js.org/configuration/externals/#function// https://github.com/liady/webpack-node-externals// 外置化应用程序依赖模块。可以使服务器构建速度更快,// 并生成较小的 bundle 文件。externals:nodeExternals({// 不要外置化 webpack 需要处理的依赖模块。// 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,// 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单whitelist:/\.css$/}),// 这是将服务器的整个输出// 构建为单个 JSON 文件的插件。// 默认文件名为 `vue-ssr-server-bundle.json`plugins:[newVueSSRServerPlugin()]})

在生成 vue-ssr-server-bundle.json 之后,只需将文件路径传递给 createBundleRenderer:

const{createBundleRenderer}=require('vue-server-renderer')constrenderer=createBundleRenderer('/path/to/vue-ssr-server-bundle.json',{// ……renderer 的其他选项})

客户端配置 (Client Config)

除了 server bundle 之外,我们还可以生成客户端构建清单 (client build manifest)。使用客户端清单 (client manifest) 和服务器 bundle(server bundle),renderer 现在具有了服务器和客户端的构建信息,因此它可以自动推断和注入资源预加载 / 数据预取指令(preload / prefetch directive),以及 css 链接 / script 标签到所渲染的 HTML。

好处是双重的:

在生成的文件名中有哈希时,可以取代 html-webpack-plugin 来注入正确的资源 URL。

在通过 webpack 的按需代码分割特性渲染 bundle 时,我们可以确保对 chunk 进行最优化的资源预加载/数据预取,并且还可以将所需的异步 chunk 智能地注入为 <script> 标签,以避免客户端的瀑布式请求 (waterfall request),以及改善可交互时间 (TTI - time-to-interactive)。

要使用客户端清单 (client manifest),客户端配置 (client config) 将如下所示:

constwebpack=require('webpack')constmerge=require('webpack-merge')constbaseConfig=require('./webpack.base.config.js')constVueSSRClientPlugin=require('vue-server-renderer/client-plugin')module.exports=merge(baseConfig,{entry:'/path/to/entry-client.js',plugins:[// 重要信息:这将 webpack 运行时分离到一个引导 chunk 中,// 以便可以在之后正确注入异步 chunk。// 这也为你的 应用程序/vendor 代码提供了更好的缓存。newwebpack.optimize.CommonsChunkPlugin({name:"manifest",minChunks:Infinity}),// 此插件在输出目录中// 生成 `vue-ssr-client-manifest.json`。newVueSSRClientPlugin()]})

六、编写通用代码

组件生命周期钩子函数,由于没有动态更新,所有的生命周期钩子函数中,只有beforeCreate和created会在服务器端渲染 (SSR) 过程中被调用。这就是说任何其他生命周期钩子函数中的代码(例如beforeMount或mounted),只会在客户端执行

2.通用代码不可接受特定平台的 API,因此如果你的代码中,直接使用了像window或document,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此(global)

解决方案:

(1)在beforeCreate,created生命周期以及全局的执行环境中调用特定的api前需要判断执行环境;

(2)使用adapter模式,写一套adapter兼容不同环境的api。

七、数据预取存储容器

通用 entry(app.js)

app.js 是我们应用程序的「通用 entry」。在纯客户端应用程序中,我们将在此文件中创建根 Vue 实例,并直接挂载到 DOM。但是,对于服务器端渲染(SSR),责任转移到纯客户端 entry 文件。app.js 简单地使用 export 导出一个 createApp 函数

服务端数据预取 (Server entry)

在entry-server.js中,我们可以通过路由获得与router.getMatchedComponents()相匹配的组件,如果组件暴露出asyncData,我们就调用这个方法。然后我们需要将解析完成的状态,附加到渲染上下文(render context)中。

// entry-server.jsimport{createApp}from'./app'exportdefaultcontext=>{// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,// 以便服务器能够等待所有的内容在渲染前,// 就已经准备就绪。returnnewPromise((resolve,reject)=>{const{app,router,store}=createApp()// 设置服务器端 router 的位置router.push(context.url)// 等到 router 将可能的异步组件和钩子函数解析完router.onReady(()=>{constmatchedComponents=router.getMatchedComponents()//当前路由匹配到组件if(!matchedComponents.length){returnreject({code:404})}// 等到 router 将可能的异步组件和钩子函数解析完// 对所有匹配的路由组件调用 `asyncData()`Promise.all(matchedComponents.map(Component=>{if(Component.asyncData){returnComponent.asyncData({store,route:router.currentRoute})}})).then(()=>{// 在所有预取钩子(preFetch hook) resolve 后,// 我们的 store 现在已经填充入渲染应用程序所需的状态。// 当我们将状态附加到上下文,// 并且 `template` 选项用于 renderer 时,// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。context.state=store.stateresolve(app)}).catch(reject)},reject)})}

客户端数据预取 (Client entry)

router.onReady该方法把一个回调排队,在路由完成初始导航时调用,这意味着它可以解析所有的异步进入钩子和路由初始化相关联的异步组件。router.beforeResolve在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后,解析守卫就被调用。

router.onReady(()=>{// 添加路由钩子函数,用于处理 asyncData.// 在初始路由 resolve 后执行,// 以便我们不会二次预取(double-fetch)已有的数据。// 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。router.beforeResolve((to,from,next)=>{constmatched=router.getMatchedComponents(to)//当前路由匹配的组件数组 constprevMatched=router.getMatchedComponents(from)// 我们只关心非预渲染的组件// 所以我们对比它们,找出两个匹配列表的差异组件letdiffed=falseconstactivated=matched.filter((c,i)=>{returndiffed||(diffed=(prevMatched[i]!==c))})if(!activated.length){returnnext()}// 这里如果有加载指示器 (loading indicator),就触发Promise.all(activated.map(c=>{if(c.asyncData){returnc.asyncData({store,route:to})}})).then(()=>{// 停止加载指示器(loading indicator)next()}).catch(next)})app.$mount('#app')})

同一个组件不同参数切换路由时会触发重用组件内部beforeRouteUpdate,通过全局mixin路由钩子来监听调用asyncData方法拉取数据进行客户端渲染

Vue.mixin({

    beforeRouteUpdate (to, from, next) {

    const { asyncData } = this.$options

        if (asyncData) {

            asyncData({

                store: this.$store,

                route: to }).then(next).catch(next)

          } else {

            next()

            }

    }

})

附:完整的导航解析流程

导航被触发。

在失活的组件里调用离开守卫。

调用全局的 beforeEach 守卫。

在重用的组件里调用 beforeRouteUpdate 守卫 (2.2+)。

在路由配置里调用 beforeEnter。

解析异步路由组件。

在被激活的组件里调用 beforeRouteEnter。

调用全局的 beforeResolve 守卫 (2.5+)。

导航被确认。

调用全局的 afterEach 钩子。

触发 DOM 更新。

用创建好的实例调用 beforeRouteEnter 守卫中传给 next 的回调函数。

导航守卫 | Vue Routerrouter.vuejs.org/zh/guide/advanced/navigation-guards.html#%E5%AE%8C%E6%95%B4%E7%9A%84%E5%AF%BC%E8%88%AA%E8%A7%A3%E6%9E%90%E6%B5%81%E7%A8%8B

八、服务器部署

进程管理pm2

cluster模式(多实例多进程模式)启动服务--watch参数,意味着当你的express应用代码发生变化时,pm2会帮你重启服务。

pm2 start server.js -i 4 --watch

或者pm2 -i 4 start npm -- run start --watch(同npm run start)

查询所有服务 pm2 list


附:pm2的cluster模式官方介绍

PM2 - Cluster Modepm2.keymetrics.io/docs/usage/cluster-mode/


nginx反向代理

修改nginx.config文件,增加对应虚拟主机反向代理到node对应的服务端口

    server {

        listen      80;

        server_name  csyry.com;

        location / {

            proxy_pass  http://127.0.0.1:8080;

            index  index.html index.htm;

        }

    }

重启nginx服务器: sudo nginx -s reload

附:nginx中文配置文档

Nginx中文文档www.nginx.cn/doc/

修改DNS

正式环境通过域名服务商修改映射解析,本机测试修改/etc/hosts文件

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,333评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,812评论 1 298
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,016评论 0 246
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,293评论 0 214
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,650评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,788评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,003评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,741评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,462评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,681评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,168评论 1 262
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,528评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,169评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,119评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,902评论 0 198
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,846评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,719评论 2 274

推荐阅读更多精彩内容