本文共计约 7k 字,预计阅读时间 15mins
在传统的软件开发当中,大多数软件都是单体式应用架构的。在瞬息万变的商业时代背景下,企业必须学会适应我们这个时代的不确定性。快速试验,快速失败。更快地推出新产品和有效地改进当前产品,从而为客户提供有意义的数字体验。
而单体应用这种软件架构对于企业来说的致命缺点就是,企业对于市场的响应速度变慢。企业决策者在一年内需要做的决策数量非常有限,由于依赖关系,其响应周期往往会变得非常漫长。每当开发或升级产品,都需要在一系列体量庞大的相关服务中同时增加新功能,这就需要所有利益相关方共同努力,以同步方式进行变更。
假设服务边界已经被正确地定义为可独立运行的业务领域,并确保在微服务设计中遵循诸多最佳实践。那么至少会以下几个方面获得显而易见的好处:
每个微服务是孤立的,独立的「模块」,它们共同为更高的逻辑目的服务。微服务之间通过 Contract 彼此沟通,每个服务都负责特定的功能。这使得每个服务都能够保持简单,简洁和可测试性。
从而微服务架构允许企业更自发地采取更深远的业务决策,因为每个微服务都是独立运作的,而且每一个管理团队可以很好地控制该服务的变更。
在前端,往往由一个前端团队创建并维护一个 Web 应用程序,使用 REST API 从后端服务获取数据。这种方式如果做得好的话,它能够提供优秀的用户体验。但主要的缺点是单页面应用(SPA)不能很好地扩展和部署。在一个大公司里,单前端团队可能成为一个发展瓶颈。随着时间的推移,往往由一个独立团队所开发的前端层越来越难以维护。
特别是一个特性丰富、功能强大的前端 Web 应用程序,却位于后端微服务架构之上。并且随着业务的发展,前端变得越来越臃肿,一个项目可能会有 90% 的前端代码,却只有非常薄的后端,甚至这种情况在 Serverless 架构的背景下还会愈演愈烈。
微前端(Micro Frontends)这个术语其实就是微服务的衍生物。将微服务理念扩展到前端开发,同时构建多个完全自治的和松耦合的 App 模块(服务),其中每个 App 模块只负责特定的 UI 元素和功能。
如果我们看到微服务提供给后端的好处,那么就可以更进一步将这些好处应用到前端。与此同时,在设计微服务的时候,就可以考虑不仅要完成后端逻辑,而且还要完成前端的视觉部分。而对于微前端来说,与微服务的许多要求也是一致的:监控、日志、HealthCheck、Analytics 等等。
这样就能使各个前端团队按照自己的步调迭代,并随时准备就绪处于可发布状态,并隔离相互依赖所产生的风险,与此同时也更容易尝试新技术。
首先让我们来创建一个典型 Web 应用程序的基本组件(Header、ProductList、ShoppingCart),以 Header 组件为例:
# src/App.js export default () => <header> <h1>Logo</h1> <nav> <ul> <li>About</li> <li>Contact</li> </ul> </nav> </header>;
然后需要注意的是我们会用到 Express 对刚刚创建的 React 组件进行服务器端渲染,使之成为一个 App 模块:
# server.js fs.readFile(htmlPath, 'utf8', (err, html) => { const rootElem = '<div id="root">'; const renderedApp = renderToString(React.createElement(App, null)); res.send(html.replace(rootElem, rootElem + renderedApp)); });
再依次创建其他 Apps 并独立部署:
在每个独立团队创建好各自的 App 模块后,我们就可以将网站或 Web 应用程序视为由各种模块的功能组合。下文将介绍多种技术实践方案来重新组合这些模块(有时作为页面,有时作为组件),而前端(不管是不是 SPA)将只需要负责路由器(Router)如何选择和决定要导入哪些模块,从而为最终用户提供一致性的用户体验。
# server.js Promise.all([ getContents('https://microfrontends-header.herokuapp.com/'), getContents('https://microfrontends-products-list.herokuapp.com/'), getContents('https://microfrontends-cart.herokuapp.com/') ]).then(responses => res.render('index', { header: responses[], productsList: responses[1], cart: responses[2] }) ).catch(error => res.send(error.message) ) );
# views/index.ejs <head> <meta charset="utf-8"> <title>Microfrontends Homepage</title> </head> <body> <%- header %> <%- productsList %> <%- cart %> </body>
但是,这种方案也存在弊端,即某些 App 模块可能会需要相对较长的加载时间,而在前端整个页面的渲染却要取决于最慢的那个模块。
比如说,可能 Header 模块的加载速度要比其他部分快得多,而 ProductList 则因为需要获取更多 API 数据而需要更多时间。通常情况下我们希望尽快将网页显示给用户,而在这种情况下后台加载时间就会变得更长。
当然,我们也可以通过修改一些后端代码来渐进式地(Progressive)往前端发送 HTML,但与此同时却徒增了后端复杂度,并且又将前端的渲染控制权交回了后端服务器。而且我们的优化也取决于每个模块加载的速度,若是进行优化就必须按一定顺序进行加载。
<body> <iframe width="100%" height="200" src="https://microfrontends-header.herokuapp.com/"></iframe> <iframe width="100%" height="200" src="https://microfrontends-products-list.herokuapp.com/"></iframe> <iframe width="100%" height="200" src="https://microfrontends-cart.herokuapp.com/"></iframe> </body>
我们也可以将每个子应用程序嵌入到各自的<iframe>
中,这使得每个模块能够使用任何他们需要的框架,而无需与其他团队协调工具和依赖关系,依然可以借助于一些库或者Window.postMessageAPI
来进行交互。
parent - > iframe - > iframe
)。function loadPage (element) { [].forEach.call(element.querySelectorAll('script'), function (nonExecutableScript) { var script = document.createElement("script"); script.setAttribute("src", nonExecutableScript.src); script.setAttribute("type", "text/javascript"); element.appendChild(script); }); } document.querySelectorAll('.load-app').forEach(loadPage);
<div class="load-app" data-url="header"></div> <div class="load-app" data-url="products-list"></div> <div class="load-app" data-url="cart"></div>
简单来说,这种方式就是在客户端浏览器通过 Ajax 加载应用程序,然后将不同模块的内容插入到对应的div
中,而且还必须手动二手手游交易平台地图克隆每个 script 的标记才能使其工作。
需要注意的是,为了避免 Javascript 和 CSS 加载顺序的问题,建议将其修改成类似于Facebookbigpipe的解决方案,返回一个 JSON 对象{ html: ..., css: [...], js: [...] }
再进行加载顺序的控制。
Web Components 是一个 Web 标准,所以像 Angular、React/Preact、Vue 或 Hyperapp 这样的主流 JavaScript 框架都支持它们。你可以将 Web Components 视为使用开放 Web 技术创建的可重用的用户界面小部件,也许会是 Web 组件化的未来。
Web Components 由以下四种技术组成(尽管每种技术都可以独立使用):
# src/index.js class Header extends HTMLElement { attachedCallback() { ReactDOM.render(<App />, this.createShadowRoot()); } } document.registerElement('microfrontends-header', Header);
<body> <microfrontends-header></microfrontends-header> <microfrontends-products-list></microfrontends-products-list> <microfrontends-cart></microfrontends-cart> </body>
在微前端的实践当中:
<microfrontends-header></microfrontends-header>
)。<link rel="import" href="/components/microfrontends/header.html"> <link rel="import" href="/components/microfrontends/products-list.html"> <link rel="import" href="/components/microfrontends/cart.html">
window
订阅此事件并在应该刷新其数据时得到通知。# angularComponent.ts const event = new CustomEvent('addToCart', { detail: item }); window.dispatchEvent(event);
# reactComponent.js componentDidMount() { window.addEventListener('addToCart', (event) => { this.setState({ products: [...this.state.products, event.detail] }); }, false); }
lodash
、moment.js
等公共库,或者跨多个团队共同使用的react
和react-dom
。通过 Webpack 等构建工具就可以把打包的时候将这些共同模块排除掉,而只需要在 HTML<header>
中的<script>
中直接通过 CDN 加载 externals 依赖。<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.2.0/react.min.js" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.2.0/react-dom.min.js" crossorigin="anonymous"></script>
我们在「三靠谱」(已和谐客户名称)的 Marketplace 项目当中也曾经探索过 AEM + React 混合开发的解决方案,其中就涉及到如何在 AEM 当中嵌入 React 组件,甚至将 AEM 组件又强行转化为 React 组件进行嵌套。现在回过头来其实也算是微前端的一种实践:
<div id="cms-container-1"> <div id="react-input-container"></div> <script> ReactDOM.render(React.createElement(Input, { ...injectProps }), document.getElementById('react-input-container')); </script> </div> <div id="cms-container-2"> <div id="react-button-container"></div> <script> ReactDOM.render(React.createElement(Button, {}), document.getElementById('react-button-container')); </script> </div>
https://single-spa.surge.sh/
开源的single-spa自称为「元框架」,可以实现在一个页面将多个不同的框架整合,甚至在切换的时候都不需要刷新页面(支持 React、Vue、Angular 1、Angular 2、Ember 等等):
请看示例代码,所提供的 API 非常简单:
import * as singleSpa from 'single-spa'; const appName = 'app1'; const loadingFunction = () => import('./app1/app1.js'); const activityFunction = location => location.hash.startsWith('#/app1'); singleSpa.declareChildApplication(appName, loadingFunction, activityFunction); singleSpa.start();
# single-spa-examples.js declareChildApplication('navbar', () => import('./navbar/navbar.app.js'), () => true); declareChildApplication('home', () => import('./home/home.app.js'), () => location.hash === "" || location.hash === "#"); declareChildApplication('angular1', () => import('./angular1/angular1.app.js'), hashPrefix('/angular1')); declareChildApplication('react', () => import('./react/react.app.js'), hashPrefix('/react')); declareChildApplication('angular2', () => import('./angular2/angular2.app.js'), hashPrefix('/angular2')); declareChildApplication('vue', () => import('src/vue/vue.app.js'), hashPrefix('/vue')); declareChildApplication('svelte', () => import('src/svelte/svelte.app.js'), hashPrefix('/svelte')); declareChildApplication('preact', () => import('src/preact/preact.app.js'), hashPrefix('/preact')); declareChildApplication('iframe-vanilla-js', () => import('src/vanillajs/vanilla.app.js'), hashPrefix('/vanilla')); declareChildApplication('inferno', () => import('src/inferno/inferno.app.js'), hashPrefix('/inferno')); declareChildApplication('ember', () => loadEmberApp("ember-app", '/build/ember-app/assets/ember-app.js', '/build/ember-app/assets/vendor.js'), hashPrefix('/ember')); start();
所谓架构,其实是解决人的问题;所谓敏捷,其实是解决沟通的问题;