duilib 子窗口位置_react portal 实现将部分UI渲染在独立窗口

544ba983e08daed703484e1cff849bad.png

这个实现启发于以下这篇文章。感谢原作者。

Using a React 16 Portal to do something cool

TLDR: 等不及的读者可以直接看我这个 demo。点击 toggle 可以将一部分UI分离。同时在主窗口点击move可以移动子窗口的方块。

Detached window

import React from "react";
import ReactDOM from "react-dom";
import { StyleSheetManager } from "styled-components";

function copyStyles(sourceDoc, targetDoc) {
  Array.from(sourceDoc.styleSheets).forEach(styleSheet => {
    if (styleSheet.cssRules) {
      // true for inline styles
      const newStyleEl = sourceDoc.createElement("style");

      Array.from(styleSheet.cssRules).forEach(cssRule => {
        newStyleEl.appendChild(sourceDoc.createTextNode(cssRule.cssText));
      });

      targetDoc.head.appendChild(newStyleEl);
    }
  });
}

class DetachedWindow extends React.Component {
  containerEl;
  externalWindow;
  styledTarget;
  observer;

  constructor(props) {
    super(props);
    this.externalWindow = null;
  }

  componentDidMount() {
    this.externalWindow = window.open(
      "",
      "",
      "width=1200,height=800,left=200,top=200"
    );

    if (!this.externalWindow) return;

      // need this in windows OS to wait for document load
    setTimeout(() => {
      this.externalWindow.document.title = this.props.windowName;
      this.styledTarget = document.createElement("div");
      this.styledTarget.setAttribute("id", "style-sheet-container");

      this.externalWindow.document.head.appendChild(this.styledTarget);

      this.transferStyle();

      // new window
      this.externalWindow.addEventListener("beforeunload", () => {
        this.props.closeWindowPortal();
      });

      this.forceUpdate();
    });
  }

  transferStyle() {
    if (!this.externalWindow) return;
    copyStyles(document, this.externalWindow.document);
  }

  componentWillUnmount() {
    if (this.styledTarget) {
      // copy style back to original document
      const styleNode = this.styledTarget.childNodes[0];
      if (styleNode !== null && styleNode?.sheet.cssRules) {
        const newStyleEl = document.createElement("style");

        Array.from(styleNode.sheet.cssRules).forEach(cssRule => {
          newStyleEl.appendChild(document.createTextNode(cssRule.cssText));
        });

        newStyleEl.setAttribute("data-origin", "detached-window");
        document.head.appendChild(newStyleEl);
      }
    }
    this.externalWindow.close();
  }

  render() {
    if (!this.externalWindow) return null;
    return ReactDOM.createPortal(
      <StyleSheetManager target={this.styledTarget}>
        {this.props.children}
      </StyleSheetManager>,
      this.externalWindow?.document.body
    );
  }
}

export { DetachedWindow };

假设我们是在 A 窗口 打开 B窗口

关键步骤是

  1. 我们在 A 窗口使用 window.open 这个来打开一个新的窗口 B。
  2. 我们在 A 的主线程上使用 React.createPortal 来把 UI 渲染在 B 窗口的 body 中。

其实这样就已经完成了把UI渲染在另一个 window 的任务。其实我们也可以用 React.render 来做这件事。只不过使用 createPortal 的话,这个组件的生命周期和父级是绑定的。它可以跟随父级一起更新。这个行为正是我想要的。

通过这样的方式创造的 A 和 B 其实是一个 js 线程控制了两个 DOM。这两个 DOM 是分别渲染在两个窗口中的。他们之间不能直接进行交互。但是可以通过 js 来交互。另外,他们的 CSSOM 也是分开的。

为了保证 UI 在两个窗口来回转移可以正常显示运行,我们需要保证 dom 结构和 style 可以在两个窗口保持一致。React 帮我们实现了 dom 结构在两个窗口的一致性。而 style 的一致性需要我们自己来处理。

关于 style 的一致性处理

现在 web 上对于应用的 style 选择非常多,各个项目都不太一样。我这里只适配了 styled-component。

关于 styled-component 对样式的处理: 它是通过 CSSOM 把样式放在 head 里面的stylesheet 上。在生产 app 上你甚至不能直接在dom中搜索到一个 class 对应的样式。但是它可以通过 document.styleSheets 的方式来获取到。

另外,它会在组件需要新的 style 时候动态地修改 styleSheet。

我这里保持 style 一致的具体方法是:

  1. 当创建新窗口的时候,将原窗口所有的 style 复制到子窗口。这样保证子窗口的初始样式和父窗口一致。
  2. 在style更新的时候, styled-component 会直接把样式更新到父窗口中(猜测是因为style-component保留了某个dom节点的 reference)。而这两个窗口的 CSSOM 是分开的,这样的话子窗口的样式就不能够更新。
    解决方案是在子窗口的UI最外层套上 StyleSheetManager,通过这个组件指明之后这个节点以下的style 修改都需要加在我们指定的targetNode(这个node存在于子窗口,所以可以作用到子窗口上)上。 这么做还有一个好处是子窗口中所有的 style 修改不会影响到父窗口。

3. 当子窗口要关闭时,我们把 targetNode 里面所有的style 复制回父窗口,保证子窗口中的UI回到父窗口以后能够正常展示(demo 中可以看到在子窗口中移动过的方块回到父窗口可以保持一样的位置)。

在实际运用的时候,我需要在子窗口渲染结束后在父窗口中重新触发一次子窗口中UI 的 render。才能让父子窗口的 js 同步。这个可能是 react 内部的某些机制导致的。明白的读者可以评论指导下。

文章到此结束,感谢阅读。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值