Skip to content

如何打造跨团队微前端生态 2.0

什么是微前端

官方定义

Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently.

  • 关键词:modern web app,multiple teams,independently

思想起源

  • 微服务历史

    • Thoughtworks Technology 2016 年将微服务的思想带到了前端世界,才有了微前端的定义。而什么是微服务呢,请允许我花些时间从微服务说起,为了理解微前端,我们必须从它的源头聊起。
    • 关于微服务,其核心理念在半个世纪前的一篇文章中就被阐述过,而且这篇文章中的很多论点在软件开发飞速发展的这半个世纪中一再被验证,这就是康威定律(Conway's Law)。
  • 康威定律

    • 理解康威定律(Conway’s Law)

      • “设计系统的架构受制于产生这些设计的组织的沟通结构。”

        “Organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations."

        -- Melvin Conway(1967)

      • 通俗来说就是:“组织形式等同系统设计。”
    • 定律 1、人类是复杂的社会动物,组织沟通方式会通过系统设计表达出来。

      Law 1. Communication dictates design.

      • 比如,n 个人团队的沟通的复杂度是 n * (n - 1) / 2, 意味你的系统复杂度 O(n^2)。
      • 所以,沟通的问题,会带来系统设计的问题,进而影响整个系统的开发效率和最终产品结果。
    • 定律 2、罗马不是一天建成的,学会先解决首要问题。

      Law 2. There is never enough time to do something right, but there is always enough time to do it over.

      • 一个任务永远不可能被做到完美,即便给你无限长的时间,但你总有时间把它做完。对于一个巨复杂的系统,我们永远无法考虑周全。
      • Eric 认为,这个时候最好的解决办法是 ——“破罐子破摔”。
      • 敏捷开发《Efficiency/Effectiveness Trade-offs》

        Problem too complicated? Ignore details.

        Not enough resources?Give up features.

        -- Eric Hollnagel (2009)`

      • 对于一个分布式系统,我们几乎永远不可能找到并修复所有的bug,单元测试覆盖 100% 也没有用,错误流淌在分布式系统的血液里。
      • 解决方法不是消灭这些问题,而是容忍这些问题,在问题发生时,能自动恢复。微服务组成的系统,每一个微服务都可能挂掉,这是常态,我们只有有足够的冗余和备份即可。
      • 即所谓的 弹性设计(Resilience) 或叫高可用设计(High Availability)
    • 定律 3、线性系统和线性组织架构间有潜在的异质同态特性。

      Law 3. There is a homomorphism from the linear graph of a system to the linear graph of its design organization.

      • 第一定律的一个具体应用。更直白的说,你想要什么样的系统,就搭建什么样的团队。
      • 系统按照业务边界划分的,按照一个 业务目标 去把自己的模块做成小系统,即 微服务的架构
      • 微服务的理念,团队间应该 「inter-operate, not integrate」。
      • 「Inter-operate」 意味着定义系统边界和接口,让团队全栈且自治。这样能减少子系统间的依赖,降低沟通成本,符合高内聚低耦合的设计原则。
      • Figure 1: Technical organization of teams

      x

      • Figure 2: Architecture driven by technical capabilities

      x

      • Figure 3: Organization of teams driven by business capabilities

      x

      • Figure 4: Business-driven architecture

      x

    • 定律 4、Divide and conquer, 分而治之。

      Law 4. The structures of large systems tend to disintegrate during development, qualitatively more so than with small systems.

      • 一个大的组织往往因为沟通成本/管理问题,总为被拆分成一个个小团队。实际上也验证了微服务是一种好的模式。
      • 依照历史经验,系统架构的核心字诀就是 「拆」和「合」。从单体架构开始,分层架构、微服务架构、网格服务架构、无服务架构,都是在拆,拆的越来越小。
  • 前端技术的滞后

    • We've seen significant benefits from introducing microservices, which have allowed teams to scale the delivery of independently deployed and maintained services. 微服务的独立部署及维护服务,已经让后端团队受益显著。
    • Unfortunately, we've also seen many teams create a front-end monolith. 不幸的是,前端团队依旧受困于单体(monolith)地狱。
    • A large, entangled browser application that sits on top of the back-end services — largely neutralizing the benefits of microservices. 前端的单体应用一定程度上削弱了微服务的能力。
    • Figure 5: Monolithic Frontends

    x

    • Figure 6: Organisation in Verticals

    x

    • 按照上图这种按业务垂直拆分的组织,也就是微前端模式,明显是一种更加友好且不那么臃肿的组织形式。

重新定义

  • 一种架构风格,将可独立交付的前端应用程序组成一个更大的整体。

    "An architectural style where independently deliverable frontend applications are composed into a greater whole."

    -- Thoughtworks Technology (2016)

  • 微前端并没有多么高深莫测,一种架构风格而已;
  • 系统“拆”成一个个独立交付的应用,“合”成一个整体。

特性总结

  • Key Benefits

    • 积小、易拼合且易于维护的代码库。

      smaller, more cohesive and maintainable codebases.

    • 更具扩展性的互相解耦且独立的团队。

      more scalable organisations with decoupled, autonomous teams.

    • 能采用增量的方式,对前端的模块进行升级、更新甚至重写。

      the ability to upgrade, update, or even rewrite parts of the frontend in a more incremental fashion than was previously possible.

  • No Free Lunches

    • 有些微前端实现可能导致依赖项的重复

      lead to duplication of dependencies

    • 团队自治会造成团队风格的差异

      team autonomy can cause fragmentation

    • 但显然,优势大于劣势。

      benefits of micro frontends often outweigh the costs

    • Complexity has to live somewhere. If you are lucky, it lives in well-defined places.

      Every abstraction is leaky.

    • 变化是一只猫。 Complexity has to live somewhere. If you embrace it, give it the place it deserves, design your system and organisation knowing it exists, and focus on adapting, it might just become a strength.

为什么需要微前端

增量升级

Incremental upgrades

  • 陈旧而庞大的前端单体模式,被过时的技术栈或赶工完成的代码质量拖住后腿。

    old, large, frontend monolith is being held back by yesteryear's tech stack, or by code written under delivery pressure.

  • 比起陈旧而庞大的前端单体模式,每个微前端可以选择在合适的时候更新,而不是被迫中止一切(stop the world)一次性更新所有。

    each micro frontend can be upgraded whenever it makes sense, rather than being forced to stop the world and upgrade everything at once.

  • 如果我们想要尝试新的技术,或者是新的交互模式,对整体的影响也会更小。

    If we want to experiment with new technology, or new modes of interaction, we can do it in a more isolated fashion than we could before.

简单、解耦的代码库

Simple, decoupled codebases

  • 每个单独的微前端项目的源代码库会远远小于一个单体前端项目的源代码库。

    The source code for each individual micro frontend will by definition be much smaller than the source code of a single monolithic frontend。

  • 当然了,一个独立的、高级的架构方式(例如微前端),不是用来取代规范整洁的优秀老代码的。

    Of course, a single, high-level architectural decision (i.e. "let's do micro frontends"), is not a substitute for good old fashioned clean code.

  • 相反,我们 加大做出错误决策的难度,增加正确决策的可能性,从而使我们进入 成功的陷阱(the pit of success)

    Instead, we're trying to set ourselves up to fall into the pit of success by making bad decisions hard, and good ones easy.

  • 微前端会促使您明确并慎重地了解数据和事件如何在应用程序的不同部分之间传递,这本是我们早就应该开始做的事情! micro frontends push you to be explicit and deliberate about how data and events flow between different parts of the application
  • E = mc^2

    • 根据《人月神话》作者Fred Brooks的划分,软件开发的复杂度可以划分为 本质复杂度(Essential complexity )偶然复杂度(Accidental complexity)
    • 前者是解决问题时固有的最小复杂度,跟你用什么样的工具、经验是否丰富、架构好不好等都无关,而后者就是除此之外在实际开发过程中引入的复杂度。
    • 通常来说,本质复杂度与业务要解决的特定问题域强相关,因此这里我把它称为更好理解的「业务复杂度」;这部分复杂度不是任何开发方法或工具能解决的,包括低代码。

    • 而偶然复杂度一般与开发阶段的技术细节强相关,因此我也相应把它称为「技术复杂度」;而这一部分复杂度,恰好就是低代码(Low code/No code)所擅长且适合解决的。

    • Error = More * Code^2,所以开发者要 尽量减少偶然复杂度

独立部署

Independent deployment

  • 与微服务一样,微前端的 独立可部署性 是关键。它减少了部署的范围,从而降低了相关风险。

    Just as with microservices, independent deployability of micro frontends is key. This reduces the scope of any given deployment, which in turn reduces the associated risk.

  • 每个微前端都应该有自己的连续交付通道,该通道可以构建、测试并将其一直部署到生产环境中。

    each micro frontend should have its own continuous delivery pipeline, which builds, tests and deploys it all the way to production.

  • 图示

x

自主的团队

Autonomous teams

  • 团队需要根据业务功能 纵向地划分,而不是根据技术种类。

    teams need to be formed around vertical slices of business functionality, rather than around technical capabilities

  • 每个微前端都封装了应用程序的单个页面,并由一个团队 全权负责,更符合 BFF 理念。

    each micro frontend encapsulates a single page of the application, and is owned end-to-end by a single team.

  • 与根据技术种类或“横向”关注点(如样式、表单或验证)来组成团队相比,这会使得团队工作 更有凝聚力

    This brings higher cohesiveness of the teams' work than if teams were formed around technical or “horizontal” concerns like styling, forms, or validation.

  • 图示

x

总之

In a nutshell

  • 微前端都是将巨大的东西分成更小、更易于管理的小部分,然后明确它们之间的依赖关系。

    slicing up big and scary things into smaller, more manageable pieces, and then being explicit about the dependencies between them.

  • 能彼此独立地运行和演进,不需要过多的协调。

    able to operate and evolve independently of each other, without excessive coordination.

其他问题

  • 如何通过服务器对微前端应用的用户进行身份验证和授权?(用户最好应该只需要认证一次。)
  • 容器可能有某种登录形式,我们通过它获得某种令牌。令牌由容器保存,可以在初始化时注入到每个微前端中。最终,微前端可以在任何发送给服务器的请求中携带令牌,然后服务器就可以执行任何需要的验证。

如何实现微前端

基座应用

Base App

  • 一个独立的容器应用,称为 基座,负责:

    a single container application, called Base App, which:

  • 渲染公共页面元素,如头、尾。

    renders common page elements such as headers and footers

  • 处理 横切关注点,如鉴权和导航。

    addresses cross-cutting concerns like authentication and navigation

  • 将不同微前端聚合,告诉每个微前端何时何地渲染自身。

    brings the various micro frontends together onto the page, and tells each micro frontend when and where to render itself

集成方式

Integration approaches

  • 服务端模版复合

    Server-side template composition
    • 图示

    x

    • 将代码分离,这样每一部分代码都是一个自我包含的领域概念,并能够被一个独立的团队交付。

      split up our code in such a way that each piece represents a self-contained domain concept that can be delivered by an independent team.

    • code
    <html lang="en" dir="ltr">
      <head>
        <meta charset="utf-8">
        <title>Feed me</title>
      </head>
      <body>
        <h1>🍽 Feed me</h1>
        <!--# include file="$PAGE.html" -->
      </body>
    </html>
    
    • nginx
    server {
        listen 8080;
        server_name localhost;
    
        root /usr/share/nginx/html;
        index index.html;
        ssi on;
    
        # Redirect / to /browse
        rewrite ^/$ http://localhost:8080/browse redirect;
    
        # Decide which HTML fragment to insert based on the URL
        location /browse {
          set $PAGE 'browse';
        }
        location /order {
          set $PAGE 'order';
        }
        location /profile {
          set $PAGE 'profile'
        }
    
        # All locations should render through index.html
        error_page 404 /index.html;
    }
    
    • 说明为何微前端不是一个新技术,并且不需要很复杂。

      micro frontends is not necessarily a new technique, and does not have to be complicated.

    • 只要我们仔细考虑我们的设计决定如何影响代码库和团队的自治,我们就能获取同样多的便利。

      careful about how our design decisions affect the autonomy of our codebases and our teams

  • 构建时集成

    Build-time integration

    • 使用 npm 方式,将不用应用打成不同的包,部署应用时引入这些包。
    • 乍一看似乎行之有效:允许我们从我们多样的应用中解耦公用依赖。

      seems to make sense, allowing us to de-duplicate common dependencies from our various applications

    • 但为了在产品任意一个部分发布修改,我们必须重新编译和发布每一个微前端。

      we have to re-compile and release every single micro frontend in order to release a change to any individual part of the product.

    • package.json
    {
      "name": "@feed-me/container",
      "version": "1.0.0",
      "description": "A food delivery web app",
      "dependencies": {
        "@feed-me/browse-restaurants": "^1.2.3",
        "@feed-me/order-food": "^4.5.6",
        "@feed-me/user-profile": "^7.8.9"
      }
    }
    
  • 通过 iframes 运行时集成

    Run-time integration via iframes

    • code
    <html>
      <head>
        <title>Feed me!</title>
      </head>
      <body>
        <h1>Welcome to Feed me!</h1>
    
        <iframe id="micro-frontend-container"></iframe>
    
        <script type="text/javascript">
          const microFrontendsByRoute = {
            '/': 'https://browse.example.com/index.html',
            '/order-food': 'https://order.example.com/index.html',
            '/user-profile': 'https://profile.example.com/index.html',
          };
    
          const iframe = document.getElementById('micro-frontend-container');
          iframe.src = microFrontendsByRoute[window.location.pathname];
        </script>
      </body>
    </html>
    
    • iframe 天生能让独立的子页面构建一个页面变得简单。

      By their nature, iframes make it easy to build a page out of independent sub-pages.

    • 它也提供了一个不错的分离性,包括样式和全局变量互不干扰。

      They also offer a good degree of isolation in terms of styling and global variables not interfering with each other.

    • 在应用程序的不同部分之间构建集成可能很困难,因此它们使 路由、历史记录和深层链接 变得更加复杂,并且它们对使 页面完全响应性 提出了一些额外的挑战。

      It can be difficult to build integrations between different parts of the application, so they make routing, history, and deep-linking more complicated, and they present some extra challenges to making your page fully responsive.

  • 通过 JavaScript 运行时集成

    Run-time integration via JavaScript

    • 下面是一个比较初始的例子,演示了微前端通过 JavaScript 集成最基本的实现技术。
    <html>
      <head>
        <title>Feed me!</title>
      </head>
      <body>
        <h1>Welcome to Feed me!</h1>
    
        <!-- These scripts don't render anything immediately -->
        <!-- Instead they attach entry-point functions to `window` -->
        <script src="https://browse.example.com/bundle.js"></script>
        <script src="https://order.example.com/bundle.js"></script>
        <script src="https://profile.example.com/bundle.js"></script>
    
        <div id="micro-frontend-root"></div>
    
        <script type="text/javascript">
          // These global functions are attached to window by the above scripts
          const microFrontendsByRoute = {
            '/': window.renderBrowseRestaurants,
            '/order-food': window.renderOrderFood,
            '/user-profile': window.renderUserProfile,
          };
          const renderFunction = microFrontendsByRoute[window.location.pathname];
    
          // Having determined the entry-point function, we now call it,
          // giving it the ID of the element where it should render itself
          renderFunction('micro-frontend-root');
        </script>
      </body>
    </html>
    
    • 与构建时集成不同,我们可以独立部署每个 bundle.js 文件。

      Unlike with build-time integration, we can deploy each of the bundle.js files independently.

    • 与 iframe 不同,我们有充分的灵活性来以我们偏好的方式构建微前端之间的集成。

      And unlike with iframes, we have full flexibility to build integrations between our micro frontends however we like.

    • 我们可以通过多种方式扩展上述代码,例如,按需下载 JavaScript 包,或者在渲染微前端时传入和传出数据。

      We could extend the above code in many ways, for example to only download each JavaScript bundle as needed, or to pass data in and out when rendering a micro frontend

  • 通过Web Components运行时集成

    Run-time integration via Web Components

    • 这里的最终结果与上一个示例非常相似,主要区别在于选择以 “ web component方式” 进行操作。

      to the previous example, the main difference being that you are opting in to doing things 'the web component way'.

    • code
    <html>
      <head>
        <title>Feed me!</title>
      </head>
      <body>
        <h1>Welcome to Feed me!</h1>
    
        <!-- These scripts don't render anything immediately -->
        <!-- Instead they each define a custom element type -->
        <script src="https://browse.example.com/bundle.js"></script>
        <script src="https://order.example.com/bundle.js"></script>
        <script src="https://profile.example.com/bundle.js"></script>
    
        <div id="micro-frontend-root"></div>
    
        <script type="text/javascript">
          // These element types are defined by the above scripts
          const webComponentsByRoute = {
            '/': 'micro-frontend-browse-restaurants',
            '/order-food': 'micro-frontend-order-food',
            '/user-profile': 'micro-frontend-user-profile',
          };
          const webComponentType = webComponentsByRoute[window.location.pathname];
    
          // Having determined the right web component custom element type,
          // we now create an instance of it and attach it to the document
          const root = document.getElementById('micro-frontend-root');
          const webComponent = document.createElement(webComponentType);
          root.appendChild(webComponent);
        </script>
      </body>
    </html>
    

样式

Styling

  • 命名约束

    • strict naming convention, such as BEM
    • block-name_element-name--modifierName { /* ... */ }
    • BEM 思路在于,项目开发中,每个组件及名字是独一无二的,组件内部元素的名字都加上组件名,并用元素的名字作为选择器,自然组件内的样式就不会与组件外的样式冲突了。
    • 问题一、若不遵守命名规范,会造成严重问题。
    • 问题二、命名中包含模块名,长长的命名会让HTML标签会显得臃肿。
  • 样式预处理

    • use a pre-processor such as SASS, whose selector nesting can be used as a form of namespacing
  • CSS Modules

    • CSS 文件的类名及动画名默认局部作用域;:global 切换到全局作用域。
    • 目前使用最广的样式冲突问题的方案。css-modules
  • CSS-in-JS

    • 简单来说 CSS-in-JS 就是将应用的CSS样式写在JavaScript文件里面。
    • 优点:提供自动局部CSS作用域的功能,避免无用的CSS样式堆积,基于状态的样式定义
    • 缺点:运行时花销大,代码可读性差。
  • Shadow DOM

    • Shadow DOM
    • 可封装复用,能实现 真正意义上的样式独立,而且不会增加 DOM 结构导致性能问题;
    • Qianku 采用的方案。
    • 缺点:兼容性问题,a more platform-based approach。

共享组件库

Shared component libraries

  • 创建这样一个库的意义在于:通过重用代码减少工作量,并提供视觉一致性。
  • 请务必确保共享组件 仅包含 UI 逻辑,并且不包含业务或域逻辑。将域逻辑放入共享库时,它会在应用程序之间创建高度耦合,并增加更改的难度。这种域建模和业务逻辑属于微前端的应用程序代码,而不是共享库中。
  • 维护共享库的工作需要强大的技术技能,还需要培养许多团队之间协作所需的人员技能。

    The job of maintaining the shared library requires strong technical skills, but also the people skills necessary to cultivate collaboration across many teams.

跨应用通信

Cross-application communication

  • 一般来说,我们建议通信越少越好,因为这通常会重新引入不恰当的耦合。但是,既然谈论这个问题,某种程度上的通信通常是需要的。
  • 我们希望微前端通过发送消息或者事件来彼此通信, 避免任何状态共享。就像跨微服务共享数据库,只要我们共享数据结构和领域模型,就会产生大量的耦合,这会变得非常难以维护。
  • 最重要的事情是 对你正在引入的耦合考虑深远,以及你将如何保持约定。就像微服务之间的集成一样,如果没有跨不同应用程序和团队的协调升级过程,你就无法对集成做出重大变更。

后端通信

Backend communication

  • 我们非常相信全栈团队的价值,他们从可视化代码到 API 开发、数据库和基础架构代码负责整个应用的开发。
  • BFF 模式在这里发挥了作用,每一个前端应用都有一个对应的后端来单独满足前端的需求。
  • BFF 可能是自包含的 ,具有自己的业务逻辑和数据库,或者也可能只是一个下游服务的聚合器。
  • 有一个指导原则是,团队构建一个特定微前端时不应该必须得等其他团队来为他们构建东西。
  • 图示

x

有哪些开源方案

Single SPA

  • 相关概念

    • Single-spa 是一个将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架。
    • single-spa applications:为一组特定路由渲染组件的微前端。
    • single-spa parcels: 不受路由控制,渲染组件的微前端。
    • 一个single-spa 的 parcel,指的是一个与框架无关的组件,由一系列功能构成,可以被应用手动挂载,无需担心由哪种框架实现。
    • utility modules: 非渲染组件,用于暴露共享javascript逻辑的微前端。
    • helpler:模块加载的能力 借助于 SystemJS
  • 原理

    • 路由拦截
    • 应用加载:父子应用协议接入(bootstrap/mount/unmount);利用有限状态机对子应用进行生命周期的管理
  • 源码解读

    • 以下是读完 single-spa 源码,进行简化,重新实现了核心逻辑:
    • 1、reroute 更改应用生命周期
    • 2、reroute 方法是 single-spa 最关键的步骤,据不同情况,实现了加载、卸载、更改组件生命周期状态、并延迟执行执行浏览器事件。
    • 3、registerApplication 注册应用,触发reroute
    • 4、start 初始化第一次执行,触发reroute
    • 5、navigation-events 路由监听:路由拦截;对浏览器的事情进行劫持,延迟执行
    • reroute code
    function reroute () {
      // 三类子应用
      const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges();
      if (!isStarted()) {
        console.log('预加载应用');
        loadApps(); // 预加载应用
      } else {
        console.log('根据路径来装载应用');
        performAppChanges(); // 根据路径来装载应用
      }
    
      async function loadApps () {
        await Promise.all(appsToLoad.map(toLoadPromise));
      }
    
      async function performAppChanges () {
        // 先卸载不需要的子应用
        appsToUnmount.map(toUnmountPromise);
        // 再去加载 + 初始化 + 挂载
        appsToLoad.map(async app => {
          app = await toLoadPromise(app);
          app = await toBootstrapPromise(app);
          app = await toMountPromise(app);
          return app;
        });
        // 初始化 + 挂载
        appsToMount.map(async app => {
          app = await toBootstrapPromise(app);
          app = await toMountPromise(app);
          return app;
        });
      }
    }
    
    • navigation-events code
    import { reroute } from "./reroute";
    // 1. 页面切换时 reroute
    function urlReroute(evt) {
      reroute();
    }
    // We will trigger an app change for any routing events.
    window.addEventListener("hashchange", urlReroute);
    window.addEventListener("popstate", urlReroute);
    // 2. 以下代码处理用户自己的 hashchange & popstate 事件
    // 拦截 + 重写
    /* We capture navigation event listeners so that we can make sure
     * that application navigation listeners are not called until
     * single-spa has ensured that the correct applications are
     * unmounted and mounted.
     */
    const capturedEventListeners = {
      hashchange: [],
      popstate: []
    };
    const routingEventsListeningTo = ["hashchange", "popstate"];
    // 拦截
    const originalAddEventListener = window.addEventListener;
    const originalRemoveEventListener = window.removeEventListener;
    // 重写
    window.addEventListener = function (eventName, fn) {
      if (
        routingEventsListeningTo.indexOf(eventName) >= 0 &&
        capturedEventListeners[eventName].indexOf(fn) < 0
      ) {
        // 先存起来,等应用 mounted 之后再执行
        capturedEventListeners[eventName].push(fn);
        return;
      }
      return originalAddEventListener.apply(this, arguments);
    };
    window.removeEventListener = function (eventName, fn) {
      if (routingEventsListeningTo.indexOf(eventName) >= 0) {
        const index = capturedEventListeners[eventName].indexOf(fn);
        if (index >= 0) {
          capturedEventListeners[eventName].splice(index, 1);
        }
        return;
      }
      return originalRemoveEventListener.apply(this, arguments);
    }
    // 3. 重写 pushState replaceState
    // 切换时,不会触发 popstate
    window.history.pushState = patchedUpdateState(window.history.pushState, "pushState");
    window.history.replaceState = patchedUpdateState(window.history.replaceState, "replaceState");
    function patchedUpdateState(orginalMethod, methodName) {
      return function() {
        const urlBefore = window.location.href;
        const result = orginalMethod.apply(this, arguments); // 调用原有方法
        const urlAfter = window.location.href;
        if (urlBefore !== urlAfter) {
          // 重新加载应用,传入事件源
          let evt = new PopStateEvent("popstate");
          urlReroute(evt);
        }
        return result;
      };
    }
    
    • 图示

    x

  • 总结

    • 兼容各种技术栈;父子应用协议接入,无需大量重构现有代码
    • 配置相对麻烦,需要依赖 SystemJS 进行动态模块加载;没有处理样式冲突问题

Qiankun

  • 概念

    • qiankun 是一个基于 single-spa 的微前端实现库。同时,由于 qiankun 的 HTML entry 及沙箱 的设计,使得微应用的接入像使用 iframe 一样简单。
  • 关键技术

    • 利用 single-spa 管理 微应用的生周期
    • HTML Entry 接入方式,实现子应用的加载。
    • JS 沙箱,确保微应用之间全局变量/事件不冲突。
    • Shadow DOM 样式隔离,确保微应用之间样式互相不干扰。
  • 原理

    • HTML Entry

      • HTML Entry 接入方式,实现子应用的加载。
      • qiankun 真正去加载解析子应用,是利用import-html-entry来实现的。
      • 根据子应用的 entry 去 fetch 获取到子应用的 html 字符串(需要子应用资源允许跨域)
      • 拿到 html 字符串后,会调用processTpl方法通过正则匹配获取 html 中对应的 js(内联、外联)、css(内联、外联)、注释、入口脚本 entry 等。
      • processTpl 方法如下,查看返回值。
      export default function processTpl(tpl, baseURI) {
        /* 对css、js等资源预处理,用于规范后面对资源的统一处理 */
        return {
          template, // html 模板
          scripts, // js 脚本(内联、外联) 
          styles, // css 样式表(内联、外联) 
          entry: entry || scripts[scripts.length - 1] // 子应用入口 js 脚本文件,若未自定义,默认为解析后的最后一个 js 脚本
        }; 
       }
      
    • JS Sandbox

      • JS 沙箱,确保微应用之间全局变量/事件不冲突。
      • SnapshotSandbox 原理就是,启动之前记录环境,并且还原回 inactive 之前的 window 环境。
      • 在 inactive 时记录修改过的记录,当在 active 的时候还原在 inactive 时候环境。这样就解决了全局环境的污染问题。
      export default class SnapshotSandbox implements SandBox {
        proxy: WindowProxy;
        name: string;
        type: SandBoxType;
        sandboxRunning = true;
        private windowSnapshot!: Window;
        private modifyPropsMap: Record<any, any> = {};
        constructor(name: string) {
          this.name = name; // 绑定沙箱名字为子应用的名字
          this.proxy = window; // 沙箱 proxy 指向window
          this.type = SandBoxType.Snapshot; // 'Snapshot'
        }
        
        active() {
          // 记录当前快照
          this.windowSnapshot = {} as Window;
      
          // 把window不在原型链上的属性和对应的值都存放进入windowSnapshot中记录下来。
          iter(window, (prop) => {
            this.windowSnapshot[prop] = window[prop];
          });
          // 恢复之前的变更
          Object.keys(this.modifyPropsMap).forEach((p: any) => {
            window[p] = this.modifyPropsMap[p];
          });
          this.sandboxRunning = true;
        }
        inactive() {
          // 记录修改过的属性
          this.modifyPropsMap = {};
      
          iter(window, (prop) => {
            //如果两个值不相等,说明window这个属性的值被修改了。
            if (window[prop] !== this.windowSnapshot[prop]) {
              // 发现了被人修改过,记录变更,恢复环境
              this.modifyPropsMap[prop] = window[prop];
              window[prop] = this.windowSnapshot[prop];
            }
          });
      
          if (process.env.NODE_ENV === 'development') {
            console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap));
          }
          this.sandboxRunning = false;
        }
      }
      // 逐个遍历 obj 的属性
      function iter(obj: object, callbackFn: (prop: any) => void) {
        for (const prop in obj) {
          if (obj.hasOwnProperty(prop)) {
            callbackFn(prop);
          }
       }
      }
      
    • Shadow DOM

      • Shadow DOM 样式隔离,确保微应用之间样式互相不干扰。
      • 开启 strictStyleIsolation 时,微应用会被插入到 qiankun 创建好的 Shadow Tree 中,微应用的样式(包括动态插入的样式)都会被挂载到这个 Shadow Host 节点下,因此微应用的样式只会作用在 Shadow Tree 内部,实现样式隔离。

      x

  • 总结

    • iankun采用运行时集成主、子应用,HTML entry 作为子应用入口的中心路由基座式微前端方案。
    • 应用卸载后,会同时卸载其样式表,做到样式的隔离。
    • 提供 js 沙箱机制,解决 js 全局变量的污染,实现子应用间的软隔离。

Module Federation

模块联邦

  • 概念

    • Module Federation 使 JavaScript 应用得以在客户端或服务器上 动态 运行另一个 bundle 或者 build 的代码。
    • Module federation: 与 Apollo GraphQL federation 的想法相同 —— 但适用于在浏览器或者 Node.js 中运行的 JavaScript 模块。
    • Host:在页面加载过程中最先被初始化的 webpack 构建;
    • Remote:部分被 host 消费的另一个 webpack 构建;
    • Bidirectional Hosts:当一个 bundle 或者 webpack build 作为一个 hostremote 运行时,它要么消费其他应用,要么被其他应用消费——均发生在 运行时(runtime)
  • 特性

    • 让代码在不同项目中通过远程调用,直接共享任意内容(上下文,状态,组件,方法,模版)
    • 去中心化运行
    • 共享模块接近 100% 适用,接近0成本
    • 缺点:不具备脚手架与种子项目
  • 例子

    • 如同

    x

    • 微应用 A

      • packages/application-a/webpack.config.js
       plugins: [
        // New
        new ModuleFederationPlugin({
         name: 'application_a',
         library: { type: 'var', name: 'application_a' },
         filename: 'remoteEntry.js',
         exposes: {
          './SayHelloFromA': './src/app',
         },
         remotes: {
          'application_b': 'application_b',
         },
         shared: ['react', 'react-dom'],
        }),
        new HtmlWebpackPlugin({
         template: './public/index.html',
        }),
       ]
      
      • packages/application-a/public/index.html
      <head>
        <!-- The remote entry for Application B -->
        <script src="http://localhost:3002/remoteEntry.js"></script>    
      </head>
      
      • packages/application-a/src/bootstrap.jsx
      import React from 'react';
      import ReactDOM from 'react-dom';
      import SayHelloFromB from 'application_b/SayHelloFromB';
      import App from './app';
      ReactDOM.render(
        <>
            <App />
            <SayHelloFromB />
        </>,
        document.getElementById('root')
      );
      
    • 微应用 B

      • packages/application-b/webpack.config.js
      plugins: [
          new ModuleFederationPlugin({
            name: 'application_b',
            library: { type: 'var', name: 'application_b' },
            filename: 'remoteEntry.js',
            exposes: {
              './SayHelloFromB': './src/app',
            },
            remotes: {
              'application_a': 'application_a',
            },
            shared: ['react', 'react-dom'],
          }),
          new HtmlWebpackPlugin({
            template: './public/index.html',
          }),
        ],
      
      • 其余配置与A对称,省略。
  • 原理

    • 使用模块联邦,每个"部分"(前端组件、逻辑组件等)将是一个 单独的 build,这些构建被编译为 "容器 container"。容器 container 可以被应用程序或其他容器引用。

    x

    • 1、由源码可见,多个 bundle 之间通过全局变量串联;
    • 2、remote 会 export get 方法,供 host 来获取他的子模块;
    • 3、host 会在 __webpack_modules__ 保存依赖的 remote 应用,remote 应用子模块的获取通过 promise 方式 按需引入。
    • code:作为 remote 的 application-b 的构建
    /* window 全局作用域下 */
    var application_b;
    application_b = (() => { // webpackBootstrap
      
      "use strict";
      var __webpack_modules__ = ({
        110: ((__unused_webpack_module, exports, __webpack_require__) => {
          var moduleMap = {
            "./SayHelloFromB": () => {
              return Promise.all(
                [__webpack_require__.e(815),__webpack_require__.e(977)]
              )
              .then(
                () => () => (__webpack_require__(977))
              );
            }
          };
          var get = (module, getScope) => {
            return moduleMap[module]();
          };
          var init = (shareScope, initScope) => {
            var name = "default"
            /* webpack/runtime/sharing */
            __webpack_require__.S[name] = shareScope;
            // handling circular init calls
            return __webpack_require__.I(name, initScope);
          };
          // This exports getters to disallow modifications
          __webpack_require__.d(exports, {
            get: () => get,
            init: () => init
          });
        })
      });
      // Load entry module and return exports
      return __webpack_require__(110);
    })();
    
    • code:作为 host 的 application-a 的构建
    (() => { // webpackBootstrap
     var __webpack_modules__ = ({
      834: ((module) => {
       "use strict";
       module.exports = application_b;
      })
     });
     /* webpack/runtime/ensure chunk */
     (() => {
      __webpack_require__.f = {};
      // This file contains only the entry chunk.
      // The chunk loading function for additional chunks
      __webpack_require__.e = (chunkId) => {
       return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
        __webpack_require__.f[key](chunkId, promises);
        return promises;
       }, []));
      };
     })();
     /* webpack/runtime/remotes loading */
     (() => {
      var chunkMapping = {
       "538": [
        764
       ]
      };
      var idToExternalAndNameMapping = {
       "764": [
        "default",
        "./SayHelloFromB",
        834
       ]
      };
      __webpack_require__.f.remotes = (chunkId, promises) => {
       if(__webpack_require__.o(chunkMapping, chunkId)) {
        chunkMapping[chunkId].forEach((id) => {
          var data = idToExternalAndNameMapping[id];
          if(data.p) return promises.push(data.p);
        
          var handleFunction = (fn, arg1, arg2, d, next, first) => {
           try {
            var promise = fn(arg1, arg2);
            if(promise && promise.then) {
             var p = promise.then((result) => next(result, d), onError);
             if(first) promises.push(data.p = p); else return p;
            } else {
             return next(promise, d, first);
            }
           } catch(error) {
            onError(error);
           }
          }
          var onExternal = (external, _, first) => external ? handleFunction(__webpack_require__.I, data[0], 0, external, onInitialized, first) : onError();
          var onInitialized = (_, external, first) => handleFunction(external.get, data[1], getScope, 0, onFactory, first);
          var onFactory = (factory) => {
           data.p = 1;
           __webpack_modules__[id] = (module) => {
           module.exports = factory();
          }
         };
         handleFunction(__webpack_require__, data[2], 0, 0, onExternal, 1);
        });
       }
      }
     })();
     
     (() => {
      Promise.all([__webpack_require__.e(816), __webpack_require__.e(538)])
       .then(__webpack_require__.bind(__webpack_require__, 816));
     })();
    })();
    
  • 对比 NPM 方式共享模块

    • pm 方式更新流程繁琐,依赖的项目需要重新构建;模块联邦方式一键更新;
    • npm 方式构建体积大速度慢;模块联邦只需构建自身,体积小,速度快;
    • 因此,微前端方式能解决 npm 方式的诸多问题,加速业务迭代。
  • 对比类 Single SPA 方案

    • Module Federation 去中心化,独立部署;single-spa 多个应用依赖于基座存活。
    • Module Federation router/store/component/module 直接共享; single-spa 子应用上下文不一致,组件,store 等状态难以直接共享
    • Module Federation 支持 SSR。

    x

    • 总之,类 Single-spa 更多的是解决不同应用或不同技术栈之间的共存方案;
    • Module Federation 更多的是解决模块分别编译、打包,以及共享模块的方案;
    • 类 Single-spa 更侧重于应用集成,Module Federation 更注重单体拆分;
    • Module Federation 动态加载,是对single-spa 的 parcel 和SystemJS 的降维碾压;
    • 类 Single-spa 已有完备的脚手架和种子项目;Module Federation 的生态处于初始阶段。

我们的规划

业务痛点

  • 前我们团队维护或有交叉的应用主要有:一个基于 react 迭代多年业务模块繁多的大型单体应用,一个基于 vue 2.x 版本的稳定且处于增长中的应用,一个基于 vue 3.x 的处于初始阶段的新应用,以及若干其他小型应用。
  • 目前我们遇到了以下痛点:
  • 1、有些新同学与当前应用的技术栈不一致,有一定学习门槛;
  • 2、开发效率下降,处理一个bug 需要读大量陈旧代码去理解逻辑;
  • 3、随着项目依赖的模块的增长及业务代码的增加,打包上线速度越来越慢,而且容易出错;
  • 4、不同应用间引用及通信的约定五花八门,没有统一规划,风格也不统一;很多功能模块不敢大刀阔斧改造,导致业务迭代受阻;
  • 5、有些逻辑相同的模块无法共享,重复开发,导致人力资源的浪费。

实施步骤

  • 基于以上背景,我们的规划分三个阶段:应用集成阶段、单体拆分阶段应用社区阶段。

x

  • 应用集成

    • 基于 Qianku,对现有的大型项目进行轻度改造,成为子应用;
    • 建立主应用作为基座,提供页面整体 layout,统一UI风格,鉴权功能,消息总线,基础路由管理等;
    • 将不同技术栈的子应用按照协议接入主应用,完成应用的集成。

    x

  • 单体拆分

    • Module Federation 的 shared 能力,作为"公共依赖加载"的解决方案
    • 将每个大型单体子应用进行拆分,按照划分的业务模块拆成子应用,远程动态加载

    x

  • 微应用社区化

    • 随着微应用的增加,应用贡献内容的粒度更小,数量更多,需要提供
    • 将整个系统进行 去中心化 改造,成为一个社区型的架构。标记应用的某些模块,如 "button"和 "dropdown" 被标记为 "exposed",因为它们会被其他团队使用;"react" 被标记为 "shared",以便可以与其他团队共享依赖。
    • 每个微应用成为社区的贡献者,贡献任意内容,包括上下文,状态,组件,方法,模块等。

    x

生态打造

  • 随着微应用的增加,应用贡献内容的粒度更小,数量更多,需要完善整个微前端生态,提升开发效率,为团队的产品迭代增速。
  • 开发脚手架工具
  • 建立种子项目
  • 可视化管理

x

总结

要点

  • 微前端是一种架构风格,而不是新的前端技术,也没多么高深莫测。
  • 没有 silver bullet。发现团队在工作中的痛点,根据团队的组织结构设计合理的系统架构,采用最适合的方案,先解决核心问题,然后再优化迭代,进而打造一个完整的生态。
  • 好的架构不是设计出来的,而是演进出来的。

References

附上脑图一张

x |