關於 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 模版