某 SPA 向 PWA 迁移实录
背景
有一单页应用 小B,近感其加载过慢、客户端载入时请求数过多,故考虑将之转换成 PWA,缩短内容到达时间,提升用户体验,以使其发挥更大的价值。
客户端
其貌不扬的 SPA,基于 Vue, VueRouter, Vuex 生态构建。起始页含 5 个 Tab,靠底部的 TabBar 切换。多数页面需要用户登录,用户信息通过模板被后台注入到 html 文件中。
服务器端
使用 Egg.js 搭建的 Node 应用,用于接收 SPA 的请求,并使用存在 redis 中的 token 等内容与后端 API 服务进行交互。
对客户端进行改动
将 小B 转换成 PWA,修改的重头在客户端,可参考项目 vue-hackernews-2.0。本文仅对部分内容进行讨论。
server 与 client 采用不同的入口
由于不再是纯前端项目,部分代码需要根据环境的不同,执行不同的操作,所以使用两个入口文件 entry-client.js
与 entry-server.js
。
依赖特定平台 API 的模块
代码中一些模块会使用到特定平台的 API,如 window 或 document,则在 Node.js 中执行时会抛出错误。这种情况下,应尽量使用同构的模块(如 axios);或者在 mounted
中动态加载需要用到浏览器 API 的模块:
export default {
mounted() {
import('utils/preview-image').then(previewImage => this.previewImage = previewImage.default || previewImage);
},
methods: {
onClickImagePreview(urls) {
this.previewImage({ urls });
}
}
}
Vuex store 中的模块重用
在服务端渲染时,为了避免有状态的单例,我们会使用如 createApp
, createStore
, createRouter
这样的工厂函数,然而在 Vuex 模块中的 state 可能会被忘记。
const module = {
state: { ... },
mutations: { ... },
actions: { ... },
getters: { ... }
}
需要修改为
const getInitialState = () => {
return { ... };
};
const module = {
state: getInitialState,
mutations: { ... },
actions: { ... },
getters: { ... }
}
与后端服务结合
由于前后端项目代码是分离的,所以采用 SSR 后需要对后端代码进行修改。主要有以下几个方面:
- 前端项目的依赖(如 vue、vue-router 等)并不一定存在于后端项目中
- session 信息的插入
对于项目的依赖,需要修改 webpack.server.config.js
,将 webpack-node-externals
插件移除,这样 node_modules
中用到的依赖也会被打包到生成的 server bundle 中。
之前项目中用户的 session 信息里存有用户的登录状态及基本信息,并通过模板渲染注入的用户访问到的 html 文件中。改用 SSR 后需要依据开发及部署环境进行不同的处理。
开发环境中,采用简单的 express 服务,主要用来渲染页面,并且将 API 请求转发到后台(依据请求路径)。可以修改 server.js
,在 renderToString
之前发送请求到后台获取用户信息,填入 context
中:
// server.js
const getUserInfo = cookies => {
return axios.get('http://localhost:7005/api/account/get-profile?need_token=true', {
headers: {
Cookie: Object.keys(cookies).map(key => `${key}=${cookies[key]}`).join('; ')
}
})
.then(res => res.data);
};
function render(req, res) {
...
getUserInfo(req.cookies)
.then(data => {
if (data.code === 0) {
context.userInfo = data.result;
}
renderer.renderToString(context, (err, html) => {
if (err) {
return handleError(err);
}
res.send(html);
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`);
}
});
});
...
}
后台则可以直接从 redis 中读到用到的信息填入 context
:
// app/controller/home.js
module.exports = app => {
class HomeController extends app.Controller {
async index() {
...
const context = {
title: 'xxx',
url,
bugtags: process.env.BUGTAGS ? `<script src="https://dn-bts.qbox.me/sdk/bugtags-1.0.3.js"></script>
<script>
new Bugtags('xxx','xxx','xxx');
</script>` : null,
userInfo: ctx.session.userInfo
};
...
}
}
}
这样下来在 entry-server.js
中就可以通过 context
拿到用户信息,填入 store
:
// entry-server.js
export default context => {
return new Promise((resolve, reject) => {
...
const { app, router, store } = createApp();
if (isDef(context.userInfo)) {
store.state.user.userInfo = context.userInfo;
store.state.user.isLogin = true;
}
...
}
}
TBC…