使用 Portal 優雅實現「浮」在頁面上的組件

產品需求

產品需求,實現一個選擇器 Selector 組件,要求浮在頁面上方。在網上隨便找了個圖,以下:html

clipboard.png

實現方案

實現這一的一個 Selector 組件並不難,不是本文的討論內容。
本文討論的主要是,在有相似於 Selector 組件同樣,「浮」在頁面的組件時,如何設計 React 組件樹?node

方案一:Seletor 組件是 App 組件的子組件。
clipboard.pngreact

優點:Selector 屬於 App 的子節點,子節點不受父節點的樣式屬性( position overflow )的干擾。app

劣勢:Selector 的顯示狀態屬於 App 節點,跨分支傳遞狀態成本過高。使用 Redux 或 Mobx 跨分支傳遞狀態,依賴第三方組件,不利於複用;而手動傳遞,至少要 4 個步驟,若是 Button 節點更深,步驟會更多。而且這樣寫出的代碼,耦合性太強,不利於維護。dom

方案二:Selector(fixed) 組件是 Button 組件的子組件。fetch

clipboard.png

優點:Selector 的顯示狀態屬於 Button 節點控制,狀態管理成本低。ui

劣勢:Selector 屬於 Button 的子節點。而當父節點 Button 有文字超出隱藏的需求時(overflow: hidden),子節點 Selector 會被隱藏。this

那麼,有沒有兩全齊美的方案呢?有。spa

方案三:在 React 組件樹設計上,Selector 是 Button 的子組件。可是在 DOM 樹的角度 Selector 是 Body 的子節點。翻譯

clipboard.png

在這個方案中,Button 和 Selector 仍是屬於 React 組件樹中的父子節點,享有父子組件狀態傳遞方便的優點。
可是,Button 和 Selector 再也不屬於 DOM 樹中的父子節點!Selector 被渲染到了 Body 節點下面,屬於 Body 的子節點。這樣 Selector 組件不再會受到 Button 組件的樣式干擾了。

在 React 中如何作到這一點呢?使用 React 16 的 Portals
這個新屬性的介紹文章很短,我就翻譯下一吧。翻譯只是意譯,只爲更好理解。

Portals

Portals 提供了一種超級棒的方法,能夠將 react 子節點的 DOM 結構,渲染到 react 父節點以外的 DOM 中。

ReactDOM.createPortal(child, container)

第一個參數 child 是任何能夠被渲染的 ReactChild,好比 element, string 或者 fragment. 第二個參數 container 是 一個 DOM 元素。

使用方法

通常來講,在 react 中是父子節點的關係,那麼在 DOM 中也是父子節點的關係。

render() {
  // 在 react 中 div 和 children 是父子的關係,在 DOM 中 div 和 children 也是父子的關係。
  return (
    <div>
      {this.props.children}
    </div>
  );
}

然而,有時候打破了這種 react 父子節點和 DOM 父子節點的映射關係是很是有用的。使用 createPortal 能夠將 react 的子節點插入到不一樣的 DOM 節點中。

render() {
  // React 並無建立一個新的 div,來包裹 children。它將 children 渲染到了 domNode 中。
  // domNode 能夠是任意一個合法的 DOM 節點,不管它在 DOM 節點中的哪一個位置。
  return ReactDOM.createPortal(
    this.props.children,
    domNode,
  );
}

portal 一個典型的用法是,當父組件有 overflow: hidden 或者 z-index 樣式時,可是子組件須要「打破」父組件容器,顯示在父組件以外。好比 dialogs,hovercards,tooltips 組件。

[在 CodePen 上嘗試一下(https://codepen.io/gaearon/pe...

Portals 的事件冒泡

雖然 portal 能夠在 DOM 樹中的任意位置,可是它的行爲依舊和普通的 React child 同樣。好比上下文環境徹底同樣,不管 child 是否是 portal; portal 也一直存在於在 React 樹上,不管它位於 DOM 樹中的什麼位置。

包括,事件冒泡。portal 節點的事件會冒泡到它的 React 樹的祖先節點上,即便這些 React 樹上的祖先節點並非 DOM 樹上的祖先節點。好比,有下面的 HTML 結構。

<html>
  <body>
    <div id="app-root"></div>
    <div id="modal-root"></div>
  </body>
</html>

在 DOM 樹中是 portal 和它的 React 父組件兄弟節點,可是因爲 React 的事件處理規則,讓 portal 的 React 父組件有能力捕獲 portal 的冒泡事件。

// These two containers are siblings in the DOM
const appRoot = document.getElementById('app-root');
const modalRoot = document.getElementById('modal-root');

class Modal extends React.Component {
  constructor(props) {
    super(props);
    this.el = document.createElement('div');
  }

  componentDidMount() {
    // The portal element is inserted in the DOM tree after
    // the Modal's children are mounted, meaning that children
    // will be mounted on a detached DOM node. If a child
    // component requires to be attached to the DOM tree
    // immediately when mounted, for example to measure a
    // DOM node, or uses 'autoFocus' in a descendant, add
    // state to Modal and only render the children when Modal
    // is inserted in the DOM tree.
    modalRoot.appendChild(this.el);
  }

  componentWillUnmount() {
    modalRoot.removeChild(this.el);
  }

  render() {
    return ReactDOM.createPortal(
      this.props.children,
      this.el,
    );
  }
}

class Parent extends React.Component {
  constructor(props) {
    super(props);
    this.state = {clicks: 0};
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    // This will fire when the button in Child is clicked,
    // updating Parent's state, even though button
    // is not direct descendant in the DOM.
    this.setState(prevState => ({
      clicks: prevState.clicks + 1
    }));
  }

  render() {
    return (
      <div onClick={this.handleClick}>
        <p>Number of clicks: {this.state.clicks}</p>
        <p>
          Open up the browser DevTools
          to observe that the button
          is not a child of the div
          with the onClick handler.
        </p>
        <Modal>
          <Child />
        </Modal>
      </div>
    );
  }
}

function Child() {
  // The click event on this button will bubble up to parent,
  // because there is no 'onClick' attribute defined
  return (
    <div className="modal">
      <button>Click</button>
    </div>
  );
}

ReactDOM.render(<Parent />, appRoot);

[在 CodePen 上嘗試一下(https://codepen.io/gaearon/pe...

父組件可以捕獲 portal 的冒泡事件的設計,容許開發者更加靈活的進行抽象,而這些抽象不依賴於 portal 。例如,若是你渲染一個 <Modal /> 組件,它的父組件可以捕獲它的事件,不管使用的是否是 portal 實現的 (fixed 也能實現)。

使用 portals 的實現 Selector

// 數據和選中的元素的狀態由 Selector 本身控制
// 不要將 data、index 狀態暴露給其餘組件
// 暴露給父組件,越多和父組件耦合的就越重
class Selector extends Component {
    componentDidMount(){
        fetch('xxx')
            .then(data => {
                this.setState({
                    data,
                })
            })
    }

    handleSelect = index => {
        this.setState({
            index
        })
    }
    
    render() {
        return (
            <List 
                data={this.state.data}
                index={this.state.index}
                onSelect={this.handleSelect}
            />
        )
    }

}

// 控制 Modal 顯示狀態都封裝在 Button 中
class Button extends Component {
    handleClick = () => {
        this.setState( prevState => ({
            show: !prevState.show
        }))
    }
    
    render() {
        return (
            <div onClick={this.handleClick}>
                <span>我是按鈕</span>
                // 爲了保存 Selector 的狀態,不要 unmount Modal,用 display: none 實現隱藏。
                <Modal show={this.state.show}>
                    <Selector />
                </Modal>
            </div>
        )
    }
}


class App extends Component {
    render() {
        return (
            <div>
                <Button />
                <Other />
            </div>
        )
    }
}

討論:屬性暴露的越多越好,仍是越少越好?

相關文章
相關標籤/搜索