React - 如何在 React 更新任何 DOM 元素时通知 popper 重新定位我的 popover

React - How to notify popper to reposition my popover whenever react updates any DOM element

相关版本:React 16.4.2、Bootstrap4.1.3、popper.js1.14.4、Typescript 3.0.3

我在 react 应用程序中使用 Bootstrap Popover 功能。

如果页面的其余部分是静态的,则 Popover 效果很好。当页面更改时(在浏览器级别),Popover 会非常快速和平滑地重新定位,因此它在锚定到的内容可见时保持可见:

这一切都很好,因为 popper.js 显然正在观察 window.scrollwindow.resize 事件,根据这个答案:

当我的 react 应用程序启动 showing/hiding DOM 元素时出现问题。因为 popper.js 不知道 react,它不知道 DOM 发生了变化,所以它不知道 Popover 可能需要重新定位。

我知道在每个 Popover 锚点上调用 popover("update") 是可行的,因为我添加了这样的代码来间歇性地执行此操作:

window.setInterval(()=> $(this.selfRef).popover("update"), 100);

但这既恶心又浪费,而且有点笨拙。

有没有办法让 react 在更新 DOM 中的任何节点时告诉我,这样我就可以告诉 popper.js 更新弹出窗口的位置?

请注意,导致 DOM 更改的 react 组件不一定位于使用 Popover 的组件附近。它可能是层次结构中完全独立的部分,恰好显示在带有弹出窗口的组件之前 - 所以我认为解决方案不是 componentWillReceiveProps() 或 Popover 组件上的类似方法,因为它是可能不是导致移动的组件。

请注意,我知道 react-bootstrapreactstrapreact-popper 等项目 - 但我不想使用它们。


编辑: 似乎 MutationObserver 可能是一种无反应的方式来做到这一点。我只是想既然 React 已经在做所有的协调工作,也许有一种方法可以让它在实际编辑 DOM.

时通知我

"The react Component that causes the DOM change isn't necessarily located near the Component that uses the Popover. It could be something in a completely separate part of the hierarchy"

如果更改 DOM 的组件和创建 Popover 的组件都在同一个 parent 中,您可以在父级中共享一个方法.popover('update')。改变 DOM 的 Component 需要触发这个事件,但它不需要专门是 Popover Component 的 "aware"。 Popover 组件不需要知道 DOM 变化的组件。

class ChangeDom extends React.Component {

  constructor(props) {
     super(props);
     this.changeDom = this.changeDom.bind(this);
  }

  changeDom () {
      this.props.domChanged();
  }

  render() {
    return (
    <div>
        <button className="ml-2 btn btn-primary" onClick={this.changeDom}>Change Dom
        </button>
    </div>)
  }
}

class Pop extends React.Component {

  constructor(props) {
     super(props);
     this.togglePopover = this.togglePopover.bind(this);
  }

  togglePopover() {
      $('[data-toggle="popover"]').popover('toggle');
  }

  render() {
    return (
    <div class="position-relative">
        <button className="mt-4 btn btn-primary" onClick={this.togglePopover} data-toggle="popover"
        </button>
    </div>)
  }
}

class Parent extends React.Component {

  domChanged(){
      $('[data-toggle="popover"]').popover("update");
  }

  render() {
    return (
    <div>
        <ChangeDom domChanged={this.domChanged} />
        <Pop />
    </div>)
  }
}

演示:https://www.codeply.com/go/NhcfE8eAEY

这是我目前对基于 MutationObserver 的解决方案的尝试。

UserApp 是放置在应用程序层次结构顶部的组件。 Popover class 在我的应用程序的各个地方被(过度)用于一堆东西。

MutationObserver 事件触发 popover("update") 导致无限递归的可能性让我对长期使用此解决方案持谨慎态度。 它现在似乎可以完成这项工作,但这是单向绑定要避免的事情之一。

从好的方面来说,即使您的应用程序中有非 React 组件(例如 Bootstrap navbar),这也能正常工作。

export class UserApp extends React.Component<any, AppState> {

  public domChangeObservers = $.Callbacks();
  public mutationObserver = new MutationObserver(
    (mutations: MutationRecord[])=>{
      // premature optimisation?
      // I figure I don't care about each individual change, if the browser
      // batched em up, just fire on the last one.
      // But is this a good idea given we have to inspect the mutation in order
      // to avoid recursive loops?
      this.domChangeObservers.fire(mutations[mutations.length-1]);
    }
  );

  constructor(props: any) {
    super(props);

    this.mutationObserver.observe(document.documentElement, {
      attributes: true,
      characterData: true,
      childList: true,
      subtree: true,
      attributeOldValue: true,
      characterDataOldValue: true
    });

  }

  componentWillUnmount(){
    this.mutationObserver.disconnect();
  }

  ...

}


const DefaultTrigger = "click";

export interface PopoverProps{
  popoverTitle: string | Element | Function;
  popoverContent: string | Element | Function;
  /** Set to "focus" to get "dismiss on next click anywhere" behaviour */
  popoverTrigger?:  string;
  /** Leaving it empty means that the popover gets created
   * as a child of the anchor (whatever you use as the child of the popover).
   * Setting this to "body" means the popover gets created out on the body
   * of the document.
   * "body" can help with stuff like when the popover ends up
   * being clipped or "under" other components (because of stuff like
   * `overflow:hidden`).
   */
  container?: string;
  allowDefaultClickHandling?: boolean;

  ignoreDomChanges?: boolean;
  id?: string;
}

export class Popover
extends PureComponent<PopoverProps, object> {

  // ! to hack around TS 2.6 "strictPropertyInitialization"
  // figure out the right way... one day
  selfRef!: HTMLSpanElement;

  onDomChange = (mutation:MutationRecord)=>{
    /*
    - popover("update") causes DOM changes which fire this handler again,
      so we need to guard against infinite recursion of DOM change events.
    - popover("update") is async, so we can't just use an "if not currently
      handling a mutation" flag, because the order of events ends up being:
      onDomChange() -> flag=true -> popover("update") -> flag=false ->
      popper.js changes DOM -> onDomChange() called again -> repeat forever
    - Can't just detect *this* popover. If DOM event occurs because popovers
      overlay each other they will recurse alternately - i.e. pop1 update
      call makes DOM changes for pop2, pop2 update makes changes for pop1,
      repeat forever.
    */
    if( Popover.isPopoverNode(mutation) ){
      return;
    }

    /*
    - tell popper.js to reposition the popover
    - probably not necessary if popover is not showing, but I duuno how to tell
    */
    $(this.selfRef).popover("update");
  };

  private static isPopoverNode(mutation: MutationRecord){
    /*
    Had a good attempt that used the structure of the mutation target to
    see if it's parent element was defined as `data-toggle="popover"`; but
    that fails when you set the `container` prop to some other element -
    especially, "body", see the comments on the Props .
    */

    if( mutation.target.nodeType != 1 ){
      return false;
    }

    // Is Element
    let element = mutation.target as Element;

    /*
     Is the mutation target a popover element?
     As defined by its use of the Bootstrap "popover" class.
     This is dodgy, it relies on Bootstrap always creating a container
     element that has the "popover" class assigned.
     BS could change their classname, or they could
     change how they structure their popover, or some other
     random widget could use the name.
     Actually, this can be controlled by overriding the popover template,
     which I will do... later.
    */
    let isPopoverNode = element.classList.contains("popover");

    // very helpful when debugging - easy to tell if recursion is happening
    // by looking at the log
    // console.log("target", isPopoverNode, mutation, mutation.target );

    return isPopoverNode;
  }

  componentDidMount(): void{
    // the popover() method is a "JQuery plugin" thing,
    // that's how Bootstrap does its stuff
    $(this.selfRef).popover({
      container: this.props.container || this.selfRef,
      placement: "auto",
      title: this.props.popoverTitle,
      content: this.props.popoverContent,
      trigger: this.props.popoverTrigger || DefaultTrigger,
    });

    if( !this.props.ignoreDomChanges ){
      UserApp.instance.domChangeObservers.add(this.onDomChange);
    }

  }

  componentWillUnmount(): void {
    if( !this.props.ignoreDomChanges ){
      UserApp.instance.domChangeObservers.remove(this.onDomChange);
    }

    // - without this, if this component or any parent is unmounted,
    // popper.js doesn't know that and the popover content just becomes
    // orphaned
    $(this.selfRef).popover("dispose");
  }

  stopClick = (e: SyntheticEvent<any>) =>{
    if( !this.props.allowDefaultClickHandling ){
      // without this, if the child element is an <a> or similar, clicking it
      // to show/dismiss the popup will scroll the content
      e.preventDefault();
      e.stopPropagation();
    }
  };

  render(){
    let popoverTrigger = this.props.popoverTrigger || DefaultTrigger;

    // tabIndex is necessary when using "trigger=focus" to get
    // "dismiss on next click" behaviour.
    let tabIndex = popoverTrigger.indexOf("focus")>=0?0:undefined;

    return <span id={this.props.id}
      tabIndex={tabIndex}
      ref={(ref)=>{if(ref) this.selfRef = ref}}
      data-toggle="popover"
      onClick={this.stopClick}
    >{this.props.children}</span>;
  }
}