【React】633- 使用 Hooks 優化 React 組件

編者按:本文作者奇舞團前端開發工程師李喆明。

注:本文內容主要是我之前分享的文字版,若想看重點的話可以看之前的Slide: https://ppt.baomitu.com/d/75fc979a

需求描述

由於我所在的業務是資訊內容類業務,因而在業務中會經常碰到如下場景:有一個內容列表,列表中需要按照一定的規則插入廣告。除了獲取廣告數據,廣告展現和點擊後需要有打點上報邏輯。正常來說我們會這麼寫:

import React from 'react';
export default class extends React.Component {
  state = {newsData: [], adData: []};
  constructor() { this.getNewsData(); }
  getNewsData() {
    const newsData = [...];
    this.setState({newsData});
    this.getAdData(newsData.length / 2); //根據新聞數和插入規則換算廣告請求數
  }
  getAdData() {
    const adData = [...];
    this.setState({adData});
  }
  render() {
    const {newsData, adData} = this.state;
    const comps = [];
    for(let i = 0; i < newsData.length; i++) {
      // 根據插入規則判斷當前新聞卡片後是否要插入廣告
      comps.push(<NewsCard {...newsData[i]} key={`news-${i}`} />);
      if(i % 2) { comps.push(<AdCard {...adData[i/2]} key={`ad-${i}`} />); }
    }
    return (<div>{comps}</div>);
  }
}
class AdCard extends React.Component {
  componentDidMount() {
    observe(this.dom, () => {});
  }
  onClick = () => {};
  onMouseUp = () => {};
  onMouseDown = () => {};
  getDOM = dom => this.dom = dom;
  render() {
    return <div
      ref={this.getDOM}
      onMouseUp={this.onMouseUp}
      onMouseDown={this.onMouseDown}
      onClick={this.onClick}
    >{this.props.title}</div>
  }
}

邏輯非常的簡單,getNewsData() 拿到資訊列表數據之後計算需要請求的廣告數調用 getAdData() 請求廣告數據,最後根據插入規則將資訊和內容渲染到列表中。廣告使用自定義組件渲染,使用 Interp Observe API 實現廣告曝光打點,監聽 DOM 對應的點擊時間實現廣告點擊打點。

如果說只有一個組件是這樣的還好說,但是從上圖可以看出,我們有大量的內容+廣告混排場景。整體的邏輯和剛纔說的都是一樣的,唯一的區別是不同的列表對應不一樣的顯現形式。在這種情況下如何設計一個既能將通用邏輯提取,又能滿足各個模塊的自定義需求的通用模塊就成了我們必須考慮的事情了。

React 組件設計模式

在具體討論方案之前,我們先簡單的瞭解一下常見的 React 組件設計模式。基本上分爲以下幾種方案:

  • Context 模式

  • 組合組件

  • 繼承模式

  • 容器組件和展示組件

  • Render Props

  • Hoc 高階組件

其中 Context 模式多用來在多層嵌套組件中進行跨組件的數據傳遞,針對我們當前組件層級不多的情況用處不是非常大,這裏就不多表。我們來看看剩下的幾個模式各自有什麼優缺點,最終來評估下是否能應用到我們的場景中。

組合組件

組合組件是通過模塊化組件構建應用的模式,它是 React 模塊化開發的基礎。除去普通的按照正常的業務功能進行模塊拆分,還有就是將配置和邏輯進行解耦的組合組件方式。例如下面的組合方式就是利用類似 Vue 的 slot 方式將配置通過子組件的形式與 <Modal /> 組件進行組合,是的組件配置更優雅。

<Modal>
  <Modal.Title>Modal Title</Modal.Title>
  <Modal.Content>Modal Content</Modal.Content>
  <Modal.Footer> <button>OK</button> </Modal.Footer>
</Modal>

又如下面的下拉選擇組件,通過將 <Select/><Option> 進行組合,即達到了組件化配置的目的,又達到了通用方法的複用。同時將點擊操作在 <Select/> 組件中直接傳遞下去方便了點擊後直接修改選擇狀態。

export default function(props) {
  return React.Children.map(props.children, child =>
    React.cloneElement(child, {onClick() { console.log('click') }}
  ));
}
<Select>
  <Option>Click Me!</Option>
  <Option>Click Me!</Option>
</Select>

繼承模式

繼承模式是使用類繼承的方式對組件代碼進行復用。在面向對象編程模式中,繼承是一種非常簡單且通用的代碼抽象複用方式。如果大部分邏輯相同,只是一些細節不一致,只要簡單的將不一致的地方抽成成員方法,繼承的時候複寫該成員方法即可達到簡單的組件複用。

不過我們知道 JS 中的繼承本質上還是通過原型鏈實現的語法糖,所以在一些場景使用上沒有其它語言的繼承那麼方便,例如無法直接實現多繼承,多繼承後的跨層級方法調用比較麻煩,適合簡單的邏輯複用。另外通過繼承方式會將父類中的所有方法都繼承過來,不小心的話非常容易繼承到不需要的功能。

容器組件和展示組件

展示組件和容器組件是將數據邏輯和渲染邏輯進行拆分從而降低組件複雜度的模式。使用容器組件可以把最開始的代碼改寫成如下的形式。這樣做最大的好處是渲染層可以抽離成無狀態組件,它不需要關心數據的獲取邏輯,直接通過 props 獲取數據渲染即可,針對展示組件能實現很好的複用。

class NewsList extends React.Component {
  state = {newsData: [], adData: []};
  constructor() { this.getNewsData(); }
  getNewsData() { this.getAdData(newsData.length / 2) }
  getAdData() {}
  render() { return <List news={this.state.newsData} ad={this.state.adData} /> }
}
function List({news, ad}) {
  const {newsData, adData} = this.state;
  const comps = [];
  for(let i = 0; i < newsData.length; i++) {
    comps.push(<NewsCard {...newsData[i]} key={`news-${i}`} />);
    if(i % 2) { comps.push(<AdCard {...adData[i/2]} key={`ad-${i}`} />); }
  }
  return (<div>{comps}</div>);
}

但是我們也可以看到即使我們把渲染邏輯拆分出去了,本身組件的數據邏輯還是非常的複雜,沒有做到很好的拆分。同時容器組件和展示組件存在耦合關係,所以無法很好的對邏輯組件進行復用。

Render Props

術語 “render prop” 是指一種在 React 組件之間使用一個值爲函數的 prop 共享代碼的簡單技術

via: Render Props

它的本質實際上是通過一個函數 prop 將數據傳遞到其它組件的方式,所以按照這個邏輯我們又可以將剛纔的代碼簡單的改寫一下。

class NewsList extends React.Component {
  state = {newsData: [], adData: []};
  constructor() { this.getNewsData(); }
  getNewsData() { this.getAdData(newsData.length / 2) }
  getAdData() {}
  render() { return this.props.render(this.state) }
}
function List({news, ad}) {
  const {newsData, adData} = this.state;
  const comps = [];
  for(let i = 0; i < newsData.length; i++) {
    comps.push(<NewsCard {...newsData[i]} key={`news-${i}`} />);
    if(i % 2) { comps.push(<AdCard {...adData[i/2]} key={`ad-${i}`} />); }
  }
  return (<div>{comps}</div>);
}
<NewsList render={({newsData, adData}) => <List news={newsData} ad={adData} />

可以看到,通過一個函數調用我們將數據邏輯和渲染邏輯進行解耦,解決了之前數據邏輯無法複用的問題。不過通過函數回調的形式將數據傳入,如果想要把邏輯拆分(例如資訊數據獲取與廣告數據獲取邏輯拆分)會變得比較麻煩,讓我想起了被 callback 支配的恐懼。

同時由於 render 的值爲一個匿名函數,每次渲染 <NewsList /> 的時候都會重新生成,而這個匿名函數執行的時候會返回一個 <List /> 組件,這個本質上每次執行也是一個“新”的組件。所以 Render Props 使用不當的話會非常容易造成不必要的重複渲染。

HoC 組件

React 裏還有一種使用比較廣泛的組件模式就是 HoC 高階組件設計模式。它是一種基於 React 的組合特性而形成的設計模式,它的本質是參數爲組件,返回值爲新組件的函數。我們來看看剛纔的代碼使用 HoC 組件修改後會變成什麼樣子。

function withNews(Comp) {
  return class extends React.Component {
    state = {newsData: []};
    constructor() { this.getNewsData(); }
    render() { return <Comp {...this.props} news={this.state.newsData} /> }
  }
}
function withAd(Comp) {
  return class extends React.Component {
    state = {adData: []};
    componentWillReceiveProps(nextProps) {
      if(this.props.news.length) { this.getAdData(); }
    }
    render() { return <Comp {...this.props} ad={this.state.adData} /> }
  }
}
const ListWithNewsAndAd = withAd(withNews(List));

可以看到這次改動最激動的地方在於我們第一次把數據邏輯進行了拆分,這也是高階組件的魅力,它不侷限於 UI 複用,使得代碼複用更加自由(當然 Render Props 也是可以實現的)。

當然這種模式也並不是完美的,它也有它的缺點。我們可以看到它的本質是通過 props 在高階組件中將多個數據傳入到子組件中,非常類似 mixin 的形式。所以它也會有 mixin 的缺點,那就是屬性名衝突的問題。由於不同的高階組件由不同的開發者開發,內部會傳遞什麼樣的屬性名到子組件中就成了未知數。同時多層組件的嵌套導致組件層級過多,在性能和調試上都會帶來問題。

初版實現

瞭解完這些設計模式之後,我們再回頭來看看我們的需求。通過觀察瞭解不同的組件中的共同部分之後,我們可以將這種類型的組件抽象爲如下描述“在一個內容列表中按照一定規則插入一定數量的和內容一致的一定樣式的廣告組件”。在這段描述中存在着三個不定因素:

  • 一定規則:不同的組件插入廣告的邏輯是不一樣的

  • 一定數量:不同的組件由於資訊內容的不同,插入邏輯的不同導致需要的廣告數量也是不一樣的

  • 一定樣式:不同的組件由於資訊內容樣式不同所以廣告的樣式自然也不相同

除卻以上三個因素之外,廣告其它的邏輯廣告數據的獲取以及廣告的曝光和點擊打點等都是通用的。最後我們將廣告組件的邏輯順着之前瞭解的設計模式抽離成三個部分:

  • 廣告數據的獲取:<Mediav.Provider />

  • 廣告模塊的渲染:<Mediav.Item /> Base 模塊

  • 廣告模塊的插入:由具體業務處理

import React from 'react';
import Mediav from '@q/mediav';
export default class extends React.Component {
  state = {newsData: []};
  constructor() { this.getNewsData(); }
  render() {
    const comps = [];
    for(let i = 0; i < newsData.length; i++) {
      comps.push(<NewsCard {...newsData[i]} key={`news-${i}`} />);
      if(i % 2) { comps.push(<AdCard key={`ad-${i}`} />); }
    }
    return (<Mediav.Provider id="xxx">{comps}</Mediav.Provider>);
  }
}
class AdCard extends Mediav.Item {
  render() {
    if(!this.props.type) { return null; }
    const {title} = this.props;
    return (<div ref={this.getDOM} onClick={this.onClick}
      onMouseUp={this.onMouseUp} onMouseDown={this.onMouseDown}
    >{title}</div>);
  }
}

通過容器組件 <Mediav.Provider /> 對數據獲取邏輯進行封裝,通過遍歷子組件找到 <Mediav.Item /> 組件的示例個數來告知需要請求的廣告數量。請求到廣告後通過 Props 注入的形式傳入到渲染組件中。而渲染組件 <AdCard /> 繼承自 <Mediav.Item />,一方面能告訴容器組件它是廣告組件的插槽,同時還能抽離廣告曝光打點和點擊打點等通用邏輯進行復用。在用戶自定義的 <AdCard /> 組件中,我們可以自定義不同模塊的廣告組件的渲染樣式,最終完成了一套廣告組件的渲染。

不過這樣實現還是有一些不足的地方。廣告曝光檢測需要依賴原生 DOM,而 Ref 使用 forwardRef() 在組件間傳遞稍微有點複雜,所以最後採用了繼承模式進行公共方法的抽離。子組件繼承後自行綁定父類的一些方法即可,在這點上理解起來有點晦澀,看起來總像是綁定了一些“不存在”的方法。

React Hooks

針對上面提出的問題,有沒有什麼方法可以解決呢?最終我想到了 Hooks 的方案,通過使用 Hooks 改寫後能完美的解決這個問題。我們先簡單的瞭解下什麼是 Hooks,它允許我們在不編寫 class 的情況下使用 state 和 React 生命週期等相關特性。

const {useState, useEffect} = React;
function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const interval = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(interval);
  });
  return <span>{count}</span>;
}
ReactDOM.render(<App />, document.getElementById('app'));

可以看到,它使用 useState 提供了 state,使用 useEffect 來做一些需要在聲明週期中執行的方法。使用 useEffect 代理了原來生命週期的概念後,讓代碼理解起來更加簡單。

當然這不是 Hooks 厲害的地方,它最厲害的地方是支持自定義 Hooks,通過自定義 Hooks 你能對邏輯進行統一的封裝。針對一個數據獲取的邏輯,我們需要定義 state,然後在初始化的時候去獲取數據,當 id 發生變化後我們需要重新獲取數據。

class User extends React.Component {
  state = {
    user: {}
  }
  constructor(...args) {
    super(...args);
    const {name} = this.props;
    this.getUserInfo(name)
  }
  componentWillReceiveProps(nextProps) {
    if(this.props.name === nextProps.name) {
      return;
    }
    this.getUserInfo(nextProps.name);
  }
  async getUserInfo(name) {
    const user = await fetch(url, {name});
    this.setState({user});
  }
  render() {
    return <div>{this.state.user.name}</div>
  }
}

可以看到我們獲取用戶信息的這個邏輯要實現需要在組件的各種地方寫邏輯,代碼一多之後非常容易造成需要各種跳行來查看某個數據邏輯的流程。而通過自定義 Hooks 我們能夠將實現這個業務邏輯的代碼全部整合到一處,最終達到業務邏輯的複用。

function useUserInfo(name) {
  const [user, setUser] = useState({});
  useEffect(() => {
    fetch(url, {name}).then(user => setUser(user));
  }, [name]);
  return user;
}
function User({name}) {
  const user = useUserInfo(name);
  return <div>{user.name}</div>
}

我們可以從下面的視頻中一窺 Hooks 的魅力,同顏色的表示是同一個業務邏輯,最終同顏色的代碼都被歸置到一處實現了邏輯的解耦。

via: https://twitter.com/prchdk/status/1056960391543062528

使用 Hooks 改進

那 Hooks 是否能應用於我們的業務場景中呢?通過我們之前的分析我們知道,實際上我們的目的就是爲了抽離出廣告數據獲取以及廣告的曝光和點擊打點這兩個通用的業務邏輯出來。所以 Hooks 針對邏輯的封裝正好可以爲我們所用。

import {useState, useEffect, useRef} from 'react';
import {useFetchMediav, useMediavEvent} from '@q/mediav';
function App() {
  const [newsData, setNewsData] = useState([]);
  const [adData] = useFetchMediav({id: "xxx", length: newsData.length / 2});
  useEffect(() => {
    const newsData = [...];
    setNewsData(newsData);
  }, []);
  const comps = [];
  for(let i = 0; i < newsData.length; i++) {
    comps.push(<NewsCard {...newsData[i]} key={`news-${i}`} />);
    if(i % 2) { comps.push(<AdCard data={adData[Math.floor(i/2)]} key={`ad-${i}`} />); }
  }
  return (<div>{comp}</div>);
}
function AdCard({data}) {
  const ref = useRef(null);
  const bind = useMediavEvent(ref, data);
  return (<div className="gg" ref={ref} {...bind}>{data.title}</div>);
}

使用 useFetchMediav() 獲取廣告數據,通過 props 傳入到 <AdCard /> 組件中,通過 useMediavEvent() 獲取打點相關的方法,並綁定到對應的元素上。使用 Hooks 修改之後的代碼不僅複用性提高了,整體代碼的邏輯也變的更加可閱讀起來。

後記

當然 Hooks 本身也不是沒有缺點。爲了在無狀態的函數組件中創造去有狀態的 Hooks,勢必是需要通過副作用將每個 Hooks 緩存在組件中的。而我們沒有指定 id 之類的東西,React 是如何區分每一個 Hooks 的呢?答案就是通過調用順序。內部通過數組(鏈表?)根據調用順序依次記錄。爲了遵守這個規則,Hooks 要求我們不能在 if 等會動態執行的地方進行 Hooks 的定義,因爲這樣有可能會導致 Hooks 執行順序發生變化。其次 useEffect() 合併了多個生命週期,某些 Effect 需要在哪些生命週期執行以及如何控制其僅在這些生命週期執行,這些都對開發者帶來了更大的挑戰。稍微處理不當的話,很可能會造成頁面的性能問題。

1. JavaScript 重溫系列(22篇全)

2. ECMAScript 重溫系列(10篇全)

3. JavaScript設計模式 重溫系列(9篇全)

4. 正則 / 框架 / 算法等 重溫系列(16篇全)

5. Webpack4 入門(上)|| Webpack4 入門(下)

6. MobX 入門(上) ||  MobX 入門(下)

7. 59篇原創系列彙總

回覆“加羣”與大佬們一起交流學習~

點這,與大家一起分享本文吧~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章