完全理解React Hooks

看完這篇文章,希望你可以從整體上對 Hooks 有個認識,并對其設計哲學有一些理解

React組件設計理論

React以一種全新的編程范式定義了前端開發約束,它為視圖開發帶來了一種全新的心智模型:

  • React認為,UI視圖是數據的一種視覺映射,即UI = F(DATA),這里的F需要負責對輸入數據進行加工、并對數據的變更做出響應
  • 公式里的F在React里抽象成組件,React是以組件(Component-Based)為粒度編排應用的,組件是代碼復用的最小單元
  • 在設計上,React采用props屬性來接收外部的數據,使用state屬性來管理組件自身產生的數據(狀態),而為了實現(運行時)對數據變更做出響應需要,React采用基于類(Class)的組件設計!

除此之外,React認為組件是有生命周期的,因此開創性地將生命周期的概念引入到了組件設計,從組件的create到destory提供了一系列的API供開發者使用
這就是React組件設計的理論基礎,我們最熟悉的React組件一般長這樣:

// React基于Class設計組件
class MyConponent extends React.Component {
  // 組件自身產生的數據
  state = {
    counts: 0
  }

  // 響應數據變更
  clickHandle = () => {
    this.setState({ counts: this.state.counts++ });
    if (this.props.onClick) this.props.onClick();
  }

  // lifecycle API
  componentWillUnmount() {
    console.log('Will mouned!');
  }

    // lifecycle API
  componentDidMount() {
    console.log('Did mouned!');
  }

  // 接收外來數據(或加工處理),并編排數據在視覺上的呈現
  render(props) {
    return (
      <>
        <div>Input content: {props.content}, btn click counts: {this.state.counts}</div>
        <button onClick={this.clickHandle}>Add</button>
      </>
    );
  }
}

Class Component的問題

組件復用困局

組件并不是單純的信息孤島,組件之間是可能會產生聯系的,一方面是數據的共享,另一個是功能的復用:

  • 對于組件之間的數據共享問題,React官方采用單向數據流(Flux)來解決
  • 對于(有狀態)組件的復用,React團隊給出過許多的方案,早期使用CreateClass + Mixins,在使用Class Component取代CreateClass之后又設計了Render Props和Higher Order Component,直到再后來的Function Component+ Hooks設計,React團隊對于組件復用的探索一直沒有停止

HOC使用(老生常談)的問題

  • 嵌套地獄,每一次HOC調用都會產生一個組件實例
  • 可以使用類裝飾器緩解組件嵌套帶來的可維護性問題,但裝飾器本質上還是HOC
  • 包裹太多層級之后,可能會帶來props屬性的覆蓋問題

Render Props

  • 數據流向更直觀了,子孫組件可以很明確地看到數據來源
  • 但本質上Render Props是基于閉包實現的,大量地用于組件的復用將不可避免地引入了callback hell問題
  • 丟失了組件的上下文,因此沒有this.props屬性,不能像HOC那樣訪問this.props.children

Javascript Class的缺陷

this的指向(語言缺陷)

class People extends Component {
  state = {
    name: 'dm',
    age: 18,
  }

  handleClick(e) {
    // 報錯!
    console.log(this.state);
  }

  render() {
    const { name, age } = this.state;
    return (<div onClick={this.handleClick}>My name is {name}, i am {age} years old.</div>);
  }
}

createClass不需要處理this的指向,到了Class Component稍微不慎就會出現因this的指向報錯。

編譯大小(還有性能)問題

// Class Component
class App extends Component {
  state = {
    count: 0
  }

  componentDidMount() {
    console.log('Did mount!');
  }

  increaseCount = () => {
    this.setState({ count: this.state.count + 1 });
  }

  decreaseCount = () => {
    this.setState({ count: this.state.count - 1 });
  }

  render() {
    return (
      <>
        <h1>Counter</h1>
        <div>Current count: {this.state.count}</div>
        <p>
          <button onClick={this.increaseCount}>Increase</button>
          <button onClick={this.decreaseCount}>Decrease</button>
        </p>
      </>
    );
  }
}

// Function Component
function App() {
  const [ count, setCount ] = useState(0);
  const increaseCount = () => setCount(count + 1);
  const decreaseCount = () => setCount(count - 1);

  useEffect(() => {
    console.log('Did mount!');
  }, []);

  return (
    <>
      <h1>Counter</h1>
      <div>Current count: {count}</div>
      <p>
        <button onClick={increaseCount}>Increase</button>
        <button onClick={decreaseCount}>Decrease</button>
      </p>
    </>
  );
}

Class Component編譯結果(Webpack):

var App_App = function (_Component) {
  Object(inherits["a"])(App, _Component);

  function App() {
    var _getPrototypeOf2;
    var _this;
    Object(classCallCheck["a"])(this, App);
    for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
      args[_key] = arguments[_key];
    }
    _this = Object(possibleConstructorReturn["a"])(this, (_getPrototypeOf2 = Object(getPrototypeOf["a"])(App)).call.apply(_getPrototypeOf2, [this].concat(args)));
    _this.state = {
      count: 0
    };
    _this.increaseCount = function () {
      _this.setState({
        count: _this.state.count + 1
      });
    };
    _this.decreaseCount = function () {
      _this.setState({
        count: _this.state.count - 1
      });
    };
    return _this;
  }
  Object(createClass["a"])(App, [{
    key: "componentDidMount",
    value: function componentDidMount() {
      console.log('Did mount!');
    }
  }, {
    key: "render",
    value: function render() {
      return react_default.a.createElement(/*...*/);
    }
  }]);
  return App;
}(react["Component"]);

Function Component編譯結果(Webpack):

function App() {
  var _useState = Object(react["useState"])(0),
    _useState2 = Object(slicedToArray["a" /* default */ ])(_useState, 2),
    count = _useState2[0],
    setCount = _useState2[1];
  var increaseCount = function increaseCount() {
    return setCount(count + 1);
  };
  var decreaseCount = function decreaseCount() {
    return setCount(count - 1);
  };
  Object(react["useEffect"])(function () {
    console.log('Did mount!');
  }, []);
  return react_default.a.createElement();
}
  • Javascript實現的類本身比較雞肋,沒有類似Java/C++多繼承的概念,類的邏輯復用是個問題
  • Class Component在React內部是當做Javascript Function類來處理的
  • Function Component編譯后就是一個普通的function,function對js引擎是友好的

Function Component缺失的功能

不是所有組件都需要處理生命周期,在React發布之初Function Component被設計了出來,用于簡化只有render時Class Component的寫法。

Function Component是純函數,利于組件復用和測試
Function Component的問題是只是單純地接收props、綁定事件、返回jsx,本身是無狀態的組件,依賴props傳入的handle來響應數據(狀態)的變更,所以Function Component不能脫離Class Comnent來存在!

function Child(props) {
  const handleClick = () => {
    this.props.setCounts(this.props.counts);
  };

  // UI的變更只能通過Parent Component更新props來做到!!
  return (
    <>
      <div>{this.props.counts}</div>
      <button onClick={handleClick}>increase counts</button>
    </>
  );
}

class Parent extends Component() {
  // 狀態管理還是得依賴Class Component
  counts = 0

  render () {
    const counts = this.state.counts;
    return (
      <>
        <div>sth...</div>
        <Child counts={counts} setCounts={(x) => this.setState({counts: counts++})} />
      </>
    );
  }
}

所以,Function Comonent是否能脫離Class Component獨立存在,關鍵在于讓Function Comonent自身具備狀態處理能力,即在組件首次render之后,“組件自身能夠通過某種機制再觸發狀態的變更并且引起re-render”,而這種“機制”就是Hooks

Hooks的出現彌補了Function Component相對于Class Component的不足,讓Function Component取代Class Component成為可能。

Function Component + Hooks組合

1、功能相對獨立、和render無關的部分,可以直接抽離到hook實現,比如請求庫、登錄態、用戶核身、埋點等等,理論上裝飾器都可以改用hook實現(如react-use,提供了大量從UI、動畫、事件等常用功能的hook實現)。

case:Popup組件依賴視窗寬度適配自身顯示寬度、相冊組件依賴視窗寬度做單/多欄布局適配

function useWinSize() {
  const html = document.documentElement;
  const [ size, setSize ] = useState({ width: html.clientWidth, height: html.clientHeight });

  useEffect(() => {
    const onSize = e => {
      setSize({ width: html.clientWidth, height: html.clientHeight });
    };

    window.addEventListener('resize', onSize);

    return () => {
      window.removeEventListener('resize', onSize);
    };
  }, [ html ]);

  return size;
}

// 依賴win寬度,適配圖片布局
function Article(props) {
  const { width } = useWinSize();
  const cls = `layout-${width >= 540 ? 'muti' : 'single'}`;

  return (
    <>
      <article>{props.content}<article>
      <div className={cls}>recommended thumb list</div>
    </>
  );
}

// 彈層寬度根據win寬高做適配
function Popup(props) {
  const { width, height } = useWinSize();
  const style = {
    width: width - 200,
    height: height - 300,
  };
  return (<div style={style}>{props.content}</div>);
}

2、有render相關的也可以對UI和功能(狀態)做分離,將功能放到hook實現,將狀態和UI分離

case:表單驗證

function App() {
  const { waiting, errText, name, onChange } = useName();
  const handleSubmit = e => {
    console.log(`current name: ${name}`);
  };

  return (
    <form onSubmit={handleSubmit}>
      <>
        Name: <input onChange={onChange} />
        <span>{waiting ? "waiting..." : errText || ""}</span>
      </>
      <p>
        <button>submit</button>
      </p>
    </form>
  );
}

React Hooks 的本質

稍微復雜點的項目肯定是充斥著大量的 React 生命周期函數(注意,即使你使用了狀態管理庫也避免不了這個),每個生命周期里幾乎都承擔著某個業務邏輯的一部分,或者說某個業務邏輯是分散在各個生命周期里的。


image.png

而 Hooks 的出現本質是把這種面向生命周期編程變成了面向業務邏輯編程,你不用再去關心本不該關心的生命周期。

image.png

一個 Hooks 演變

我們先假想一個常見的需求,一個 Modal 里需要展示一些信息,這些信息需要通過 API 獲取且跟 Modal 強業務相關,要求我們:

  • 因為業務簡單,沒有引入額外狀態管理庫
  • 因為業務強相關,并不想把數據跟組件分開放
  • API 數據會隨機變動,因此需要每次打開 Modal 才獲取最新數據
  • 為了后期優化,不可以有額外的組件創建和銷毀
    我們可能的實現如下:
class RandomUserModal extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      user: {},
      loading: false,
    };
    this.fetchData = this.fetchData.bind(this);
  }

  componentDidMount() {
    if (this.props.visible) {
      this.fetchData();
    }
  }

  componentDidUpdate(prevProps) {
    if (!prevProps.visible && this.props.visible) {
      this.fetchData();
    }
  }

  fetchData() {
    this.setState({ loading: true });
    fetch('https://randomuser.me/api/')
      .then(res => res.json())
      .then(json => this.setState({
        user: json.results[0],
        loading: false,
      }));
  }

  render() {
    const user = this.state.user;
    return (
      <ReactModal
        isOpen={this.props.visible}
      >
        <button onClick={this.props.handleCloseModal}>Close Modal</button>
        {this.state.loading ?
          <div>loading...</div>
          :
          <ul>
            <li>Name: {`${(user.name || {}).first} ${(user.name || {}).last}`}</li>
            <li>Gender: {user.gender}</li>
            <li>Phone: {user.phone}</li>
          </ul>
        }
      </ReactModal>
    )
  }
}

我們抽象了一個包含業務邏輯的 RandomUserModal,該 Modal 的展示與否由父組件控制,因此會傳入參數 visible 和 handleCloseModal(用于 Modal 關閉自己)。

為了實現在 Modal 打開的時候才進行數據獲取,我們需要同時在 componentDidMount 和 componentDidUpdate 兩個生命周期里實現數據獲取的邏輯,而且 constructor 里的一些初始化操作也少不了。

其實我們的要求很簡單:在合適的時候通過 API 獲取新的信息,這就是我們抽象出來的一個業務邏輯,為了這個業務邏輯能在 React 里正確工作,我們需要將其按照 React 組件生命周期進行拆解。這種拆解除了代碼冗余,還很難復用。

下面我們看看采用 Hooks 改造后會是什么樣:

function RandomUserModal(props) {
  const [user, setUser] = React.useState({});
  const [loading, setLoading] = React.useState(false);

  React.useEffect(() => {
    if (!props.visible) return;
    setLoading(true);
    fetch('https://randomuser.me/api/').then(res => res.json()).then(json => {
      setUser(json.results[0]);
      setLoading(false);
    });
  }, [props.visible]);
  
  return (
    // View 部分幾乎與上面相同
  );
}

很明顯地可以看到我們把 Class 形式變成了 Function 形式,使用了兩個 State Hook 進行數據管理(類比 constructor),之前 cDMcDU 兩個生命周期里干的事我們直接在一個 Effect Hook 里做了(如果有讀取或修改 DOM 的需求可以看 這里)。做了這些,最大的優勢是代碼精簡,業務邏輯變的緊湊,代碼行數也從 50+ 行減少到 30+ 行。

Hooks 的強大之處還不僅僅是這個,最重要的是這些業務邏輯可以隨意地的的抽離出去,跟普通的函數沒什么區別(僅僅是看起來沒區別),于是就變成了可以復用的自定義 Hook。具體可以看下面的進一步改造:

// 自定義 Hook
function useFetchUser(visible) {
  const [user, setUser] = React.useState({});
  const [loading, setLoading] = React.useState(false);
  
  React.useEffect(() => {
    if (!visible) return;
    setLoading(true);
    fetch('https://randomuser.me/api/').then(res => res.json()).then(json => {
      setUser(json.results[0]);
      setLoading(false);
    });
  }, [visible]);
  return { user, loading };
}

function RandomUserModal(props) {
  const { user, loading } = useFetchUser(props.visible);
  
  return (
    // 與上面相同
  );
}

這里的 useFetchUser 為自定義 Hook,它的地位跟自帶的 useState 等比也沒什么區別,你可以在其它組件里使用,甚至在這個組件里使用兩次,它們會天然地隔離開。

業務邏輯復用

這里說的業務邏輯復用主要是需要跨生命周期的業務邏輯。單單按照組件堆積的形式組織代碼雖然也可以達到各種復用的目的,但是會導致組件非常復雜,數據流也會很亂。組件堆積適合 UI 布局,但是不適合邏輯組織。為了解決這些問題,在 React 發展過程中,產生了很多解決方案,我認知里常見的有以下幾種:

Mixins

壞處遠遠大于帶來的好處,因為現在已經不再支持,不多說,可以看看這篇文章:Mixins Considered Harmful

Class Inheritance

官方 很不推薦此做法,實際上我也沒真的看到有人這么做。

High-Order Components (HOC)

React 高階組件 在封裝業務組件上簡直是屢試不爽,它的實現是把自己作為一個函數,接受一個組件,再返回一個組件,這樣它可以統一處理掉一些業務邏輯并達到復用目的。

比較常見的一個就是 react-redux 里的 connect 函數:

image.png

但是它也被很多人吐槽嵌套問題:


image.png

Render Props

Render Props 其實很常見,比如 React Context API

class App extends React.Component {
  render() {
    return (
      <ThemeProvider>
        <ThemeContext.Consumer>
          {val => <div>{val}</div>}
        </ThemeContext.Consumer>
      </ThemeProvider>
    )
  }
}

它的實現思路很簡單,把原來該放「組件」的地方,換成了回調,這樣當前組件里就可以拿到子組件的狀態并使用。

但是,同樣這會產生 Wrapper Hell 問題:


image.png

Hooks

Hooks 本質上面說了,是把面向生命周期編程變成了面向業務邏輯編程,寫法上帶來的優化只是順帶的。

這里,做一個類比,await/async 本質是把 JS 里異步編程思維變成了同步思維,寫法上表現出來的特點就是原來的 Callback Hell 被打平了。

總結對比:

  • await/async 把 Callback Hell 干掉了,異步編程思維變成了同步編程思維
  • Hooks 把 Wrapper Hell 干掉了,面向生命周期編程變成了面向業務邏輯編程

這里不得不客觀地說,HOC 和 Render Props 還是有存在的必要,一方面是支持 React Class,另一方面,它們不光適用于純邏輯封裝,很多時候也適合邏輯 + 組件的封裝場景,雖然此時使用 Hooks 也可以,但是會顯得啰嗦點。另外,上面詬病的最大的問題 Wrapper Hell,我個人覺得使用 Fragment 也可以基本解決。

狀態盒子

首先,React Hooks 的設計是反直覺的,為什么這樣說呢?可以先試著問自己:為什么 Hooks 只能在其它 Hooks 的函數或者 React Function 組件里?

在我們的認知里,React 社區一直推崇函數式、純函數等思想,引入 Hooks 概念后的 Functional Component 變的不再純了,useXxx 與其說是一條執行語句,不如說是一個聲明。聲明這里放了一個「狀態盒子」,盒子有輸入和輸出,剩下的內部實現就一無所知,重要的是,盒子是有記憶的,下次執行到此位置時,它有之前上下文信息。

類比「代碼」和「程序」的區別,前者是死的,后者是活的。表達式 c = a + b 表示把 ab 累加后的值賦值給 c,但是如果寫成 c := a + b 就表示 c 的值由 ab 相加得到。看起來表述差不多,但實際上,后者隱藏著一個時間的維度,它表示的是一種聯系,而不單單是個運算。這在 RxJS 等庫中被大量使用。

image.png

這種聲明目前是通過很弱的 use 前綴標識的(但是設計上會簡潔很多),為了不弄錯每個盒子和狀態的對應關系,書寫的時候 Hooks 需要 use 開頭且放在頂層作用域,即不可以包裹 if/switch/when/try 等。如果你按文章開頭引入了那個 ESLint Plugin 就不用擔心會弄錯了。

總結

這篇文章可能并沒有一個很條理的目錄結構,大多是一些個人理解和相關思考。因此,這不能替代你去看真正的文檔了解更多。如果你看完后還是覺得廢話太多,不知所云,那我希望你至少可以在下面幾點上跟作者達成共鳴:

  • Hooks 本質是把面向生命周期編程變成了面向業務邏輯編程;
  • Hooks 使用上是一個邏輯狀態盒子,輸入輸出表示的是一種聯系;
  • Hooks 是 React 的未來,但還是無法完全替代原始的 Class。

參考:
https://zhuanlan.zhihu.com/p/92211533
https://segmentfault.com/a/1190000017182184

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,578評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,701評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,691評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,974評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,694評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,026評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,015評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,193評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,719評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,442評論 3 360
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,668評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,151評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,846評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,255評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,592評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,394評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,635評論 2 380

推薦閱讀更多精彩內容