从微组件到代码共享

发表于 3年以前  | 总阅读数:529 次

前言

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

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

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

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

业务背景

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

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

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

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

项目介绍

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

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

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

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

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

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

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

利用子应用复用微组件

从一个用法开始

如果是一个组件很简单,也很好实现,我们知道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解决不了这样的场景。

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

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

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

每个组件其实所需要接收的参数有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组件没有区别。比如这样。

所以我们就需要封装一个底层的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来实现。

基于这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[1] 的API,可以直接远程加载一个组件资源。在现有的设计下,大多数这个资源都是一个已经被编译好的umd的js文件。

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

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

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

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

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

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

共享代码

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

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:被其他应用所使用的应用

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

示例

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

  • app1

  • App.js(react页面入口)

  • bootstrap.js(项目启动文件)

  • index.js(项目入口文件)

  • src

  • webpack.config.js(webpack配置文件)

  • app2

  • App.js(react页面入口)

  • Button.js(Button Component)

  • bootstrap.js(项目启动文件)

  • index.js(项目入口文件)

  • src

  • 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中的。

并且可以看到,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 "key"); 
        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

到这里基本我们就明白了,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之间直接互相使用。

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

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

Bit

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

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

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

修改一下这个默认的组件代码。比如这里我们把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 发布一个新版本

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

  • before:
  • after:

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

总结

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

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

本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/29nIdcijMLBKjrqdMch3zA

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237231次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8065次阅读
 目录