从微组件到代码共享

前端工程师 @ 公众号:ELab团队

前言

随着前端应用越来越复杂,越来越庞大。前有巨石应用像滚雪球一般不断的叠高,后有中后台应用随着历史长河不断地积累负债,或者急需得到改善。微前端的工程方案在前端er心中像一道曙光不断的被提起,被实践,多年至今终于有了比较好的指引。它在解决大型应用之间复杂的依赖关系,或是解决我们技术栈的迁移历史负担,都在一定程度上扮演了极其关键的桥梁。

本文会先从复用组件,窥探到代码共享。聊一聊中后台项目在微前端的场景下,从工程化的角度下如何跨技术栈复用业务组件,再介绍一下其它的共享代码方案。

在正文开始之前,希望读者能对以下关键词有所了解,以便后文一起交流探讨

  • 微前端
  • 共享组件
  • Garfish(字节开源的微前端框架)
  • Webpack & module federation
  • Bit

业务背景

image.png

如上图,我们先看这么个场景。这个 modal 被红色框起来的部分,其实是一个业务复杂较复杂的react组件来渲染的。在这里就需要渲染出5个react组件。同时这个modal是过去用vue实现的代码,我们的react组件是需要被渲染在vue代码中的,也就是 React in Vue。

在我们的中后台系统里,过去全都是vue的技术栈。而我们新的业务希望全面的往react迁移,其中不乏有比较复杂的业务组件。如下

image.png

image.png

image.png

基于微前端的工程方案,我们就可以尽可能少的修改vue的代码。同时,我们也能达到组件级别的嵌入。

从工程的角度解决微组件共享

项目介绍

先试想一下,其实大多数中后台项目,都是像如上的场景一般。我们可能仅是为了应用之间的解耦,这有利于构建,团队独立维护,改善项目结构,代码复用等等。其实更需要解决的是团队内部自身的工程问题,基本不会涉及到跨产品部门的复用或业务共享。我们更多关注的是,当下在不同repo之间的代码和在不同技术栈之间的组件,如何达到共享。那么我们需要共享微组件的职责就很清晰了。

在我们团队的中后台应用有三个repo,过去的巨石应用(vue),新建的两个monorepo(react)。(拆了两个是业务之间比较独立。)

image.png

在我们有了monorepo之后,其实所有的业务组件或者业务代码,都已经在物理的层面上可以良好的复用。剩下的问题就在于如何跨repo(跨物理层面)在过去的技术栈(vue)中直接复用。而我们的方式就是基于微前端来做。

image.png

当我们有了master这样的宿主介入之后,项目的可操作空间就不太一样了。微前端为的是能在同一个应用下,提供一个相同的运行环境。(本文不过多探讨iframe的方式。)

monorepo能很好地解决我们同一个repo下的代码复用问题。如果我们把每一个 repo 都抽象的看做一个模块,那就只需要想办法在这个模块能exports东西出去,不就可以达到跨repo之间的复用?同时它也是一种解决了物理层面上无法复用的手段。

image.png

所以我们的做法就变得很清晰了,在新的react repo里,其实我们就会自然的沉淀下许许多多的基础组件或者是带有复杂业务的业务组件。比如上图的biz-ui,每一个biz-ui里的组件,都是一个完整的业务组件。而我们最终的目标,就是想办法把这些业务组件通过微前端的方式,给其它项目使用。

Micro-components app 子应用,就是我们的exports,它也是一个子应用。所有需要在当前repo exports的业务组件,都可以在这里被注册。

利用子应用复用微组件

从一个用法开始

image.png

如果是一个组件很简单,也很好实现,我们知道garfish有提供loadApp的接口,我们可以直接通过加载一个子应用,这个子应用渲染某个react组件。大致代码如下

// loadApp.vue 
<template> 
  <div :id="id"></div> 
</template> 
<script lang="ts"> 
import { defineComponent } from '@vue/composition-api'; 
 
let id = 9999; 
let beforeDestroy: (() => void) | undefined = undefined; 
 
export default defineComponent({ 
  props: [], 
  data() { 
    return { 
      id 
    }; 
  }, 
  async mounted() { 
    _const_ app = _await_ Garfish.loadApp('xxx', { 
      domGetter: () => document.getElementById(this.id), 
    }) 
 
    _// 渲染:编译子应用的代码 -> 创建应用容器 -> 调用 provider.render 渲染_ 
    _const_ success = _await_ app.mount(); 
  }, 
  beforeDestroy() { 
    console.info(this.microComponentKey, '微前端组件卸载'); 
    beforeDestroy?.(); 
  }, 
  watch: { 
  }, 
}); 
</script> 
复制代码

这样的代码在我们系统里还是跑了几个月的,没有任何问题。但是如果有了多例就不一样了,我们会调用多次loadApp,加载了大量子应用的代码,导致性能很差,甚至直接卡死。有人说加cache行不行?其实也是不可行的,上述的代码过于简陋,我们还需要处理props变化的情况,以及loadApp,传递props给react的情况。如果单纯只是cashe解决不了这样的场景。

所以我们特意设计了一个子应用,这个子应用专门作为组件级别的渲染,暂且称之为 微组件子应用

image.png

而在vue那,我们需要保证全局只会load 一个微组件子应用,这个子应用的domGetter可挂在到body上,仅仅作为一个container。而我们的react组件,全通过portal的形式进行渲染到任意位置即可。

基于这个思路,我们需要去设计一个微组件渲染的数据结构。再看一眼这个图,我们这个数据结构会有哪些东西

image.png

每个组件其实所需要接收的参数有domId、props和事件或其它属性。所以我们的数据结构其实可以大致如下。

type Meta = { 
  domId: string; 
  componentKey: string; // 为了指定由哪个组件渲染 
  props?: Record<any, any>; 
  [_key_: string]: any; // 事件和其它透传属性 
}; 
复制代码

有了这个结构,我们 react 的 render 函数就简单了,统一渲染一个protal数组即可。

  portalRender.map(_meta_ => { 
    const { domId, componentKey, props: _props, ...rest } = _meta_; 
    const container = document.getElementById(domId); 
    if (!container) { 
      return null; 
    } 
     
    return ReactDOM.createPortal( 
      <Suspense _fallback_={null}> 
        <Portals 
          _componentKey_={componentKey} 
          {...{ domId, ..._props, ...rest }} 
        /> 
      </Suspense>, 
      container, 
      domId, 
    ); 
  }) 
复制代码

在vue这边,我们先设想一下应该如何使用这样的组件呢?当然肯定是和单纯的一个vue组件没有区别。比如这样。

image.pngimage.png

所以我们就需要封装一个底层的vue组件去负责管理子应用的load和props的传递。

// loadCMSMicro.vue 伪代码 
<template> 
  <div :id="id"></div> 
</template> 
<script> 
import { microComponentManager } from '../src/MicroComponentManager'; 
 
let id = 0; 
 
export default { 
  data() { 
    return { 
      id: `${++id}`, 
      beforeDestroy: undefined, 
    }; 
  }, 
  props: { 
    props: { 
      required: false, 
      default: () => ({}), 
    }, 
    componentKey: { 
      type: String, 
      require: true, 
    }, 
    subAppName: { 
      type: String, 
      require: true, 
      default: '', 
    }, 
  }, 
  async mounted() { 
    const { unMount, error } = await MicroComponent.loadComponent(); 
    this.beforeDestroy = unMount; 
  }, 
  beforeDestroy() { 
    this.beforeDestroy && this.beforeDestroy({ domId: this.id, type: 'remove' }); 
  }, 
}; 
</script> 
复制代码

而MicroComponent,需要去负责保持只能load一个子应用单例以及props的传递和变化

class MicroComponent { 
  private _loaded = false; 
  private _app: any; 
  private _count = 0; 
 
  async loadComponent() { 
    try { 
      this._count++; 
      if (!this._loaded) { 
        this._loaded = true; 
        this._app = await window.Garfish.loadApp(this._subAppName, { 
          domGetter: () => document.body, 
          props: { 
            subAppName: this._subAppName, 
          }, 
        }); 
 
        await this._app.mount(); 
      } 
 
      const unMount = (_params_: PropsChange) => { 
        this.emitPropsChange(_params_); 
        this._count--; 
        if (this._count === 0) { 
          console.info('[微组件] 子应用卸载了'); 
          this._app.unmount(); 
          this._loaded = false; 
          this._app = null; 
        } 
      }; 
 
      if (!this._app) { 
        return { 
          unMount, 
        }; 
      } 
 
      console.info('[微组件] 加载完毕'); 
      this._debounceEmitPropChange(); 
 
      return { 
        unMount, 
      }; 
    } catch (e) { 
      console.error(`[微组件] 子应用加载失败: ${e}`); 
      this._loaded = false; 
      this._app = null; 
      this._count = 0; 
      return { 
        error: 'CMS 加载子应用失败', 
      }; 
    } 
  } 
} 
复制代码

我们需要用两个flag来控制mount和unmunt。为了保证只能load一个子应用,用一个loaded开关来控制。而count是因为我们有多例其实就是个引用计数,必须保证每个微组件都卸载了,才能去unmount掉我们的子应用。

props如何传递呢?这里其实就是如何进行不同应用之间的数据共享,同时他是保持一份的。我们可以通过garfish提供的API来实现。

image.png

基于这2个API,我们可以在garfish上构建出这么个对象来传递我们的数据。在之前提到过,我们可能是多个子应用export出来的组件,其实这部分的数据存储就是一个二维结构。

garfish[subAppName][domId] = { 
  domId: 1, 
  props: {}, 
  ...rest, 
} 
复制代码

当我们初始化一个vue的组件时,就需要把对应的meta数据挂载到garfish上。修改一下我们刚刚上面的组件代码

... 
export default { 
... 
  async mounted() { 
    const formatEvents = Object.keys(this.$listeners).reduce((_pre_, _cur_) => { 
      _pre_[toUpper(_cur_)] = this.$listeners[_cur_]; 
      return _pre_; 
    }, {}); 
 
    microComponentManager.setMeta(this.subAppName, this.id, { 
      ...formatEvents, 
      ...this.$props, 
    }); 
 
    const module = microComponentManager.getSubApp(this.subAppName); 
    const { unMount, error } = await module.loadComponent(); 
    this.beforeDestroy = unMount; 
  }, 
... 
}; 
</script> 
复制代码

因为需要保持每一个子应用都是唯一的单例,我们继续引入microComponentManager来帮我们管理所有的子应用实例。

搞定了初始化和数据传递的的问题后,我们来思考一下props change的问题。其实也很简单,只要三个步骤。

  1. 监听vue组件的props变化,重新修改数据set到garfish上
  2. 发送事件,通知react获取最新的数据
  3. React rerender
<script> 
// vue 
export default { 
  ... 
  watch: { 
    props: { 
      immediate: true, 
      deep: true, 
      handler(_newProps_) { 
        const module = microComponentManager.getSubApp(this.subAppName); 
 
         microComponentManager.setMeta(this.subAppName, this.id, { 
          ...microComponentManager.getMeta(this.subAppName, this.id), 
          ..._newProps_, 
        }); 
 
        // 发送事件通知react 
        module.emitPropsChange({ domId: this.id, type: 'new' });  
      }, 
    }, 
  }, 
}; 
</script> 
复制代码

react组件则接收到事件后,对数据进行更新,重新渲染

// react 
export const MicroContainer = (_props_: Props) => { 
  const { subAppName, microComponents } = _props_; 
  const [portalRender, setPortalRender] = useState<Meta[]>([]); 
  const pendingUpdate = useRef<Meta[]>([]); 
 
  const { run } = useDebounceFn(() => { 
    setPortalRender([...pendingUpdate.current]); 
  }, 10); 
 
  const onChange = (_params_: PropsChange[]) => { 
    const removeIds = _params_.filter(_item_ => _item_.type === 'remove'); 
    const updateIds = _params_.filter(_item_ => _item_.type === 'new'); 
 
    if (removeIds.length > 0) { 
      pendingUpdate.current = pendingUpdate.current.filter(_item_ => { 
        return removeIds.find(_elm_ => _elm_.domId !== _item_.domId); 
      }); 
    } 
 
    updateIds.forEach(({ _domId_ }) => { 
      const meta = microComponentManager.getMeta(subAppName, _domId_); 
      const { componentKey, ...rest } = meta; 
 
      const target = pendingUpdate.current.find(_item_ => _item_.domId === _domId_); 
 
      if (target) { 
        Object.assign(target, rest); 
      } else { 
        pendingUpdate.current.push({ 
          ...rest, 
          domId, 
          componentKey, 
        }); 
      } 
    }); 
 
    run(); 
  }; 
 
  useEffect(() => { 
    microComponentManager.on( 
      MICRO_COMPONENT_EVENTS.PROPS_CHANGE, 
      onChange, 
      subAppName, 
    ); 
 
    return () => { 
      microComponentManager.off( 
        MICRO_COMPONENT_EVENTS.PROPS_CHANGE, 
        onChange, 
        subAppName, 
      ); 
    }; 
  }, []); 
 
  return ( 
   ... 
  ); 
}; 
复制代码

我们的MicroComponent也需要增加相应的事件发送代码。

export class MicroComponent { 
  private _loaded: boolean = false; 
  private _app: any; 
  private readonly _subAppName: string; 
  private _count: number = 0; 
  private _pendingPropsChange: PropsChange[] = []; 
  private readonly _debounceEmitPropChange: (..._args_: any[]) => void; 
 
  constructor(_subAppName_: string) { 
    this._subAppName = _subAppName_; 
    this._debounceEmitPropChange = debounce( 
      () => this._checkPendingProps(), 
      50, 
    ); 
  } 
 
  async loadComponent() { ... } 
 
  emitPropsChange(_params_: PropsChange) { 
    this._pendingPropsChange.push(_params_); 
    this._debounceEmitPropChange(); 
  } 
 
  private _checkPendingProps() { 
    setTimeout(() => { 
      _// 放到下一个 macrotask 里执行,等待微前端框架和子应用渲染完毕_ 
      if (this._pendingPropsChange.length === 0 || !this._app) { 
        return; 
      } 
 
      this.emit(MICRO_COMPONENT_EVENTS.PROPS_CHANGE, this._pendingPropsChange); 
 
      this._pendingPropsChange = []; 
    }); 
  } 
 
  emit(_event_: keyof typeof MICRO_COMPONENT_EVENTS, _params_?: PropsChange[]) { 
    window.Garfish.channel.emit(genEventKey(this._subAppName, _event_), _params_); 
  } 
} 
复制代码

我们用一个pending队列来存放所有的事件,这是避免一瞬间发送过多事件导致无意义的开销。比如一个列表的页面,可能同时创建了100个微组件,此时如果不做一次debounce则会一瞬间发送100次。一个优化的小细节。

另外需要注意的是注意到我们发送事件的地方用了个setTimeout,这是由于我们的app.mount,其实仅仅只是把子应用给渲染完了,此时不代表react的组件被渲染完毕,我们在react里的useEffect还是没有执行的。所以我们需要放到下一个macroTask来发送事件,为了保证react里先监听。

以上其实就是整套方案的核心代码了

总结

总的来说,我们的实现方案就是基于loadApp,把一个子应用仅仅当做多应用之间渲染和通信的媒介挂在在了body上。所有的组件都通过portal的方式,挂载到指定的dom位置上。

优势

  1. 原理代码实现简单轻量,复用便捷,开发高效,无关技术栈
  2. 接入简单,可以实现ReactInVue,VueInReact
  3. 无论需要复用多少个组件,都只需要load1个子应用,开销低
  4. 可以挂载到任何garfish的应用里,组件复用,达到跨团队级别的复用
  5. 只需要发布一次,所有地方全都生效且最新版本
  6. 可以跨repo搭建自己需要共享的组件子应用

劣势

  1. 无法对组件版本进行管理
  2. 需要基于garfish的环境才能达到共享
  3. 需要创建一个子项目,相比共享组件的方案更重
  4. keep-alive场景下可能有问题
  5. 依赖管理不方便控制(React,组件库等)

可以看出这个方案也有一个最大的局限性。版本不可控,在我们的业务里是不需要对这样需要共享的组件进行版本管理的。以下介绍的方案大家需要注意下,如果你的共享组件需要版本管理则不可采用这种方案。所以,我们再来看看,现在共享组件的标准实现方案。

运行时组件市场

我们上述的方案,其实是通过组件复用的场景细分采用工程化的方案来解决物理隔离,技术栈不同的组件复用。而如果我们需要一个更加通用化的微组件方案,必然会需要平台的支持,版本的支持,loader的支持。所以我们来看看现有的组件市场的发展方向。

Garfish 提供了 loadComponent 的API,可以直接远程加载一个组件资源。在现有的设计下,大多数这个资源都是一个已经被编译好的umd的js文件。

image.pngimage.png

不过在字节内部的另一个微前端框架有另外一种设计,使用的API与 federation 非常相似。

以上的例子无论是哪种API的设计,都不妨碍我们深入理解微组件。不难发现,需要抽象一个微组件必须具备的API需要有

  • Load(指定资源,无论是key还是url)
  • mount/unmout (生命周期)
  • update (props change)

当组件的API被合理的设计好之后,我们还有一个关键就在于如何管理这些组件。于是「组件市场」就这么诞生了。组件市场必须具备的职责只需要两点

  • 组件的上传与下架
  • 可以是以name的方式或者url的方式下载代码

以往我们已经现有的物料平台或者是区块平台,都可以很简单且自然的支持这两个功能。

image.png

共享代码

其实上面讲了两种微组件的方案。我们可以扩展性的思考一下,共享组件其实就是共享代码的一种细分,解决了共享代码,我们就顺便解决了共享组件的问题。而往往共享代码会有更大的使用场景。

Module Federation

概念

Module Federation(以下简称MF)的中文直译为“模块联邦”,从Webpack官网中我们可以找到使用其的动机:

Multiple separate builds should form a single application. These separate builds should not have dependencies between each other, so they can be developed and deployed individually.

This is often known as Micro-Frontends, but is not limited to that.

可以看出MF想要达到的目的就是把多个无相互依赖、单独部署的应用合并为一个应用,即MF提供了在某个应用中可以远程加载其他服务器上应用的能力。 对于MF来说,有两种角色:

  • Host:引用了其他应用的应用
  • Remote:被其他应用所使用的应用

image.pngimage.pngimage.png

同时,一个应用既可以作为host也可以作为remote,即可以利用MF实现一个去中心化的应用部署群。 并且,MF允许应用之间共享依赖实例,例如:host使用了react,remote也使用了react,remote经过配置后,可以在被host加载运行时优先使用host的react实例,而不会重复加载,这样可以做到在一个应用中只存在一个react实例。

示例

我们将使用Webpack官网给出的demo作为示例,向大家展示如何使host应用(app1)在运行时动态加载并使用remote应用(app2)的内容。 先来看看demo中的文件结构:

  • app1
    • src
      • App.js(react页面入口)
      • bootstrap.js(项目启动文件)
      • index.js(项目入口文件)
    • webpack.config.js(webpack配置文件)
  • app2
    • src
      • App.js(react页面入口)
      • Button.js(Button Component)
      • bootstrap.js(项目启动文件)
      • index.js(项目入口文件)
    • webpack.config.js(webpack配置文件)

app1和app2是两个独立部署的应用。

下面来看看app1中的具体代码内容:

// app1 index.js 
import bootstrap from "./bootstrap"; 
bootstrap(() => {}); 
 
 
// app1 bootstrap.js 
import React from "react"; 
import ReactDOM from "react-dom"; 
import App from "./App"; 
ReactDOM.render(<App />, document.getElementById("root")); 
 
 
// app1 App.js 
import React from "react"; 
 
const RemoteButton = React.lazy(() => import("app2/Button")); 
 
const App = () => ( 
  <div> 
    <h1>Basic Host-Remote</h1> 
    <h2>App 1</h2> 
    <React.Suspense fallback="Loading Button"> 
      <RemoteButton /> 
    </React.Suspense> 
  </div> 
); 
 
export default App; 
复制代码

可以发现App.js中有一行非常关键的代码:

const RemoteButton = React.lazy(() => import("app2/Button"));
复制代码

那么问题来了:

  1. 这个app2/Button是从哪里来的呢?
  2. 这一段引用的组件代码长啥样?

我们先来看看app2项目中的webpack配置(这里我们就不贴app2的代码内容了,因为没有什么特别的地方并且在这里并不需要关心):

// app2 webpack.config.js 
// ... 
new ModuleFederationPlugin({ 
  // 作为remote时的模块名 
  name: "app2", 
  library: { type: "var", name: "app2" }, 
  // export的内容被打成包时的文件名 
  filename: "remoteEntry.js", 
  // 作为remote时,export哪些内容被host消费 
  exposes: { 
    "./Button": "./src/Button", 
  }, 
  // 作为remote时,优先使用host的这些依赖,若host没有,则再用自己的 
  shared: { react: { singleton: true }, "react-dom": { singleton: true } }, 
}), 
// ... 
复制代码

从上面配置可以知道:

  1. app2项目作为remote时的模块名是app2;
  2. export的内容是Button组件;
  3. 要export的内容会独立打包成一个名叫remoteEntry.js的文件;
  4. export的内容在被host消费时,会优先使用host的react和react-dom实例。

那么app1中又是如何配置使用app2模块的内容的呢,下面我们来看看app1的webpack配置中关于MF的部分:

// app1 webpack.config.js 
// ... 
new ModuleFederationPlugin({ 
  // 作为remote时的模块名 
  name: "app1", 
  // 作为host时会消费哪些remote的资源 
  remotes: { 
    app2: 'app2@localhost://3002', 
  }, 
  // 作为remote时,优先使用host的这些依赖,若host没有,则再用自己的 
  shared: { 
      react: { singleton: true },  
      "react-dom": { singleton: true }  
  }, 
}), 
// ... 
复制代码

从上面配置中中可以知道app1中使用了跑在localhost:3002上的app2模块内容。至此,在app1如何配置使用app2内容的问题就解决了。

把项目跑起来,可以看到app1的页面,从前面的代码可以知道,App2 Button组件是来自app2中的。

image.png

并且可以看到,app1下载了app2的remoteEntry.js文件,并使用了里面的相关内容,共享代码成功。

实现原理

在讲MF的实现原理之前,我们先来简单简单讲下webpack的模块打包原理,这对理解MF的模块原理至关重要,如果你对这部分内容已经熟知,可以跳过。

先看个简单的栗子🌰(webpack配置没有什么特殊的,这里就不贴了):

// moduleA.js 
export function aFn() {console.log('A')}; 
 
// moduleB.js 
export function bFn() {console.log('A')}; 
 
// index.js 项目主入口文件 
import { aFn } from './ModuleA'; 
 
// 或动态import 
import('./ModuleB').then((module) => module.bFn());
复制代码

经过webpack打包后形成两个chunk文件:

  1. main.js (其中包含index.js和ModuleA.js的内容)
  2. src_ModuleB_js.js

来看看main.js里的内容(简化过后):

// main.js 
 
(() => { 
    // 保存着main chunk中的所有模块,key是module id,value是模块内容 
    // __unused_webpack_module: 当前模块 
    // __webpack_exports__: 模块的导出 
    //  __webpack_require__: 模块加载对象 
    var __webpack_modules__ = ({ 
        "./src/ModuleA.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { /**内容省略**/ }), 
        "./src/index.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { /**内容省略**/ }), 
    }); 
 
    // 保存已加载的模块 
    var __webpack_module_cache__ = {}; 
 
    // 模块加载方法 
    function __webpack_require__(moduleId) { 
        // 检查是否已加载过该模块,若是则直接返回模块的exports对象 
        var cachedModule = __webpack_module_cache__[moduleId]; 
        if (cachedModule !== undefined) { 
            return cachedModule.exports; 
        } 
        // 创建一个模块缓存,并放进__webpack_module_cache__中 
        var module = __webpack_module_cache__[moduleId] = { 
            id: moduleId, 
            loaded: false, 
            exports: {} 
        }; 
     
        // 执行模块加载方法,并将模块内容挂在到module.exports上 
        __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__); 
     
        // 标记该模块已加载 
        module.loaded = true; 
     
        // 返回模块的exports对象 
        return module.exports; 
    } 
 
    // expose the modules object (__webpack_modules__) 
    __webpack_require__.m = __webpack_modules__; 
 
    // startup 
    // Load entry module and return exports 
    __webpack_require__("./src/index.js"); 
})(); 
复制代码

这就是整个项目的启动文件,其实就是一个IIFE。

其中内部变量__webpack_modules__维护了一个该chunk所包含的所有modules的map,key就是module id,value就是模块内容。

从上面的main.js中可以知道其实__webpack_require__模块加载的核心所在,主要做了两件事:

  1. 先从缓存的模块列表中寻找,若找到直接返回该模块的内容;
  2. 若在缓存模块列表中未找到,则执行该模块的加载函数并加入缓存列表中。

当我们是动态import时则会调用__webpack_require__.e

var _ModuleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./ModuleA */ "./src/ModuleA.js"); 
__webpack_require__.e(/*! import() */ "src_ModuleB_js").then( 
    __webpack_require__.bind(__webpack_require__, /*! ./ModuleB */ "./src/ModuleB.js") 
).then( 
    module => module.bFn() 
); 
复制代码

至此可以发现__webpack_require__.e只是返回了一个promise,然后再执行了__webpack_require__方法。可见,在__webpack_require__.e执行完成后,main chunk中的__webpack_modules__就会有ModuleB的内容,这是怎么做到的呢:

简单来说就是main chunk中维护了一个__webpack_modules__的map,用于维护该chunk中有哪些module,而其他的chunk,也会将自己内部的modules加到main chunk的__webpack_modules__

讲到这里,想必那么MF的实现方式,会不会也是将下载好的远程模块放进主chunk所维护的模块列表,从而实现代码共享 🤔。

仔细看了上面的MF Demo打包后的结果,发现果真如此。下面让我们来简单看看下面两个问题:

  1. app1如何下载和使用app2的代码;
  2. app1与app2如何实现依赖共享。

来看看从app2的remoteEntry.js里的实现,它了一个全局变量 app2,它的值为一个包含init和get方法的对象:

// app2/remoteEntry.js 
 
var app2 
(() => { 
    var moduleMap = { 
        "./Button": () => { 
            return Promise.all([ 
                __webpack_require__.e("webpack_sharing_consume_default_react_react-_2849"),  
                __webpack_require__.e("src_Button_js")]).then(() => ( 
                    () => ((__webpack_require__(/*! ./src/Button */ "./src/Button.js"))) 
                    )); 
    }}; 
    var get = (module, getScope) => { 
        // 内容省略 
    }; 
    var init = (shareScope, initScope) => { 
        // 内容省略 
    }; 
    // app2的赋值过程远比这个复杂,这里为了便于读者理解删去了许多代码 
    app2 = { 
        get: () => (get), 
        init: () => (init), 
    }; 
})() 
复制代码

既然要从app2下载代码,那么main.js中的__webpack_modules__必然维护着app2/remoteEntry.js的模块加载方法:

var __webpack_modules__ = [ 
    // ... 
    { 
        "webpack/container/reference/app2": ((module, __unused_webpack_exports, __webpack_require__) => { 
            "use strict"; 
            module.exports = new Promise((resolve, reject) => { 
                if(typeof app2 !== "undefined") return resolve(); 
                __webpack_require__.l("//localhost:3002/remoteEntry.js", (event) => { 
                    if(typeof app2 !== "undefined") return resolve(); 
                    // 省略一堆error定义 
                    reject(new Error()); 
                }, "app2"); 
            }).then(() => (app2)); 
        }) 
    }, 
    // ... 
] 
复制代码

其中调用了__webpack_require__.l来下载app2/remoteEntry.js文件,具体代码不贴了,简单讲讲这个方法做了那些事情:

  1. 新建一个script标签
  2. src设置为app2/remoteEntry.js的地址
  3. 将script标签添加到document中
  4. 下载结束后执行回调方法(第二个参数)

而federation实现的核心在于加载器的变化__webpack_require__.e。通过之前的介绍,我们知道它的功能就是异步加载模块。但是在federation中它就完全不一样了,他会作为remote的加载器!

__webpack_require__.e = (chunkId) => { 
    return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => { 
        __webpack_require__.f[key](chunkId, promises); 
        return promises; 
    }, [])); 
}; 
复制代码

核心关键就在于 __webpack_require__.f对象 我们可以把f理解为federation的缩写。 在.f上挂了3个方法分别为

webpack_require.f.j 负责创建script加载代码

webpack_require.f.consumes 负责执行 app2.init

webpack_require.f.remotes 负责执行 app2.get

image.png

到这里基本我们就明白了,federation基于__webpack__require__这个对象作为window上的runtime,而f这个对象管理了其它应用的依赖和初始化。在federation下,每一个模块(main.js 或 remoteEntry.js)其实都是一个__webpack_modules__,是一个不断套娃的过程。

总结一下,federation给我们前端模块化和应用模块化打开了一种新的思路,他基于window(实际上是__webpack_require__)这个桥梁作为不同的模块和应用之间的通信媒介。而host和expose本身就是一种场景的设计,不难发现,我们前文所述的微组件解决方案也是基于这种抽象的思维(基于微前端把repo直接作为host和expose)来实现的。

而 federation 也有一些局限性,比如我们必须要求新项目都是webpack5以上,我们的技术栈需要保持一致,共享代码时在runtime下如何解决单例问题,在TS中的话,还需要去考虑如何共享类型的问题等等。

应用场景

federation 还有许多实用的场景

一、当我们是一个巨大的应用想要拆分独立部署和构建,但是host和subapp之间又有应用之间的依赖需要共享同时我们的依赖是有状态关系的。我们可以人为的抽离一个shared层,把需要复用的api或组件放在这个shared层上,不同的sub之间直接互相使用。

image.png

二、另外在某些微前端的场景下,我们的路由配置表其实是可以通过federation直接进行共享,无需统一配置在master上。

三、federation还能解决构建时长的问题。比如Umi甚至通过federation带来的灵感解决了构建时长的问题。有兴趣的可以点击链接看一看。

image.png

Bit

一句话介绍Bit:是一个集成了npm + git功能,组件文档,可视化,CI/CD一站式的标准化的组件管理平台

提到代码复用,就不得不说一下bit这个平台。bit整体使用上手都非常简单,由于篇幅原因就不过多介绍。首先跟着官网教程走一遍,初始化一个bit 组件库workspace并且发布好一个组件。

我们在任意一个已有的项目下,我们通过bit init 即可初始化我们的workspace。再通过 bit import 来“download”一个组件,比如我们这里就 bit import meckodo.test/ui/button

image.png

修改一下这个默认的组件代码。比如这里我们把div换成了button

// before 
export function Button({ _text_ }: ButtonProps) { 
  return <div>{_text_}</div>; 
} 
 
// after 
export function Button({ _text_ }: ButtonProps) { 
  return <button _type_="button">{_text_}</button>; 
} 
复制代码

修改完成后,做一个类似git一样的提交 bit tag --all --message "change to button" 再通过bit export 发布一个新版本

image.png

到官网上就可以预览到我们更新的组件了

  • before:

image.png

  • after:

image.png

不难发现,bit的好处就在于。我们任意一个项目都可以非常方便的“download”(import)组件同时,在当前项目下很方便的直接发布(export)新版本。bit不仅仅支持了组件的形式,其实还支持了普通的js/ts代码。在团队内部的业务下,如果有这样跨repo级别共享代码的需求就会非常方便。

总结

本文介绍在微前端项目中我们是如何跨项目跨技术栈复用组件的的使用场景,进而思考到其他工具的是如何复用代码的原理和更广泛的适用范围。

其中较为重要的个人认为是去熟悉内在的一些思想。深入的思考分层和抽象搭建新的“桥梁”,如何去寻找“桥梁”把不同的模块组织起来。会发现前文所说的工程角度来解决组件的共享,其实就是基于garfish这个桥梁,对共享的数据进行了一些同步,这就和webpack的__webpack__require__有异曲同工之处。而把repo抽象为模块,针对性的进行exports,也是从federation中借鉴了灵感。

参考链接

garfish.top/

探索 webpack5 新特性 Module federation 在腾讯文档的应用

webpack 打包的代码怎么在浏览器跑起来的

mp.weixin.qq.com/s/zhkgudIje…

❤️ 谢谢支持

以上便是本次分享的全部内容,希望对你有所帮助^_^

喜欢的话别忘了 分享、点赞、收藏 三连哦~。

欢迎关注公众号 ELab团队 收货大厂一手好文章~

我们来自字节跳动,是旗下大力教育前端部门,负责字节跳动教育全线产品前端开发工作。

我们围绕产品品质提升、开发效率、创意与前沿技术等方向沉淀与传播专业知识及案例,为业界贡献经验价值。包括但不限于性能监控、组件库、多端技术、Serverless、可视化搭建、音视频、人工智能、产品设计与营销等内容。

欢迎感兴趣的同学在评论区或使用内推码内推到作者部门拍砖哦 🤪

字节跳动校/社招投递链接: job.toutiao.com/s/ewdMxeu

文章分类
前端
文章标签