昨天提到了關於 SEO 的狀況,那麼,我們是不是使用 SSR 就能解決那些問題?關於這件事情我還是持保留態度。我們今天會帶大家來看一下 Vue 的 SSR 是怎麼運作的。
至於為何我沒有採用,沒有為什麼,因為我不會用啊(啊哈哈哈哈)
SSR 要如何開始?
SSR 的全名為 Server Side Render,顧名思義,就是先在伺服器端渲染的意思。那麼,跟原本使用 JavaScript 前端渲染,到底為何又要牽扯到後端?最主要的目的還是為了解決 SEO 的問題,而其他的事情大概是連帶的一些好處。
比起使用預先渲染,或是別人幫我渲染,對於 SSR 來說,他的好處多半是你自己可以控制所需要渲染的區塊,以及最終渲染的結果來做後續處理。對於 Vue 來說,爾或是各種前端渲染框架,這樣的作法可以讓你不用寫兩次程式碼,然後一次就能搞定(理想狀況下)。
官方有些先決條件必須要留意:
- vue & vue-server-renderer 2.3.0+
- vue-router 2.5.0+
- vue-loader 12.0.0+ & vue-style-loader 3.0.0+
滿足了以上這些條件之後,我們就可以開始準備使用 SSR 的工作。但是在開始之前,有些事情還是必須要跟大家說明一下。SSR 並不是萬靈丹,他只是提供了比單純前端渲染,對於 SEO 更友善,且對於內容呈現的效率更好一點的方式,其他就沒有了。缺點倒也是不少,對於各位影響比較大的,多數在於許多第三方套件,並不一定能完全支援 SSR 的架構,所以這一點必須自己去衡量。
再者,如果你想要支援 SSR,你所撰寫的程式碼會跟平常有一點不一樣。官方也有提及,你的程式碼必須是所謂的通用程式碼( Universal Code ),差異在於,當我們使用伺服器端渲染你的程式碼時,當你用了 window
或是 document
這一類 瀏覽器端 才會有的全域變數時,在 NodeJS 執行過程中,就會拋出錯誤。
請記得,因為 SSR 是在 伺服器端 運作,所以,所有關於 瀏覽器端 的事情,對他來說都是未知的東西,因此,倘若你,或是你所使用的第三方套件當中,有使用瀏覽器端的專屬操作的時候,那麼你的 SSR 就會因此而中斷。所以,你就必須想辦法替換,或是在這個區塊避開 SSR 的運作。
結構更動
首先,在使用 SSR 之前,有幾個項目是必須要注意的:
- 預設入口依舊為
main.js
,但是他不做$mount
。 main.js
需要提供一個建立 App 的函式,提供後續使用。- 需要額外建立兩個入口檔案,用以區分前端與後端渲染:
entry-client.js
給前端用的檔案。entry-server.js
給後端用的檔案。
- 你的所有程式碼必須符合通用程式碼規範,連同你所使用的套件也是。除非,你的套件在後端上不會用到。
- 準備一台 NodeJS 來幫你做後端渲染,例如 Express 或 Koa。
首先我們來看 main.js
會變成什麼樣子,這邊盡量精簡程式碼,主要讓你們方便理解為主:
import Vue from 'vue'
import Vuex from 'vuex'
import Router from 'vue-router'
import App from './App.vue'
Vue.use(Vuex)
Vue.config.productionTip = false
export function createApp() {
const router = new Router({
mode: 'history',
routes: [
{
path: '/hello',
name: 'Hello',
component: () => import('@/components/Hello.vue')
}
]
})
const app = new Vue({
router,
render: h => h(App)
});
return { app, router };
}
原本的 new Vue
不見了,取而代之的是一個建立 App 的函式 createApp()
,然後就沒有然後了。
接著我們要提供兩個入口檔案,分別給前後端使用,以下是 entry-client.js
的部分:
import { createApp } from './main'
const { app, router } = createApp()
router.onReady(() => {
app.$mount("#app")
})
這裡是 entry-server.js
的部分:
import { createApp } from './main'
export default context => {
return new Promise((resolve, reject) => {
const { app, router } = createApp()
router.push(context.url)
router.onReady(() => {
resolve(app)
}, reject)
})
}
你會發現我有使用 Router,是的,如果你不要用也可以,這裡會提及,是因為 Router 在 SSR 的部分有幾個地方需要注意,所以我直接使用 Router 的範例給大家做參考。再者,如果你真的做要簡單的 SPA ( Single Page Application ) 的話,jQuery 就很好用了, 不要學人家用什麼 Vue 而且還用 SSR 你不覺得奇怪我都覺得很奇怪。
至於為何要提及 Router 這個部分,你留意到我的 Router 是使用懶加載了嗎?是的,當我們使用 SSR 的時候,建議在 Router 的部分使用懶加載操作,一方面可以減低客戶端在初始化的時間,也能有效降低伺服器的壓力。
然後這樣就可以跑了嗎? 當然不行。 我們還有不少設定檔需要跟著做調整,而且還 真的不少 ,所以,我說那個 醬汁 設定檔呢?我們以 vue.config.js
來看(注意!前方高能!)
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const nodeExternals = require('webpack-node-externals')
const webpack = require('webpack')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const path = require('path')
const resolve = file => path.resolve(__dirname, file)
// 正式環境或是開發環境
const isProd = process.env.NODE_ENV === 'production'
// 用環境變數來區隔建立的目標。
const TARGET_NODE = process.env.BUILD_TARGET === 'node'
// 一個是伺服器端,一個是客戶端。
const target = TARGET_NODE ? 'server' : 'client'
module.exports = {
publicPath: '/',
assetsDir: 'static',
css: {
extract: true,
sourceMap: !isProd
},
devServer: {
headers: { 'Access-Control-Allow-Origin': '*' },
disableHostCheck: true
},
productionSourceMap: !isProd,
transpileDependencies: [],
configureWebpack: config => ({
entry: `./src/entry-${target}.js`,
target: TARGET_NODE ? 'node' : 'web',
node: TARGET_NODE ? undefined : false,
output: {
libraryTarget: TARGET_NODE ? 'commonjs2' : undefined
},
externals: TARGET_NODE
? nodeExternals({
whitelist: [/\.css$/, /\?vue&type=style/]
})
: undefined,
optimization: {
splitChunks: TARGET_NODE ? false : undefined
},
plugins: [
TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin(),
new webpack.DefinePlugin({
'process.env.VUE_ENV': `'${target}'`,
'process.env.NODE_DEPLOY': `'${process.env.NODE_DEPLOY}'`
})
].concat(
!isProd
? []
: new CopyWebpackPlugin([
{
from: resolve('./server'),
to: resolve('./dist/server'),
toType: 'dir',
ignore: [
'.DS_Store'
]
},
{
from: resolve('./package.json'),
to: resolve('./dist')
},
{
from: resolve('./yarn.lock'),
to: resolve('./dist')
},
{
from: resolve('./package-lock.json'),
to: resolve('./dist')
}
])
)
}),
chainWebpack: config => {
if (TARGET_NODE) {
const isExtracting = config.plugins.has('extract-css')
if (isExtracting) {
const langs = ['css', 'postcss', 'scss', 'sass', 'less', 'stylus']
const types = ['vue-modules', 'vue', 'normal-modules', 'normal']
for (const lang of langs) {
for (const type of types) {
const rule = config.module.rule(lang).oneOf(type)
rule.uses.delete('extract-css-loader')
rule
.use('vue-style')
.loader('vue-style-loader')
.before('css-loader')
}
}
config.plugins.delete('extract-css')
}
config.module
.rule('vue')
.use('cache-loader')
.tap(options => {
// Change cache directory for server-side
options.cacheIdentifier += '-server'
options.cacheDirectory += '-server'
return options
})
}
config.module
.rule('vue')
.use('vue-loader')
.tap(options => {
if (TARGET_NODE) {
options.cacheIdentifier += '-server'
options.cacheDirectory += '-server'
}
options.optimizeSSR = TARGET_NODE
return options
})
if (TARGET_NODE) {
config.plugins.delete('hmr')
}
}
}
第一次執行
上述設定都好了之後,你可以試著跑跑看:
NODE_ENV=production BUILD_TARGET=node yarn build
你會收到兩個警告,一個是 server
資料夾不存在,另一個是 package-lock.json
不存在。不過沒關係,由於我們是使用 yarn
所以 package-lock.json
不存在可以忽略,或是你乾脆把 vue.congif.js
裡面拷貝這個檔案的設定拿掉也可以。
至於 server
我們後續會繼續提到,他主要是放置我們 NodeJS 的服務器設定的地方,例如你要使用 Express 或是 Koa,相關的執行檔案會放在這裡面。
接著我們跑一下非伺服器端,也就是前端渲染的建置:
NODE_ENV=production yarn build --no-clean
請注意,後者執行的必須要加上 --no-clean
的參數,不然你會把之前 dist/
的東西清除。
這是我們第一次執行建置,是不是能跑我們還不知道,你會發現我們總共多出了兩個檔案:
vue-ssr-server-bundle.json
vue-ssr-client-manifest.json
這兩個檔案,其實之後是要餵給 NodeJS 的服務使用的,這個時候,我們可以來看看 server
裡面需要什麼東西。首先,我們需要一個 js 檔案來執行服務,我們這邊採用 Koa 來當作網頁伺服的服務提供,所以,我們建立好 server
資料夾後,可以建立一個 index.js
來撰寫程式碼。
詳細內容我就不貼出來了,這邊就片段的介紹執行的部分。
// 寫個變數來判斷是正式環境還是開發環境。
const isProd = process.env.NODE_ENV === 'production'
// 讀取 index 樣版檔案。
const template = fs.readFileSync(resolve('./index.template.html'), 'utf-8')
// 這是建立伺服器端元件的工具。
const { createBundleRenderer } = require('vue-server-renderer')
// 把剛剛的檔案讀取進來
const bundle = require("../vue-ssr-server-bundle.json")
const clientManifest = require("../vue-ssr-client-manifest.json")
然後關於 Koa 的部分就不多著墨,我們先把靜態檔案路徑的地方搞定,
app.use(mount('/static', koaStatic(resolve('../static'))))
接著,我們做一個簡單的 async 函式來幫我們處理 SSR 的請求,
async function ssrRequestHandle(ctx, next) {
ctx.set('Content-Type', 'text/html')
const context = {
title: 'SSR PAGE TITLE',
description: '',
keywords: '',
ssrHeadAddInfo: '',
url: ctx.url,
cookies: ctx.cookie || {},
userAgent: ctx.header['user-agent']
}
try {
ctx.body = await renderer.renderToString(context)
} catch (err) {
handleError(ctx, err)
}
}
當中的 handleError
是處理例外錯誤的地方,你可以自由發揮。最後我們把這個 ssrRequestHandle
丟去給 Koa 使用。
app.use(async (ctx, next) => {
await ssrRequestHandle(ctx, next)
})
請注意!我們這邊並沒有判斷所謂的 特定使用者頁面 ,換句話說,有些不適合做 SSR 的頁面你必須要在這邊就先過濾掉。不然,你可能會發現,例如,使用者登入後,畫面跟其他使用者好像長得差不多,然後資料可能也一樣之類的狀況。
接著,我們需要在 server
資料夾,放上一個 index.template.html
來讓他當作樣版使用,
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{{ title }}</title>
<meta name="description" content="{{ description }}">
<meta name="keywords" content="{{ keywords }}"> {{{ ssrHeadAddInfo }}}
</head>
<body data-server-rendered-page="true">
<!--vue-ssr-outlet-->
</body>
</html>
請注意,這個文件中有 <!--vue-ssr-outlet-->
請不要忽略,他是 Vue SSR 套件用來將資料注入的地方,如果你拿掉了,會有錯誤。
然後我們可以把 Koa 跑起來看一下,
node dist/server/index.js
我們上面有提過了,對於使用 SSR 的部分,不能使用瀏覽器方面的變數,所以你就會像這個樣子出現了錯誤。我們把資訊修正過之後,你就可以看到正常渲染的頁面了。
你會發現 HelloKitty
被呼叫了兩次,原因是 favicon.ico
因為找不到檔案的關係,所以他就直接再次去呼叫 index.html
,所以 Koa 就很乖的再次幫你渲染一次了。不過這個靜態檔案的問題,你可以用 Koa 的套件去解決,我們這邊就不多著墨:
然後我們剛剛不是有使用 Router 嗎?我們設定了一個叫做 Hello
的頁面,我們現在切換到那個頁面看看:
看起來運作正常,但是你會發現,欸?我的 <meta>
與 title
怎麼好像沒改變?是的,這個部分你需要自己製作工具來連帶更新 TDK( Title, Description, Keyword )。
更新頁面資料
那麼,我們要怎麼更新畫面上的資料呢?首先,我們的 index.template.html
已經有先放好了 TDK 與其他擴充的變數( ssrHeadAddInfo
),我以當我們在使用伺服器端渲染時,我們的元件,特別是 Router 底下的元件,必須要能夠去更新 TDK 才行。
官方文件有提及這一點:
所以你有 serverPrefetch
這個方法可以用,對了,上述文件不要覺得是英文的很難懂,因為你若是切到中文,不好意思,我等一下講的東西,中文資料都沒有。
serverPrefetch
是伺服器渲染專用的方法,他必須要回傳一個 Prmoise 物件,在擁有此方法的物件中,該物件會等待這個方法完成後,才會進行渲染的動作。
export default {
name: 'Hello',
components: {
},
data () {
return {
foo: '爽 2'
}
},
serverPrefetch () {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve()
}, 3000)
})
},
mounted () {
console.log('Hello mounted')
}
}
如果我們這麼做,那我們直接在 Hello
頁面重新整理的話,他會等 3 秒之後,才會將頁面返回。也就是說,我們在這邊可以用來取得物件。然後,有一個雷點在這裡:
serverPrefetch
雖然可以使用this
,但是你無法直接更改元件內的數值,例如,你想把上述的foo: '爽 2'
,透過serverPrefetch
來改成foo: '爽 100'
是沒有用的。再說一次,這樣改是沒有用的!請搭配 Vuex 來操作這些事情。
好的,現在你知道了 serverPrefetch
這件事情,那麼我們要怎麼更新 TDK 呢?我們在 Vue 這個物件當中,有一個屬性叫做 $ssrContext
,他代表了幾件事情:
- 可替換你在
index.template.html
裡面的變數。 - 你需要在
server/index.js
對於ssrRequestHandle
的函式做修改。 - 他只能在伺服器端運作。
- 所以你的
entry-server.js
拿到的context
也是從 Koa 進去的。 - 從前端看這個屬性,會永遠都是
undefined
。
所以,我們剛剛如果要換 TDK 的話,可以這樣做:
export default {
name: 'Hello',
components: {
},
data () {
return {
foo: '爽 2'
}
},
serverPrefetch () {
this.$ssrContext.title = 'SSR HELLO PAGE'
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve()
}, 3000)
})
},
mounted () {
console.log('Hello mounted')
}
}
我們的 $ssrContext
其實來源是上面的 Koa 的設定:
async function ssrRequestHandle(ctx, next) {
ctx.set('Content-Type', 'text/html')
const context = {
title: 'SSR PAGE TITLE',
description: '',
keywords: '',
ssrHeadAddInfo: '',
url: ctx.url,
cookies: ctx.cookie || {},
userAgent: ctx.header['user-agent']
}
try {
ctx.body = await renderer.renderToString(context)
} catch (err) {
handleError(ctx, err)
}
}
這樣你應該知道你可以設定什麼東西了。所以剛剛的例子裡面,我們就可以把網頁的 title
修改成 SSR HELLO PAGE
的標題了。
搭配 Vuex
剛剛提到你無法直接修改元件中的變數,所以,你還是得把 Vuex 拿進來使用,操作方式跟一般的 Vuex 沒有太大的差異,差別只在於,你的元件與伺服器之間,必須要先把儲存的事情做好,這樣才能正確的顯示資料。
所以我們改一下 main.js
加入 Vuex 的套件,然後我們寫一個簡單的資料:
import Vue from 'vue'
import Vuex from 'vuex'
import Router from 'vue-router'
import App from './App.vue'
Vue.use(Router)
Vue.use(Vuex)
Vue.config.productionTip = false
export function createApp() {
const router = new Router({
mode: 'history',
routes: [
{
path: '/hello',
name: 'Hello',
component: () => import('@/components/Hello.vue')
}
]
})
const store = new Vuex.Store({
state: {
age: 18
},
mutations: {
incrementAge: function (state) {
state.age++
}
},
actions: {
ohMyAge: function ({ commit }) {
commit('incrementAge')
}
},
getters: {
getAge: function (state) {
return state.age
}
}
})
const app = new Vue({
router,
store,
render: h => h(App)
});
return { app, router, store };
}
接著我們需要更換 entry-server.js
的部分:
import { createApp } from './main'
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
router.push(context.url)
router.onReady(() => {
// 加入這件事情
context.rendered = () => {
context.state = store.state
}
resolve(app)
}, reject)
})
}
最後修改 entry-client.js
的地方:
import { createApp } from './main'
const { app, router, store } = createApp()
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
router.onReady(() => {
app.$mount('#app')
})
以上的修改在官方文件也是有說明:
最後我們將應用程式重新建置,然後跑一次試試看:
然後你就會看到資料從原本的 18 變成了 19,那麼我們 Hello.vue
做了什麼事情呢?
import { mapActions, mapGetters } from 'vuex'
export default {
name: 'Hello',
components: {
},
data () {
},
serverPrefetch () {
return new Promise((resolve, reject) => {
this.$ssrContext.title = 'SSR HELLO PAGE'
setTimeout(() => {
// 從這邊呼叫 Vuex Actions
this.ohMyAge()
resolve()
}, 3000)
})
},
mounted () {
console.log('Hello mounted')
},
computed: {
changeFoo () {
return '爽 ' + this.getAge
},
...mapGetters({
getAge: 'getAge'
})
},
methods: {
...mapActions({
ohMyAge: 'ohMyAge'
})
}
}
然後我們會想說 serverPrefetch
是從伺服器後端所操作,那麼,當我使用前端的 Router 的時候,原本的 this.ohMyAge()
就不會被呼叫到了。所以,我們還是需要把這件事情,放到 mounted
或是 beforeRouteEnter
裡面,這樣對於前端渲染的動作才會有動作。
我們先來看看結果:
有沒有發現哪裡怪怪的?是的,原本的 18
突然變成 21
了,這個狀況在 Hello
頁面重新整理之後,就會發生這種怪事。照理來說,我們在 Vuex.Store
的預設值是 18
,進入了 Hello
頁面,理論上應該是 19
,但是,由於我們調整了 mounted
或 routeEnterBefore
,所以,原本的 ohMyAge()
會 再次 被呼叫一次。
那麼,理論上應該是
20
才對,為何是21
呢?
我把 console.log()
埋到 Vuex 當中,印出來看看發生了什麼事情:
有沒有一種超想罵髒話的感覺,有沒有!有沒有啊!
我們再來看看伺服器端的渲染,印出了什麼東西來。
然後我們換到 mounted
這個生命週期的勾子,再試一次:
有沒有一種超想罵髒話的感覺,有沒有!有沒有啊!
剛剛有提到過,mounted
是屬於 前端渲染 的事件,所以,他並不會在後端跑一次。而剛剛的 beforeRouteEnter
會在 後端先跑一次 ,然後 前端再跑一次 ,所以你會得到 21
的這個結果。但是,其實 20
應該也不對,照理說我們需要的是剛剛的 19
,其他應該都不是正確答案。
那麼,我們要怎麼確保這些事情只做一次呢?首先,我們可以告訴他,這個呼叫 Actions 的動作是從那邊來的,舉例來說:
export default {
// 前略...
serverPrefetch () {
return new Promise((resolve, reject) => {
this.$ssrContext.title = 'SSR HELLO PAGE'
setTimeout(() => {
this.setSource('server')
this.ohMyAge()
resolve()
}, 3000)
})
},
computed () {
needInitialize () {
return this.getSource === 'client' || this.getSoruce === null
},
...mapGetters({
getAge: 'getAge',
getSource: 'getSource'
})
},
mounted () {
console.log('Hello mounted')
if (this.needInitialize) {
this.ohMyAge()
}
},
methods: {
...mapActions({
ohMyAge: 'ohMyAge'
}),
...mapMutations({
setSource: 'setSource'
})
},
// 後略...
}
然後我們在 Vue.Store
裡面動個手腳,
const store = new Vuex.Store({
state: {
age: 18,
source: null
},
mutations: {
incrementAge: function (state) {
state.age += 1
},
setSource: function (state, source) {
state.source = source
}
},
actions: {
ohMyAge: function ({ commit }) {
commit('incrementAge')
}
},
getters: {
getAge: function (state) {
return state.age
},
getSource: function (state) {
return state.source
}
}
})
所以,這樣你最後會拿到的結果就是 19
了。
有沒有一種超想罵髒話的感覺,有沒有!有沒有啊!
最後加起來再來一次。這裡有一個專案都寫好了,可以參考一下:
我不保證上面的專案你一定可以跑,因為我的設定也跟他有點不太一樣。至於會踩到什麼雷點?可能每個人的環境不同,所以多少會有點差異。這個專案裡面的諸多設定,大概都是從大家回報的問題中,慢慢累積而來的。
雷點
SSR app with async components, 500s when using component styles
normalizeFile get undefined file
Vuejs SSR Issues
SSR build says “document is not defined” for a Vuejs cli3 SPA app using a Vue cli3 library
Stackoverflow tagged vue-ssr
我沒有不敬的意思。
小結
對於 SSR 的部分,大抵上只要有大框架,基本上不會有太大問題,只是必須要留意你的程式碼必須要能前後端間容。且必須要留意哪些生命週期會在前端還是後端還是都會執行。
beforeRouterEnter
beforeCreate
created
以上生命週期,在重新整理路由時,前後端都會執行,還會不會有,這就留給你自己了(燦笑)。我就跟你說我不會用了,所以如果有什麼奇怪的問題,我可以回答的我會盡量回答,不能回答的我可以幫你 Google 一下。而且,其實為了要跑這個東西,前端 NodeJS 後端 API 跑 PHP,其實挺累的。