🎗 本文节选自 Web 开发导论/微前端与大前端,着眼阐述了微服务与微前端的设计理念以及微服务的潜在可行方案,须要致敬的是,本文的不少考虑借鉴了 Phodal 关于微前端的系列讨论以及 Web Architecture Links 中声明的其余文章,此外结合了本身浅薄的考量与实践体悟,框架代码能够参阅 Ueact/micro-frontend。
微服务与微前端,都是但愿将某个单一的单体应用,转化为多个能够独立运行、独立开发、独立部署、独立维护的服务或者应用的聚合,从而知足业务快速变化及分布式多团队并行开发的需求。如康威定律(Conway’s Law)所言,设计系统的组织,其产生的设计和架构等价于组织间的沟通结构;微服务与微前端不只仅是技术架构的变化,还包含了组织方式、沟通方式的变化。微服务与微前端原理和软件工程,面向对象设计中的原理一样相通,都是遵循单一职责(Single Responsibility)、关注分离(Separation of Concerns)、模块化(Modularity)与分而治之(Divide & Conquer)等基本的原则。html
在某些场景下,微前端也包含了对于系统的纵向切分;即不一样的团队会负责系统中某个特性/模块,从数据库、服务端到用户界面完整的流线。每一个团队会更多地着眼于业务模型与特色。独立并不意味着彻底的切割,各个特性/模块之间的共现组件能够经过 NPM/Git Submodule 等方式进行协同开发与复用。微前端的落地,须要考虑到产品研发与发布的完整生命周期;咱们会关注如何保证各个团队的独立开发与灵活的技术栈选配,如何保证代码风格、代码规范的一致性,如何合并多个独立的前端应用,如何在运行时对多个应用进行有效治理,如何保障多应用的体验一致性,如何保障个应用的可测试与可依赖性等方面。具体而言,咱们可能从应用组合、应用隔离、应用协调与治理、开发环境等几个方面进行考虑:前端
应用组合:react
应用隔离:git
应用协调与治理:github
开发环境:数据库
此外值得一提的是,微前端化自己是为了保证系统的持续集成与快速迭代,那么对于各个子模块与系统自己的可用性与稳定性势必会带来挑战,这就要求咱们在设计微前端解决方案时,考虑持续构建的时机与对应的测试方案;除了标准的单元测试、集成测试、端到端测试以外,咱们还须要保证模块的依赖一致性与功能模块的可生成性;关于此部分的详细讨论参阅 Web 自动化测试概述。bootstrap
📚 更多关于微服务的讨论参考 微服务理念、架构与实践速览
微服务是一个简单而泛化的概念,不一样的行业领域、技术背景、业务架构对于微服务的理解与实践也是不一致的。与微服务相对的,便是单体架构的巨石型(Monolithic)应用,典型的便是将全部功能都部署在一个 Web 容器中运行的系统。虽然不少的文章对于巨石型应用颇多诟病,但并不意味着其就真的一无可取,毕竟微服务自己也是有代价的。除了组织的结构以外,微服务每每还要求组织具有快速的环境提供(Rapid Provisioning)与云开发、基本的监控(Basic Monitoring)、快速的应用发布(Rapid Application Deployment)、DevOps 等能力。后端
微服务应用每每由多个粒度较小,版本独立,有明确边界并可扩展的服务构成,各个服务之间经过定义好的标准协议相互通讯。在构建微服务架构时,模块化(Modularity)和分而治之(Divide & Conquer)是基本的思路。而后须要考虑单一职责(Single Responsibility)原则,即一个服务应当承担尽量单一的职责,服务应基于有界的上下文(Bounded Context),一般是边界清晰的业务领域构建。从系统衍化的角度,在系统早期流量较少时,只需一个应用将全部功能都部署在一块儿,以减小部署节点和成本。随着流量逐步增大,咱们过渡为了包含多个相互隔离应用的垂直应用架构;便是将不一样职能的模块分红不一样的服务,也逐步开始了微服务化的步伐。接下来,随着垂直应用愈来愈多,应用之间交互不可避免,将核心业务抽取出来,做为独立的服务,逐渐造成稳定的服务中台。api
基于这些思考,咱们能够将微服务中的挑战与关注点,划分为如下方面:浏览器
📖 图片源于 Awesome-MindMap/MicroService-MindMap
组合与隔离,本就是一体两面,每每某种组合方案就天然解决了隔离的痛点,而某种隔离方案又会限制组合的方式。笔者首先从硬/软隔离的角度来对方案进行分类,服务端路由分发与 iFrame 是典型的基于浏览器的硬隔离方案,其自然支持多技术栈、多源的灵活组合,不过其在应用协调与治理方面须要投入较大的精力。Web Components 及其衍生方案一样能带来浏览器级别的隔离与松散的应用协调,可是较差的浏览器兼容性也限制了其应用场景。
iFrame 能够建立一个全新的独立的宿主环境,iFrame 的页面和父页面是分开的,做为独立区域而不受父页面的 CSS 或者全局的 JavaScript 影响。iFrame 的不足或缺陷也很是明显,其会进行资源的重复加载,占用额外的内存;其会阻塞主页面的 onload 事件,和主页面共享链接池,而浏览器对相同域的链接有限制,因此会影响页面的并行加载。
iFrame 的改造门槛较低,可是从功能需求的角度看,其没法提供 SEO,而且须要咱们自定义应用管理与应用通信机制。iFrame 的应用管理不只要关注其加载与生命周期,还须要考虑到浏览器缩放等场景下的界面重适配问题,以提供用户一致的交互体验;这里咱们再简要讨论下同源场景中的跨界面通信解决方案。
📖 详细解读参阅 DOM CheatSheet
BroadcastChannel 可以用于同源不一样页面之间完成通讯的功能。它与 window.postMessage 的区别就是,BroadcastChannel 只能用于同源的页面之间进行通讯,而 window.postMessage 却能够用于任何的页面之间;BroadcastChannel 能够认为是 window.postMessage 的一个实例,它承担了 window.postMessage 的一个方面的功能。
const channel = new BroadcastChannel('channel-name'); channel.postMessage('some message'); channel.postMessage({ key: 'value' }); channel.onmessage = function(e) { const message = e.data; }; channel.close();
Shared Worker 相似于 Web Workers,不过其会被来自同源的不一样浏览上下文间共享,所以也能够用做消息的中转站。
// main.js const worker = new SharedWorker('shared-worker.js'); worker.port.postMessage('some message'); worker.port.onmessage = function(e) { const message = e.data; }; // shared-worker.js const connections = []; onconnect = function(e) { const port = e.ports[0]; connections.push(port); }; onmessage = function(e) { connections.forEach(function(connection) { if (connection !== port) { connection.postMessage(e.data); } }); };
localStorage 是常见的持久化同源存储机制,其会在内容变化时触发事件,也就能够用做同源界面的数据通讯。
localStorage.setItem('key', 'value'); window.onstorage = function(e) { const message = e.newValue; // previous value at e.oldValue };
Web Components 的目标是减小单页应用中隔离 HTML,CSS 与 JavaScript 的复杂度,其主要包含了 Custom Elements, Shadow DOM, Template Element,HTML Imports,Custom Properties 等多个维度的规范与实现。Shadow DOM 它容许在文档(document)渲染时插入一棵 DOM 元素子树,可是这棵子树不在主 DOM 树中。所以开发者可利用 Shadow DOM 封装本身的 HTML 标签、CSS 样式和 JavaScript 代码。子树之间能够相互嵌套,对其中的内容进行了封装,有选择性的进行渲染。这就意味着咱们能够插入文本、从新安排内容、添加样式等等。其结构示意以下:
简单的 Shadow DOM 建立方式以下:
<html> <head></head> <body> <p id="hostElement"></p> <script> // 建立 shadow DOM var shadow = document.querySelector('#hostElement').attachShadow({mode: 'open'}); // 给 shadow DOM 添加文字 shadow.innerHTML = '<p>Here is some new text</p>'; // 添加CSS,将文字变红 shadow.innerHTML += '<style>p { color: red; }</style>'; </script> </body> </html>
咱们也能够将 React 应用封装为 Custom Element 而且封装到 Shadow DOM 中:
import React from 'react'; import retargetEvents from 'react-shadow-dom-retarget-events'; class App extends React.Component { render() { return <div onClick={() => alert('I have been clicked')}>Click me</div>; } } const proto = Object.create(HTMLElement.prototype, { attachedCallback: { value: function() { const mountPoint = document.createElement('span'); const shadowRoot = this.createShadowRoot(); shadowRoot.appendChild(mountPoint); ReactDOM.render(<App />, mountPoint); retargetEvents(shadowRoot); } } }); document.registerElement('my-custom-element', { prototype: proto });
Shadow DOM 的兼容性较差,仅在 Chrome 较高版本浏览器中可使用。
与硬隔离相对的,笔者称为单体应用软隔离,其更多地依赖于应用框架或者开发构建流程,来实现容错与样式、DOM 等隔离。单体应用软隔离又能够从应用的组合时机与技术栈的支持状况这两个维度,划分不一样的解决方案。对于须要支持不一样技术栈(React, Angular, Vue.js, etc.)的场景,咱们每每须要完全的类后端微服务化,每一个前端应用都是独立的服务化应用,而宿主应用则提供统一的应用管理和启动机制;此时若须要解决资源重复加载、冗余的问题,则须要依赖统一构建或者由宿主应用提供公共依赖库,子应用打包时仅打包自身或非公用库代码。若是是相同技术栈的场景,那么咱们能够方便地利用框架自己的懒加载能力,在开发阶段以模块划分为微应用进行开发,构建时以单体应用的形式构建,在运行时是以应用模块的形式存在。
📌 本部分会随着笔者的实践逐步完善丰富,能够保持关注 Web 开发导论 或者 Ueact。
典型的应用组合方式分为构建时(Build Time)组合与运行时(Runtime)组合,以下图所示便是典型的构建时组合方案:
🎗 图片源自 Building application in a "Microfrontends" way
构建时组合的优点在于可以进行较好地依赖管理,抽取公共模块,减小最终的包体大小,不过其最终的产出还是单体应用,各个应用模块没法进行独立部署。 与之相对的,运行时组合可以保障真正地独立开发与独立部署:
运行时组合中,咱们能够选择在使用 Tailor 这样的工具进行服务端组合(SSI),也可使用 JSPM, SystemJS 这样的动态导入工具,进行客户端组合。运行时组合同时能提供按需加载的特性,优化首页的加载速度。不过运行时组合可能重复加载依赖项(经过浏览器缓存或 HTTP2 适度解决),而且不一样于 iFrame 的硬隔离,运行时组合仍可能面临难以预料的第三方依赖冲突。
React 这样的声明式组件框架,自然就支持应用的组合,咱们能够传入渲染锚点以进行应用组合,也能够将不一样框架的应用封装为 Web Components。首先咱们能够将 React 应用定义为自定义元素:
📎 完整代码参考 fe-boilerplate/micro-frontend
window.customElements.define( 'react-app', class ReactApp extends HTMLElement { ... render() { render(<App title={this.title} />, this); } ... } );
而后在前端中直接使用该自定义元素:
<react-app title="React Separate Running App" />
在单体应用中,框架将路由指定到对应的组件或者内部服务中;而微前端中,咱们须要将应用内的组件调用变成了更细粒度的应用间组件调用,即原先咱们只是将路由分发到应用的组件执行,如今则须要根据路由来找到对应的应用,再由应用分发到对应的组件上。具体的实践中,可能宿主应用使用 Hash Router 已经占用了 Hash 标记位,那么就须要为子应用提供专属的查询键,来进行子应用内跳转。
在 React 中可使用 ErrorBoundary, 来限制应用崩溃的影响;若是是自定义的应用加载器,也能够实现 Promise 容错方案。Redux 能够考虑在宿主应用建立统一的 Store,每一个应用中按照命名空间划分使用子状态空间:
const subConnect = subAppName => (mapStateToProps, mapDispatchToProps) => connect( state => mapStateToProps({ ...state[subAppName] }, state), mapDispatchToProps );
对于 Action 可使用命名空间形式:
`app/service-name/action`;
而对于应用治理方面,single-spa 或者 ueact-component 都定义了跨框架的组件生命周期,譬如在 single-spa 中,能够将 React 生命周期归一化:
const reactLifecycles = singleSpaReact({ React, ReactDOM, rootComponent, domElementGetter: () => document.getElementById('main-content') }); export const bootstrap = [reactLifecycles.bootstrap]; export const mount = [reactLifecycles.mount]; export const unmount = [reactLifecycles.unmount];
而后将其导出为单一应用而且异步加载:
// src/index.js import { registerApplication, start } from 'single-spa'; registerApplication( // Name of our single-spa application 'root', // Our loading function () => import('./root.app.js'), // Our activity function () => true ); start();