深度解析!Vue3 & React Hooks 新UI元件原理

wh7577發表於2021-09-09

前言

在某個月黑風高的晚上…沒劇刷的我無意想起以前處理的一些彈窗的坑。

圖片描述
然後又無意間刷到“Portal”,才知道Modal的實現還有如此妙的方式,順而想著乾脆把UI元件庫的實現原理看完。

本文將講述以下三種UI元件的實現原理:

  1. Modal彈窗類。
  2. Steps步驟條。
  3. Transfer穿梭框。

1. Modal彈窗的基本原理

我給彈窗類的定義是脫離固定的層級關係,不再受制於層疊上下文的元件。

常見的Modal模態框、Dialog對話方塊、Notification通知框等都是最最常用的互動方式。

圖片描述
在我們頁面有時需要一些特定的彈窗時,透過改UI元件過於麻煩。

這時切圖仔級別的會想:簡單啊,建立一個<div/> 給絕對定位不就得了。

倘若只是當前路由頁用,也還湊合。可一旦涉及到了元件複用以及抽象為宣告式,就會有很大的隱患

  1. 若無封裝,元件程式碼需要處處貼上。
  2. 即使封裝了,都是在每個路由頁下建立<div/>,易造成樣式汙染。
  3. 類購物車的彈窗,又該如何處理資料及渲染?
  4. 再進一步想,萬一元件庫會作為績效考核,拿到每個環境都長得不一樣,咋整?

圖片描述

1.1 Jquery時代的彈窗實現

初初入行時,去各種資源站,找JqueryUI元件,想必三四年經驗的前端們都曾樂此不疲。

圖片描述

這個時代(也就三四年前)的彈窗,因為沒有React/Vue根節點的概念,普遍都是:

  1. 直接操作真實 dom,使用熟知的dom 操作方法將指令所在的元素 append 到另外一個 dom 節點上去。 如:document.body.appendChild
  2. 再透過overflow: hiddendisplay:none(或調整z-index)來隱藏。

這種操作真實dom的代價,在大型專案中不停觸發重繪/迴流,是很糟糕的,且內部資料/樣式不易更改。像以下這種情況就容易出現:

  1. 原本圖片固定在區域內。
    圖片描述
  2. 小彈窗展示後,溢位了。
    圖片描述
    隨著React / Vue先進庫的發展,也陸續有了多種方案選擇。。。

1.2 React / Vue早期實現。

其實React / Vue早期的實現和Jquery時代的並無二異:依賴於父節點資料,在當前元件內掛載彈窗。

Vue的情況稍好,有自定義指令這條路走。

以下引自:

vue-dom-portal為例,程式碼非常簡單無非就是將當前的 dom 移動到指定地方:

function (node = document.body) {
  if (node === true) return document.body;
  return node instanceof window.Node ? node : document.querySelector(node);
}

const homes = new Map();

const directive = {
  inserted(el, { value }, vnode) {
    const { parentNode } = el;
    const home = document.createComment("");
    let hasMovedOut = false;

    if (value !== false) {
      parentNode.replaceChild(home, el); // moving out, el is no longer in the document
      getTarget(value).appendChild(el); // moving into new place
      hasMovedOut = true;
    }

    if (!homes.has(el)) homes.set(el, { parentNode, home, hasMovedOut }); // remember where home is or should be
  },
  componentUpdated(el, { value }) {
    // 對比子元件更新
    const { parentNode, home, hasMovedOut } = homes.get(el); // recall where home is

    if (!hasMovedOut && value) {
      parentNode.replaceChild(home, el);
      getTarget(value).appendChild(el);

      homes.set(el, Object.assign({}, homes.get(el), { hasMovedOut: true }));
    } else if (hasMovedOut && value === false) {
      parentNode.replaceChild(el, home);
      homes.set(el, Object.assign({}, homes.get(el), { hasMovedOut: false }));
    } else if (value) {
      getTarget(value).appendChild(el);
    }
  },
  unbind(el, binding) {
    homes.delete(el);
  }
};

function plugin(Vue, { name = "dom-portal" } = {}) {
  Vue.directive(name, directive);
}

plugin.version = "0.1.6";

export default plugin;

if (typeof window !== "undefined" && window.Vue) {
  window.Vue.use(plugin);
}

可以看到在 inserted 的時候就拿到例項的 el(真實 dom),然後進行替換操作,在 componentUpdated 的時候再次根據指令的值去操作 dom。

為了能夠在不同宣告週期函式中使用快取的一些資料,這裡在 inserted 的時候就把當前節點的父節點和替換成的 dom 節點(一個註釋節點),以及節點是否移出去的狀態都記錄在外部的一個 map 中,這樣可以在其他的宣告週期函式中使用,可以避免重複計算。

但是React / Vue的實現都有類似的通病:

  1. 生命週期的執行會很混亂。
  2. 需要透過reduxprops管理資料,可這對於一個UI元件來說過於臃腫了。

React官方也意識到構建脫離於父元件的元件挺麻煩的,於是在v16版本推了一個叫“Portal ”的功能。而Vue3也是借鑑並吸納了優秀外掛,將Portal作為內建元件了。

1.3 傳送門Portal方案

圖片描述
React / Vue的第二套方案都是基於操作虛擬dom

定義一套元件,將元件內的 vnode/ReactDOM 轉移到另外一個元件中去,然後各自渲染。

2. ReactPortal

React Portal之所以叫Portal,因為做的就是和“傳送門”一樣的事情:render到一個元件裡面去,實際改變的是網頁上另一處的DOM結構。

ReactDOM.createPortal(child, container)
  1. 第一個引數(child)是任何可渲染的 React 子元素,例如一個元素,字串或碎片。
  2. 第二個引數(container)則是一個 DOM 元素。

v16中,使用Portal建立Dialog元件簡單多了,不需要牽扯到componentDidMountcomponentDidUpdate,也不用呼叫API清理Portal,關鍵程式碼在render中,像下面這樣就行:

import React from 'react';
import {createPortal} from 'react-dom';

class Dialog extends React.Component {
  constructor() {
    super(...arguments);

    const doc = window.document;
    this.node = doc.createElement('div');
    doc.body.appendChild(this.node);
  }

  render() {
    return createPortal(
      <div class="dialog">
        {this.props.children}
      </div>, //塞進傳送門的JSX
      this.node //傳送門的另一端DOM node
    );
  }

  componentWillUnmount() {
    window.document.body.removeChild(this.node);
  }

當然,我們作為一個React Hooks選手,不騷一下咋行。

2.1 熱門元件庫Ant Design中的實現

原本是想從Ant Design庫中一窺究竟,卻發現事情並不簡單。。

圖片描述

前後定址了三個庫/地方,才發現實現的關鍵:

  1. import Dialog from 'rc-dialog';
  2. import Portal from 'rc-util/lib/PortalWrapper';
  3. import Portal from './Portal';

具體實現也算如我所料:

import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';

export default class Portal extends React.Component {
  static propTypes = {
    getContainer: PropTypes.func.isRequired,
    children: PropTypes.node.isRequired,
    didUpdate: PropTypes.func,
  }

  componentDidMount() {
    this.createContainer();
  }

  componentDidUpdate(prevProps) {
    const { didUpdate } = this.props;
    if (didUpdate) {
      didUpdate(prevProps);
    }
  }

  componentWillUnmount() {
    this.removeContainer();
  }

  createContainer() {
    this._container = this.props.getContainer();
    this.forceUpdate();
  }

  removeContainer() {
    if (this._container) {
      this._container.parentNode.removeChild(this._container);
    }
  }

  render() {
    if (this._container) {
      return ReactDOM.createPortal(this.props.children, this._container);
    }
    return null;
  }
}

圖片描述

render裡用了ReactDOM.createPortal`

**這也是為什麼多數Modal元件不會提供篡改整體樣式的API,只能透過全域性重置樣式。`

2.1 React Hooks版彈窗:useModal

圖片描述

步驟一:建立一個Modal元件

import React from 'react'
import ReactDOM from 'react-dom'

type Props = {
  children: React.ReactChild
  closeModal: () => void
}

const Modal = React.memo(({ children, closeModal }: Props) => {
  const domEl = document.getElementById('modal-root')

  if (!domEl) return null
  return ReactDOM.createPortal(
    <div>
      <button onClick={closeModal}>Close</button>
      {children}
    </div>,
    domEl
  )
})

export default Modal

步驟二:自定義useModal

import React, { useState } from 'react'

import Modal from './Modal'

// Modal元件最基礎的兩個事件,show/hide
export const useModal = () => {
  const [isVisible, setIsVisible] = useState(false)
  
  const show = () => setIsVisible(true)
  const hide = () => setIsVisible(false)
  
  const RenderModal = ({ children }: { children: React.ReactChild }) => (
    <React.Fragment>
      {isVisible && <Modal closeModal={hide}>{children}</Modal>}
    </React.Fragment>
  )

  return {
    show,
    hide,
    RenderModal,
  }
}

很好理解,不懂的建議轉行寫Vue

圖片描述

步驟三:使用它

import React from 'react'

import { useModal } from './useModal'

const App = React.memo(() => {
  const { show, hide, RenderModal } = useModal()
  return (
    <div>
      <div>
        <p>some content...</p>
        <button onClick={show}>開啟</button>
        <button onClick={hide}>關閉</button>
        <RenderModal>
          <p>這裡面的內容將會被渲染到'modal-root'容器裡.</p>
        </RenderModal>
      </div>
      <div id='modal-root' />
    </div>
  )
})

export default App

3. Vue 3Portal

Vue雖說是借鑑,但使用方式可容易多了。

<OtherComponent>
  <Portal target="#popup-target">
    <Modal />
  </Portal>
</OtherComponent>
....
<div id="popup-target"></div>

在上面的示例中,該<Modal />元件將在id=portal-target的容器中渲染,即使它位於OtherComponent元件內。

這,這…這也太香了吧。
進一步的用法如下:

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <b> {{ user.name }} </b>  
    <button @click="isPopUpOpen = true">Remove user</button>
    <Portal target="#popup-target">
      <div v-show="isPopUpOpen">
        <p>Are you sure?</p>
        <button @click="removeUser">Yes</button>
        <button @click="isPopUpOpen = false">No</button>
      </div>
    </Portal>
  </div>
</template>

圖片描述

然後我再去找了下Vue 3的原始碼實現:
packages/runtime-core/src/components/Portal.ts目錄中:

import { ComponentInternalInstance } from '../component'
import { SuspenseBoundary } from './Suspense'
import { RendererInternals, MoveType } from '../renderer'
import { VNode, VNodeArrayChildren, VNodeProps } from '../vnode'
import { isString, ShapeFlags, PatchFlags } from '@vue/shared'
import { warn } from '../warning'

export const isPortal = (type: any): boolean => type.__isPortal

export interface PortalProps {
  target: string | object
}

export const PortalImpl = {
  __isPortal: true,
  process(
    n1: VNode | null,
    n2: VNode,
    container: object,
    anchor: object | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    optimized: boolean,
    {
      mc: mountChildren,
      pc: patchChildren,
      pbc: patchBlockChildren,
      m: move,
      o: { insert, querySelector, setElementText, createComment }
    }: RendererInternals
  ) {
    const targetSelector = n2.props && n2.props.target
    const { patchFlag, shapeFlag, children } = n2
     if (n1 == null) {
      // insert an empty node as the placeholder for the portal
      insert((n2.el = createComment(`portal`)), container, anchor)
      if (__DEV__ && isString(targetSelector) && !querySelector) {
        warn(
          `Current renderer does not support string target for Portals. ` +
            `(missing querySelector renderer option)`
        )
      }
    } else {
    //....中間忽略了,大致的意思就是對比兩個VNode,以及在不同生命週期的邊界處理
    // 核心就是透過createComment,建立註釋節點,將其插入不同節點中。
    // 最後setElementText,重置插入節點的內容。
    }
  }
}

// Force-casted public typing for h and TSX props inference
export const Portal = (PortalImpl as any) as {
  __isPortal: true
  new (): { $props: VNodeProps & PortalProps }
}

重要的解釋,都在上述註釋中了,臨時看的,說得不對的謝謝指正。

其中:createCommentVueDOM.createComment的進一步封裝。

結語&參考

這篇算是自己半夜無聊折騰出來的,原定計劃是一篇寫三種元件,但彈窗類的實現比較有意思。

圖片描述

這個系列我會看著寫,不出意外下一篇就是講Steps步驟條和Transfer穿梭框的實現(當然,太難了就忽悠一下,嘿嘿。)

參考文章:

❤️ 看完三件事

如果你覺得這篇內容對你挺有啟發,我想邀請你幫我三個小忙:

  1. 點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-)
  2. 關注前端勸退師,不定期分享原創知識。
  3. 也看看其它文章

也可以來我的GitHub部落格裡拿所有文章的原始檔:

前端勸退指南
一起玩耍呀。~

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2480/viewspace-2825140/,如需轉載,請註明出處,否則將追究法律責任。

相關文章