快速实现一个基于 Vue.js 的微前端应用

此类文章近两年已被各路大牛写烂了,在这里,我只是谈谈自己的理解。以及如何快速的实现一个基于 Vue.js 的简单易用的微前端。

缘起何处

我们团队在公司内部主要为国内某 TOP3 建筑公司编写 ERP 中后台应用,由于是建筑领域,其 ERP 系统十分庞杂。在我于 2018 年底入职时,该系统就已编写了三年。但是由于需求方依然在提需求,所以依然在编写新应用,维护,迭代和重构。

当时团队内部的前端编写模式是,原始项目 A 使用 Vue-CLI2 创建。现在需求方提交新模块 B 的需求给到产品,当产品交付原型图后,项目 Leader 复制项目 A 改改配置成了新的项目 B。

由于在我们的项目中存在许多不同 domain,比如 /notify, /workflow, /construction, /appMgt, /form, /auth, /print, /pan, /market, /hr, /asset 等。之前我们使用 nginx 反向代理实现 domain 分发到对应项目。在应用布局中顶部菜单直接分成了 notify.example.comworkflow.example.com 来实现不同项目 domain 的分发。

随着项目越来越多,暴露的问题越发明显:

  • 技术债堆积
  • 代码重复冗余
  • 重复依赖
    • 版本不兼容
    • 加载和执行
    • 影响构建体积
  • 项目个数跟维护成本正相关
    • 公共代码的更新需要复制粘贴并构建所有项目

找到问题,接下来就是解决问题了。在2018 年恰逢其时地了解到 Micro-FrontEnds 概念后,替我们指明了方向,我们也需要在项目中实现类似微前端的概念来解决上述问题。

于是,开始在社区调研。当时写微前端的文章并不多,实践的团队也比较少,留下印象最深刻的是这篇phodal/microfrontends文章,文中提到了微前端的各种实现方式、实现成本、工程成本等问题,比较全面。

真正意义上的微前端应该是框架无关的,现在社区中首推 Single-SPA。但是在 18 年的时候,Single-SPA 还未完全 Production Ready,于是我们决定实现我们团队内部的微前端框架。

因为技术栈很统一,所以无需做到框架无关,我们最终的选择是微前端:微应用化。它不仅完美解决了上述问题,同时实施成本低、技术难度小、维护成本低。

微应用化

在多 domain 时通过 nginx 反向代理的情况下,维护公共代码很痛苦。将各个业务模块拆成子应用后,其各个业务的公共部分则被拆成独立的应用。社区称之为基座应用,或者主应用。

我们将所有 domain 即子应用的公共部分封装到主应用中,单独维护,同时发布到内部 npm 私仓。以给子应用在开发环境中将其作为依赖安装。在需要修改公共代码或者提供公共服务的时候,只需要重新构建和部署主应用即可,解决了一大痛点。

然后将各个子模块拆成独立的业务模块,使其都在主运行时中运行。从而实现子应用的独立开发、独立维护、独立部署。

实现方案

图片摘自phodal/microfrontends

不过我们的实现细节上有所不同,我们将 URL Change 交由 VueRouter 的 beforeHook 处理,将 应用管理服务Loaderinstall 等交由 VueMfe 处理。

主应用 App

主应用 App

主运行时本身也是一个独立完整的应用,独立运行、开发和部署。我们的项目中,主应用包含了下列内容(通常都包含了以下内容):

  • 公共依赖

在我们的项目中使用 UMD 引入公共依赖,同时维护了一份公共的 Webpack externals 配置,以避免主应用和各个子应用在打包时重复构建公共依赖。

  • 公共配置,比如:Webpack 配置,Env Variables 配置,App 配置等
  • 公共插件,比如:ProgressBar, MicroFrontend, LazyLoad, Vuex, VueRouter, Element-UI 等
  • 公共服务,比如:Utils, Http, Socket, Storage 等
  • 公共数据,比如:Auth, Config, Message 等 Vuex modules,我们通过 Vuex 实现全局 Store 共享,借助其 dynamic-module-registration 的能力,实现子应用之间共享数据的注册和销毁。
  • 公共鉴权和校验,比如:路由权限校验 Router before/after Hook、用户角色校验 AuthManager.hasAuth(authKey) 等公共状态校验
  • 公共资源,比如:样式、字体、图标、图片、Theme 变量
  • 公共组件,比如:<ContentBlock />,<FilePreview />
  • 公共布局,比如:<DefaultContainer />, <DetailContrainer />
  • 公共路由,比如:/index, /error/401,/error/404

子应用 SubApp

在提出了所有公共代码之后,子应用变成了纯业务代码的容器被主应用在运行时加载执行。因此在启动子应用之前需要先启动主应用,以拥有主应用运行时的能力。

在开发环境下,将子应用的入口设置为主应用,兼容主应用的 Webpack 配置,将 devServer 的 contentBase 也设置为主运行时的 public 目录,以保持主应用/子应用开发和生产环境下的一致性。

vue.config.js:
SubApp vue.config.js

子应用通过主应用的中心化路由,动态加载执行。而在 Vue.js 中,如何实现中心化路由呢?Vue-Router本身提供了router.addRoutes(routes: Array<RouteConfig>)API,但是这个 API 有一个很致命的缺点,就是不支持嵌套路由,而在实际业务中,子应用通常都是某个 Layout 下的嵌套路由。具体可以参考这个 ISSUE 的讨论,Dynamically add child routes to an existing route,根据 vuejs/rfcs Dynamic routing ,官方团队也正在征集社区意见和实现这个功能。

为了解决这个问题,需要我们自己打补丁增强一下 VueRouter 的 addRoutes 功能,实现支持动态嵌套路由、动态加载应用等功能。这便是 vue-mfe 的由来。

动态嵌套路由

vue-mfe 内部维护了一套独立的pathListpathMap,虽然独立维护,会增加内存开销成本,好处是不会对VueRouter本身的功能造成任何影响。

当调用 router.addRoutes(routes: RouteConfig[], parentPath: string)时,深度优先找到 parentPath 所在的旧路由 oldRoute,并将其 children 与新的 routes 合并后生成新的路由 options: newRouterOptions。

再使用 options: newRouterOptions 重新实例化 new VueRouter(options: newRouterOptions),拿到新的 router.matcher 并将其赋值给app.$router.matcher 以达到支持动态嵌套路由、动态更新应用路由注册表的目的。

动态加载应用

  1. 使用VueMfe.createApp(AppConfig)注册微前端主应用App,初始化 Router,刷新 VueMfe 内部路由注册表pathListpathMap
  2. 注册 beforeEach 钩子,拦截路由to是否已存在于当前路由中,若不存在则认为这是一个需要被动态加载的子应用。
  3. 执行getAppPrefix(to)获取当前路由的子应用prefix前缀,执行 install 方法。
  4. install会尝试优先获取 SubApp 自身的 resources 配置 config.resources[prefix],其次取主应用的 resources 配置。如果都获取不到,则会抛出无法找到 prefix资源的异常。
  5. 获取到 SubApp 资源后,广播加载开始LOAD_START事件,开始安装 SubApp 的静态资源和路由,执行 SubApp 的 init 初始化方法,加载成功后广播加载成功LOAD_SUCCESS事件。
  6. 执行 next(to) 跳转到用户访问的路由prefix实现完整闭环。

VueMfe动态加载应用

构建子应用

因为不同的 App 由不同的 webpack build context 构建,无法共享 chunkId 和 moduleId。所以需要将子应用打包成 umd 格式的 library,暴露 SubApp 的配置项到 root 全局变量供 VueMfe 安装。而且后续其他资源控制权则继续交由 webpack 控制。

而在 19 年末有了 webpack5 提供的 module-federation,正式为了解决这个问题提出,但目前还是 beta 版本。新曙光,而且很多大佬已经开始了探索。后续,会继续跟上 webpack5 的升级。

构建步骤:

  1. 将子应用打包成 umd 格式的 library。
  2. 构建的入口必须是 export default VueMfe.createSubApp(SubAppConfig)的文件,以保证 root 的 全局变量是 SubAppConfig 供 VueMfe 直接安装。
  3. 如果有使用 CDN 则将 CDN 地址配置到 SubApp 或者 App 的 resources 即可。

在我们团队中,在更新到 vue-cli3 之后,因为 cli3 封装了所有的 webpack 配置,通过 service api 形式暴露,所以写了一个插件 vue-cli-plugin-mfe 来构建子应用。我们分别拆分了 3 个 command:

  1. build构建 umd 格式文件
  2. upload上传构建后的文件到 CDN
  3. publish发布应用通知更新前端资源

build 的主要代码如下:

  1. 删除了 vue-cli3 自带的相关插件,这些插件对主应用生效即可,子应用并不需要:
api.chainWebpack((config) => {
  config.plugins
    .delete("html") // for cli-3.2+
    .delete("html-index") // for cli-3.5+
    .delete("prefetch")
    .delete("prefetch-index")
    .delete("preload")
    .delete("preload-index")
    .delete("workbox")
    .delete("workbox-index")
    .delete("copy")
    .delete("pwa")
    .end()
})
  1. 配置子应用打包成 umd 格式,及其全局变量名称
api.configureWebpack({
  // 打包入口
  entry: "./src/portal.entry.js" || options.entry, // options.entry
  devtool: args.disableSourceMap ? false : "source-map", // disable all
  output: {
    path: api.resolve(args.output),
    library: {
      root: "__domain__app__" + camelizedName,
      amd: packageName,
      commonjs: packageName,
    },
    libraryTarget: "umd",
    filename: "js/" + camelizedName + "-[chunkhash:8].umd.js",
    // libraryExport: name,
    chunkLoadTimeout: 120000,
    crossOriginLoading: "anonymous",
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendors: {
          name: camelizedName + "-" + "chunk-vendors",
          // eslint-disable-next-line no-useless-escape
          test: /[\/]node_modules[\/]/,
          priority: -10,
          chunks: "initial",
        },
        common: {
          name: camelizedName + "-" + "chunk-common",
          minChunks: 2,
          priority: -20,
          chunks: "initial",
          reuseExistingChunk: true,
        },
      },
    },
  },
  plugins: [
      // 配置 CDN 下载地址
    args.downloadUrl &&
      new WebpackRequireFrom({
        path: args.downloadUrl + args.name + "/",
      }),
      // 生成 manifest.json 文件
    new WebpackManifest(),
      // 将构建后的资源打包成 .tar.gz 包
    new WebpackArchiver({
      source: api.resolve(args.output),
      destination: outputPath,
      format: "tar",
    }),
  ].filter(Boolean),
})

upload 的主要代码如下:

      // filePath 是打包后的 .tar.gz 包路径
  const name = args.name
  const url = args.uploadUrl
  const download = args.downloadUrl || args.uploadUrl
  const fileSize = fs.statSync(filePath).size

  log()
  log(
    JSON.stringify({
      name,
      moduleName,
      uploadUrl: url,
      downloadUrl: download,
      file: filePath,
      fileSize,
    })
  )
  log()

request
      .post({
        url,
        formData: {
          file: fs.createReadStream(filePath),
          moduleName,
        },
      })
      .on("error", (error) => {
        stopSpinner(false)
        log(chalk.red(`Upload module ${chalk.cyan(name)} failed`))
        log(chalk.red(JSON.stringify(error)))

        reject(error)
      })
      .on("complete", (res, body) => {
        stopSpinner(false)

        if (res.statusCode !== 200) {
          log(chalk.red(`Upload module ${chalk.cyan(name)} failed`))
          log(
            chalk.red(
              `Remote server ${url} status error. Code: ${chalk.red(
                res.statusCode
              )}, Body: ${chalk.red(body ? JSON.stringify(body) : "")}`
            )
          )

          reject(body)
        } else {
          done(`Upload module ${chalk.yellow(name)} complete.`)
          info(`Checkout it out on package-server ${chalk.cyan(`${download}`)}`)

          resolve(res.resume())
        }
      })

publish 的主要代码如下:

我们的发版只是通过 RESTful API 接口向客户端的 socket 推送一条更新消息:

  const name = args.name
  const publishUrl = args.publishUrl
  const arr = publishUrl.split('?')
  let url = arr[0]
  const querystring = arr.slice(1).join('')
  let data = qs.parse(querystring)

    return request
      .get(url)
      .on('error', (error) => {
        stopSpinner(false)
        log(chalk.red(`Publish module ${chalk.cyan(name)} failed`))
        log(chalk.red(error))

        reject(error)
      })
      .on('complete', (res, body) => {
        stopSpinner(false)

        if (res.statusCode !== 200) {
          log(chalk.red(`Publish module ${chalk.cyan(name)} failed`))
          log(
            chalk.red(
              `Remote server ${url} status error. Code: ${chalk.red(
                res.statusCode
              )}, Body: ${chalk.red(JSON.stringify(body))}`
            )
          )

          reject(body)
        } else {
          done(`Publish module ${chalk.yellow(name)} complete.`)
          info('Hey, Congratulations!')

          resolve(res.resume())
        }

构建 & 部署过程的核心代码既如上所示。

回顾历程

截止目前为止,团队内已使用微前端架构 1 年多了。虽然不够完美,或者一些概念在日新月异的前端领域可能已经过时。但是这套方案,在团队内部切切实实解决了开篇提到的种种问题。
如果你有什么更好的建议,欢迎联系我。谢谢!

References

原文地址:https://www.cnblogs.com/givingwu/p/12900255.html