分享vue專案的服務端渲染學習過程
最近抽出了點時間,弄了下vue ssr專案,至於ssr的優點就不多提了。學習路線參照了官方例項,有興趣的同學可以去看下。
我的專案地址,主要使用了ssr+typescript+vuex+vue-cli 2.0,有興趣的同學,歡迎start。
那麼就先講下前期的打包配置吧,本地開發,也就是所謂的dev,需要熱更新等一系列便於除錯的外掛,所以需要區分webpack的配置。程式碼就不多提了,可以看下官方配置,也可以看下我的配置。
如果是使用js+vue-cli 2.0的同學,那麼官方例項可以完美支援,一點都不需要動。我用的是ts+vue-cli 2.0寫的,webpack 4.0以上才支援ts,所以需要升級webpack版本,但是4.0以後,有很多外掛都棄用了,坑的一批。比如壓縮css的外掛ExtractTextPlugin,需要替換成MiniCssExtractPlugin,但是坑比的是服務端渲染還不能用(document is no defined),這裡也是需要注意的點,千萬不要在服務端和客戶端都能觸發的鉤子中操作dom,比如created,asyncData。所以不能像之前那樣寫在base.config裡面了,也就是服務端不能使用,如果你也碰到了這個問題,可以看下我的這篇
server.js
專案的起始點就是server.js檔案,看下package.json,script命令就可以看出來,其實啟動專案就是執行node server.js。官方例項用的那些快取外掛就不多提了,其實有些快取配置可以配在nginx裡面的。細心的同學一看app.all()就知道了,其實這就是開了個node伺服器而已,我們前端跳轉的路由,就相當於一個get請求,伺服器接到這個請求,會根據vue提供的ssr外掛,把頁面渲染好之後再發送到客戶端。在渲染的同時,會有個上下文物件context,記錄這你想要往客戶都傳輸的資訊,什麼都可以傳,比如頁面渲染時間,語言版本等等。
function render (req, res) { const s = Date.now() res.setHeader("Content-Type", "text/html"); res.setHeader("Server", serverInfo); // 往響應頭裡新增一些服務端資訊 const handleError = err => { if (err.url) { res.redirect(err.url) } else if(err.code === 404) { res.status(404).send('404 | Page Not Found') } else { res.status(500).send('500 | Internal Server Error') console.error(`error during render : ${req.url}`) console.error(err.stack) } } const context = { title: 'Confession-Wall', url: req.url } renderer.renderToString(context, (err, html) => { if (err) { return handleError(err) } res.send(html); if (!isProd) { console.log(`頁面渲染耗時: ${Date.now() - s}ms`); } }) } app.all(`${config.BasePath}*`, isProd ? render : (req, res) => { if (req.method !== 'GET') return next(); readyPromise.then(() => render(req, res)) }) const port = process.env.PORT || 3000 app.listen(port, () => { console.log(`server started at localhost:${port}`); })
main.ts
下圖為main.ts的程式碼,由於為了每個使用者從服務端拿到的是新的沒有汙染的程式碼,所以store,router,vue,每次都要new一個新的例項。
// main.ts
import Vue from 'vue';
import App from './App.vue';
import LocalStore from './store/index'
import LocalRouter from './router/index'
// 在伺服器端渲染時把當前的路由資訊,同步進store中,也就相當於vuex store中多了個route module
import { sync } from 'vuex-router-sync';
Vue.directive('focus', {
inserted: function (el) {
el.focus();
}
});
export function createApp () {
const store = new LocalStore();
const router = new LocalRouter();
sync(store, router);
const app = new Vue({
router,
store,
render: h => h(App)
})
return { app, router, store };
}
vuex-router-sync
至於使用了vuex-router-sync外掛的效果如何,我們可以在瀏覽器的console裡可以打印出來,因為服務端向客戶端同步資料,是通過向window全域性中注入一個物件,用來記錄服務端的store資訊。如下圖所示,你會看到store裡面會多了個route的module,不是必須的,你也可以不是使用,或者通過你自己的方式實現。
當然從服務端能帶過來的不僅是store,也不僅僅往store裡新增route資訊,只要你需要的任何騷操作資訊都可以,下面會講到。
entry-server.ts
接下來應該到了entry-server.ts檔案了,註釋裡寫了我在學習時對其的理解。
// entry-server
import { createApp } from './main';
export interface Context {
title: string;
url: string;
state: any
}
// context由server.js中注入
export default (context: Context) => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
const { url } = context
const { fullPath } = router.resolve(url).route
// 判斷req裡的請求地址是否等於當前路由
if (fullPath !== url) {
return reject({ url: fullPath })
}
// 如果等於,則把當前url,push進router中,便於客戶端接管
router.push(url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 如果路由匹配,則觸發伺服器端asyncData鉤子,此鉤子便是你元件定義的鉤子函式,
// 預設寫在與methods同級,所以取的是其options,其實可以自行定義其位置,和實現方法
// 可以在這裡對鉤子重寫,使之擁有更多功能
Promise.all(matchedComponents.map((Component:any) => {
if (Component.options.asyncData) {
return Component.options.asyncData({
store,
route: router.currentRoute
})
}
})).then(() => {
// 把服務端請求到的資料,注入windows中的__INITIAL_STATE__中,便於客戶端接管vuex store
context.state = store.state;
resolve(app);
}).catch(reject);
}, reject)
})
}
需要注意的地方是這裡暴露出來的方法,返回的是一個promise,之前寫的時候不注意,踩了個大坑。Component.options.asyncData,這裡其實可以自由發揮的,按官方那個例項來看,一般asyncData鉤子是與methods同級,所以這裡你去拿元件上的asyncData就可以了。至於叫不叫asyncData,你可以自行發揮,你也可以放在methods裡面,怎麼樣的行。只要在這裡能取到相應地方的相應方法就可以了。傳入的引數你也可以自行發揮,比如傳如isServer: true,用以區分是服務端渲染還是客戶端渲染觸發了這個鉤子,以及重定向方法之類的。context.state就是向客戶端注入的內容,可以自行新增東西。比如:
context.state = {
store: store.state,
text: '我是服務端注入的內容'
}
此時客戶端接受的window.__INITIAL_STATE__就如圖所示,這是你客戶端同步狀態的時候就要取對store了。
entry-client.ts
接下來應該就是entry-client.ts檔案了。
import Vue from 'vue';
import 'es6-promise/auto';
import { createApp } from './main';
import { Route } from 'vue-router';
/**
* 當元件複用時,觸發asyncData鉤子,重新請求資料
*/
Vue.mixin({
beforeRouteUpdate (to: any, from: any, next: any) {
const { asyncData } = (this as any).$options
if (asyncData) {
asyncData({
store: (this as any).$store,
route: to
}).then(next).catch(next)
} else {
next()
}
}
})
const { app, router, store } = createApp()
// 獲取服務端渲染時,注入的__INITIAL_STATE__資訊,並同步到客戶端的vuex store中
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
router.onReady(() => {
router.beforeResolve( async (to: Route, from: Route, next: any) => {
const matched = router.getMatchedComponents(to)
const prevMatched = router.getMatchedComponents(from)
let diffed = false
// 校驗to的路由地址和from的路由地址是否相等,如果不相等則在客戶端觸發asyncData鉤子
const activated = matched.filter((c: any, i: any) => {
return diffed || (diffed = (prevMatched[i] !== c))
})
const asyncDataHooks = activated.map((c:any) => c.options.asyncData).filter((_: any) => _)
if (!asyncDataHooks.length) {
return next()
}
await Promise.all(asyncDataHooks.map( async (hook: any) => await hook({ store, route: to })))
.then(() => {
next()
})
.catch(next)
})
app.$mount('#app'); // 掛在到app上
})
// 如果瀏覽器支援serviceWorker則註冊
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/service-worker.js').then((registration) => {
console.log('serviceWorker註冊成功')
}).catch(() => {
console.log('serviceWorker註冊失敗')
})
}
// 向window type中插入__INITIAL_STATE__以至於ts不報錯
declare global {
interface Window {
__INITIAL_STATE__: any
}
}
index.template.html
跟官方例項一樣,也是可以改造的,你可以在server.js的context中注入你任何想注入的類容,像下面{{ title }}一樣注入到你渲染後的模板中,比如頁面構建時間,當前語言版本,等一系列操作。body中沒東西,就會預設在window中注入名為__INITIAL_STATE__的物件,當然你也可以自定義,比如使用名字為__INIT_STATE__的物件,可以在body中加入這段程式碼
{{{ renderState({ windowKey: '__INIT_STATE__', contextKey: 'state', }) }}} {{{ renderScripts() }}}
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ title }}</title>
<meta charset="utf-8">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" sizes="120x120" href="./public/logo-120.png">
<meta name="viewport" content="width=device-width, initial-scale=1, minimal-ui">
<link rel="shortcut icon" sizes="48x48" href="./public/logo-48.png">
<meta name="theme-color" content="#f60">
<link rel="manifest" href="./manifest.json">
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
.vue檔案
如果你前面拿的是與methods同級的屬性,那麼就寫在同級就行了,鉤子函式的名字和引數與你前面entry-client.ts裡保持一致,跟客戶端渲染的created鉤子差不多,裡面放一些請求,和改變vuex的東西進行資料預取。至於vuex的形式,以及用不用vuex做狀態管理都無所謂。
async asyncData({store, route}:any) {
const id = route.query.id;
let params: Detail.ArticDetail.RequestParams = {
id: id
};
store.commit('detail/articDetail/$assignParams', params);
await store.dispatch('detail/articDetail/getArticDetail');
}
拿之前的老專案重構的,由於vue-cli 2.0用起來不太好,以及當初自己摸索的vuex寫法比較噁心,就只搭個例項了,專案地址,有興趣的,覺得寫了這麼多廢話有點用的同學歡迎start。
有興趣的同學,可以一起討論,無時不在。