某 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.jsentry-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…