如何使用 Lavas 构建 MPA 项目

Lavas 目前支持两种渲染模式,分别是服务端渲染 (SSR) 和 浏览器端渲染。 而浏览器端渲染时,整个项目构建完成后只有一个 HTML 入口 index.html,因此这时本质上等价于单页应用,即 SPA。

从小型站点的开发需求来说,SPA 和 SSR 已经能够覆盖绝大部分。让我们更进一步,考虑如下两种情况:

  1. 整个站点的一部分需要使用 SSR 模式渲染,另一部分使用 SPA 模式渲染。

  2. 整个站点均为浏览器端渲染,但存在多个入口 HTML,即多页应用 (MPA)。

如何使用 Lavas 来满足此类需求呢?

解决思路

我们可以把 Lavas 项目看做一个单独的单元,或称入口。

对于上述两种情况,都可以认为由多个入口组成:

  1. 情况 1,相当于一些入口采用 SSR 模式,另一些入口采用 SPA 模式

  2. 情况 2,相当于项目中存在多个 SPA 模式的入口

因此,我们把问题归结为:多个 Lavas 项目如何整合到一起?

在 Lavas 构建完成后,SPA 项目本质上是一些静态页面,包含一个入口 index.html 和其他静态资源;SSR 项目本质上是一个 express (也可以是 koa )中间件。因此通过架设一个 express 服务器可以很方便分别对两者进行整合。

实现方式

为了表述方便,我们来创建一个简单但可能很实用的需求,通过解决这个需求来学习如何整合。

示例需求

我们假设开发者存在这样的开发需求:一个电商站点从业务模块角度能分成两部分,分别是:

  • /user/* 部分,用户信息浏览/注册/修改等等相关,采用 SPA 模式渲染。一般这类用户信息敏感的内容不需要考虑 SEO,也就不需要 SSR。

  • 剩余部分,是站点的主要内容,例如 / 展示站点首页,/detail/* 查看商品详情,/search/* 进行商品搜索等等。这部分考虑到 SEO 需求,使用 SSR 模式进行渲染。

我们假设开发者已经分别为两者开发了单独的 Lavas 应用。前者名为 lavas-user,后者名为 lavas-main。我们需要这么几个步骤:

  1. 修改基础路由互相区分

  2. 提取共享模块避免重复 (可选)

  3. 配置服务器

  4. 构建

修改基础路由

观察示例需求的两个模块,lavas-user 拥有明显的 URL 特征 ( /user 开头),而 lavas-main 没有。因此我们修改 lavas-user 的 base,lavas-main 不作修改。

打开 lavas-user/lavas.config.js 配置文件的build 段的 publicPath 以及 router 段的 base,均修改为 /user/ (不要遗漏最后的 /),如下:

  1. // ...
  2. build: {
  3. // ...
  4. publicPath: '/user/'
  5. },
  6. router: {
  7. mode: 'history',
  8. base: '/user/',
  9. pageTransition: {
  10. enable: false
  11. }
  12. }
  13. // ...

info base 配置项是 vue-router 的一个配置项,用以设定基准路由。修改后的 lavas-main,原本使用 /view 的路由就变成了 /user/view, 原本使用 /register 就变成了 /user/register,以此类推。 为了配合 base, 用以管理静态资源路径前缀的配置项 publicPath 也应做相同的修改,否则会导致系统找不到静态资源而报错。

提取共享模块 (可选)

如果 lavas-main 和 lavas-user 两个模块都引用了相同的内容,开发者又对重复代码无法接受,可以考虑将共同的代码抽离出来。

举例来说,由 lavas init 初始化的项目都会有 /components 目录,里面会有共同使用的组件 (离线通知,Service Worker 更新通知和页面切换进度条)。如果要把这块提取出来的话,我们可以通过 webpack 的 alias 实现。

  1. lavas-project
  2. ├── components/
  3. ├──OfflineToast.vue
  4. ├──UpdateToast.vue
  5. └──ProgressBar.vue
  6. ├── lavas-user/
  7. └──lavas.config.js
  8. └── lavas-main/
  9. └──lavas.config.js

Lavas 为开发者提供了 alias 配置项(文档),修改 /lavas.config.js 即可。

  1. // ...
  2. build: {
  3. alias: {
  4. base: {
  5. 'common': path.resolve(__dirname, '../')
  6. }
  7. }
  8. }

将项目中使用到公共目录 components 中组件的地方改为以 common 开头 ,如下,如果我们将 UpdateToast 移到公共目录下

  1. import UpdateToast from 'common/components/UpdateToast';

除了 components,其他的公用内容也可以提出到公共目录,同样使用 common 为前缀来引用,非常方便,并且项目会清晰。

配置服务器

最后一步是在两个 Lavas 服务之前搭建一个分发服务器,对不同的 URL 转发到不同的 Lavas 服务。以 express 为例,我们新建 server.js,内容如下:

  1. const path = require('path');
  2. const express = require('express');
  3. const app = express();
  4. const historyMiddleware = require('connect-history-api-fallback');
  5. const LavasCore = require('lavas-core-vue');
  6. const port = 8080; // 对外端口
  7. function registerSPA(url, dirPath) {
  8. if (url.endsWith('/')) {
  9. url = url.substring(0, url.length - 1);
  10. }
  11. // fix trailing slash (/user -> /user/)
  12. app.use('/', function (req, res, next) {
  13. let requestUrl = req.url.replace(/\?.+?$/, '');
  14. if (requestUrl === url) {
  15. req.url = url + '/';
  16. }
  17. next();
  18. });
  19. app.use(url, historyMiddleware({
  20. htmlAcceptHeaders: ['text/html'],
  21. disableDotRule: false // ignore paths with dot inside
  22. }));
  23. app.use(url, express.static(dirPath));
  24. }
  25. // SPA
  26. registerSPA('/user', 'lavas-user/dist');
  27. // NOT required when SSR is enabled
  28. // app.listen(port, () => {
  29. // console.log('server started at localhost:' + port);
  30. // });
  31. // SSR
  32. let core = new LavasCore(path.resolve(__dirname, 'lavas-main/dist'));
  33. core.init('production')
  34. .then(() => core.runAfterBuild())
  35. .then(() => {
  36. app.use(core.expressMiddleware());
  37. app.listen(port, () => {
  38. console.log('server started at localhost:' + port);
  39. });
  40. }).catch(err => {
  41. console.log(err);
  42. });
  43. // catch promise error
  44. process.on('unhandledRejection', (err, promise) => {
  45. console.log('in unhandledRejection');
  46. console.log(err);
  47. // cannot redirect without ctx!
  48. });

文件中的所有项目文件路径(如 lavas-user/dist)以及启动端口号(如 8080)都可以根据项目实际情况进行修改。

文件的上半部分对 SPA 进行处理,核心是把 /user 开头的路由转发到 lavas-user 的入口 lavas-user/dist/index.html 上。其中还涉及到一个 URL 结尾 / 的小问题,我们在最后进行叙述。SPA 部分的最后是启动 express 服务器并监听端口,但因为 SSR 部分包含异步操作,因此 如果项目包含 SSR 部分,则这里可以注释,由 SSR 部分负责启动

文件后半部分是对 SSR 进行处理,这部分和 lavas-main/server.prod.js 比较类似,就不再赘述了。

构建

  1. 分别对两个项目使用 lavas build 命令进行构建

  2. 复制任意一个项目的 node_modules 目录到根目录,供上述 server.js 使用

  3. 如果想精简项目内容,可以只将两个项目的 dist 目录移动出来,其余源码部分和 node_modules 都可以去除 (可选)

最终目录结构

如果按照文档上列出的 server.js 中的配置,最终目录应该如下:

  1. lavas-project
  2. ├── lavas-user/
  3. ├── dist
  4. ├── index.html
  5. └── something else (favicon.ico, lavas/, static/...)
  6. └── something else (node_modules/, .lavas/, pages/, store/...)
  7. ├── lavas-main/
  8. ├── dist
  9. ├── server.prod.js
  10. └── something else (lavas/, node_modules/, static/...)
  11. └── something else (node_modules/, .lavas/, pages/, store/...)
  12. ├── node_modules/
  13. └── server.js

更进一步,精简后的代码结构可以是:

  1. lavas-project
  2. ├── lavas-user-dist/
  3. ├── index.html
  4. └── something else (favicon.ico, lavas/, static/...)
  5. ├── lavas-main-dist/
  6. ├── server.prod.js
  7. └── something else (lavas/, node_modules/, static/...)
  8. ├── node_modules/
  9. └── server.js

这应该是上线需求的最小集合了,为了适应这样的修改,还需要对 server.js 中的引用路径进行改动,这里就不重复了。

express 处理 SPA 路由的小问题 (扩展)

提示:这部分内容由 Lavas 内部处理,并不需要开发者进行参与,仅仅作为解答开发者疑问的扩展阅读存在。

server.js 中,我们能够发现存在一段代码:

  1. // fix trailing slash (/user -> /user/)
  2. app.use('/', function (req, res, next) {
  3. let requestUrl = req.url.replace(/\?.+?$/, '');
  4. if (requestUrl === url) {
  5. req.url = url + '/';
  6. }
  7. next();
  8. });

这段代码是用来处理一个 express 的路由问题的。Vue 官方推荐开发者在上线 Vue SPA 项目时使用 connect-history-api-fallback,这个中间件的核心是修改 express 的 req.url,让我们看看如下代码(截取自该中间件):

  1. rewriteTarget = options.index || '/index.html';
  2. logger('Rewriting', req.method, req.url, 'to', rewriteTarget);
  3. req.url = rewriteTarget;
  4. next();

然后我们使用方式如下:

  1. app.use('/user', historyMiddleware({
  2. htmlAcceptHeaders: ['text/html'],
  3. disableDotRule: false // ignore paths with dot inside
  4. }))

在这种配置下,当我们访问 /user/,经过中间件之后 req.url 会被设置为 /user/index.html,再进入 express.static,一切正常。但当访问 /user 时(没有后面那个 /),经过中间件之后会变成 /userindex.html,这样是无法被 express.static 识别的,当然落到 SSR 之后也无法匹配,因此会报出 404 错误。

因此我们在使用中间件之前还增加了一段修复代码,在访问 /user 的时候自动添加最后的 /。我们也考虑过 express 的 strict routing,但似乎也没法生效。如果开发者有更好的方法,欢迎告诉我们!