vue2 + koa2 + webpack4 的SSR之旅

cd-dongzi發表於2018-03-25

前言

因為自己的部落格完全的前後端分離寫的,在 seo 這一塊也沒考慮過,於是乎,便開始了本次的SSR之旅

技術棧

vue2 + koa2 + webpack4 + mongodb

因為webpack也已經到了 4.1 的版本了,所以順帶把webpack3遷移到了webpack4

服務端渲染(SSR)

大概意思就是在服務端生成html片段,然後返回給客戶端

所以vue-ssr也可以理解為就是把我們以前在客戶端寫的 .vue檔案 轉換成 html片段,返回給客戶端。

實際上當然是會複雜點,比如服務端 返回 html 片段,客戶端直接接受顯示,不做任何操作的話,我們是無法觸發事件(點選事件等等)的。 為了解決上述問題。 所以 你通過 vue-server-renderer 進行渲染的話, 會在根節點上附帶一個 data-server-rendered="true" 的特殊屬性。 讓客戶端 Vue 知道這部分 HTML 是由 Vue 在服務端渲染的,並且應該以啟用模式進行掛載

**啟用模式:**指的是 Vue 在瀏覽器端接管由服務端傳送的靜態 HTML,使其變為由 Vue 管理的動態 DOM 的過程。 大概意思就是 服務端 已經渲染好了 html, 只不過服務端渲染過來的是靜態頁面,無法操作DOM 。 但是因為dom元素已經生成好了, 沒有必要丟棄重新建立。 所以客戶端便只需要啟用這些靜態頁面,讓他們變成動態的(能夠響應後續的資料變化)就行。

SSR優勢

  • 更好的 SEO,由於搜尋引擎爬蟲抓取工具可以直接檢視完全渲染的頁面。
  • 更快的內容到達時間(time-to-content),特別是對於緩慢的網路情況或執行緩慢的裝置。無需等待所有的 JavaScript 都完成下載並執行,才顯示伺服器渲染的標記,所以你的使用者將會更快速地看到完整渲染的頁面。通常可以產生更好的使用者體驗,並且對於那些「內容到達時間(time-to-content)與轉化率直接相關」的應用程式而言,伺服器端渲染(SSR)至關重要。

SSR開發需要注意的問題

  • 服務端渲染只會執行 vue 的兩個鉤子函式 beforeCreatecreated
  • 服務端渲染無法訪問 windowdocument等只有瀏覽器才有的全域性物件。(假如你專案裡面有全域性引入的外掛和JS檔案或著在beforeCreatecreated 用到了的這些物件的話,是會報錯的,因為服務端不存在這些物件。實在要用的話,可以試下這個外掛jsdom

基本上只要你對node有了解,會配置webpackvue能正常使用,基本上這東西實現起來還是比較輕鬆的,尤其官網給出了完整的例子HackerNews Demo,當然這個是基於express框架的,使用koa的話裡面中介軟體的使用需要做點修改。其餘的基本只需要跟著官網的例子來一遍就基本OK了 上面官網的例子需要終端翻牆才能訪問資料,如果不想的話可以看下這個例子,跟官網例子基本一樣掘金網站

這裡也大概說下官網的實現

專案目錄

src
├── components
│   ├── Foo.vue
│   ├── Bar.vue
│   └── Baz.vue
├── router
│   └── index.js
├── store
│   └── index.js
├── App.vue
├── app.js # universal entry
├── entry-client.js # 執行於客戶端的專案入口
└── entry-server.js # 執行於服務端的專案入口
複製程式碼

需要用到幾個知識點

  • vuex的使用,因為應用程式依賴於一些非同步資料,那麼在開始渲染過程之前,需要先預取和解析好這些資料。所以會使用的vuex來作為 資料預取儲存容器

  • asyncData自定義函式(獲取介面資料):

    <template>
      <div>{{ item.title }}</div>
    </template>
    <script>
    export default {
      // 自定義獲取資料的函式。
      asyncData ({ store, route }) {
        // 觸發 action 後,會返回 Promise
        return store.dispatch('fetchItem', route.params.id)
      },
      computed: {
        // 從 store 的 state 物件中的獲取 item。
        item () {
          return this.$store.state.items[this.$route.params.id]
        }
      }
    }
    </script>
    複製程式碼
  • 避免狀態單例: 當編寫純客戶端(client-only)程式碼時,我們習慣於每次在新的上下文中對程式碼進行取值。但是,Node.js 伺服器是一個長期執行的程式。當我們的程式碼進入該程式時,它將進行一次取值並留存在記憶體中。這意味著如果建立一個單例物件,它將在每個傳入的請求之間共享。 所以我們為每個請求建立一個新的根 Vue 例項 因此,我們不應該直接建立一個應用程式例項,而是應該暴露一個可以重複執行的工廠函式,為每個請求建立新的應用程式例項:

    // router.js
    import Vue from 'vue'
    import Router from 'vue-router'
    Vue.use(Router)
    export function createRouter () {
      return new Router({
        mode: 'history',
        routes: [
          // ...
        ]
      })
    }
    複製程式碼
    // store.js
    import Vue from 'vue'
    import Vuex from 'vuex'
    Vue.use(Vuex)
    // 假定我們有一個可以返回 Promise 的
    // 通用 API(請忽略此 API 具體實現細節)
    import { fetchItem } from './api'
    export function createStore () {
      return new Vuex.Store({
        state: {
          items: {}
        },
        actions: {
          fetchItem ({ commit }, id) {
            // `store.dispatch()` 會返回 Promise,
            // 以便我們能夠知道資料在何時更新
            return fetchItem(id).then(item => {
              commit('setItem', { id, item })
            })
          }
        },
        mutations: {
          setItem (state, { id, item }) {
            Vue.set(state.items, id, item)
          }
        }
      })
    }
    複製程式碼
    // app.js
    import Vue from 'vue'
    import App from './App.vue'
    import { createRouter } from './router'
    import { createStore } from './store'
    export function createApp () {
      // 建立 router 和 store 例項
      const router = createRouter()
      const store = createStore()
      // 建立應用程式例項,將 router 和 store 注入
      const app = new Vue({
        router,
        store,
        render: h => h(App)
      })
      // 暴露 app, router 和 store。
      return { app, router, store }
    }
    複製程式碼
    import {createApp} from './app'
    const {app, router, store} = createApp()
    複製程式碼

    按照上面的步驟方法,為每個請求建立新的應用例項,就不會因為多個請求造成 交叉請求狀態汙染(cross-request state pollution) 了

實現步驟

  1. 首先,獲取當前訪問的路徑,因為renderToString支援傳入一個上下文的渲染物件,所以我們傳入一個context物件,包含當前的url
```
// server.js 
const context = {
    url: ctx.url
}
renderer.renderToString(context, (err, html) => {
    if (err) {
        return reject(err)
    }
    console.log(html)
})
```
複製程式碼
  1. 然後中間經過webpack等配置,能讓服務端的專案入口entry-server.js接收到context
```
// entry-server.js
import {createApp} from './app'
export default context => {
    // 因為有可能會是非同步路由鉤子函式或元件,所以我們將返回一個 Promise.
    return new Promise((resolve, reject) => {
        const { app, router, store } = createApp()

        const { url } = context

        // 設定伺服器端 router 的位置
        router.push(url)

        // 等到 router 將可能的非同步元件和鉤子函式解析完
        router.onReady(() => {

            // 獲取當前路徑的元件
            const matchedComponents = router.getMatchedComponents()

            // 沒有返回404
            if (!matchedComponents.length) {
                return reject({ code: 404 })
            }

            // 如果該路徑存在,而且該路徑存在需要呼叫介面來預取資料的情況,便等所有`asyncData`函式執行完畢.
            // `asyncData`函式是元件自定義靜態函式, 用來提前獲取資料。
            Promise.all(matchedComponents.map( ({asyncData}) => asyncData && asyncData({
                store,
                route: router.currentRoute
            }))).then( () => {
                // 執行完畢後,因為獲取到的資料都統一存入 vuex 中, 上方 `asyncData` 裡面執行的方法就是呼叫 vuex 的 action, 然後把資料存入的 vuex 的 state 中
                // 所以我們便 store 裡面的 state 賦值給 `context.state`
                // 然後 `renderToString` 解析 html 的時候會把 `context.state` 裡面的資料 嵌入到 html 的 `window.__INITIAL_STATE__` 變數中 
                // 這樣我們到時候處理 客戶端 的時候,便可以把客戶端中 vuex 中的state 替換成 `window.__INITIAL_STATE__` 中的資料,來完成客戶端與服務端的資料統一
                context.state = store.state
                resolve(app)
            }).catch(reject)
        })
    })
}

```
複製程式碼
  1. 上面把我們當前訪問路徑的元件解析完成返回給客戶端,客戶端啟用這些靜態的html,因為我們服務端生成 html 獲取資料是通過 asyncData 函式,但是我們只有第一次請求服務端需要渲染,以後再進行頁面切換的時候不需要進行渲染的,但是 介面的呼叫 又放入了 asyncData 函式中,所以頁面切換的時候,我們客戶都需要處理 asyncData 函式,以前我們一般把資料放入 created 鉤子函式中,現在放入的時asyncData裡面,所以我們進行客戶端切換的時候,需要執行它。獲取資料
```
import {createApp} from './app'
const {app, router, store} = createApp()

// 把store中的state 替換成 window.__INITIAL_STATE__ 中的資料
if (window.__INITIAL_STATE__) {
    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))
        })

        const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
        if (!asyncDataHooks.length) {
            return next()
        }

        // 這裡如果有載入指示器(loading indicator),就觸發
        Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
            .then(() => {
                // 停止載入指示器(loading indicator)
                next()
            })
            .catch(next)
    })
    
    // 掛載到根節點上
    app.$mount('#app')
})

```
複製程式碼

基本上這樣就實現了vue-ssr的過程,具體原始碼及配置可以在我的 github 檢視。

webpack4

最明顯的點 是 webpack4 以後擁有預設值了,簡單配置一下便能使用 以下是預設值:

  • entry 的預設值是 ./src
  • output.path 的預設值是 ./dist
  • mode 的預設值是 production
  • UglifyJs 外掛預設開啟 caches 和 parallizes

在 mode 為 develoment 時:

  • 開啟 output.pathinfo
  • 關閉 optimization.minimize

在 mode 為 production 時:

  • 關閉 in-memory caching
  • 開啟 NoEmitOnErrorsPlugin
  • 開啟 ModuleConcatenationPlugin
  • 開啟 optimization.minimize

因為給自己部落格做ssr的通知也升級了webpack,接下來便看下 遷移至 webpack4 需要修改的部分 webpack 配置

  1. 將CLI移入到 webpack-cli 中,需要安裝 webpack-cli

  2. 通過設定 mode 變數來確定當前模式, 不配置會有警告

  • 命令列中配置 webpack --mode development
  • 檔案中配置
```
module.exports = {
    mode: 'development',
    entry: {
      app: resolve('src')
    },
    ...
```
複製程式碼
  1. webpack.optimize.CommonsChunkPlugin has been removed, please use config.optimization.splitChunks instead webpack4不再提供 webpack.optimize.CommonsChunkPlugin 來分割程式碼,需要用到新的屬性 optimization.splitChunks
```
output: {
  filename: assetsPath('js/[name].[chunkhash].min.js'),
},
optimization: {
  runtimeChunk: {
      name: "manifest"
  },
  splitChunks: {
    chunks: "initial",         // 必須三選一: "initial" | "all"(預設就是all) | "async"
    minSize: 0,                // 最小尺寸,預設0
    minChunks: 1,              // 最小 chunk ,預設1
    maxAsyncRequests: 1,       // 最大非同步請求數, 預設1
    maxInitialRequests: 1,    // 最大初始化請求書,預設1
    name: () => {},              // 名稱,此選項課接收 function
    cacheGroups: {                 // 這裡開始設定快取的 chunks
      priority: "0",                // 快取組優先順序 false | object |
      vendor: {                   // key 為entry中定義的 入口名稱
        chunks: "initial",        // 必須三選一: "initial" | "all" | "async"(預設就是非同步)
        test: /react|lodash/,     // 正則規則驗證,如果符合就提取 chunk
        name: "vendor",           // 要快取的 分隔出來的 chunk 名稱
        minSize: 0,
        minChunks: 1,
        enforce: true,
        maxAsyncRequests: 1,       // 最大非同步請求數, 預設1
        maxInitialRequests: 1,    // 最大初始化請求書,預設1
        reuseExistingChunk: true   // 可設定是否重用該chunk(檢視原始碼沒有發現預設值)
      }
    }
  }
},
...
```
複製程式碼
  1. compilation.mainTemplate.applyPluginsWaterfall is not a function
解決方案: `yarn add webpack-contrib/html-webpack-plugin -D`
複製程式碼
  1. Use Chunks.groupsIterable and filter by instanceof Entrypoint instead:
解決方案: `yarn add extract-text-webpack-plugin@next -D`
複製程式碼

升級webpack4也遇到了幾個問題

  1. 設定 optimization.splitChunks 打包。分別會打包 jscss 各一份, 不知道啥情況。

  2. 升級4以後,我用 DllPlugin打包, 但是 verdon 打包出來還是一樣大,並不會把 我指定的 模組提取出來。

  3. import 做按需載入好像不生效。 例如:const _import_ = file => () => import(file + '.vue'), 然後通過 _import_('components/Foo') 便能直接按需載入, 但是webpack4就沒生效,都是一次性載入出來的。

上面是我們升級4遇到的幾個問題,可能是我配置方面出錯了,但是webpack4 以前都是正常的。 具體我這邊的配置放到了 github 上。

總結

以上就是我這次個人部落格SSR 之旅。

github

部落格地址

相關文章