?? 如何優(yōu)雅地解決多個 React、Vue App 之間的狀態(tài)共享萌业?

前言

人生是個積累的過程坷襟,你總會有摔倒,即使跌倒了咽白,你也要懂得抓一把沙子在手里啤握。 —— 丁磊

碼過的每一個需求、踩過的每一個坑晶框、修過的每一個 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-DesignModal躬它,在需要用戶處理事務(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-DesignModal 組件的彈窗,發(fā)現(xiàn)彈窗是通過 rc-dialog 包實現(xiàn)的仪吧。

image

image

那么我們接著找 rc-dialog 的實現(xiàn)庄新,然后我們發(fā)現(xiàn) rc-dialog 在掛載時候使用了 Portal 組件包了一層。

image

那我們接著找 rc-util 包看看他的 Portal 組件是如何實現(xiàn)的薯鼠。

image

唉择诈,我一說 “ 啪 ” 就 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螟左、VuePortal(傳送門)的知識以及使用場景

傳送門可以將組件放在 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')
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

總結(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 我吧 ??

參考

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市荡含,隨后出現(xiàn)的幾起案子咒唆,更是在濱河造成了極大的恐慌,老刑警劉巖释液,帶你破解...
    沈念sama閱讀 216,544評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件全释,死亡現(xiàn)場離奇詭異,居然都是意外死亡误债,警方通過查閱死者的電腦和手機浸船,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,430評論 3 392
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來找前,“玉大人糟袁,你說我怎么就攤上這事√墒ⅲ” “怎么了项戴?”我有些...
    開封第一講書人閱讀 162,764評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長槽惫。 經(jīng)常有香客問我周叮,道長,這世上最難降的妖魔是什么界斜? 我笑而不...
    開封第一講書人閱讀 58,193評論 1 292
  • 正文 為了忘掉前任仿耽,我火速辦了婚禮,結(jié)果婚禮上各薇,老公的妹妹穿的比我還像新娘项贺。我一直安慰自己,他們只是感情好峭判,可當(dāng)我...
    茶點故事閱讀 67,216評論 6 388
  • 文/花漫 我一把揭開白布开缎。 她就那樣靜靜地躺著,像睡著了一般林螃。 火紅的嫁衣襯著肌膚如雪奕删。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,182評論 1 299
  • 那天疗认,我揣著相機與錄音完残,去河邊找鬼伏钠。 笑死,一個胖子當(dāng)著我的面吹牛谨设,可吹牛的內(nèi)容都是我干的熟掂。 我是一名探鬼主播,決...
    沈念sama閱讀 40,063評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼铝宵,長吁一口氣:“原來是場噩夢啊……” “哼打掘!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起鹏秋,我...
    開封第一講書人閱讀 38,917評論 0 274
  • 序言:老撾萬榮一對情侶失蹤尊蚁,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后侣夷,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體横朋,經(jīng)...
    沈念sama閱讀 45,329評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,543評論 2 332
  • 正文 我和宋清朗相戀三年百拓,在試婚紗的時候發(fā)現(xiàn)自己被綠了琴锭。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,722評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡衙传,死狀恐怖决帖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情蓖捶,我是刑警寧澤地回,帶...
    沈念sama閱讀 35,425評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站俊鱼,受9級特大地震影響刻像,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜并闲,卻給世界環(huán)境...
    茶點故事閱讀 41,019評論 3 326
  • 文/蒙蒙 一细睡、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧帝火,春花似錦溜徙、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,671評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至宏浩,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間靠瞎,已是汗流浹背比庄。 一陣腳步聲響...
    開封第一講書人閱讀 32,825評論 1 269
  • 我被黑心中介騙來泰國打工求妹, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人佳窑。 一個月前我還...
    沈念sama閱讀 47,729評論 2 368
  • 正文 我出身青樓制恍,卻偏偏與公主長得像,于是被迫代替她去往敵國和親神凑。 傳聞我的和親對象是個殘疾皇子净神,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,614評論 2 353

推薦閱讀更多精彩內(nèi)容

  • 基本使用 React基本使用 直接渲染 html,相當(dāng)于 vue 中的 v-html React 事件 ...
    _1633_閱讀 467評論 0 1
  • 傳統(tǒng)MVC框架的缺陷 什么是MVC溉委? V即View視圖是指用戶看到并與之交互的界面鹃唯。 M即Model模型是管理數(shù)據(jù)...
    周南安閱讀 354評論 0 0
  • 背景 大家都在使用React,之前呢瓣喊,也給大家分享過一次主題為“淺談Hooks&&生命周期”的內(nèi)容坡慌。今天呢,作為延...
    賀賀v5閱讀 434評論 0 0
  • 幾個月前遇到了寫模態(tài)窗(modal)的需求藻三,當(dāng)初其實沒什么思路洪橘,不知道怎么用更React的方式實現(xiàn)模態(tài)窗,于是去學(xué)...
    leozdgao閱讀 7,989評論 0 12
  • 在文章《Vue組件開發(fā)三板斧:prop棵帽、event熄求、slot》中聊了常用的組件開發(fā)常用API和一些采坑心得,這里逗概,...
    娜姐聊前端閱讀 1,034評論 0 2