🔥 如何优雅地解决多个 React、Vue App 之间的状态共
前言
人生是个积累的过程,你总会有摔倒,即使跌倒了,你也要懂得抓一把沙子在手里。 —— 丁磊
码过的每一个需求、踩过的每一个坑、修过的每一个 bug
、学过的每一个知识以及看过的每一篇文章都不会成为无用功,它们都将为自己的技术城堡添砖加瓦。今天我们将从实现不同的 React、Vue App
之间的状态共享这个需求着手,学习 React
、Vue
中那些我们很少用到,但是一旦遇到这些特殊的需求就非它莫属的特性 🤹🏻
需求 & 问题
需求现状
我在字节的日常业务开发中,我需要将不同的业务组件挂载在一个不属于我们接管的平台页面中,由于每个业务组件都有各自不同的挂载位置和时机,并且都可以看做一个单独的 React
应用,所以我们用 Webpack
进行多入口打包,打出多个 React
应用,然后在这个页面通过引入 sdk
的方式挂载业务组件。
问题
多入口打包这样的做法会导致业务组件内部状态可以共享,但是各个业务组件之间的状态无法很好的共享。并且每个组件内部可能需要相同的数据,所以会导致相同的网络请求会在同一个页面发送多次的情况。
所以我们面临问题以及最终目的就是解决多个 React
应用之间的状态共享:
- 某个状态需要在多个挂载在页面不同
DOM
节点的业务组件间共享(访问 + 更新) - 某组件内交互需要触发其他组件的状态更新
解决方案
一、将状态挂载在全局 window 对象、EventEmitter
触发更新
使用类继承 EventEmitter
通过在类中申明公共变量来进行存储和共享数据,使用事件订阅发送的方式来实现数据共享以及更新。使用单例模式同步在 window
中,以实现多个组件使用同一个发布订阅实例,来同步和共享数据。EventEmitter
我们直接使用 eventemitter3 库提供的 on
监听事件以及emit
触发事件。以下是 TS Demo
代码
import EventEmitter from 'eventemitter3'
// 定义触发的事件常量
export const ACTION = {
ADD_COUNT: 'add-count',
} as const
// 申明 Store 接口
export interface IStore {
count: {
value:number,
addCount:() => void
}
}
// 通过继承 EventEmitter 的 class 中声明 store 来存储数据
export class MyEmitter extends EventEmitter {
public store: IStore = {
count:{
value:1,
addCount:()=>{this.count.value++}
}
}
}
// 将类实例挂载在 Window 中,并保证不同组件中使用的是同一个实例
export const getMyEmitter: () => MyEmitter = () => {
if (window.myEmitter) {
return window.myEmitter
}
window.myEmitter = new Emitter()
const currentEmitter = window.myEmitter
const store = currentEmitter.store
ee.on(ACTION.ADD_COUNT, store.count.addCount, store.count)
return window.myEmitter
}
这样一个非常原始的状态共享方式就完成啦,接下来我们就看看在 React
中是如何使用的吧
import React,{ useState, useEffect} from 'react'
import {getMyEmitter, ACTION} from './getMyEmitter'
// 使用
const emitter = getMyEmitter()
const CountDemo = ()=>{
return <div>{emitter.store.count.value}</div>
}
// 触发事件
const ButtonDemo = ()=>{
return <button onClick={()=>{emitter.emit(ACTION.ADD_COUNT)}}>add count</button>
}
优点
这样的解决方案比较原始,但是的确可以解决我们的面临的问题:
- 解决多入口打包应用无法使用统一数据源问题,统一维护管理多应用数据状态
- 单一数据源
缺点
但是缺点也非常的明显:
- 数据暴露在全局
window
对象,不优雅、不安全 - 使用事件触发的方式来同步数据好像不是
React
推荐做法 - 一旦需要注册的事件变多,将难以管理事件和状态
二、单入口打包 + 传送门
React 推荐做法
在方案一中我们说了,使用事件触发的方式同步数据不是 React
推荐做法,那数据共享的推荐做法是什么呢?React
的推荐做法是 提升状态 到各个组件最近的父级节点,借助 React
官方文档 useContext
demo 来简单理解:
[图片上传失败...(image-47b384-1606792981436)]
// 需要共享的数据
import ReactDOM from "react-dom";
import React, { createContext, useContext, useReducer } from "react";
import "./styles.css";
const ThemeContext = createContext();
const DEFAULT_STATE = {
theme: "light"
};
const reducer = (state, actions) => {
switch (actions.type) {
case "theme":
return { ...state, theme: actions.payload };
default:
return DEFAULT_STATE;
}
};
const ThemeProvider = ({ children }) => {
return (
<ThemeContext.Provider value={useReducer(reducer, DEFAULT_STATE)}>
{children}
</ThemeContext.Provider>
);
};
const ListItem = props => (
<li>
<Button {...props} />
</li>
);
const App = props => {
const [state] = useContext(ThemeContext);
const bg = state.theme === "light" ? "#ffffff" : "#000000";
return (
<div
className="App"
style={{
background: bg
}}
>
<ul>
<ListItem value="light" />
<ListItem value="dark" />
</ul>
</div>
);
};
const Button = ({ value }) => {
const [state, dispatcher] = useContext(ThemeContext);
const bgColor = state.theme === "light" ? "#333333" : "#eeeeee";
const textColor = state.theme === "light" ? "#ffffff" : "#000000";
return (
<button
style={{
backgroundColor: bgColor,
color: textColor
}}
onClick={() => {
dispatcher({ type: "theme", payload: value });
}}
>
{value}
</button>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(
<ThemeProvider>
<App />
</ThemeProvider>,
rootElement
);
真正要解决的问题
如果是使用 React
推荐做法来实现数据共享,那么我们就需要在保证各个业务组件依旧可以挂载在页面不同的 DOM
节点的前提下,将所有业务组件都放在同一颗 React Tree
下,因为只有所有业务组件都在同一颗 React Tree
下时才能让 React
的事件冒泡、状态共享、React
的生命周期按照预期进行工作。所以我们首先需要将多入口打包的方式改成单入口打包,至少针对单页面是这样的。多入口打包的方式改成单入口打包非常简单,直接改 webpack 的配置就 ok 了。然后接着解决如何保证在同一颗 React Tree
的前提下将不同的业务组件挂载在不同的 DOM 节点。
再简单说明一下我们现在需要解决的问题。我们都知道将一个 React APP
应用挂载在某个 DOM
节点就是直接 ReactDOM.render(<App />, targetElement)
就好了,但是业务组件各自都有各自不同的挂载 DOM
节点,如果业务组件都各自执行 ReactDOM.render
的话,那就不能保证所有业务组件都在同一颗 React Tree
下,也就不能让 React
的事件冒泡、状态共享、React
的生命周期按照预期进行工作了。
所以接下来我们要解决的问题就是:如何保证让不同的业务组件可以挂载在不同的 DOM
节点的前提下,他们依旧是在同一颗 React Tree
下的呢?
开始解决问题
在 ReactDOM.render
主应用后可以让子组件挂载在页面上的不同位置 🤔,这让我想到了 Ant-Design 中 Modal
,在需要用户处理事务,又不希望跳转页面以致打断工作流程时,可以使用 Modal
在当前页面正中打开一个浮层,承载相应的操作。Modal
其中有一个 getContainer
属性,说的是 Modal
默认的挂载位置是 document.body
,可以指定 Modal
挂载的 HTML
节点,当值为 false
事挂载在当前 DOM
。
[图片上传失败...(image-c6ccaa-1606792981436)]
那不就意味着我们在 React
应用写的 Modal
组件,它本来的挂载位置是跟随主应用的,但是 Ant-Design
把它默认提到了 document.body
中,这不就是我们要找的解决方法吗?我们来看看 Ant-Design
源码是通过什么来实现的呢?
我们先找到 Ant-Design
的 Modal
组件的弹窗,发现弹窗是通过 rc-dialog
包实现的。
那么我们接着找 rc-dialog
的实现,然后我们发现 rc-dialog
在挂载时候使用了 Portal
组件包了一层。
那我们接着找 rc-util
包看看他的 Portal
组件是如何实现的。
唉,我一说 “ 啪 ” 就 Github 撸了起来,很快啊!然后上来就是,一个 Ant-Design
Modal
,吭,一个rc-dialog
,一个re-util
,我全部找到了,找到了啊!找到以后,自然是,传统 React API 以点到为止。ReactDOM 放在了鼻子上,我没看文档。我笑一下,准备关掉 Github,因为这时间,按传统 Github 的点到为止,最终我已经找到了答案 ——ReactDOM.CreatePortal
。
最终我们发现 ReactDOM.createPortal
可以将组件放在 HTML
的任意 DOM
中,被 Portal
的组件行为和普通的 React
子节点行为一致,因为它仍然在 React Tree
中, 且与 DOM Tree
中的位置无关,也就是说像 context
、事件冒泡以及 React
的生命周期这样的 Feature
依旧可以使用。
我们对 ReactDOM.createPoral
进行简单封装就可以随处使用啦
interface IWrapPortalProps {
elementId: string // 创建带 id 的 createPortal container
effect: (container: HTMLElement, targetDom: Element) => void // 获取挂载位置,将 container 插入目标节点
targetDom?: Element
}
/**
*
* 通过 createPortal 实现在不同的 DOM 上挂载依旧在同一颗 React tree 上
* @param {*} IWrapPortalProps
* @returns
*/
export const WrapPortal: React.FC<IWrapPortalProps> = (props) => {
const [container] = useState(document.createElement('div'))
useEffect(() => {
container.id = props.elementId
if (!props.targetDom) {
return
}
props.effect(container, props.targetDom, props.elementId)
return () => {
container.remove()
}
}, [container, props])
return ReactDOM.createPortal(props.children, container)
}
// 使用
const effect = (container: HTMLElement, targetDom: Element) => {
targetDom!.insertAdjacentElement('afterbegin', container)
}
const targetDom = document.body
<WrapPortal effect={effect} targetDom={targetDom} elementId={'modal-root'}>
<button>Modal</button>
</WrapPortal>
传送门
接下来我们就复习一下 React、Vue
中 Portal
(传送门)的知识以及使用场景
传送门可以将组件放在 HTML
的任意 DOM
中,被 Portal
的组件行为和普通的 React、Vue
子节点行为一致,因为它仍然在 React、Vue Tree
中, 且与 DOM Tree
中的位置无关,也就是说像 context
、事件冒泡以及 React、Vue
的生命周期这样的 Feature
依旧可以使用。
-
事件冒泡正常工作 —— 通过将事件传播到
React
树的祖先节点,事件冒泡将按预期工作,而与DOM
中的Portal
节点位置无关。 - React、Vue 可以控制 Portal 节点及其生命周期 —— 通过 Portal 渲染子元素时,React、Vue 仍然可以控制其生命周期。
-
Portal 仅影响 DOM 结构 ——
Portal
仅影响HTML DOM
结构且不影响React、Vue
组件树。 -
预定义 HTML 挂载点 —— 使用
Portal
时,需要定义一个 HTML DOM 元素作为Portal
组件的挂载点。
当我们需要在正常 DOM
层次结构之外呈现子组件而又不通过 React
组件树层次结构破坏事件传播等的默认行为时,React、Vue Portal
就会显得非常有用:
- 模态对话框
- 工具提示
- 悬浮卡片
- 加载提示组件
- 在
Shawdow DOM
内挂载React、Vue
组件
Vue 3.0
新增了 Teleport
的概念,在 Vue 2
中是不支持这个特性的。
const app = Vue.createApp({});
app.component('modal-button', {
template: `
<button @click="modalOpen = true">
Open full screen modal! (With teleport!)
</button>
<teleport to="body">
<div v-if="modalOpen" class="modal">
<div>
I'm a teleported modal!
(My parent is "body")
<button @click="modalOpen = false">
Close
</button>
</div>
</div>
</teleport>
`,
data() {
return {
modalOpen: false
}
}
})
app.mount('#app')
image
Vue2
没有传送门的概念,是不是就不支持了呢?我们可以使用这个 3K
Star
的开源项目 portal-vue
<template>
<div>
<button @click="disabled = !disabled">Toggle "Disable"</button>
<Design-Container>
<Design-Panel color="green" text="Source">
<p>
The content below this paragraph is
rendered in the right/bottom (red) container by PortalVue
if the portal is enabled. Otherwise, it's shown here in place.
</p>
<Portal to="right-disable" :disabled="disabled">
<p class="red">This is content from the left/top container (green).</p>
</Portal>
</Design-Panel>
<Design-Panel color="red" text="Target" left>
<PortalTarget name="right-disable"></PortalTarget>
</Design-Panel>
</Design-Container>
</div>
</template>
<script>
export default {
data: () => ({
disabled: false,
}),
}
</script>
image
总结
-
之前:我们是向宿主平台某个页面提供多个业务组件,按照多入口打包方式打包成多个 chunk 给宿主使用。
-
问题:多入口的方式对于数据共享非常不友好,能解决但是不优雅,也就是文中的方案一。
-
解决:所以我们想要用相对正规的数据共享方式解决,Redux、Mobx、unstate、React Context 等。但是正规的方式都是在一个 React App 工作的,由于多入口打包打成了多个 React 应用,所以我们先针对单页面改用单入口打包,保证多个业务组件都在同一个 React App 上。与此同时,针对各个业务组件要挂载在不同 DOM 的需求,我们再用 Portal 对业务组件包裹一层,保证他们都在同一颗 React Tree。
🌈 今天的文章分享就到这里啦,如果喜欢这篇文章的话请点赞、Star 我吧 🎯