关于前端:????-如何优雅地解决多个-ReactVue-App-之间的状态共享

前言

人生是个积攒的过程,你总会有摔倒,即便跌倒了,你也要懂得抓一把沙子在手里。 —— 丁磊

码过的每一个需要、踩过的每一个坑、修过的每一个 bug 、学过的每一个常识以及看过的每一篇文章都不会成为无用功,它们都将为本人的技术城堡添砖加瓦。明天咱们将从实现不同的 React、Vue App 之间的状态共享这个需要着手,学习 ReactVue 中那些咱们很少用到,然而一旦遇到这些非凡的需要就非它莫属的个性 ????????

需要 & 问题

需要现状

我在字节的日常业务开发中,我须要将不同的业务组件挂载在一个不属于咱们接管的平台页面中,因为每个业务组件都有各自不同的挂载地位和机会,并且都能够看做一个独自的 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 来简略了解:

// 须要共享的数据
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

那不就意味着咱们在 React 利用写的 Modal 组件,它原本的挂载地位是追随主利用的,然而 Ant-Design 把它默认提到了 document.body 中,这不就是咱们要找的解决办法吗?咱们来看看 Ant-Design 源码是通过什么来实现的呢?

咱们先找到 Ant-DesignModal 组件的弹窗,发现弹窗是通过 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、VuePortal(传送门)的常识以及应用场景

传送门能够将组件放在 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')

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>

???? 明天的文章分享就到这里啦,如果喜爱这篇文章的话请点赞、Star、关注我吧 ????

参考

  • 传送门:React Portal
  • Vue teleport
  • React createportal
  • https://reactjs.org/docs/reac…

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理