翻译自 https://micro-frontends.org/javascript
本文描述了采用不一样 JavaScript 技术框架的多个团队中协同构建一个现代化前端 Web 应用所须要的技术、策略和方法。css
微前端这个术语最初来自 2016 年的 ThoughtWorks 技术雷达[ https://www.thoughtworks.com/radar/techniques/micro-frontends ],它将微服务的概念扩展到了前端领域。目前的趋势是构建一个功能丰富且强大的前端应用,即单页面应用(SPA),其自己通常都是创建在一个微服务架构之上。前端层一般由一个单独的团队开发,随着时间的推移,会变得愈来愈庞大而难以维护。这就是传说中的前端巨无霸(Frontend Monolith) [ https://www.youtube.com/watch?v=pU1gXA0rfwc ]。前端
微前端背后的理念是将一个网站或者 Web App 当成特性的组合体,每一个特性都由一个独立的团队负责。每一个团队都有擅长的特定业务领域或是它关心的任务。这里,一个团队是跨职能的,它能够端到端,从数据库到用户界面完整的开发它所负责的功能。java
然而,这个概念并不新鲜,过去它叫针对垂直系统的前端一体化或独立系统。不过微前端显然是一个更加友好而且不那么笨重的术语。node
一体化的前端react
垂直化组织方式nginx
在介绍中我使用了措辞“构建一个现代化前端应用”,让咱们先给出一些这个术语有关的设定。git
从一个更普遍的角度来看,Aral Balkan 曾写过一个相关的博客,他把这个概念叫作文档-应用连续统一体。他提出了一个滑动比例尺的概念,在比例尺的最左边是一个网站,由静态文档构成,经过连接相互链接;最右边是一个纯行为驱动的,几乎没内容的应用程序,好比在线图片编辑器。github
若是你把你的项目定位在这个范围的左侧,那在 Web 服务器级别的集成会比较合适。在这个模型中,服务器会收集页面中各个组件的内容并将其 HTML 字符串链接起来返回给用户。内容更新则采用从服务端从新加载的方式或者经过 ajax 进行部分替换。Gustaf Nilsson Kotte 针对这个主题写过一篇综合性的文章。web
当用户界面须要提供及时反馈时,即便采用不可靠链接,一个纯粹的服务端渲染网站也不够用。为了实现 Optimistic UI 或 Skeleton Screens 这样的技术你须要在设备自己对 UI 进行更新。Google 提出的 PWA 巧妙的描述了这种兼顾各方的作法(渐进加强),同时提供 App 同样的性能体验。这种类型的应用在上面的比例尺中位于文档-应用连续统一体中间的某个地方。在这里纯粹的服务端方案已经再也不够用,咱们必须将主要逻辑放到浏览器中,这正是本文会重点描述的。
技术无关
每个团队在选择和升级他们的技术栈时应该可以作到不须要和其余团队进行对接。Custom Elements 是一个隐藏实现细节的很是好的方法,同时可以对外提供一个统一接口。
隔离团队代码
即便全部的团队都使用一样的框架,也不要共享一个运行时。构建独立的应用,不要依赖于共享状态或全局变量。
创建各团队的前缀
当隔离已经不可能时要商定一个命名规范。对 CSS、Events、Local Storage 和 Cookie 创建命名空间来避免碰撞并声明全部权。
本地浏览器特性优先于自定义 API
采用浏览器事件进行数据沟通而不是构建一个全局的发布者-订阅者系统。若是你确实须要构建一个跨团队的 API,那就确保它越简单越好。
构建自适应网站
即便 JavaScript 执行失败或是根本没有执行,你的特性也应该是可以使用的。采用通用渲染或渐进式加强来提升可感知的性能。
自定义元素 Custom Elements 面向 Web 组件规范中互操做方面,在浏览器中是一个适用于功能集成的基本元素。每一个团队采用本身选择的 Web 技术构建他们的组件,并将它们封装到一个 自定义元素 中(好比 <order-minicart></order-minicart> )。这个特定元素的 DOM 声明(标签名、属性和事件)对于其余团队来讲体现为一个协定或者叫公共 API。这样作的好处是其余人可使用这个组件及其功能而不须要知道实现细节,他们只须要可以和 DOM 交互便可。
但仅仅自定义元素是不能知足解决方案的全部需求的。为了处理渐进加强、通用渲染或路由咱们还须要软件的其余部分。
本文分为两部分。首先咱们会介绍页面组合(Page Composition) —— 如何使用不一样团队提供的组件组合成一个页面。而后咱们会给出一些示例展现客户端页面转化(Page Transition)的实现。
除了采用不一样框架编写的客户端或服务端代码集成,还有不少副主题须要讨论:隔离 js的机制、规避 CSS 冲突、按需加载资源、不一样团队共享公共资源、处理数据获取和思考提供给用户的加载状态。咱们将会依次讨论这些主题。
以下的拖拉机模型商店的产品页面将会做为后续示例的基础。
这个页面主要功能是经过一个变量选择器在三个不一样拖拉机模型之间进行选择转换,变量改变时产品图片、名称、价格和推荐都会更新。还有一个购买按钮,点击后会将选中的模型添加到购物车中,同时顶部的迷你购物车也会相应更新。
全部的 HTML 页面都经过纯 JavaScript和 ES6 模板字符串在客户端生成,没有任何依赖。代码使用一个简单的状态/标记分离方式,一旦有变化整个 HTML 页面都会从新渲染 —— 没有炫酷的 DOM 对比功能,也暂时没有通用渲染。固然也没有团队分离 —— 全部代码都在一个 js/css 文件中。
在以下示例中,这个页面被分隔成不一样的组件和片断,分别被三个不一样的团队负责。交易组(蓝色)负责全部跟付帐流程有关的事情 —— 也就是购买按钮和迷你购物车。推荐组(绿色)负责页面中的产品推荐部分。页面自己则由产品组(红色)负责。
产品组决定哪一个功能点被采用以及该功能在页面布局的位置。页面包含的信息能够由产品组自身提供,好比产品名称、图片和可采用的参数,但还能够包括其余团队提供的片断(自定义元素)。
让咱们把购买按钮做为一个示例。产品组简单的将 <blue-buysku="t_porsche"></blue-buy> 加入到页面中指望的位置就可使用这个按钮了。要让这个按钮起做用,交易组还须要在页面中注册元素 blue-buy。
class BlueBuy extends HTMLElement { constructor() { super(); this.innerHTML = ` < button type = "button" > buy for 66, 00€ < /button>`; } disconnectedCallback() { ... } } window.customElements.define('blue-buy', BlueBuy);
如今每当浏览器遇到一个新的 blue-buy 标签时,都会调用这个构造器。其中, this 是这个自定义元素 DOM 根节点的引用。全部标准 DOM 元素的属性和方法均可以使用,好比 innerHTML 或 getAttribute()。
根据标准文档的定义,当命名自定义元素时惟一的需求是名称中必须包含一个破折号 - 以确保和将来新的 HTML 标签进行兼容。在后面的示例中则使用了 [team_color]-[feature] 命名规范。团队命名空间预防了碰撞,这种方法让一个功能点的权责变得更分明:只要看看 DOM 就知道了。
当用户在变量选择器中选择了另一个拖拉机时,购买按钮必须相应的进行更新。要达到这种效果,产品组只须要从 DOM 中移除相应元素,并插入一个新的。
container.innerHTML; // => <blue-buy sku="t_porsche">...</blue-buy> container.innerHTML = '<blue-buy sku="t_fendt"></blue-buy>';
老元素的 disconnectedCallback 方法会被同步调用进行一些清理资源的操做好比移除事件监听器。而后新建立的 t_fendt 元素的 constructor 会被调用。
另一个性能更好的选择是仅仅更新现有元素的 sku 属性。
document.querySelector('blue-buy').setAttribute('sku', 't_fendt');
若是产品组使用了以 DOM 对比为特点的模板引擎,好比 React,那它的算法就会自动完成上述功能。
要支持这种效果,自定义元素能够实现 attributeChangedCallback 并指定一个 observedAttributes 列表来触发这个回调。
const prices = { t_porsche: '66,00 €', t_fendt: '54,00 €', t_eicher: '58,00 €', }; class BlueBuy extends HTMLElement { static get observedAttributes() { return ['sku']; } constructor() { super(); this.render(); } render() { const sku = this.getAttribute('sku'); const price = prices[sku]; this.innerHTML = ` < button type = "button" > buy for $ { price } < /button>`; } attributeChangedCallback(attr, oldValue, newValue) { this.render(); } disconnectedCallback() {...} } window.customElements.define('blue-buy', BlueBuy);
为避免重复,引入一个 render() 方法并在 constructor 和 attributeChangedCallback 中调用。这个方法收集须要的数据,并填充新标签的 innerHTML 属性。当决定在自定义元素中采用一个更加成熟的模板引擎或框架时,这里即是初始化代码所呆的地方。
上例采用了 Custom Element 规范 V1 版,目前已经在 Chrome, Safari 和 Opera 中获得支持。可是经过 document-register-element 这个轻量级且通过大量测试的 polyfill 可让该特性在全部浏览器中运行。在底层,它使用了普遍支持的 Mutation Observer API,因此并无在背后使用 DOM 树监听这种侵入式的 hack 方法。
由于自定义元素 Custom Element 是一个 Web 标准,全部的主流 JavaScript 框架都支持,好比 Angular、React、Preact、Vue 或 Hyperapp。但深刻到细节时,就会发现有些框架依然存在实现上的问题。能够访问 Custom Elements Everywhere 这个兼容性测试套件,Rob Dodson 把没有解决的问题都高亮显示了。
然而,对于全部的交互来讲从上至下传递属性是不够的。在咱们的示例中,当用户对购买按钮执行一次点击事件时,迷你购物车应该刷新。
上面这两个片断都由交易组(蓝色)维护的,因此为了达到迷你购物车和按钮通讯的效果他们能够构建一种内建的 JavaScript API 进行通讯。但这样就须要组件实例之间相互了解,同时也违背了隔离的原则。
一种更加干净的方法是采用发布者订阅者机制:一个组件能够发布信息,其余组件则订阅指定的主题(topic)。幸运的是浏览器内建了这个特性,这也正是 click、 select、 mouseover 等浏览器事件的工做机制。除了这些本地事件,还有一种可能性是经过 newCustomEvent(...) 来建立更加高级别的事件。事件老是绑定到它们建立或者分配的 DOM 节点上,大部分本地事件也支持冒泡的特性,这让监听 DOM 中特定子树节点的全部事件成为可能。若是你想要监听页面上的全部事件,将事件监听器附加到 window 元素上就 OK 了。以下是本示例中 blue:basket:changed 事件建立的大概样子:
class BlueBuy extends HTMLElement { [...] connectedCallback() { [...] this.render(); this.firstChild.addEventListener('click', this.addToCart); } addToCart() { // maybe talk to an api this.dispatchEvent(new CustomEvent('blue:basket:changed', { bubbles: true, })); } render() { this.innerHTML = ` < button type = "button" > buy < /button>`; } disconnectedCallback() { this.firstChild.removeEventListener('click', this.addToCart); } }
如今迷你购物车能够在 window 对象上订阅这个事件了,在须要刷新数据时它就会获得通知。
class BlueBasket extends HTMLElement { connectedCallback() { [...] window.addEventListener('blue:basket:changed', this.refresh); } refresh() { // fetch new data and render it } disconnectedCallback() { window.removeEventListener('blue:basket:changed', this.refresh); } }
采用这种方法实现时,迷你购物车片断增长了一个不在它范围以内(window)的 DOM 元素监听器。对于大部分应用来讲,这个作法没有什么问题,可是若是你不太满意这种作法,还可让页面自身(产品组)去监听这个事件,并经过调用 DOM 元素的 refresh() 方法来通知迷你购物车。
// page.js const $ = document.getElementsByTagName; $('blue-buy')[0].addEventListener('blue:basket:changed', function() { $('blue-basket')[0].refresh(); });
命令式调用 DOM 方法其实至关罕见,但好比在 video 元素 API 中就有这种作法。若是可能的话,仍是应该推荐这种命令式的方法(属性更改)。
在浏览器中采用自定义元素 Custom Elements 来集成组件是个绝好的作法。但实际在构建一个 Web 中可访问的站点时,极可能是初次加载性能才是关键点,在全部的 JS 框架所有加载并执行以前用户只会看到白屏。另外,还有一个值得思考的是若是 JavaScript 执行失败或者被阻塞时网站会发生什么。Jeremy Keith 在他的 ebook/播客 Resilient Web Design 中解释了这个问题的重要性。因此可以在服务端渲染核心内容才是关键。不幸的是 Web 组件规范根本没有讨论服务端渲染。JavaScript 没有,Custom Elements 也没有:(
为了引入服务端渲染,前面的示例进行了重构。每一个团队都有他们本身的 express 服务器,自定义元素的 render() 方法也都经过 url 来进行访问。
$ curl http://127.0.0.1:3000/blue-buy?sku=t_porsche <button type="button">buy for 66,00 €</button>
自定义元素的标签名被用做路径名,属性名成为了查询参数。这样为每一个组件用服务端渲染内容的方法就有了。再配合上 <blue-buy> 自定义元素,一种很是接近于通用 Web 组件的东西就出来了:
<blue-buy sku="t_porsche"> <!--#include virtual="/blue-buy?sku=t_porsche" --> </blue-buy>
#include 注释是服务端包含 Server Side Includes 的一部分,这个功能在大部分 Web 服务器中都支持。没错,这个就是很早之前咱们在网站中嵌入当前日期所采用的一样技术。也有几个其余可选技术好比 ESI、nodesi、compoxure 和 tailor,可是对于咱们的项目 SSI 已经被证实是一个简单同时也至关稳定的解决方案。
在 Web 服务器将完整的页面发送到浏览器以前 #include 注释被替换为 /blue-buy?sku=t_porsche 的返回值。在 Nginx 中配置以下:
upstream team_blue { server team_blue: 3001; } upstream team_green { server team_green: 3002; } upstream team_red { server team_red: 3003; } server { listen 3000; ssi on; location / blue { proxy_pass http: //team_blue; } location / green { proxy_pass http: //team_green; } location / red { proxy_pass http: //team_red; } location / { proxy_pass http: //team_red; } }
指令 ssi:on; 用来开启 SSI 功能, upstream 和 location 块用来确保每一个团队的 url 都会被正确分配到对应的服务,好比以 /blue 开头的 url 会被路由到相应的应用服务( team_blue:3001)。另外, / 路由被映射到负责首页和产品页的产品组(红色)。
下面的动画演示了在一个 JavaScript 被禁用的浏览器中拖拉机商店使用状况。
变量选择按钮如今是一个真实的连接了,每一次点击都会让整个页面从新加载。右边的终端展现了一个请求如何被路由到产品组的流程,产品组则控制整个产品页,里面的标记则由推荐组和交易组的内容片断来提供。
当打开启用 JavaScript 的开关后,在服务端日志消息中只有第一条请求才会显示。全部后续的拖拉机变化逻辑都在客户端处理了,就和前面第一个示例同样。在后面的示例中,产品数据将会从 JavaScript 代码中被抽离出来,并在须要的时候经过一个 REST API 进行加载。
你能够在本机运行这个代码。只须要安装 Docker Compose[ https://docs.docker.com/compose/install/ ]。
git clone https://github.com/neuland/micro-frontends.git cd micro-frontends/2-composition-universal docker-compose up --build
Docker 会在 3000 端口启动 Nginx,并为每一个团队构建 node.js 镜像。当你在浏览器中打开 http://127.0.0.1:3000/ 时应该会看到一个红色的拖拉机。经过 docker-compose 给出的组合日志能够很轻松的看到网络中发生了什么。很差的是目前还不能控制输出信息的颜色,因此你不得不接受一个事实,那就是蓝色的交易组可能被高亮成绿色 :)
src 中的文件会被映射到独立的容器中,当你进行代码更改后 node 应用会重启。修改 nginx.conf 须要重启 docker-compose 才能生效。而后你就尽情瞎搞并提供反馈吧。
待续...
关注 Github Repo[ https://github.com/neuland/micro-frontends ] 来获取通知
若是你喜欢咱们的文章,关注咱们的公众号和咱们互动吧。