React將組件作為參數進行傳遞的3種方法實例

前言

在日常的開發中,開發通用組件的機會其實並不多,尤其是在各種組件庫已經遍地都是的情況下。而作為一個通用組件庫的使用者,經常會看到把 React 組件作為參數傳遞下去的場景,每當這個時候,其實或多或少都會有一些疑問,比如:有些組件傳遞下去的是組件名,而有些組件傳遞下去的是一個箭頭函數返回一個組件,而有些直接傳遞一個 jsx 創建好的元素,這些傳遞方案的適用場景如何,有什麼不同,是否會導致組件的 memo 失效,是否會引發組件的不必要渲染?

本文是筆者在閱讀瞭 antd、mui, react-select 的 api 之後,結合自己日常業務中使用的組件 api 格式,對傳遞一個組件作為 React 組件參數的方式的思考和總結,如果有寫的不到位的,歡迎補充和指點。

大體來講,傳遞組件的方式,分為三種:

  • 傳遞 jsx 創建好的元素
  • 傳遞組件本身
  • 傳遞返回 jsx 創建好的元素的函數

下文也主要展開介紹這三種方式並結合實際場景對比這三種方案。

方式一:直接傳遞 jsx 創建好的元素

在 antd 的組件 api 中,最常見的方式便是這個方法,以 button 為例,有一個 icon 參數便是允許使用者傳遞一個經過 jsx 創建好的元素。簡化後的示例如下:

function DownloadOutlined() {
    return /* icon 的實現*/;
} 

function Button({ icon, children }) {
    return <button>
        {icon}
        {children}
    </button>
}

function App() {
    return <Button icon={<DownloadOutlined />}>test</Button>
}

可以看出來,icon 直接傳遞瞭一個 jsx 創建好的組件,從而滿足瞭用戶自定義 icon 的需求。

相比於通過字符串枚舉內置 icon, 給瞭用戶更大的定制空間。

方式二:直接傳遞組件本身

這一用法在 antd 中很少出現,在 react-select 中比較常見。

這裡為瞭方便還是以 Button 為例,修改下上文的 Button 組件,將其參數改為傳遞 DownloadOutlined 而非經過 jsx 創建好的元素 <DownloadOutlined />

function DownloadOutlined() {
    return /* icon 的實現*/;
} 

function Button({ icon: Icon, children }) {
    return <button>
    // 渲染方式進行瞭改變
    <Icon />
    {children}
    </Button>
}

function App() {
    return <Button icon={DownloadOutlined}>test</Button>
}

通過直接傳遞組件本身的方式,也可將其傳遞給子組件進行渲染,當然,子組件渲染的地方也改成瞭 <Icon /> 而非上文的 {icon}。ps: 上文中由於 jsx 語法要求,將 icon 變量名改成瞭首字母大寫的 Icon。

方式三:傳遞一個返回組件的函數

這一用法用 Button 示例改寫如下:

function DownloadOutlined() {
    return /* icon 的實現*/;
} 

function Button({ icon, children }) {
    return <button>
    // 渲染方式進行瞭改變
    {icon()}
    {children}
    </Button>
}

function App() {
    return <Button icon={() => <DownloadOutlined />}>test</Button>
}

在這一例子中,由於傳遞的是個函數,那麼返回值在渲染時,改成執行函數即可。

三種方案的對比

上文中分別介紹瞭這三種方案的實現方法,從結果來看,三種方案都能滿足傳遞組件作為組件參數的場景。

但是在實際的場景中,往往不會這麼簡單,往往有更多需要考慮的情況。

情況一: 考慮是否存在不必要的渲染?

三種方案下,當父組件發生渲染時,Button 組件是否會發生不必要的渲染。示例如下:

import React, { useState } from 'react';

function DownloadOutlined() {
  return <span>icon</span>;
}

const Button1 = React.memo(({ icon, children }) => {
  console.log('button1 render');

  return (
    <button>
      {icon}
      {children}
    </button>
  );
});

const Button2 = React.memo(({ icon: Icon, children }) => {
  console.log('button2 render');

  return (
    <button>
      <Icon />
      {children}
    </button>
  );
});

const Button3 = React.memo(({ icon, children }) => {
  console.log('button3 render');
  return (
    <button>
      {icon()}
      {children}
    </button>
  );
});

export default function App() {
  const [count, setCount] = useState(0);
  console.log('App render');

  return (
    <>
      <Button1 icon={<DownloadOutlined />}>button1</Button1>
      <Button2 icon={DownloadOutlined}>button2</Button2>
      <Button3 icon={() => <DownloadOutlined />}>button3</Button3>
      <button onClick={() => setCount((pre) => pre + 1)}>render</button>
    </>
  );
}

在該示例中,點擊 render button,此時,期望的最小渲染應該是僅僅渲染 app 組件即可,Button1 – Button3 由於並未依賴 count 的變化,同時 Button1 – Button3 都通過 React.memo 進行包裹,期望的是組件不進行渲染。

實際輸出如下:

可以看出,Button1 和 Button3 均進行瞭渲染,這是由於這兩種方案下,icon的參數發生瞭變化,對於 Button1, <DownloadOutlined />, 本質是 React.createElement(DownloadOutlined), 此時將會返回一個新的引用,就導致瞭 Button1 參數的改變,從而使得其會重新渲染。而對於 Button3,就更加明顯,每次渲染後返回的箭頭函數都是新的,自然也會引發渲染。而隻有方案二,由於返回的始終是組件的引用,故不會重新渲染。

要避免(雖然實際中,99%的場景都不需要避免,也不會有性能問題)這種情況,可以通過加 memo 解決。改動點如下:

export default function App() {
  const [count, setCount] = useState(0);
  console.log('App render');

  const button1Icon = useMemo(() => {
      return <DownloadOutlined />;
  }, []);

  const button3Icon = useCallback(() => {
      return () => <DownloadOutlined />;
  }, []);

  return (
    <>
      <Button1 icon={butto1Icon}>button1</Button1>
      <Button2 icon={DownloadOutlined}>button2</Button2>
      <Button3 icon={button3Icon}>button3</Button3>
      <button onClick={() => setCount((pre) => pre + 1)}>render</button>
    </>
  );
}

通過 useMemo, useCallback包裹後,即可實現 Button1, Button3 組件參數的不變,從而避免瞭多餘的渲染。相比之下,目前看,直接傳遞組件本身的方案寫法似乎更為簡單。

實際的場景中,Icon 組件往往不會如此簡單,往往會有一些參數來控制其比如顏色、點擊行為以及大小等等,此時,要將這些參數傳遞給 Icon 組件,這也是筆者想要討論的:

情況二:需要傳遞來自父組件(App)的參數的情況。

在現有的基礎上, 以傳遞 size 到 Icon 組件為例,改造如下:

import React, { useState, useMemo, useCallback } from 'react';

// 增加 size 參數, 控制 icon 大小
function DownloadOutlined({ size }) {
  return <span style={{ fontSize: `${size}px` }}>icon</span>;
}

// 無需修改
const Button1 = React.memo(({ icon, children }) => {
  console.log('button1 render');

  return (
    <button>
      {icon}
      {children}
    </button>
  );
});

// 增加 iconProps,來傳遞給 Icon 組件
const Button2 = React.memo(({ icon: Icon, children, iconProps = {} }) => {
  console.log('button2 render');

  return (
    <button>
      <Icon {...iconProps} />
      {children}
    </button>
  );
});

// 無需修改
const Button3 = React.memo(({ icon, children }) => {
  console.log('button3 render');
  return (
    <button>
      {icon()}
      {children}
    </button>
  );
});

export default function App() {
  const [count, setCount] = useState(0);
  const [size, setSize] = useState(12);
  console.log('App render');
  
  // 增加size依賴
  const button1Icon = useMemo(() => {
    return <DownloadOutlined size={size} />;
  }, [size]);
  
  // 增加size依賴
  const button3Icon = useCallback(() => {
    return <DownloadOutlined size={size} />;
  }, [size]);

  return (
    <>
      <Button1 icon={button1Icon}>button1</Button1>
      <Button2 icon={DownloadOutlined} iconProps={{ size }}>
        button2
      </Button2>
      <Button3 icon={button3Icon}>button3</Button3>
      <button onClick={() => setCount((pre) => pre + 1)}>render</button>
      <button onClick={() => setSize((pre) => pre + 1)}>addSize</button>
    </>
  );
}

通過上述改動,可以發現,當需要從 App 組件中,向 Icon 傳遞參數時,Button1 和 Button3 組件本身不需要做任何改動,僅僅需要修改 Icon jsx創建時的參數即可,而 Button2 的 Icon 由於渲染發生在內部,故需要額外傳遞 iconProps 作為參數傳遞給 Icon。與此同時,render按鈕點擊時,由於 iconProps 是個引用類型,導致觸發瞭 Button2 的額外渲染,當然可以通過 useMemo 來控制,此處不再贅述。

接下來看情況三,當子組件(Button1 – button3)需要傳遞它自身內部的狀態到 Icon 組件中時,需要做什麼改動。

設想一個虛構的需求, Button1 – Button3 組件內部維護瞭一個狀態,count,也就是每個組件點擊的次數,而 DownloadOutlined 也接收一個參數,count, 隨著 count 的變化,他的顏色會從 rbg(0, 0, 0) 變化為 rgb(count, 0, 0)

DownloadOutlined 改動如下:

// 增加 count 參數,控制 icon 顏色
function DownloadOutlined({ size = 12, count = 0 }) {
  console.log(count);
  return (
    <span style={{ fontSize: `${size}px`, color: `rgb(${count}, 0, 0)` }}>
      icon
    </span>
  );
}

Button2 的改造(Button1放在最後)如下:

const Button2 = React.memo(({ icon: Icon, children, iconProps = {} }) => {
  console.log('button2 render');
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(pre => pre + 40)}>
      {/* 將count參數註入即可 */}
      <Icon {...iconProps} count={count} />
      {children}
    </button>
  );
});

Button3的改造如下:

const Button3 = React.memo(({ icon, children }) => {
  console.log('button3 render');
  const [count, setCount] = useState(0);

  return (
    // 此處為瞭放大顏色的改變,點擊一次加 40
    <button onClick={() => setCount(pre => pre + 40)}>
      {/* 將 count 作為參數傳遞給 icon 函數 */}
      {icon({count})}
      {children}
    </button>
  );
});

相應的,App 組件傳入也需要做改動

export default function App() {
  /* 省略 */
  
  const button3Icon = useCallback((props) => {
    // 接收參數並將其傳遞給icon組件
    return <DownloadOutlined size={size} {...props} />;
  }, [size]);

  /* 省略 */
}

而對於 button1, 由於 icon 渲染的時機,是在 App 組件中,而在 App 組件中,獲取 Button1 組件內部的狀態並不方便(可以通過 ref, 但是略顯麻煩)。此時可以借助 React.cloneElement api來新建一個 Icon 組件並將子組件參數註入,改造如下:

const Button1 = React.memo(({ icon, children }) => {
  console.log('button1 render');
  const [count, setCount] = useState(0);
  // 借助 cloneElement 向icon 註入參數
  const newIcon = React.cloneElement(icon, {
    count,
  });

  return (
    <button onClick={() => setCount((pre) => pre + 40)}>
      {newIcon}
      {children}
    </button>
  );
});

從這個例子可以看出,如果傳入的組件(icon),需要獲取即將傳入組件(Button1, Button2, Button3)內部的狀態,那麼直接傳遞 jsx 創建好的元素,並不方便,因為在父組件(App)中獲取子組件(Button1)內部的狀態並不方便,而直接傳遞組件本身,和傳遞返回 jsx 創建元素的函數,前者由於元素真正的創建,就是發生在子組件內部,故可以方便的獲取子組件狀態,而後者由於是函數式的創建,通過簡單的參數傳遞,即可將內部參數傳入 icon 中,從而方便的實現響應的需求。

總結

本文先簡單介紹瞭三種將組件作為參數傳遞的方案:

  • 傳遞 jsx 創建好的元素: icon = {<Icon />}
  • 傳遞組件本身: icon={Icon}
  • 傳遞返回 jsx 創建好的元素的函數: icon={() => <Icon />}

接下來,從三個角度對其進行分析:

  • 是否存在不必要的渲染
  • Icon 組件需要接收來自父組件的參數
  • Icon 組件需要接收來自子組件的參數

其中,三種方案,在不做 useMemo, useCallback 這樣的緩存情況下,直接傳遞組件本身,由於引用不變,可以直接避免非必要渲染,但是當需要接收來自父組件的參數時,需要開辟額外的字段 iconProps 來接收父組件的參數,在不做緩存的情況下,由於參數的對象引用每次都會更新從而也存在不必要渲染的情況。當然,這種不必要的渲染,在絕大部分場景下,並不會存在性能問題。

考慮瞭來自父組件的傳參後,除瞭方案二直接傳遞組件本身的方案需要對子組件增加 iconProps 之外,其餘兩個方案由於 jsx 創建組件元素的寫法本身就在父組件中,隻需稍作改動即可將參數攜帶入 Icon 組件中。

而當需要接收來自子組件的參數場景下,方案一顯得略有不足,jsx 的創建在父組件已經創建好,子組件中需要註入額外的參數相對麻煩(使用 cloneElement 實現參數註入)。而方案三由於函數的執行時機是在子組件內部,可以很方便的將參數通過函數傳參帶入 Icon 組件,可以很方便的滿足需求。

從實際開發組件的場景來看,被作為參數傳遞的組件需要使用子組件內部參數的,一般通過方案三傳遞函數的方案來設計,而不需要子組件內部參數的,方案一二三均可,實際的開銷幾乎沒有差異,隻能說方案一寫法較為簡單,也是 antd 的 api 中最常見的用法。而方案三,多見於需要子組件內部狀態的情況,比如 antd 的面包屑 itemRender,Form.list的 children 的渲染,通過函數註入參數給被作為參數傳遞的組件方便靈活的進行渲染。

最後,由於筆者之前寫過一段時間vue,不免還是想到瞭 vue 中 slot 的寫法,說實話,還是回去翻瞭下文檔,其實就是方案一和方案三的合集,由於slot本身是在父組件渲染的,所以直接具備父組件的作用域,能夠訪問父組件的狀態,需要註入父組件參數的,直接在插槽的組件中使用即可,而作用域插槽便是提供子組件的作用域,使插槽中的組件可以獲取到子組件的參數。

到此這篇關於React將組件作為參數進行傳遞的3種方法的文章就介紹到這瞭,更多相關React組件作參數傳遞內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: