項目收益
- 整體開發效率提升20%。
- 加快首屏渲染速度,減少白屏時間,弱網環境下頁面打開速度提升40%。
權衡
在選擇使用SSR之前,需要考慮以下事項!
- SSR需要可以運行Node.js的服務器,學習成本相對較高。
- 對于服務器而言,比僅提供靜態文件,必須處理更高負載,考慮頁面緩存等問題。
- 一套代碼兩個執行環境。beforeCreate 和created 生命周期在服務器端渲染和客戶端都會執行,如果在兩套環境中加入具有副作用的代碼或特定平臺的API,會引起問題。
推薦在實踐之前先了解官方文檔,可以對vue ssr有一定的認知。
首先搭建一個簡單的 SSR 服務
安裝依賴
yarn add vue vue-server-renderer koa
vue-server-renderer 是vue srr 服務器端渲染的核心模塊,我們會使用koa搭建服務器。
const Koa = require('koa');
const server = new Koa();
const Vue = require('vue');
const renderer = require('vue-server-renderer').createRenderer();
const router = require('koa-router')();
const app = new Vue({
data: {
msg: 'vue ssr'
},
template: '<div>{{msg}}</div>'
});
router.get('*', (ctx) => {
// 將 Vue 渲染為 HTML后返回
renderer.renderToString(app, (err, html) => {
if (err) {
throw err;
}
ctx.body = html;
})
});
server.use(router.routes()).use(router.allowedMethods());
module.exports = server;
這樣一個簡單的服務器端渲染就實現了。
ssr 具體實現
在上面ssr服務的基礎上,將逐步完善為實際應用的程序。
- 目錄結構
app
├── src
│ ├── components
│ ├── router
│ ├── store
│ ├── index.js
│ ├── App.vue
│ ├── index.html
│ ├── entry-server.js // 運行在服務器端
│ └── entry-client.js // 運行在瀏覽器
└── server
├── app.js
└── ssr.js
2、由于服務器端和客戶端的差異,需要由不同的入口函數來實現。 這兩個入口函數分別是entry-server.js和entry-client.js。 服務器端入口文件:
import cookieUtils from 'cookie-parse';
import createApp from './index.js';
import createRouter from './router/router';
import createStore from'./store/store';
export default context => {
return new Promise((resolve, reject) => {
const router = createRouter();
const app = createApp({ router });
const store = createStore({ context });
const cookies = cookieUtils.parse(context.cookie || '');
// 設置服務器端 router 的位置
router.push(context.url);
// 等到 router 將可能的異步組件和鉤子函數解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
if (!matchedComponents.length) {
return reject(new Error('404'));
}
// 對所有匹配的路由組件調用 asyncData,進行數據預取。
Promise.all(
matchedComponents.map(({ asyncData }) => {
asyncData && asyncData({
store,
route: router.currentRoute,
cookies,
context: {
...context,
}
})
})
)
.then(() => {
context.meta = app.$meta;
context.state = store.state;
resolve(app);
})
.catch(reject);
}, () => {
reject(new Error('500 Server Error'));
});
});
}
客戶端入口文件:
import createApp from './index.js';
import createRouter from './router/router';
export const initClient = () => {
const router = createRouter();
const app = createApp({ router });
const cookies = cookieUtils.parse(document.cookie);
router.onReady(() => {
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__);
}
// 添加路由鉤子函數,用于處理 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()
}
Promise.all(activated.map(c => {
if (c.asyncData) {
// 將cookie透傳給數據預取的函數,在服務器進行數據預取時需要手動將cookie傳給后端服務器。
return c.asyncData({
store,
route: to,
cookies,
context: {
}
})
}
})).then(() => {
next()
}).catch(next)
});
app.$mount('#app')
});
}
3、改造app.js適應ssr 由于nodejs服務器是一個長期運行的進程,當代碼進入該進程時,會進行一次取值并保留在內存中,這將導致請求會共享一個單利對象。為了避免這個問題,程序采用暴露一個重復執行的工廠函數,為每個請求創建不同的實例。
import Vue from 'vue';
import App from './App.vue';
export default function createApp({ router }) {
const app = new Vue({
router,
render: h => h(App),
});
return app;
};
4、自動加載router 和 store 模塊。 一個spa項目,由于router和store都是在統一的入口文件里管理,我們根據項目需要把各個功能模塊的相關store和router拆分開來,當項目變大之后,每次手動修改import會產生很多副作用,為了減少修改store和router入口引起的副作用,需要自動加載項目的router和store。下面是store的實現,router實現和store類似。
// store 實現
// ...
// 使用require.context匹配出module模塊下的所有store,一次性加載到router里面。
const storeContext = require.context('../module/', true, /\.(\/.+)\/js\/store(\/.+){1,}\.js/);
// ...
const getStore = (context) => {
storeContext.keys().filter((key) => {
const filePath = key.replace(/^(\.\/)|(js\/store\/)|(\.js)$/g, '');
let moduleData = storeContext(key).default || storeContext(key);
const namespaces = filePath.split('/');
moduleData = normalizeModule(moduleData, filePath);
store.modules = store.modules || {};
const storeModule = getStoreModule(store, namespaces); // 遞歸創建模塊
VUEX_PROPERTIES.forEach((property) => {
mergeProperty(storeModule, moduleData[property], property); // 將每個模塊的store統一掛載管理
});
return true;
});
};
export default ({ context }) => {
getStore(context);
return new Vuex.Store({
modules: {
...store.modules,
},
});
};
5、 webpack 構建配置
├── webpack.base.conf.js // 通用配置
├── webpack.client.conf.js // 客戶端打包配置
├── webpack.server.conf.js // 服務器端打包配置
webpack.base.conf.js 是構建項目的通用配置,可以根據需要修改相應的配置,這里說一下 webpack.client.conf.js和webpack.server.conf.js的配置。
webpack.server.conf.js 配置 通過VueSSRServerPlugin插件會生成服務器bundle對象,默認是vue-ssr-server-bundle.json,里面盛放著服務器的整個輸出。
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const path = require('path');
const baseConfig = require('./webpack.base.conf.js');
const resolve = (src = '') => path.resolve(__dirname, './', src);
const config = merge(baseConfig, {
entry: {
app: ['./src/entry-server.js'],
},
target: 'node',
devtool: 'source-map',
output: {
filename: '[name].js',
publicPath: '',
path: resolve('./dist'),
libraryTarget: 'commonjs2'
},
externals: nodeExternals({
// 告訴Webpack不要捆綁這些模塊或其任何子模塊
}),
plugins: [
new VueSSRServerPlugin(),
]
});
module.exports = config;
webpack.client.conf.js配置 客戶端構建和服務器端類似,是通過VueSSRClientPlugin插件來生成客戶端構建清單vue-ssr-client-manifest.json,里面包含了所有客戶端需要的靜態資源以及依賴關系。因此可以自動推斷和注入資源以及數據預取等。
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const merge = require('webpack-merge');
const webpack = require('webpack');
const baseConfig = require('./webpack.base.conf');
const UploadPlugin = require('@q/hj-webpack-upload'); // 將首次加載和按需加載的資源上傳到cdn(在開源基礎上二次開發)
const path = require('path');
const resolve = (src = '') => path.resolve(__dirname, './', src);
const config = merge(baseConfig, {
...baseConfig,
entry: {
app: ['./src/entry-client.js'],
},
target: 'web',
output: {
filename: '[name].js',
path: resolve('./dist'),
publicPath: '',
libraryTarget: 'var',
},
plugins: [
new VueSSRClientPlugin(),
new webpack.HotModuleReplacementPlugin(),
new UploadPlugin(cdn, {
enableCache: true, // 緩存文件
logLocal: false,
src: path.resolve(__dirname, '..', Source.output),
dist: path.resolve(__dirname, '..', Source.output),
beforeUpload: (content, location) => {
if (path.extname(location) === '.js') {
return UglifyJs.minify(content, {
compress: true,
toplevel: true,
}).code;
}
return content;
},
compilerHooks: 'done',
onError(e) {
console.log(e);
},
}),
],
});
module.exports = config;
5、SSR服務器端實現 下面是基于koa實現的ssr服務器端,app.js 主要是搭建服務器環境,ssr的實現是在ssr.js中,通過一個中間件的形式和主程序關聯。
// ssr.js
//...
// 將bundle渲染為字符串。
async render(context) {
const renderer = await this.getRenderer();
return new Promise((resolve, reject) => {
// 獲取到首次渲染的字符串
renderer.renderToString(context, (err, html) => {
if (err) {
reject(err);
} else {
resolve(html);
}
});
});
}
// 獲取renderer對象
getRenderer() {
return new Promise((resolve, reject) => {
// 讀取模板文件和之前通過構建生成的服務器端和客戶端json文件
const htmlPath = `${this.base}/index.html`;
const bundlePath = `${this.base}/vue-ssr-server-bundle.json`;
const clientPath = `${this.base}/vue-ssr-client-manifest.json`;
fs.stat(htmlPath, (statErr) => {
if (!statErr) {
fs.readFile(htmlPath, 'utf-8', (err, template) => {
const bundle = require(bundlePath);
const clientManifest = require(clientPath);
// 生成renderer對象
const renderer = createBundleRenderer(bundle, {
template,
clientManifest,
runInNewContext: false,
shouldPrefetch: () => {
return false;
},
shouldPreload: (file, type) => {
return false;
},
});
resolve(renderer);
});
} else {
reject(statErr);
}
});
});
}
// ...
// app.js
const Koa = require('koa');
const server = new Koa();
const router = require('koa-router')();
const ssr = require('./ssr');
server.use(router.routes()).use(router.allowedMethods());
server.use(ssr(server));
// 錯誤處理
app.on('error', (err, ctx) => {
console.error('server error', err, ctx);
});
module.exports = server;
以上便是vue ssr的簡單實現,實際項目中需要完善各種項目需要的配置。 下面在此基礎上說幾個問題。
- 上面提到過,vue的生命周期函數中,只有beforeCreate和created會在服務器端渲染時被調用,并且程序一直存在于服務器并不會銷毀,擋在這兩個生命周期中產生副作用的代碼時,比如在其中使用了setTimeout或setInterval就會產生副作用,為了避免這些問題,可以將產生副作用的代碼放到vue的其他生命周期中。服務端沒有window、document對象, 如果在服務器端使用就會報錯中斷,所以需要根據運行環境做相應的兼容處理。
- 預取數據時cookie穿透的問題。 在服務器端asyncData預取數據時,不會把客戶端請求中的cookie帶上,所以需要手動將客戶端中的cookie在預取數據時加到請求頭部。
- 在spa中需要動態修改頁面的head標簽以便利于搜索引擎,這里推薦使用vue-meta。
// src/index.js
// ...
Vue.use(Meta);
// ...
// entry-server.js
// ...
context.meta = app.$meta();
// ...
部署方案
在完成整體代碼的開發后 , 我們還需要考慮部署問題 。在之前的活動 SSR 改造中 , 我們通過外部負載均衡到各服務器 , 在各服務器上使用 PM2 對各個服務器上的 Node 進程進行管理 。這種方式在實際使用中存在一些問題 。
-
運行環境
-
人肉運維 。手動在運行服務器上配置相關環境 ( Node 、 PM2 ) 。后續如果遇到需要擴容 、 更新環境依賴時 , 需要同步人工同步各服務器之間環境 。
-
本地開發環境與服務端環境需完全一致 。出現過不一致導致的問題 。概率較小但需謹慎對待
-
運維
-
回滾機制 , 現在的回滾機制是相當于發布一個新版本到線上 , 重新觸發 CI 發布流程 。如果是運行環境出現了問題 , 是比較棘手的 。沒辦法快速的先回滾到指定版本和環境 。
為了解決以上提到的一些問題 。我們引入了新的技術方案 。
- Docker :容器技術 。輕量級 、 快速的 ”虛擬化“ 方案
- Kubernetes :容器編排方案
使用 Docker 接入整個開發 、 生產 、 打包流程 , 保證各運行環境一致 。
使用 Kubernetes 作為容器編排方案。
整合后 , 大概方案流程如下
- 本地開發時使用 Docker 開發
- 推送代碼至 Gitlab 觸發 CI
- CI 基于基礎鏡像打包 , 每個 COMMIT ID 對應一個鏡像 , 推送至私有倉庫 ,觸發 CD
- CD 通過 kubectl 控制 K8s 集群更新應用
整個開發 、 打包 、 部署上都使用了 Docker , 以此來保證所有階段的環境一致 。
本地開發
在本地開發階段 , 我們將依賴下載及開發模式分開 。
# 依賴下載
docker run -it \
-v $(pwd)/package.json:/opt/work/package.json \
-v $(pwd)/yarn.lock:/opt/work/yarn.lock \
-v $(pwd)/.yarnrc:/opt/work/.yarnrc \
# 掛載 package.json 、 yarn.lock 、 .yarnrc 到 /opt/work/ 下
-v mobile_node_modules:/opt/work/node_modules \
# /opt/work/node_modules 掛載為 mobile_node_modules 數據卷
--workdir /opt/work \
--rm node:13-alpine \
yarn
在依賴下載中 , 思路是將 node_modules 目錄作為一個數據卷 。在需要使用時將其掛載到指定目錄下 , 之后只需要將會影響到依賴下來的相關文件掛載到容器中 , 將 node_modules 數據卷掛載到文件夾 。這樣子就能持久化存儲依賴文件 。
# 開發模式
docker run -it \
-v $(pwd)/:/opt/work/ \
# 掛載項目目錄至 / opt/work/ 下
-v mobile_node_modules:/opt/work/node_modules \
# 掛載 node_modules 數據卷到 /opt/work/node_modules 目錄下
--expose 8081 -p 8081:8081 \ # HotReload Socket
--expose 9229 -p 9229:9229 \ # debugger
--expose 3003 -p 3003:3003 \ # Node Server
# 暴露各個端口
--workdir /opt/work \
node:13-alpine \
./node_modules/.bin/nodemon --inspect=0.0.0.0:9229 --watch server server/bin/www
開發模式下 , 我們只需要將之前的 node_modules 數據卷掛載到 node_modules 目錄 , 再將項目目錄掛載到容器中 。暴露指定端口即可開始開發 。這里 8081 為寫死的 HotReload Socket 接口 、 3003 為 Node 服務接口 、 9229 為 debugger 接口 。再把啟動命令設置為開發模式指令就可以正常開發 。
開發完成后 , 我們推送代碼 , 觸發 CI 。
CI
上面是我們的 CI 流程 。
在 CI 階段 , 我們通過 Dockerfile 為每一次提交記錄都生成一個與之對應的鏡像 。這樣做的好處在于我們能隨時通過提交記錄找到對應的鏡像進行回滾 。
FROM node:13-alpine
COPY package.json /opt/dependencies/package.json
COPY yarn.lock /opt/dependencies/yarn.lock
COPY .yarnrc /opt/dependencies/.yarnrc
RUN cd /opt/dependencies \
&& yarn install --frozen-lockfile \
&& yarn cache clean \
&& mkdir /opt/work \
&& ln -s /opt/dependencies/node_modules /opt/work/node_modules
# 具體文件處理
COPY ci/docker/docker-entrypoint.sh /usr/bin/docker-entrypoint.sh
COPY ./ /opt/work/
RUN cd /opt/work \
&& yarn build
WORKDIR /opt/work
EXPOSE 3003
ENV NODE_ENV production
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server/bin/www"]
上面是我們使用到的一個 Dockerfile 。
- 使用 node:13-alpine 作為基礎鏡像
- 復制依賴相關文件到容器中下載依賴 , node_modules 軟連接到 /opt/work 下 。清理安裝緩存
- 復制項目文件到容器中 , 執行客戶端代碼打包命令
- 設置環境變量 , 對外暴露服務端口 , 設置鏡像啟動命令
docker build -f Dockerfile --tag frontend-mobile:COMMIT_SHA .
最后使用以上命令將該版本打包為一個鏡像 , 推送至私有倉庫 。
我們在 Dockerfile 優化編譯速度及鏡像體積時使用到的一些技巧:
- 前置合并不變的操作 , 將下載依賴和編譯分開為兩個RUN 指令 , 可以利用 Docker 的層緩存機制 。在依賴不變的情況下 , 跳過依賴下載部分 , 直接使用之前的緩存。
- 每次操作后清理不需要的文件 , 如 yarn 生成的全局緩存 ,這些緩存不會影響到我們程序的運行 。還有很多包管理工具也會生成一些緩存 , 按各種需要清理即可 。
- ‘.dockerignore’ 中忽略不影響到編譯結果的文件 , 下次這些文件變動時 , 打包會直接使用之前的鏡像 , 改個 README 或者一些 K8s 發布配置時就不會重新打包鏡像 。
在打包完成后 , 我們推送鏡像至私有倉庫 , 觸發 CD 。
CD
部署階段 , 我們使用 Kubernetes 進行容器編排 。引用官方介紹
K8s 是用于自動化部署 , 擴展和管理容器化應用程序的開源系統 。
K8s 非常的靈活且智能 。我們只需要描述我們需要怎么樣的應用程序 。K8s 就會根據資源需求和其他約束自動放置容器 。括一些自動水平擴展 , 自我修復 。能方便我們去追蹤監視每個應用程序運行狀況 。
我們使用的目的很簡單 , 就是自動運維還有非侵入式日志采集和應用監控 。
Deployment 表示一個期望狀態 。描述需要的應用需求 。
Service 負責對外提供一個穩定的入口訪問我們的應用服務或一組 Pod 。
Ingress 路由 , 外部的請求會先到達 Ingress 。由它按照已經制定好的規則分發到不同的服務 。
Pod 在集群中運行的進程 , 是最小的基本執行單元 。
CD 容器通過 kubectl 控制 K8s 集群 。在每個分支提交代碼觸發 CD 之后 , 會為每個分支單獨創建一個 Deployment 。對應每個分支環境 。通過 Service 暴露一組指定 Deployment 對應的 Pod 服務 , Pod 運行的是 Deployment 指定的應用鏡像 。最后使用 Ingress 根據域名區分環境對外提供服務 。
K8s 配置
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend-mobile # deployment 名稱
namespace: mobile # 命名空間
labels:
app: frontend-mobile # 標簽
spec:
selector:
matchLabels:
# 對應的 Pod 標簽, 被其選擇的 Pod 的現有副本集將受到此部署的影響
app: frontend-mobile
replicas: 8 # Pod 節點數量, 默認為 1
template: # 相當于 Pod 的配置
metadata:
name: frontend-mobile # Pod 名稱
labels:
app: frontend-mobile # Pod 標簽
spec:
containers:
- name: frontend-mobile
image: nginx:latest
ports:
- containerPort: 3003
resources: # 設置資源限制
requests:
memory: "256Mi"
cpu: "250m" # 0.25 個cpu
limits:
memory: "512Mi"
cpu: "500m" # 0.5 個cpu
livenessProbe:
httpGet:
path: /api/serverCheck
port: 3003
httpHeaders:
- name: X-Kubernetes-Health
value: health
initialDelaySeconds: 15
timeoutSeconds: 1
---
apiVersion: v1
kind: Service
metadata:
name: frontend-mobile # Service 名稱
namespace: mobile # 命名空間
labels:
app: frontend-mobile # 標簽
spec:
selector:
app: frontend-mobile # 對應的 Pod 標簽
ports:
- protocol: TCP
port: 8081 # 服務端口
targetPort: 3003 # 代理端口
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: frontend-mobile
namespace: mobile # 命名空間
labels:
app: frontend-mobile # 標簽
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- host: local-deploy.com
http:
paths:
- path: /
backend:
serviceName: frontend-mobile # 引用的服務名稱
servicePort: 8081 # 引用的服務端口, 對應 Service 中的 port
在 Deployment 配置上選擇資源配額小 , 數量多的方式進行部署 。把單個 Pod 資源配額設置小的原因是 SSR 服務容易內存泄漏 , 設置小一些可以在出現內存泄漏問題時直接將 Pod 重啟 。在排查到問題之前先解決暫時解決服務問題 。
其他配置可自行參考官方文檔 , 不過多介紹 。
https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/
至此 , 部署流程已全部結束 。