一次微前端的探索实践之旅

2020-07-14 11:41:09

本文记录最近一次微前端的探索实践。

这几年“微服务”的讨论甚嚣尘上,那么“微前端”又是什么硬核技术呢?

微前端最早由ThoughtWorks 2016年提出,目前社区有很多关于微前端的介绍,也有一些落地的具体实践方案,对于微前端的概念感兴趣或不熟悉的同学,可以在知乎上看到很多相关内容,本文不再做过多介绍。

微前端的核心价值:

技术栈无关

主体应用不限制接入应用的技术栈,子应用具备完全独立自主权;

开发、部署独立

子应用git仓库独立,开发独立,部署独立;

运行时独立

每个子应用之间状态隔离互不影响,运行时状态不共享;

微前端架构主要在于解决一个应用在相对长时间的跨度下,由于参与的人员、团队的增多、变迁,不同业务的迭代、堆积,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题,这类问题在企业级 Web 应用中尤其常见。

一个具体的业务场景

今天结合我们最近做的一个具体的业务场景,来一次微前端的探索实践之旅,其中有很多细节的实现方案不是很好,希望可以和各位同仁一起探讨。

以上的Chrome的拓展程序图片就是我们做的一个类似的东西,我们在做一个IDE,里面有个模块就是这种工具箱,每个工具都是一个独立的SPA,在点击安装并打开后可以是一个独立的web页面,从而进行工具层面的自由操作。

这个业务场景就特别适用于微前端架构,因为不是简单的widgets组件,且每个工具都是由其他人提供的独立包,由此我们就开始了微前端的探索之旅,从刚开始的异步组件到现在的独立包一共探索实践了三种方案,至此也还不算完美,需要继续寻求更好的方案。

1. 动态import一个npm组件

这个方案最简单,当时都没细想怎么实现,因为几乎没有开发成本,结果验证失败了;

方案

  1. 组件仓库是独立的,开发完成一个组件后发npm包;
  2. 点击工具箱列表页面中每个工具的安装,然后开始调node服务去npm install拉下来这个组件包;
  3. 打开工具页面后去异步加载这个npm包组件;
<template>
    <component v-bind:is="toolComponent"></component>
</template>

<script>
  export default {
    data () {
      const toolComponent = this.render;
      return {
        toolComponent: toolComponent
      }
    },
    methods: {
      render () {
        this.toolComponent =  (resolve) => ({
          component: import(`${toolName}`),
          loading: LoadingComponent,
          delay: 200,
          timeout: 10000
        });
      }
    },
  }
</script>

问题

很多做过工程化的应该知道,这个方案是行不通的,官方的动态组件是指这些组件是明确存在的,以至于在运行时可以明确的找到对应的组件。我这种未知的组件在构建时就有很多问题了,首先webpack在构建时会发现import的路径是一个变量,会出现找不到这个路径的错误,就算我把路径精确到node_modules的某个命名空间下(@tool),它会把该路径下所有的包都进行打包,本身就是不合理的,虽然编译时通过了,但运行时依旧找不到,因为在运行时新npm install的包已经不会出现在构建结果了,导致无组件可渲染,所以这个方案不适用于我的这个场景,对于正常的一些动态组件渲染是友好的,怪我当初想的太简单了。

优点

  • 仓库独立,工具开发完成后直接发npm包;
  • 开发相互独立;
  • 减小主包体积;

缺点

  • npm包版本不一致导致工具使用不一;
  • npm包下载失败概率大,导致无法渲染组件;

这个技术方案本身对于我的业务场景就是不可行的,可以作为一个失败的经验来记录,但是其中还是有一些技术细节值得讨论一下。

2. 动态加载一个库js

这个方案也相对简单一些,开发成本较低,可以满足于当下的业务场景,但是还算不上微前端。

方案

  1. 在新的仓库下独立开发一个项目,开发完成后打包为一个库lib;
"scripts": {
    "lib": vue-cli-service build --target lib --name toolName --dest libs ./config/index.js",
}
  1. 把该组件挂载到window上,便于主体应用的引用,同时作为打包库的入口文件;
import toolName from '@/views/Widgets.vue'

if (typeof window !== 'undefined') {
  window.modules = {
    'toolName': toolName
  }
}
  1. 因为最终只打包为一个js,禁止抽取公共js和对css和图片进行内联处理;
module.exports = {
  chainWebpack: config => {
    config.optimization.delete('splitChunks')
    config.module
      .rule("images")
      .use("url-loader")
      .loader("url-loader")
      .tap(options => Object.assign(options, { limit: Infinity }));

    const svgRule = config.module.rule('svg')
    svgRule.uses.clear()

    svgRule
      .test(/\.svg$/)
      .use('svg-url-loader')
      .loader('svg-url-loader')
  },

  css: {
    extract: false
  },
}
  1. 直接把打包好的js上传至CDN,供主体包异步加载使用;
  2. 主体包动态异步加载该js;
<template>
    <component v-bind:is="toolName"></component>
</template>

<script>
  export default {
    data () {
      return {
        toolName: ''
      }
    },
    created () {
      this.loadAsyncComponent(this.$route.params.id)
    },
    methods: {
      loadAsyncComponent(toolId) {
        this.toolName = toolId
        Vue.component(toolId, (resolve, reject) => {
          this.loadScript(this.toolList[toolId].link)  // CDN地址
            .then(() => {
              resolve(window.modules[toolId])
            }).catch((e) => {
              reject(e)
            })
        })
        },
        loadScript(url) {
          return new Promise((resolve, reject) => {
            var script = document.createElement('script')
            script.type = 'text/javascript'
            if (script.readyState) { // IE
              script.onreadystatechange = function() {
                if (script.readyState === 'loaded' || script.readyState === 'complete') {
                  script.onreadystatechange = null
                  resolve()
                }
              }
            } else { // Others
              script.onload = function() {
                resolve()
              }
              script.onerror = function (e) {
                reject(e)
              }
            }
            script.src = url
            document.getElementsByTagName('body')[0].appendChild(script)
          })
        }
    },
  }
</script>

优点

  • 独立开发,独立部署;
  • 开发完成后直接构建一个umd的lib易使用;
  • 一个js上传为CDN好管理;
  • 可以被多项目异步加载使用;

缺点

  • 打包为一个js容易体积大;
  • 可能与主包有重复的资源;
  • 不能独立使用,依赖于主包框架;

这个技术方案基本实现了这个场景下的需求,但是在技术上的追求永不止步,我们希望这个工具可以独立使用,不依赖于任何其他主体,所以就有了第三种方案。

3. 动态加载一个独立SPA(zip包)

这个方案最复杂,前后端做的事情都比较多,同时也最完善,可以支持独立使用,也可以被其他的项目引用,同时其中的很多技术细节值得商榷,可以算得上是一个微前端架构了;

方案

  1. 工具项目独立开发,是一个完整的SPA项目;
  2. 把SPA项目的构建结果打包为一个zip包并上传至CDN(详情看上一篇文章);
  3. 工具列表页中点击某个工具的安装时去拉zip包并解压(详情看上一篇文章);
  4. 服务端处理解压后的SPA项目中的资源路径问题(koa-rewrite-url);
// 改写SPA中js等资源路径添加包名路径(koa-rewrite-url待开源)
app.use(rewriteUrl())
  1. 服务端处理解压后的SPA项目中的路由问题(koa-rewrite-resources);
// 在SPA的index.html中增加base,保证路由生效(koa-rewrite-resources待开源)
app.use(rewriteResources(port))
  1. 通过koa-simple-serve起多个静态路由与主体服务分割(也可以起多个端口对应不同的SPA项目);
app.use(Serve('/', path.join(__dirname, '../public')))
app.use(Serve('/tool', path.join(__dirname, '../tool')))
  1. 打开新的路由或一个静态服务使用工具;

问题

这个方案在开发的过程中碰到了不少坑,尤其是最后一步如何可以把多个SPA在一个项目下跑起来,请教了社区内的不少node大佬,还自己写了两个中间件才基本实现,如果主体项目本身路由是history模式时,建议第6步直接起不同的端口服务来运行不同的工具页面,不然前端路由和多个静态路由会有冲突,如果执意不想多端口的话,前端路由还是使用#模式比较稳妥,这个方案整体下来还是有很多问题值得商榷的。

优点

  • 独立开发完整的SPA项目;
  • 支持独立使用,不依赖于主体框架;
  • 工具的安装卸载打开功能较完善;
  • 与主体项目彻底分离,功能完全独立;

缺点

  • 开发难度较大,node层中间价对不同项目可能处理复杂;
  • 如果是一个端口的话路由不好控制;

这个技术方案对业务场景的需求实现还算不错,也基本实现了一个微前端的架构,但是感觉还不够理想,未来还会继续改善提升。

总结

离开具体的业务场景谈技术方案谈性能都是扯淡,我们必须以开放进步的心态去面对并解决业务难题,与时俱进,拥抱技术,不断探索更好更优的方案来改善现有的实现,欢迎更多的人来一起探讨,谢谢!

如果说人生是一场旅行,而我是这场旅行的主人!