服務端渲染(SSR)vs客戶端渲染(CSR)

1、什麼是瀏覽器端渲染 (CSR)?

CSR是Client Side Render簡稱;頁面上的內容是咱們加載的js文件渲染出來的,js文件運行在瀏覽器上面,服務端只返回一個html模板。javascript

CSR加載圖

2、什麼是服務器端渲染 (SSR)?

SSR是Server Side Render簡稱;頁面上的內容是經過服務端渲染生成的,瀏覽器直接顯示服務端返回的html就能夠了。css

SSR加載圖


本文以Vue.js 作爲演示框架來區分SSR和CSR。默認狀況下,Vue.js能夠在瀏覽器中輸出 Vue 組件,進行生成 DOM 和操做 DOM。然而也能夠將同一個組件渲染爲服務器端的 HTML 字符串,將它們直接發送到瀏覽器,最後將這些靜態標記"激活"爲客戶端上徹底可交互的應用程序。html

服務器渲染的 Vue.js 應用程序也能夠被認爲是"同構"或"通用",由於應用程序的大部分代碼均可以在服務器和客戶端上運行。vue

附:vue-ssr官方文檔java

基本用法 | Vue SSR 指南​

3、不一樣渲染方式在瀏覽器解析狀況

從輸入頁面URL到頁面渲染完成大體流程爲:node

  • 解析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不只能夠減小白屏時間還能夠大幅減小首屏加載時間。webpack


4、node服務(server.js)

第一步 利用express框架寫一個簡單node服務nginx

Express是基於Node.js平臺,快速、開放、極簡的 Web 開發框架git

/* 第一步 利用express框架寫一個簡單node服務 */
var express = require('express');
var app = express();

app.get('*', function(req, res){
 res.send('hello world');
});

const port = process.env.PORT || 8080
app.listen(port, () => {
 console.log(`server started at localhost:${port}`)
})
複製代碼

附:express文檔github

Express - 基於 Node.js 平臺的 web 應用開發框架​

第二步 利用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結合 */
var express = require('express');
var app = express();

const Vue = require('vue')
const renderer = require('vue-server-renderer').createRenderer()

app.get('*', function(req, res){
    render(req,res)
});
function render(req, res) {
    const app = new Vue({
        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}`)
        }
    })
}

const port = process.env.PORT || 8080
app.listen(port, () => {
  console.log(`server started at localhost:${port}`)
})
複製代碼


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

建立 renderer 時提供一個頁面模板。多數時候,咱們會將頁面模板放在特有的文件中,例如index.template.html

<!DOCTYPE html> <html lang="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文件 */
var express = require('express');
var app = express();
const Vue = require('vue')

const path = require('path')
const resolve = file => path.resolve(__dirname, file)
const renderer = require('vue-server-renderer').createRenderer({
    template: require('fs').readFileSync( resolve('./src/index.template.html'), 'utf-8')
})

app.get('*', function(req, res){
    render(req,res)
});
function render(req ,res){
    const app = new Vue({
        data: {
            url: req.url
        },
        template: `<div>req.url:{{ url }}</div>`
    })
    const context = {
        title: 'ssr測試',
    }
    renderer.renderToString(app,context, (err, html) => {
        if (err) {
            res.status(500).end('Internal Server Error')
            return
        }else{
            res.end(`${html}`)
        }
    }) 
}


const port = process.env.PORT || 8080
app.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 提供如下優勢:

  • 內置的 source map 支持(在 webpack 配置中使用 devtool: 'source-map'
  • 在開發環境甚至部署過程當中熱重載(經過讀取更新後的 bundle,而後從新建立 renderer 實例)
  • 關鍵 CSS(critical CSS) 注入(在使用 *.vue 文件時):自動內聯在渲染過程當中用到的組件所需的CSS。更多細節請查看 CSS 章節。
  • 使用 clientManifest 進行資源注入:自動推斷出最佳的預加載(preload)和預取(prefetch)指令,以及初始渲染所需的代碼分割 chunk。
/* 第一步 利用express框架寫一個簡單node服務 第二步 利用vue-server-renderer提供的createRenderer將vue與node結合 第三步 讀入index.template.html文件 第四步 引入已經打包好的vue-ssr-server-bundle.json */
var express = require('express');
var app = express();

const path = require('path')
const resolve = file => path.resolve(__dirname, file)
const templatePath = resolve('./src/index.template.html')
const bundle = require('./dist/vue-ssr-server-bundle.json')

const { createBundleRenderer } = require('vue-server-renderer')
let renderer = createBundleRenderer(bundle, {
    template: require('fs').readFileSync(templatePath, 'utf-8'),
})

app.get('*', function (req, res) {
    render(req, res)
});
function render(req, res) {
    const context = {
        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}`)
        }
    })
}

const port = process.env.PORT || 8080
app.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 */
var express = require('express');
var app = express();

const path = require('path')
const resolve = file => path.resolve(__dirname, file)
const templatePath = resolve('./src/index.template.html')
//const bundle = require('./dist/vue-ssr-server-bundle.json')

const webpack = require('webpack')
const serverConfig = require('./build/webpack.server.config')
const MFS = require('memory-fs')
const readFile = (fs, file) => {
    try {
        return fs.readFileSync(path.join(serverConfig.output.path, file), 'utf-8')
    } catch (e) { }
}


const { createBundleRenderer } = require('vue-server-renderer')
let renderer;
app.get('*', function (req, res) {
    render(req, res)
});
function render(req, res) {
    const context = {
        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}`)
        }
    })
}


const serverCompiler = webpack(serverConfig)
const mfs = new MFS()
serverCompiler.outputFileSystem = mfs //打包至內存中
serverCompiler.watch({}, (err, stats) => {
    if (err) throw err
    let bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
    renderer = createBundleRenderer(bundle, {
        template: require('fs').readFileSync(templatePath, 'utf-8'),
    })
})

const port = process.env.PORT || 8080
app.listen(port, () => {
    console.log(`server started at localhost:${port}`)
})
複製代碼

附:webpack在Node.js 中的API

Node.js API | webpack 中文網​


5、剖析構建流程

構建流程


通用配置(Base Config)


服務器配置 (Server Config)

服務器配置,是用於生成傳遞給 createBundleRenderer 的 server bundle。它應該是這樣的:

const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base.config.js')
const VueSSRServerPlugin = 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: [
    new VueSSRServerPlugin()
  ]
})
複製代碼

在生成 vue-ssr-server-bundle.json 以後,只需將文件路徑傳遞給 createBundleRenderer

const { createBundleRenderer } = require('vue-server-renderer')
const renderer = 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。

好處是雙重的:

  1. 在生成的文件名中有哈希時,能夠取代 html-webpack-plugin 來注入正確的資源 URL。
  2. 在經過 webpack 的按需代碼分割特性渲染 bundle 時,咱們能夠確保對 chunk 進行最優化的資源預加載/數據預取,而且還能夠將所需的異步 chunk 智能地注入爲 <script> 標籤,以免客戶端的瀑布式請求 (waterfall request),以及改善可交互時間 (TTI - time-to-interactive)。

要使用客戶端清單 (client manifest),客戶端配置 (client config) 將以下所示:

const webpack = require('webpack')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base.config.js')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(baseConfig, {
  entry: '/path/to/entry-client.js',
  plugins: [
    // 重要信息:這將 webpack 運行時分離到一個引導 chunk 中,
    // 以即可以在以後正確注入異步 chunk。
    // 這也爲你的 應用程序/vendor 代碼提供了更好的緩存。
    new webpack.optimize.CommonsChunkPlugin({
      name: "manifest",
      minChunks: Infinity
    }),
    // 此插件在輸出目錄中
    // 生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin()
  ]
})
複製代碼

6、編寫通用代碼

  1. 組件生命週期鉤子函數,因爲沒有動態更新,全部的生命週期鉤子函數中,只有beforeCreatecreated會在服務器端渲染 (SSR) 過程當中被調用。這就是說任何其餘生命週期鉤子函數中的代碼(例如beforeMountmounted),只會在客戶端執行

   2.通用代碼不可接受特定平臺的 API,所以若是你的代碼中,直接使用了像windowdocument,這種僅瀏覽器可用的全局變量,則會在 Node.js 中執行時拋出錯誤,反之也是如此(global)

解決方案:

  • (1)在beforeCreate,created生命週期以及全局的執行環境中調用特定的api前須要判斷執行環境;
  • (2)使用adapter模式,寫一套adapter兼容不一樣環境的api。

7、數據預取存儲容器

通用 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.js
import { createApp } from './app'

export default context => {
  // 由於有可能會是異步路由鉤子函數或組件,因此咱們將返回一個 Promise,
  // 以便服務器可以等待全部的內容在渲染前,
  // 就已經準備就緒。
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()
    // 設置服務器端 router 的位置
    router.push(context.url)
    // 等到 router 將可能的異步組件和鉤子函數解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()//當前路由匹配到組件
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }
      // 等到 router 將可能的異步組件和鉤子函數解析完
      // 對全部匹配的路由組件調用 `asyncData()`
      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)
    }, reject)
  })
}
複製代碼


客戶端數據預取 (Client entry)

router.onReady該方法把一個回調排隊,在路由完成初始導航時調用,這意味着它能夠解析全部的異步進入鉤子和路由初始化相關聯的異步組件。router.beforeResolve在導航被確認以前,同時在全部組件內守衛和異步路由組件被解析以後,解析守衛就被調用。

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))
    })

    if (!activated.length) {
      return next()
    }

    // 這裏若是有加載指示器 (loading indicator),就觸發

    Promise.all(activated.map(c => {
      if (c.asyncData) {
        return c.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()
    }
  }
})
複製代碼

附:完整的導航解析流程

  1. 導航被觸發。
  2. 在失活的組件裏調用離開守衛。
  3. 調用全局的 beforeEach 守衛。
  4. 在重用的組件裏調用 beforeRouteUpdate 守衛 (2.2+)。
  5. 在路由配置裏調用 beforeEnter
  6. 解析異步路由組件。
  7. 在被激活的組件裏調用 beforeRouteEnter
  8. 調用全局的 beforeResolve 守衛 (2.5+)。
  9. 導航被確認。
  10. 調用全局的 afterEach 鉤子。
  11. 觸發 DOM 更新。
  12. 用建立好的實例調用 beforeRouteEnter 守衛中傳給 next 的回調函數。
導航守衛 | Vue Router​

8、服務器部署

進程管理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 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中文文檔​

修改DNS

正式環境經過域名服務商修改映射解析,本機測試修改/etc/hosts文件

相關文章
相關標籤/搜索