前言
因為自己的部落格完全的前後端分離寫的,在 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
的兩個鉤子函式beforeCreate
和created
- 服務端渲染無法訪問
window
和document
等只有瀏覽器才有的全域性物件。(假如你專案裡面有全域性引入的外掛和JS檔案或著在beforeCreate
和created
用到了的這些物件的話,是會報錯的,因為服務端不存在這些物件。實在要用的話,可以試下這個外掛jsdom
基本上只要你對node
有了解,會配置webpack
,vue
能正常使用,基本上這東西實現起來還是比較輕鬆的,尤其官網給出了完整的例子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) 了
實現步驟
- 首先,獲取當前訪問的路徑,因為
renderToString
支援傳入一個上下文的渲染物件,所以我們傳入一個context物件,包含當前的url
```
// server.js
const context = {
url: ctx.url
}
renderer.renderToString(context, (err, html) => {
if (err) {
return reject(err)
}
console.log(html)
})
```
複製程式碼
- 然後中間經過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)
})
})
}
```
複製程式碼
- 上面把我們當前訪問路徑的元件解析完成返回給客戶端,客戶端啟用這些靜態的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
配置
-
將CLI移入到
webpack-cli
中,需要安裝webpack-cli
-
通過設定
mode
變數來確定當前模式, 不配置會有警告
- 命令列中配置
webpack --mode development
- 檔案中配置
```
module.exports = {
mode: 'development',
entry: {
app: resolve('src')
},
...
```
複製程式碼
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(檢視原始碼沒有發現預設值)
}
}
}
},
...
```
複製程式碼
compilation.mainTemplate.applyPluginsWaterfall is not a function
解決方案: `yarn add webpack-contrib/html-webpack-plugin -D`
複製程式碼
Use Chunks.groupsIterable and filter by instanceof Entrypoint instead:
解決方案: `yarn add extract-text-webpack-plugin@next -D`
複製程式碼
升級webpack4
也遇到了幾個問題
-
設定
optimization.splitChunks
打包。分別會打包js
、css
各一份, 不知道啥情況。 -
升級4以後,我用
DllPlugin
打包, 但是 verdon 打包出來還是一樣大,並不會把 我指定的 模組提取出來。 -
import 做按需載入好像不生效。 例如:
const _import_ = file => () => import(file + '.vue')
, 然後通過_import_('components/Foo')
便能直接按需載入, 但是webpack4
就沒生效,都是一次性載入出來的。
上面是我們升級4遇到的幾個問題,可能是我配置方面出錯了,但是webpack4
以前都是正常的。
具體我這邊的配置放到了 github 上。
總結
以上就是我這次個人部落格的 SSR
之旅。