深度解析!Vue3 & React Hooks 新UI元件原理
前言
在某個月黑風高的晚上…沒劇刷的我無意想起以前處理的一些彈窗的坑。
然後又無意間刷到“Portal
”,才知道Modal
的實現還有如此妙的方式,順而想著乾脆把UI
元件庫的實現原理看完。
本文將講述以下三種UI元件的實現原理:
-
Modal
彈窗類。 -
Steps
步驟條。 -
Transfer
穿梭框。
1. Modal
彈窗的基本原理
我給彈窗類的定義是脫離固定的層級關係,不再受制於層疊上下文的元件。
常見的Modal
模態框、Dialog
對話方塊、Notification
通知框等都是最最常用的互動方式。
在我們頁面有時需要一些特定的彈窗時,透過改UI
元件過於麻煩。
這時切圖仔級別的會想:簡單啊,建立一個<div/>
給絕對定位不就得了。
倘若只是當前路由頁用,也還湊合。可一旦涉及到了元件複用以及抽象為宣告式,就會有很大的隱患:
- 若無封裝,元件程式碼需要處處貼上。
- 即使封裝了,都是在每個路由頁下建立
<div/>
,易造成樣式汙染。 - 類購物車的彈窗,又該如何處理資料及渲染?
- 再進一步想,萬一元件庫會作為績效考核,拿到每個環境都長得不一樣,咋整?
1.1 Jquery
時代的彈窗實現
初初入行時,去各種資源站,找Jquery
的UI
元件,想必三四年經驗的前端們都曾樂此不疲。
這個時代(也就三四年前)的彈窗,因為沒有React
/Vue
根節點的概念,普遍都是:
-
直接操作真實 dom,使用熟知的dom 操作方法將指令所在的元素 append 到另外一個 dom 節點上去。 如:
document.body.appendChild
。 - 再透過
overflow: hidden
或display:none
(或調整z-index
)來隱藏。
這種操作真實dom
的代價,在大型專案中不停觸發重繪/迴流,是很糟糕的,且內部資料/樣式不易更改。像以下這種情況就容易出現:
- 原本圖片固定在區域內。
- 小彈窗展示後,溢位了。
隨著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
的實現都有類似的通病:
- 生命週期的執行會很混亂。
- 需要透過
redux
或props
管理資料,可這對於一個UI
元件來說過於臃腫了。
React
官方也意識到構建脫離於父元件的元件挺麻煩的,於是在v16
版本推了一個叫“Portal
”的功能。而Vue3
也是借鑑並吸納了優秀外掛,將Portal
作為內建元件了。
1.3 傳送門Portal
方案
React / Vue
的第二套方案都是基於操作虛擬dom
:
定義一套元件,將元件內的 vnode/ReactDOM
轉移到另外一個元件中去,然後各自渲染。
2. React
的Portal
React Portal
之所以叫Portal
,因為做的就是和“傳送門”一樣的事情:render
到一個元件裡面去,實際改變的是網頁上另一處的DOM
結構。
ReactDOM.createPortal(child, container)
- 第一個引數(
child
)是任何可渲染的React
子元素,例如一個元素,字串或碎片。 - 第二個引數(
container
)則是一個DOM
元素。
在v16
中,使用Portal
建立Dialog
元件簡單多了,不需要牽扯到componentDidMount
、componentDidUpdate
,也不用呼叫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
庫中一窺究竟,卻發現事情並不簡單。。
前後定址了三個庫/地方,才發現實現的關鍵:
import Dialog from 'rc-dialog';
import Portal from 'rc-util/lib/PortalWrapper';
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 3
的Portal
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 }
}
重要的解釋,都在上述註釋中了,臨時看的,說得不對的謝謝指正。
其中:createComment
是Vue
對DOM.createComment
的進一步封裝。
結語&參考
這篇算是自己半夜無聊折騰出來的,原定計劃是一篇寫三種元件,但彈窗類的實現比較有意思。
這個系列我會看著寫,不出意外下一篇就是講Steps
步驟條和Transfer
穿梭框的實現(當然,太難了就忽悠一下,嘿嘿。)
參考文章:
❤️ 看完三件事
如果你覺得這篇內容對你挺有啟發,我想邀請你幫我三個小忙:
- 點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-)
- 關注前端勸退師,不定期分享原創知識。
- 也看看其它文章
也可以來我的GitHub
部落格裡拿所有文章的原始檔:
前端勸退指南:
一起玩耍呀。~
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2480/viewspace-2825140/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- React Hooks原始碼深度解析ReactHook原始碼
- React教程:元件,Hooks和效能React元件Hook
- React Hooks 原始碼解析(譯)ReactHook原始碼
- 快速瞭解 React Hooks 原理ReactHook
- React Hooks 原始碼解析(3):useStateReactHook原始碼
- React hooks 狀態管理方案解析ReactHook
- react hooks 如何自定義元件(react函式元件的封裝)ReactHook元件函式封裝
- React Hooks 實現的中文輸入元件ReactHook元件
- 11 - Vue3 UI Framework - Card 元件VueUIFramework元件
- 09 - Vue3 UI Framework - Table 元件VueUIFramework元件
- 10 - Vue3 UI Framework - Tabs 元件VueUIFramework元件
- 05 - Vue3 UI Framework - Button 元件VueUIFramework元件
- 08 - Vue3 UI Framework - Input 元件VueUIFramework元件
- 06 - Vue3 UI Framework - Dialog 元件VueUIFramework元件
- 前端面試必考題:React Hooks 原理剖析前端面試ReactHook
- Flutter原理深度解析Flutter
- CAS原理深度解析
- [react] hooksReactHook
- React HooksReactHook
- React UI元件開發心得ReactUI元件
- React 的 KeepAlive 實戰指南:深度解析元件快取機制React元件快取
- 深度解析:在 React 中實現類似 Vue 的 KeepAlive 元件ReactVue元件
- react之react HooksReactHook
- 07- Vue3 UI Framework - Switch 元件VueUIFramework元件
- 在React類元件中使用Hooks的實踐技巧React元件Hook
- 如何開發React UI元件庫ReactUI元件
- React Hooks 指北ReactHook
- React Hooks (Proposal)ReactHook
- 探React HooksReactHook
- 理解 React HooksReactHook
- Why React HooksReactHook
- React Hooks 梳理ReactHook
- 30分鐘精通React今年最勁爆的新特性——React HooksReactHook
- 深入解析React受控元件與非受控元件React元件
- React-hooks 父元件透過ref獲取子元件資料和方法ReactHook元件
- React Hooks的理解ReactHook
- React Hooks總結ReactHook
- 實現 React HooksReactHook