前言
人生是個積累的過程坷襟,你總會有摔倒,即使跌倒了咽白,你也要懂得抓一把沙子在手里啤握。 —— 丁磊
碼過的每一個需求、踩過的每一個坑晶框、修過的每一個 bug
排抬、學(xué)過的每一個知識以及看過的每一篇文章都不會成為無用功,它們都將為自己的技術(shù)城堡添磚加瓦授段。今天我們將從實現(xiàn)不同的 React蹲蒲、Vue App
之間的狀態(tài)共享這個需求著手,學(xué)習(xí) React
侵贵、Vue
中那些我們很少用到届搁,但是一旦遇到這些特殊的需求就非它莫屬的特性 ????
需求 & 問題
需求現(xiàn)狀
我在字節(jié)的日常業(yè)務(wù)開發(fā)中,我需要將不同的業(yè)務(wù)組件掛載在一個不屬于我們接管的平臺頁面中窍育,由于每個業(yè)務(wù)組件都有各自不同的掛載位置和時機卡睦,并且都可以看做一個單獨的 React
應(yīng)用,所以我們用 Webpack
進行多入口打包漱抓,打出多個 React
應(yīng)用表锻,然后在這個頁面通過引入 sdk
的方式掛載業(yè)務(wù)組件。
問題
多入口打包這樣的做法會導(dǎo)致業(yè)務(wù)組件內(nèi)部狀態(tài)可以共享乞娄,但是各個業(yè)務(wù)組件之間的狀態(tài)無法很好的共享瞬逊。并且每個組件內(nèi)部可能需要相同的數(shù)據(jù),所以會導(dǎo)致相同的網(wǎng)絡(luò)請求會在同一個頁面發(fā)送多次的情況仪或。
所以我們面臨問題以及最終目的就是解決多個 React
應(yīng)用之間的狀態(tài)共享:
- 某個狀態(tài)需要在多個掛載在頁面不同
DOM
節(jié)點的業(yè)務(wù)組件間共享(訪問 + 更新) - 某組件內(nèi)交互需要觸發(fā)其他組件的狀態(tài)更新
解決方案
一确镊、將狀態(tài)掛載在全局 window 對象、EventEmitter
觸發(fā)更新
使用類繼承 EventEmitter
通過在類中申明公共變量來進行存儲和共享數(shù)據(jù)范删,使用事件訂閱發(fā)送的方式來實現(xiàn)數(shù)據(jù)共享以及更新蕾域。使用單例模式同步在 window
中,以實現(xiàn)多個組件使用同一個發(fā)布訂閱實例瓶逃,來同步和共享數(shù)據(jù)束铭。EventEmitter
我們直接使用 eventemitter3 庫提供的 on
監(jiān)聽事件以及emit
觸發(fā)事件。以下是 TS Demo
代碼
import EventEmitter from 'eventemitter3'
// 定義觸發(fā)的事件常量
export const ACTION = {
ADD_COUNT: 'add-count',
} as const
// 申明 Store 接口
export interface IStore {
count: {
value:number,
addCount:() => void
}
}
// 通過繼承 EventEmitter 的 class 中聲明 store 來存儲數(shù)據(jù)
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
}
這樣一個非常原始的狀態(tài)共享方式就完成啦契沫,接下來我們就看看在 React
中是如何使用的吧
import React,{ useState, useEffect} from 'react'
import {getMyEmitter, ACTION} from './getMyEmitter'
// 使用
const emitter = getMyEmitter()
const CountDemo = ()=>{
return <div>{emitter.store.count.value}</div>
}
// 觸發(fā)事件
const ButtonDemo = ()=>{
return <button onClick={()=>{emitter.emit(ACTION.ADD_COUNT)}}>add count</button>
}
優(yōu)點
這樣的解決方案比較原始,但是的確可以解決我們的面臨的問題:
- 解決多入口打包應(yīng)用無法使用統(tǒng)一數(shù)據(jù)源問題昔汉,統(tǒng)一維護管理多應(yīng)用數(shù)據(jù)狀態(tài)
- 單一數(shù)據(jù)源
缺點
但是缺點也非常的明顯:
- 數(shù)據(jù)暴露在全局
window
對象懈万,不優(yōu)雅拴清、不安全 - 使用事件觸發(fā)的方式來同步數(shù)據(jù)好像不是
React
推薦做法 - 一旦需要注冊的事件變多,將難以管理事件和狀態(tài)
二会通、單入口打包 + 傳送門
React 推薦做法
在方案一中我們說了口予,使用事件觸發(fā)的方式同步數(shù)據(jù)不是 React
推薦做法,那數(shù)據(jù)共享的推薦做法是什么呢涕侈?React
的推薦做法是 提升狀態(tài) 到各個組件最近的父級節(jié)點沪停,借助 React
官方文檔 useContext
demo 來簡單理解:
[圖片上傳失敗...(image-47b384-1606792981436)]
// 需要共享的數(shù)據(jù)
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
推薦做法來實現(xiàn)數(shù)據(jù)共享,那么我們就需要在保證各個業(yè)務(wù)組件依舊可以掛載在頁面不同的 DOM
節(jié)點的前提下裳涛,將所有業(yè)務(wù)組件都放在同一顆 React Tree
下木张,因為只有所有業(yè)務(wù)組件都在同一顆 React Tree
下時才能讓 React
的事件冒泡、狀態(tài)共享端三、React
的生命周期按照預(yù)期進行工作舷礼。所以我們首先需要將多入口打包的方式改成單入口打包,至少針對單頁面是這樣的郊闯。多入口打包的方式改成單入口打包非常簡單妻献,直接改 webpack 的配置就 ok 了。然后接著解決如何保證在同一顆 React Tree
的前提下將不同的業(yè)務(wù)組件掛載在不同的 DOM 節(jié)點团赁。
再簡單說明一下我們現(xiàn)在需要解決的問題育拨。我們都知道將一個 React APP
應(yīng)用掛載在某個 DOM
節(jié)點就是直接 ReactDOM.render(<App />, targetElement)
就好了,但是業(yè)務(wù)組件各自都有各自不同的掛載 DOM
節(jié)點欢摄,如果業(yè)務(wù)組件都各自執(zhí)行 ReactDOM.render
的話至朗,那就不能保證所有業(yè)務(wù)組件都在同一顆 React Tree
下,也就不能讓 React
的事件冒泡剧浸、狀態(tài)共享、React
的生命周期按照預(yù)期進行工作了矗钟。
所以接下來我們要解決的問題就是:如何保證讓不同的業(yè)務(wù)組件可以掛載在不同的 DOM
節(jié)點的前提下唆香,他們依舊是在同一顆 React Tree
下的呢?
開始解決問題
在 ReactDOM.render
主應(yīng)用后可以讓子組件掛載在頁面上的不同位置 ??吨艇,這讓我想到了 Ant-Design 中 Modal
躬它,在需要用戶處理事務(wù),又不希望跳轉(zhuǎn)頁面以致打斷工作流程時东涡,可以使用 Modal
在當(dāng)前頁面正中打開一個浮層冯吓,承載相應(yīng)的操作。Modal
其中有一個 getContainer
屬性疮跑,說的是 Modal
默認(rèn)的掛載位置是 document.body
组贺,可以指定 Modal
掛載的 HTML
節(jié)點,當(dāng)值為 false
事掛載在當(dāng)前 DOM
祖娘。
[圖片上傳失敗...(image-c6ccaa-1606792981436)]
那不就意味著我們在 React
應(yīng)用寫的 Modal
組件失尖,它本來的掛載位置是跟隨主應(yīng)用的,但是 Ant-Design
把它默認(rèn)提到了 document.body
中,這不就是我們要找的解決方法嗎掀潮?我們來看看 Ant-Design
源碼是通過什么來實現(xiàn)的呢菇夸?
我們先找到 Ant-Design
的 Modal
組件的彈窗,發(fā)現(xiàn)彈窗是通過 rc-dialog
包實現(xiàn)的仪吧。
那么我們接著找 rc-dialog
的實現(xiàn)庄新,然后我們發(fā)現(xiàn) rc-dialog
在掛載時候使用了 Portal
組件包了一層。
那我們接著找 rc-util
包看看他的 Portal
組件是如何實現(xiàn)的薯鼠。
唉择诈,我一說 “ 啪 ” 就 Github 擼了起來,很快叭硕稀吭从!然后上來就是,一個 Ant-Design
Modal
恶迈,吭涩金,一個rc-dialog
,一個re-util
暇仲,我全部找到了步做,找到了啊奈附!找到以后全度,自然是,傳統(tǒng) React API 以點到為止斥滤。ReactDOM 放在了鼻子上将鸵,我沒看文檔。我笑一下佑颇,準(zhǔn)備關(guān)掉 Github顶掉,因為這時間,按傳統(tǒng) Github 的點到為止挑胸,最終我已經(jīng)找到了答案 ——ReactDOM.CreatePortal
痒筒。
最終我們發(fā)現(xiàn) ReactDOM.createPortal
可以將組件放在 HTML
的任意 DOM
中,被 Portal
的組件行為和普通的 React
子節(jié)點行為一致茬贵,因為它仍然在 React Tree
中簿透, 且與 DOM Tree
中的位置無關(guān),也就是說像 context
解藻、事件冒泡以及 React
的生命周期這樣的 Feature
依舊可以使用老充。
我們對 ReactDOM.createPoral
進行簡單封裝就可以隨處使用啦
interface IWrapPortalProps {
elementId: string // 創(chuàng)建帶 id 的 createPortal container
effect: (container: HTMLElement, targetDom: Element) => void // 獲取掛載位置,將 container 插入目標(biāo)節(jié)點
targetDom?: Element
}
/**
*
* 通過 createPortal 實現(xiàn)在不同的 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>
傳送門
接下來我們就復(fù)習(xí)一下 React螟左、Vue
中 Portal
(傳送門)的知識以及使用場景
傳送門可以將組件放在 HTML
的任意 DOM
中蚂维,被 Portal
的組件行為和普通的 React戳粒、Vue
子節(jié)點行為一致,因為它仍然在 React虫啥、Vue Tree
中蔚约, 且與 DOM Tree
中的位置無關(guān),也就是說像 context
涂籽、事件冒泡以及 React苹祟、Vue
的生命周期這樣的 Feature
依舊可以使用。
-
事件冒泡正常工作 —— 通過將事件傳播到
React
樹的祖先節(jié)點评雌,事件冒泡將按預(yù)期工作树枫,而與DOM
中的Portal
節(jié)點位置無關(guān)。 - React景东、Vue 可以控制 Portal 節(jié)點及其生命周期 —— 通過 Portal 渲染子元素時砂轻,React、Vue 仍然可以控制其生命周期斤吐。
-
Portal 僅影響 DOM 結(jié)構(gòu) ——
Portal
僅影響HTML DOM
結(jié)構(gòu)且不影響React搔涝、Vue
組件樹。 -
預(yù)定義 HTML 掛載點 —— 使用
Portal
時和措,需要定義一個 HTML DOM 元素作為Portal
組件的掛載點庄呈。
當(dāng)我們需要在正常 DOM
層次結(jié)構(gòu)之外呈現(xiàn)子組件而又不通過 React
組件樹層次結(jié)構(gòu)破壞事件傳播等的默認(rèn)行為時,React派阱、Vue Portal
就會顯得非常有用:
- 模態(tài)對話框
- 工具提示
- 懸浮卡片
- 加載提示組件
- 在
Shawdow DOM
內(nèi)掛載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')
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>
總結(jié)
之前:我們是向宿主平臺某個頁面提供多個業(yè)務(wù)組件腺劣,按照多入口打包方式打包成多個 chunk 給宿主使用彩届。
問題:多入口的方式對于數(shù)據(jù)共享非常不友好,能解決但是不優(yōu)雅誓酒,也就是文中的方案一。
解決:所以我們想要用相對正規(guī)的數(shù)據(jù)共享方式解決贮聂,Redux靠柑、Mobx、unstate吓懈、React Context 等歼冰。但是正規(guī)的方式都是在一個 React App 工作的,由于多入口打包打成了多個 React 應(yīng)用耻警,所以我們先針對單頁面改用單入口打包隔嫡,保證多個業(yè)務(wù)組件都在同一個 React App 上甸怕。與此同時,針對各個業(yè)務(wù)組件要掛載在不同 DOM 的需求腮恩,我們再用 Portal 對業(yè)務(wù)組件包裹一層梢杭,保證他們都在同一顆 React Tree。
?? 今天的文章分享就到這里啦秸滴,如果喜歡這篇文章的話請點贊武契、Star 我吧 ??