微前端之间的通信
松散耦合
命名约定
交换事件
共享数据
集中式 API
激活函数
组件聚合
结论
在实施各种基于微前端的解决方案方面积累了丰富的经验后,我将尝试分享我所学到的知识。
本文最初发表于Bits and Pieces
微前端已成为开发中大型 Web 应用的可行选择。尤其对于分布式团队而言,能够独立开发和部署似乎很有吸引力。虽然像Piral这样的框架让这变得非常容易,但我们可能还是想从头开始实现微前端解决方案。一个问题很快浮现:一个微前端如何与另一个微前端通信?
过去,我积累了丰富的微前端解决方案实施经验,并将分享我的经验。这些方法大多侧重于客户端通信(例如使用 JS),但我也会尝试涉及服务器端的拼接。
无论您选择如何实现微前端 (MF),请务必使用Bit ( Github ) 等工具将 UI 组件共享到组件中心。这是最大化代码重用、构建更具可扩展性和可维护性的代码库以及在不同的微前端 (有些甚至使用 Bit 作为微前端的实现) 之间保持一致 UI 的好方法。
松散耦合
在微前端中实现任何通信模式的最重要方面是松耦合。这个概念并不新鲜,也并非微前端独有。在微服务后端中,我们应该格外小心,避免直接通信。但很多时候,我们仍然会这样做——为了简化流程或基础设施,或两者兼而有之。
微前端解决方案中如何实现松耦合?嗯,一切都始于良好的命名。但在探讨这一点之前,我们需要先回顾一下。
我们先来看看直接通信的可能性。例如,我们可以提出以下实现:
// microfrontend A
window.callMifeA = msg => {
//handle message;
};
// microfrontend B
window.callMifeA({
type: 'show_dialog',
name: 'close_file'
});
乍一看,这可能看起来不错:我们想从微前端 B 到 A 进行通信——我们可以这样做。消息格式使我们能够很好地处理不同的场景。但是,如果我们在微前端 A 中更改名称(例如,更改为mifeA
),那么这段代码就会崩溃。
或者,如果微前端 A 因任何原因缺失,那么这段代码就会崩溃。最后,这种方式始终假设它callMifeA
是一个函数。
下图说明了这种解耦耦合的问题。
这种方式唯一的好处是,我们“确定”(至少在函数调用有效的情况下)可以与微前端 A 进行通信。我们真的确定吗?我们怎么确保它callMifeA
没有被其他微前端修改呢?
因此,让我们使用中央应用程序外壳来解耦它:
// application shell
const mife = [];
window.registerMife = (name, call) => {
mife.push({
name,
call,
});
};
window.callMife = (target, msg) => {
mife.filter(m => m.name === target).forEach(m => m.call(msg));
};
// microfrontend A
window.registerMife('A', msg => {
//handle message;
});
// microfrontend B
window.callMife('A', {
type: 'show_dialog',
name: 'close_file'
});
现在,callMife
无论怎样调用都应该有效 - 我们只是不应该期望预期的行为能够得到保证。
引入的池子也可以画进图中。
到目前为止,命名约定尚未真正到位。将我们的微前端等命名为“microfrontends”A
并不B
理想。
命名约定
在这样的应用程序中,构造名称的方式有很多种。我通常将它们分为三类:
- 根据他们的领域量身定制(例如,机器)
- 根据他们的提供内容(例如建议)
- 域名提供(例如机器推荐)
有时,在非常大的系统中,旧的命名空间层次结构(例如world.europe.germany.munich
)是有意义的。然而,很多时候,它很早就开始出现不一致的情况。
和往常一样,命名约定最重要的部分就是坚持它。没有什么比不一致的命名方案更令人不安了。它比糟糕的命名方案更糟糕。
虽然可以使用自定义 linting 规则等工具来确保应用一致的名称方案,但实际上只有代码审查和集中治理才能发挥作用。linting 规则可以用来确保/^[a-z]+(\.[a-z]+)*$/
找到某些模式(例如,使用类似 的正则表达式)。将各个部分映射回实际名称是一项更加艰巨的任务。谁首先定义了领域特定的语言和术语?
为了缩短我们的探索过程:
命名事物永远是一个未解决的问题。
我的建议是选择一个看起来有意义的命名约定并坚持下去。
交换事件
命名约定对于事件方面的沟通也很重要。
已经引入的通信模式也可以通过使用自定义事件 API 来简化:
// microfrontend A
window.addEventListener('mife-a', e => {
const { msg } = e.detail;
//handle message;
});
// microfrontend B
window.dispatchEvent(new CustomEvent('mife-a', {
detail: {
type: 'show_dialog',
name: 'close_file'
}
}));
虽然乍一看这很有吸引力,但它也有一些明显的缺点:
- 再次调用微前端A的事件是什么?
- 我们应该如何正确输入这个内容?
- 我们能否在这里支持不同的机制——比如扇出、直接……?
- 死字和其他东西?
消息队列似乎是不可避免的。如果不支持上述所有功能,一个简单的实现可以从以下开始:
const handlers = {};
window.publish = (topic, message) => {
window.dispatchEvent(new CustomEvent('pubsub', {
detail: { topic, message },
}));
};
window.subscribe = (topic, handler) => {
const topicHandlers = handlers[topic] || [];
topicHandlers.push(handler);
handlers[topic] = topicHandlers;
};
window.unsubscribe = (topic, handler) => {
const topicHandlers = handlers[topic] || [];
const index = topicHandlers.indexOf(handler);
index >= 0 && topicHandlers.splice(index, 1);
};
window.addEventListener('pubsub', ev => {
const { topic, message } = ev.detail;
const topicHandlers = handlers[topic] || [];
topicHandlers.forEach(handler => handler(message));
});
上面的代码将被放置在应用程序外壳中。现在不同的微前端都可以使用它了:
// microfrontend A
window.subscribe('mife-a', msg => {
//handle message;
});
// microfrontend B
window.publish('mife-a', {
type: 'show_dialog',
name: 'close_file'
});
这实际上是最接近原始代码的方法——但采用松散耦合而不是不可靠的直接方法。
应用程序外壳的运行方式也可能与上图所示不同。重要的是,每个微前端都可以独立访问事件总线。
共享数据
虽然在松散耦合的世界中调度事件或排队消息似乎很简单,但数据共享似乎并非如此。
有多种方法可以解决这个问题:
- 单一位置,多个所有者 - 每个人都可以读写
- 单一位置,单一所有者 — 每个人都可以读取,但只有所有者可以写入
- 单一所有者,每个人都需要直接从所有者那里获得一份副本
- 单一引用,每个有引用的人都可以修改原始内容
由于松耦合,我们应该排除最后两个选项。我们需要一个单一的位置——由应用程序外壳决定。
让我们从第一个选项开始:
const data = {};
window.getData = name => data[name];
window.setData = (name, value) => (data[name] = value);
很简单,但效果不太好。我们至少需要添加一些事件处理程序,以便在数据发生变化时收到通知。
下图显示了附加到 DOM 的读取和写入 API。
变更事件的添加仅影响setData
功能:
window.setData = (name, current) => {
const previous = data[name];
data[name] = current;
window.dispatchEvent(new CustomEvent('changed-data', {
detail: {
name,
previous,
current,
},
}));
};
虽然拥有多个“所有者”可能有一些好处,但也会带来很多问题和混乱。或者,我们可以想出一种只支持单个所有者的方法:
const data = {};
window.getData = name => {
const item = data[name];
return item && item.value;
}
window.setData = (owner, name, value) => {
const previous = data[name];
if (!previous || previous.owner === owner) {
data[name] = {
owner,
name,
value,
};
window.dispatchEvent(new CustomEvent('changed-data', {
detail: {
name,
previous: previous && previous.value,
current: value,
},
}));
}
};
这里,第一个参数必须指所有者的名称。如果尚未有人认领所有权,则此处可接受任何值。否则,提供的所有者名称需要与当前所有者一致。
这个模型乍一看确实很有魅力,但是我们owner
很快就会遇到一些有关参数的问题。
解决此问题的一种方法是代理所有请求。
集中式 API
全局对象。它们确实很实用,在很多情况下非常有用。但同样,它们也是很多问题的根源。它们可以被操纵。它们对单元测试不太友好,而且相当隐蔽。
一个简单的解决方法是将每个微前端视为一种通过其自己的代理与应用程序外壳通信的插件。
初始设置可能如下所示:
// microfrontend A
document.currentScript.setup = api => {
api.setData('secret', 42);
};
// microfrontend B
document.currentScript.setup = api => {
const value = api.getData('secret'); // 42
};
每个微前端都可以由一组(主要是 JS)文件表示——通过引用单个入口脚本组合在一起。
使用可用微前端列表(例如,存储在变量中microfrontends
),我们可以加载所有微前端并传入单独创建的 API 代理。
const data = {};
const getDataGlobal = name => {
const item = data[name];
return item && item.value;
}
const setDataGlobal = (owner, name, value) => {
const previous = data[name];
if (!previous || previous.owner === owner) {
data[name] = {
owner,
name,
value,
};
window.dispatchEvent(new CustomEvent('changed-data', {
detail: {
name,
previous: previous && previous.value,
current: value,
},
}));
}
};
microfrontends.forEach(mife => {
const api = {
getData: getDataGlobal,
setData(name, value) {
setDataGlobal(mife.name, name, value);
},
};
const script = document.createElement('script');
script.src = mife.url;
script.onload = () => {
script.setup(api);
};
document.body.appendChild(script);
});
太棒了!现在请注意,currentScript
此技术是必需的,因此 IE 11 或更早版本需要特别注意。
下图显示了在共享数据的情况下中央 API 如何影响整体通信。
这种方法的优点在于对象可以完全类型化。此外,由于它只是被动地声明了一个粘合层(函数),api
因此整个方法允许渐进增强。setup
这个集中式 API 代理肯定也对我们迄今为止涉及的所有其他领域有所帮助。
激活函数
微前端的核心在于“什么时候轮到我?”或者“我应该在哪里渲染?”。实现这一点最自然的方式是引入一个简单的组件模型。
最简单的方法是引入路径和路径映射:
const checkActive = location => location.pathname.startsWith('/sample');
window.registerApplication(checkActive, {
// lifecycle here
});
生命周期方法现在完全依赖于组件模型。在最简单的方法中,我们引入了load
、mount
和unmount
。
检查需要从一个公共运行时执行,该运行时可以简单地称为“激活器”,因为它将确定某些东西何时处于活动状态。
这些组件的外观很大程度上仍然取决于我们。例如,我们已经可以提供底层组件的元素,从而形成一个激活器层级结构。为每个组件指定一个 URL,并且仍然能够将它们组合在一起,这将非常强大。
组件聚合
另一种可能性是通过某种组件聚合。这种方法有几个好处,但是仍然需要一个通用的中介层。
虽然我们可以使用任何(或至少大多数)框架来提供聚合器组件,但在本例中,我们将尝试使用 Web 组件来实现——只是为了用纯 JavaScript 来说明这个概念。实际上,我们将使用 LitElement,它是一个基于 LitElement 的小型抽象,以便更简洁一些。
基本思想是拥有一个通用组件,每当我们想要包含来自其他微前端的“未知”组件时就可以使用它。
考虑以下代码:
@customElement('product-page')
export class ProductPage extends LitElement {
render() {
return html`
<div>
<h1>My Product Page</h1>
<!-- ... -->
<component-reference name="recommendation"></component-reference>
<!-- ... -->
<component-reference name="catalogue"></component-reference>
</div>
`;
}
}
这里我们创建了一个新的 Web 组件来代表我们的产品页面。该页面本身已经自带了代码,但是,我们想在代码的某个地方使用来自不同微前端的其他组件。
我们不应该知道这些组件来自哪里。不过,使用聚合组件 ( component-reference
) 我们仍然可以创建引用。
让我们看看如何实现这样的聚合器。
const componentReferences = {};
@customElement('component-reference')
export class ComponentReference extends LitElement {
@property() name = '';
render() {
const refs = componentReferences[this.name] || [];
const content = refs.map(r => `<${r}></${r}>`).join('');
return html([content]);
}
}
我们还需要添加注册功能。
window.registerComponent = (name, component) => {
const refs = componentReference[name] || [];
componentReference[name] = [...refs, component];
};
显然这里还有很多内容没有讲到:如何避免冲突。如何相应地转发属性/props。如何增强鲁棒性和可靠性,例如,当引用发生变化时如何保持响应性。更多便捷方法……
这里缺少的功能列表很长,但请记住上面的代码只向您展示了这个想法。
下图展示了微前端如何共享组件。
它的用法非常简单:
@customElement('super-cool-recommender')
export class SuperCoolRecommender extends LitElement {
render() {
return html<p>Recommender!</p>
;
}
}
window.registerComponent('recommendation', 'super-cool-recommender');
结论
遵循松耦合原则时,有很多种可能的模式可以应用。但最终,你需要一个通用的 API。这个 API 是 DOM 还是来自其他抽象,则由你决定。我个人更倾向于使用集中式 API,因为它具有沙盒和模拟功能。
可以通过Piral以更加强大和优雅的方式使用所提供的模式,它为您提供具有无站点 UI 的微前端。
鏂囩珷鏉ユ簮锛�https://dev.to/florianrappl/communication- Between-micro-frontends-41fe