前端微组件实践

lxf2023-03-14 12:14:02

背景

我们有多个平台需要用到一些公共的业务组件,比如说消息中心,用户反馈等等。如果在多个平台全部开发一遍,或者是ctrl c+v,显示是很不适合后续的维护的。因此希望有一套方案,能够做到一次开发,多处使用。另外这些平台还有以下的特点:

  • 平台使用的基础UI库为vue+antd。
  • 各个平台开发和上线的时间不同,因此依赖的版本也不一致,既有vue2.x+antd1.x的,也有vue3.x+antd2.x的。因此要考虑库兼容的问题。
  • 这些平台的代码牵连比较多,因此不适合改动太大。
  • 平台由团队的多个成员维护,因此要考虑到大家的改造和学习成本。
  • 这一套方案日后还可能平移到更多的平台使用,因此最好能有清晰简明的文档

技术选型和调研

npm

首先容易想到的肯定是npm方案,但是npm方案有以下的问题不好解决。

  1. 我们的npm私库建设不太成熟,这些业务组件放到公网上,会有一些安全问题。
  2. 我们的vue和antd版本不一致,虽然可以通过npm的版本控制来做两个版本,或者是通过一些转换工具,但终归是比较繁琐,不够优雅。
  3. 由于前端构建流水线的原因,前端每一次更新npm包的版本后,都需要手动上传node_modules到指定服务器,操作成本巨大。

综上,npm方案不合适。

webpack module federation(模块联邦)

module federation(简称MF)是webpack 5.0推出的远程模块的一个方案。属于微前端的一种实现方案。引用官方的表述

每个构建都充当一个容器,也可将其他构建作为容器。通过这种方式,每个构建都能够通过从对应容器中加载模块来访问其他容器暴露出来的模块。

主要是使用ModuleFederationPlugin 这个插件。使用起来非常的简单。

作为远程模块提供方的工程

// 引入插件
const { ModuleFederationPlugin } = require("webpack").container;
​
// 插件配置
new ModuleFederationPlugin({
      name: "app2", // 必传,且唯一,作为被依赖的key标志,依赖方使用方式 ${name}/${expose}
      library: { type: "var", name: "app2" },       // library用于声明一个挂载的变量名(全局),其中这里的 name 为作为 umd 的 name
      filename: "remoteEntry.js",       // 构建后被依赖部分的入口文件名称
      exposes: {        // exposes暴露对外提供的modules模块
        Button: "./src/Button",
      },
      shared: ["react", "react-dom"],       // 声明共享的第三方依赖,声明后,依赖方使用时,优先使用依赖方自己的,如果没有使用被依赖方的
}),

作为远程模块使用方的工程

// webpack module federation配置
new ModuleFederationPlugin({
      name: "app1",
      library: { type: "var", name: "app1" },
      remotes: {            // 依赖方声明需要依赖的资源,对应被依赖方的key
        app2: "app2",
      },
      shared: ["react", "react-dom"]
})
​
// 引用被依赖方暴露的资源文件
<script src="http://localhost:3002/remoteEntry.js"></script>
​
// 使用方式,通过import('远程资源包名称/模块名')的方式直接引入。
const RemoteButton = React.lazy(() => import("app2/Button"));

官方还提供了各种语言的用例供我们参考。

github.com/module-fede…

这个方案一开始是我最偏向于使用的方案,因为使用它,对原先项目的改造足够简单,理解成本也足够低,更加上有webpack的背书。但经过实际使用之后,发现了它的一些问题:

  1. 使用过程中会有一些报错,需要查询官方文档和阅读用例代码逐个解决,整体的过程很费劲。
  2. 对于vue和antd版本的兼容性问题,MF不能提供帮助,虽然或许可以通过一些配置来解决,但是过程也是很艰难的。(哪怕官方提供的用例代码中,有vue3转vue2的办法,但经过一顿折腾之后,还是没有完全解决语言兼容的问题,最后我还是放弃了)
  3. 我们的平台工程代码都是通多vue-cli4.x初始化生成本,当时依赖的webpack版本是4.x,所以在开始接入MF之前,需要升级版本,虽然最后升级成功了,但也带了很多未知的风险。

所以最终还是没有选择使用MF。

小结

webpack5.0是2020年10月份发布的,姑且认为MF是同期发布的,MF的发布时间距离现在是2年几个月的时间左右。在业内,虽然有了一些解析MF的资料出现,也有了一些对MF的实践,以及基于MF的微前端框架,但不得不说,MF依然不是微前端主流,当前时间点,MF并不是一个很好的微前端落地方案的选择。

但这并不影响我看好MF,MF在某些场景下,是很好的微前端解决方案,因为它足够轻量,使用和理解起来足够简单。它当前的问题是不够易用。期待以后能有更多基于MF的易用的框架出现,以及webpack官方的努力,解决一些包版本的兼容行的问题,使大家能够丝滑的接入MF。

single-spa和qiankun

single-spa是通过监听 url change 事件,在路由变化时匹配到渲染的子应用并进行渲染,这个思路也是目前实现微前端的主流方式。同时single-spa要求子应用修改渲染逻辑并暴露出三个方法bootstrapmountunmount,分别对应初始化、渲染和卸载,这也导致子应用需要对入口文件进行修改。因为qiankun是基于single-spa进行封装,所以这些特点也被qiankun继承下来,并且需要对webpack配置进行一些修改。

但本次的微组件方案力求代码改造成本小,学习成本小,所以single-spa和qiankun就直接不考虑了。

最终方案:micro-app

直接引用官方文档的一段话来介绍micro-app

micro-app并没有沿袭single-spa的思路,而是借鉴了WebComponent的思想,通过CustomElement结合自定义的ShadowDom,将微前端封装成一个类WebComponent组件,从而实现微前端的组件化渲染。并且由于自定义ShadowDom的隔离特性,micro-app不需要像single-spaqiankun一样要求子应用修改渲染逻辑并暴露出方法,也不需要修改webpack配置,是目前市面上接入微前端成本最低的方案。

micro-app完美的解决了语言版本不兼容的各种问题,并且使用起来足够简单。微组件提供方可以完全是一个普通的vue应用,微组件使用方也只需要进行很小的代码引入就可以了。

而且官方文档的用例也很齐全,学习成本相对较小。附上官方文档:zeroing.jd.com/micro-app/d…

micro-app的一些实践

虽然说在基本诉求上,micro-app是能够非常好的满足我们需求的。但也并非能够是micro-app开箱即用,我们希望的形态是新建立一个业务组件库,把公共业务组件全部放入这个库里,然后将这个库整体作为一个微应用提供出来,其他的各个业务平台能够方便的远程引用这个库里的一个或多个微组件。所以我们仍需结合实际业务场景,对micro-app做一些改造。

路由模式改造成组件模式

micro-app的用法一个url对应一个微应用,微应用本身和我们普通的前端应用一样,然后再路由上做了一些额外的监听,这样就能把基座应用的路由变化应用到子应用。

但我们的需求是希望有一个公共组件库,能够输出多个业务组件,并且子组件是不需要路由的。

所以我们创建一个exporter.vue文件作为同一个组件输出,通过基座应用传过来的moduleName参数来进行控制。

<template>
  <component :is="curComponents" v-bind="{ ...innerData }" @customEvent="onCustomEvent" />
</template><script>
import feedback from './components/feedback.vue';
import feedbackModal from './components/feedbackModal.vue';
const componentsMap = {
  feedback,
  'feedback-modal': feedbackModal,
};
export default {
  name: 'exporter',
  data() {
    return {
      innerData: {},
      curModuleName: '',
    };
  },
  computed: {
    curComponents() {
      return componentsMap[this.curModuleName] || null;
    },
  },
};
</script>

然后在App.vue里应用exporter.vue即可。

优雅的数据通信

数据传入

我们需要在数据初次传入和每次数据变化的时候进行事件的监听,然后借助postMessage触发组件的重新渲染。

main.js文件

// 与基座进行数据交互
function handleMicroData() {
  // 是否是微前端环境
  if (window.__MICRO_APP_ENVIRONMENT__) {
    // 主动获取基座下发的数据
    const appData = window.microApp.getData() || {};
    console.log('微组件数据加载', window.microApp.getData());
    postData(appData);
​
    // 监听基座下发的数据变化
    window.microApp.addDataListener((newData) => {
      console.log('微组件数据更新', newData);
      postData(newData);
    });
  }
}
​
function postData(componentData) {
  window.moduleName = componentData.moduleName;
  window.componentData = componentData.data || {};
  window.postMessage({ type: 'componentDataChange', data: { moduleName: componentData.moduleName } });
}

exporter.vue文件

<template>
  <component :is="curComponents" v-bind="{ ...innerData }" @customEvent="onCustomEvent" />
</template><script>
import feedback from './components/feedback.vue';
import feedbackModal from './components/feedbackModal.vue';
const componentsMap = {
  feedback,
  'feedback-modal': feedbackModal,
};
export default {
  name: 'exporter',
  data() {
    return {
      innerData: {},
      curModuleName: '',
    };
  },
  mounted() {
    window.addEventListener('message', (e) => {
      const { type, data } = e.data;
      if (type === 'componentDataChange' && window.moduleName === data.moduleName) {
        this.curModuleName = window.moduleName;
        this.innerData = window.componentData;
        console.log(`微组件${this.curModuleName}渲染成功`);
      }
    });
  },
  destroyed() {
    console.log('destroyed');
    window.removeEventListener('message');
  },
  computed: {
    curComponents() {
      return componentsMap[this.curModuleName] || null;
    },
  },
};
</script>

事件处理

我们在exporter.vue中监听一个统一的customEvent事件,来作为远程组件的统一入口事件。

那么在业务组件里,只需要在源代码的基础上,增加一行this.$emit('customEvent', <原事件名>)即可。如

handleCancel() {
      // 原先的事件
      this.$emit('cancel');
      // 在微组件环境下需要增加的代码
      this.$emit('customEvent', 'cancel');
    
      this.$refs.ruleForm.resetFields();
},

在基座应用中优雅的使用微组件

官方文档给的vue环境下,微组件的使用是这样的。

<!-- my-page.vue -->
<template>
  <div>
    <h1>子应用</h1>
    <!-- 
      name(必传):应用名称
      url(必传):应用地址,会被自动补全为http://localhost:3000/index.html
      baseroute(可选):基座应用分配给子应用的基础路由,就是上面的 `/my-page`
     -->
    <micro-app name='app1' url='http://localhost:3000/' baseroute='/my-page'></micro-app>
  </div>
</template>

经过一些代码改造后,我们新增了更多的参数,如果我们要使用的话,大概是这样的。

<!-- my-page.vue -->
<template>
  <div>
    <h1>子应用</h1>
    <!-- 
      name(必传):应用名称
      url(必传):应用地址,会被自动补全为http://localhost:3000/index.html
      microAppData(必传):传入到appData里的数据,包含两个属性
        --moduleName(必传):组件名称
        --data(非必传):组件的参数数据
     -->
    <micro-app name='app1' url='http://localhost:3000/' baseroute='/my-page' :data="{moduleName:'demo',data:{}}"></micro-app>
  </div>
</template>

然后,还需要监听远程事件,所以整体上的代码大概是这样的

<template>
  <micro-app :name="moduleName" :url="url" :data="microAppData"></micro-app>
</template><script>
import microApp from '@micro-zoe/micro-app';
// 本地开发模式地址不固定
const path = location.pathname.split('/').slice(0, -1).join('/');
const url = process.env.NODE_ENV === 'development' ? 'http://localhost:8081/common-components' : `${location.origin}${path}/common-components`;
export default {
  ...
  mounted() {
    microApp.addDataListener(this.moduleName, (remoteData) => {
      if (remoteData.isEvent) {
        ...
      }
    });
  },
  computed: {
    microAppData() {
      return {
        moduleName: this.moduleName,
        data: {
          ...this.data,
        },
      };
    },
  },
  ...
};
</script>

另外,还需要做一些引入和初始化的工作。在main.js里添加以下代码

import microApp from '@micro-zoe/micro-app';
​
microApp.start({
  ...
});

如果每次使用远程组件都需要做这么多准备工作,无疑是非常繁琐的。所以我们封装以下,对文件的引入以及组件的参数传入进行一些简化。

首先是useMicroComponents.js文件,我们在这个文件里写micro-app的注册以及一些自定义的逻辑,然后全局注册一个封装好的远程组件统一入口标签。

import microApp from '@micro-zoe/micro-app';
import MicroComponents from './microComponents.vue';
import Vue from 'vue';
​
Vue.component('micro-components', MicroComponents);
​
microApp.start({
  ...
});
​

然后创建microComponents.vue作为远程组件的统一入口组件,然后在组件里对参数和事件做一些处理。

<template>
  <micro-app :name="moduleName" :url="url" :data="microAppData"></micro-app>
</template><script>
import microApp from '@micro-zoe/micro-app';
// 本地开发模式地址不固定
const path = location.pathname.split('/').slice(0, -1).join('/');
const url = process.env.NODE_ENV === 'development' ? 'http://localhost:8081/common-components' : `${location.origin}${path}/common-components`;
export default {
  name: 'micro-components',
  props: {
    moduleName: String,
    data: {
      type: Object,
      default: null,
    },
  },
  data() {
    return {
      url,
    };
  },
  mounted() {
    microApp.addDataListener(this.moduleName, (remoteData) => {
      if (remoteData.isEvent) {
        this.$emit(remoteData.detail.type, remoteData.detail.data);
      }
    });
  },
  computed: {
    microAppData() {
      return {
        moduleName: this.moduleName,
        data: {
          ...this.data,
        },
      };
    },
  },
};
</script>

这样,在使用微组件的时候,只需要传入远程组件名称,和一些必要的业务数据及业务逻辑即可。如下

<template>
  <micro-components moduleName="feedback-modal" :data="componentData" @cancel="$emit('cancel')" @ok="$emit('ok')" />
</template><script>
import { GET, POST } from '@/request/axios';
export default {
  name: 'feedback-modal-remote',
  props: {
    visible: {
      type: Boolean,
      default: false,
    },
  },
  data() {
    return {
      requester: { GET, POST },
    };
  },
  computed: {
    componentData() {
      return {
        requester: this.requester,
        visible: this.visible,
      };
    },
  },
};
</script>

我们的这两个文件是足够干净的,除了最基础的micro-appvue之外,没有任何其他的外部依赖。我们可以把他们打成npm包来进行管理。

一些疑难问题的解决

  • nginx服务转发的时候,url末尾不能带上/,但是micro-app在url传入的时候做了一些处理,导致url会自动被带上末尾的/

这是,需要使用自定fetch这个钩子。检测url末尾是否有/,如果有,则去除。

microApp.start({
  fetch(url, options) {
    let innerUrl = url;
    // 对url做一些兼容,框架会自动在url后面加上'/',然后就会导致资源请求不到,同时就会导致请求的静态资源多了一层'common-components'
    if (url.slice(-1) === '/') {
      innerUrl = url.slice(0, -1);
    }
    return window.fetch(innerUrl, Object.assign(options)).then((res) => {
      return res.text();
    });
  },
});
  • 由于使用了publicPath进行项目的编译,经过micro-app的处理之后,会在资源url上多加了一层publicPath。比如,原本要引入的资源是https://example.com/common-components/example.js,经过处理后变成了https://example.com/common-components/common-components/example.js。这时候需要做一个正则匹配,把多余的路径给去掉。
microApp.start({
  fetch(url, options) {
    let innerUrl = url;
    // 对url做一些兼容,框架会自动在url后面加上'/',然后就会导致资源请求不到,同时就会导致请求的静态资源多了一层'common-components'
    if (url.slice(-1) === '/') {
      innerUrl = url.slice(0, -1);
    }
    const reg = new RegExp('/common-components/common-components/');
    if (reg.test(url)) {
      innerUrl = url.replace('common-components/', '');
    }
    return window.fetch(innerUrl, Object.assign(options)).then((res) => {
      return res.text();
    });
  },
});

番外,一个自认为优雅的在线文档方案

作为一个组件库,在提供组件的同时是很有必要提供一份文档出来的。在线文档的方案当前业内已经很多了,像vuepress等等。但是它们都是需要单独部署和维护的,总感觉这样会比较麻烦。所以我直接在公共组件库里,内置了一个简易版的在线文档。可以直接使用md的形式书写文档,然后在线解析成网页

安装依赖和做一些webpack配置

我们需要安装marked以及一些相关loader,html-loadermarkdown-loader,然后再webpack中配置loader。

最后再安装一个自己喜欢的md的主题css,这里安装的是github-markdown-css

configureWebpack: () => {
    return {
      module: {
        rules: [
          // 配置读取 *.md 文件的规则
          {
            test: /.md$/,
            use: [{ loader: 'html-loader' }, { loader: 'markdown-loader', options: {} }],
          },
        ],
      },
    };
  },

写一个通用的vue组件来渲染md文件

写一个MdDoc.vue组件

<template>
  <!-- markdown渲染器组件 -->
  <div>
    <div v-html="articalContent" class="markdown-body"></div>
  </div>
</template><script>
import { marked } from 'marked';
export default {
  props: {
    fileMd: String,
  },
  data() {
    return {
      articalContent: '',
    };
  },
​
  created() {
    const htmlMd = marked(this.fileMd);
    this.articalContent = htmlMd;
  },
};
</script>

然后在main.js里引入md的主题css,以及注册全局组件。

import Vue from 'vue';
import 'github-markdown-css';
​
Vue.component('MdDoc', MdDoc);

使用

home.vue为例,引入md文件,然后传入,就能方便的把home.md渲染出来了。

<template>
  <div class="doc-container">
    <div>
      <md-doc :fileMd="fileMd" />
    </div>
  </div>
</template><script>
import fileMd from './home.md';
​
export default {
  name: 'home',
  data() {
    return {
      fileMd,
    };
  },
};
</script>

效果如图

前端微组件实践

总结

总的来说,当前业界内可落地的微前端方案已经越来越多,除了文章里介绍到的,还有很多的并未提及的,比如说基于MF的框架mf-lite、hel-micro,以及基于web components的框架magic-microservices,等等等等。

真正难的并非是使用这些微前端方案或框架,而且在前期明确自己的需求,做好充分的调研,寻找最契合自己业务场景的一套方案,然后再次基础上,吸取前人的经验去进行改造和优化。

软件工程界有句话叫做“没有银弹”,意思永远不会有现成的方案能完美解决你所有的问题。软件开发的工作之一便是解决各种接踵而来的问题。

然后就会有人在这些问题中汲取经验,创造更优秀的工具,产出更通用的方案,造福后面的人。而后面的人基于这些工具和方案,又会产生一批新的问题,然后再次创造更优秀的工具和产出更通用的方案。长此以往,循环往复,不断的总结与积累,我想,这也可看作是人类6000多年来文明进化的一个小小的缩影吧。