React Hooks:初探·實踐

keylennHo發表於2020-03-31

前言

這篇文章主要介紹了React Hooks的一些實踐用法和場景,遵循我個人一貫的思(tao)路(是什麼-為什麼-怎麼做)

是什麼

Hooks are a new addition in React 16.8. They let you use state and other React features without writing a class.

簡單來說,上面這段官腔大概翻(xia)譯(shuo)就是告訴我們class能夠做到的老子用hooks基本可以做到,放棄抵抗吧,少年!

其實按照我自己的看法:React Hooks是在函式式元件中的一類以use為開頭命名的函式。 這類函式在React內部會被特殊對待,所以也稱為鉤子函式。

  • 函式式元件

Hooks只能用於Function Component, 其實這麼說不嚴謹,我更喜歡的說法是建議只在於Function Component使用Hooks

Should I use Hooks, classes, or a mix of both?
excuse me?

  • use開頭

React 約定,鉤子一律使用use字首命名,便於識別,這沒什麼可說的,要被特殊對待,就要服從一定的規則

  • 特殊對待

Hooks作為鉤子,存在與每個元件相關聯的“儲存器單元”的內部列表。 它們只是我們可以放置一些資料的JavaScript物件。 當你像使用useState()一樣呼叫Hook時,它會讀取當前單元格(或在第一次渲染時初始化它),然後將指標移動到下一個單元格。 這是多個useState()呼叫每個get獨立本地狀態的方式

為什麼

解決為什麼要使用hooks的問題,我決定從hooks解決了class元件的哪些痛點和hooks更符合react的元件模型兩個方面講述。

1. class元件不香嗎?

class元件它香,但是暴露的問題也不少。Redux 的作者 Dan Abramov總結了幾個痛點:

  • Huge components that are hard to refactor and test.
  • Duplicated logic between different components and lifecycle methods.
  • Complex patterns like render props and higher-order components.

第一點:難以重構和測試的巨大元件。 如果讓你在一個程式碼行數300+的元件里加一個新功能,你不慌嗎?你嘗試過註釋一行程式碼,結果就跑不了或者邏輯錯亂嗎?如果需要引入redux或者定時器等那就更慌了~~

第二點:不同元件和生命週期方法之間的邏輯重複。 這個難度不亞於蜀道難——難於上青天!當然對於簡單的邏輯可能通過HOCrender props來解決。但是這兩種解決辦法有兩個比較致命的缺點,就是模式複雜和巢狀。

第三點:複雜的模式,比如render props和 HOC。 不得不說我在學習render props的時候不禁發問只有在render屬性傳入函式才是render props嗎?好像我再任意屬性(如children)傳入函式也能實現一樣的效果; 一開始使用HOC的時候開啟React Develops Tools一看,Unknown是什麼玩意~看著一層層的巢狀,我也是無能為力。

以上這三點都可以通過Hooks來解決(瘋狂吹捧~)

2. hooks更符合React的程式設計模型

我們知道,react強調單向資料流和資料驅動檢視,說白了就是元件和自上而下的資料流可以幫助我們將UI分割,像搭積木一樣實現頁面UI。這裡更加強調組合而不是巢狀,class並不能很完美地詮釋這個模型,但是hooks配合函式式元件卻可以!函式式元件的純UI性配合Hooks提供的狀態和副作用可以將元件隔離成邏輯可複用的獨立單元,邏輯分明的積木他不香嗎!

真香

怎麼做

別問,問就是文件,如果不行的話,請熟讀並背誦文件...

但是(萬事萬物最怕But), 既然是實踐,就得假裝實踐過,下面就說說本人的簡單實踐和想法吧。

1. 轉變心智模型

jQuery
我認為學習Hooks的主要成本不在於api的學習,而是在於心智模型的轉變~就像是當年react剛出時,jQuery盛行的時代,這也需要時間去理解這種基於virtual DOM的心智模型。出於本能,我們總喜歡在新事物的身上尋找舊事物的共同點,這種慣性思維應該批判性地對待(上升到哲學層面了,趕緊迴歸正題....),如果你在學習的過程中也有過把class元件的那套搬到Hooks,那麼恭喜你,你可能會陷入無限的wtf····, 下面舉幾個例子

  1. state一把梭
// in class component
class Demo extends React.Component {
 constructor(props) {
   super(props)
   this.state = {
     name: 'Hello',
     age: '18',
     rest: {},
   }
 }
 ...
}

// in function component
function Demo(props) {
 const initialState = {
   name: 'Hello',
   age: '18',
   rest: {},
 }
 const [state, setState] = React.useState(initialState)
 ...
}
複製程式碼
  1. 嘗試模擬生命週期
// 這麼實現很粗糙,可以配合useRef和useCallback,但即使這樣也不完全等價於componentDidMount
function useDidMount(handler){
  React.useEffect(()=>{
      handler && handler()
  }, [])
}
複製程式碼
  1. 在useEffect使用setInterval有時會事與願違
// count更新到1就不動了
function Counter() {
  const [count, setCount] = React.useState(0);
  useEffect(() => {
    let id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  ...
}
複製程式碼

其實,在class component環境下思考問題更像是在特定的時間點做特定的事情,例如我們會在constructor中初始化state,會在元件掛載後(DidMount)請求資料等,會在元件更新後(DidUpdate)處理狀態變化的邏輯,會在元件解除安裝前(willUnmount)清除一些副作用

然而在hooks+function component環境下思考問題應該更趨向於特定的功能邏輯,以功能為一個單元去思考問題會有一種豁然開朗的感覺。例如改變document的title、網路請求、定時器... 對於hooks,只是為了實現特定功能的工具而已

你會發現大部分你想實現的特定功能都是有副作用(effect)的,可以負責任的說useEffect是最干擾你心智模型的Hooks, 他的心智模型更接近於實現狀態同步,而不是響應生命週期事件。還有一個可能會影響你的就是每一次渲染都有它自己的資源,具體表現為以下幾點

  • 每一次渲染都有它自己的Props 和 State:當我們更新狀態的時候,React會重新渲染元件。每一次渲染都能拿到獨立的狀態值,這個狀態值是函式中的一個常量(也就是會說,在任意一次渲染中,props和state是始終保持不變的)
  • 每一次渲染都有它自己的事件處理函式:和props和state一樣,它們都屬於一次特定的渲染,即便是非同步處理函式也只能拿到那一次特定渲染的狀態值
  • 每一個元件內的函式(包括事件處理函式,effects,定時器或者API呼叫等等)會捕獲某次渲染中定義的props和state(建議在分析問題時,將每次的渲染的props和state都常量化)

2. 所謂Hooks實踐

useState —— 相關的狀態放一起

  • 不要所有state一把梭,可以寫多個useState,基本原則是相關的狀態放一起
  • setXX的時候建議使用回撥的形式setXXX(xxx => xxx...)
  • 管理複雜的狀態可以考慮使用useReducer(如狀態更新依賴於另一個狀態的值)
// 實現計數功能
 const [count, setCount] = React.useState(0);
 setCount(count => count + 1)
 
// 展示使用者資訊
const initialUser = {
  name: 'Hello',
  age: '18',
}
const [user, setUser] = React.useState(initialUser)
複製程式碼

useEffect —— 不接受欺騙的副作用

  • 不要對依賴陣列撒謊,effect中用到的所有元件內的值都要包含在依賴中。這包括props,state,函式等元件內的任何東西
  • 不要濫用依賴陣列項, 讓Effect自給自足
  • 通過返回一個函式來清除副作用,在重新渲染後才會清除上一次的effects
// 修改上面count更新到1就不動了,方法1
function Counter() {
  const [count, setCount] = React.useState(0);
  useEffect(() => {
    let id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, [count]);
  ...
}
// 修改上面count更新到1就不動了,方法2( 與方法1的區別在哪裡 )
function Counter() {
  const [count, setCount] = React.useState(0);
  useEffect(() => {
    let id = setInterval(() => {
      setCount(count => count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  ...
}
複製程式碼

關於useEffect, 牆裂推薦Dan Abramov的A Complete Guide to useEffect,一篇支稱整篇文章架構的深度好文!

useReducer —— 強大的狀態管理機制

  • 把元件內發生了什麼(actions)和狀態如何響應並更新分開表述,是Hooks的作弊模式
/** 修改需求:每秒不是加多少可以由使用者決定,可以看作不是+1,而是+step*/

// 方法1
function Counter() {
  const [count, setCount] = React.useState(0);
  const [step, setStep] = React.useState(1);
  useEffect(() => {
    let id = setInterval(() => {
      setCount(count => count + step);
    }, 1000);
    return () => clearInterval(id);
  }, [step]);
  ...
}
// 方法2( 與方法1的區別在哪裡 )
const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {
    return { ...state, count: count + step };
  } else if (action.type === 'step') {
    return { ...state, step: action.step };
  }
}

function Counter() {
  const [state, dispatch] = React.useReducer(reducer, initialState);
  const { count, step } = state;

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);
  ...
}
複製程式碼

useCallback —— FP裡使用函式的好搭檔

說這個之前,先說一說如果你要在FP裡面使用函式,你要先要思考有替代方案嗎?

方案1: 如果這個函式沒有使用元件內的任何值,把它提到元件外面去定義

方案2:如果這個函式只是在某個effect裡面用到,把它定義到effect裡面

如果沒有替代方案,就是useCallback出場的時候了。

  • 返回一個 memoized 回撥, 不要對依賴陣列撒謊
// 場景1:依賴元件的query
function Search() {
  const [query, setQuery] = React.useState('hello');
  
  const getFetchUrl = React.useCallback(() => {
    return `xxxx?query=${query}`;
  }, [query]);  

  useEffect(() => {
    const url = getFetchUrl();
  }, [getFetchUrl]); 
  ...
}

// 場景2:作為props
function Search() {
   const [query, setQuery] = React.useState('hello');

  const getFetchUrl = React.useCallback(() => {
    return `xxxx?query=${query}`;
  }, [query]);  

  return <MySearch getFetchUrl={getFetchUrl} />
}

function MySearch({ getFetchUrl }) {
  useEffect(() => {
    const url = getFetchUrl();
  }, [getFetchUrl]); 
  ...
}
複製程式碼

useRef —— 有記憶功能的可變容器

  • 返回一個可變的 ref 容器物件,其 .current 屬性被初始化為傳入的引數(initialValue)。返回的 ref 物件在元件的整個生命週期內保持不變,也就是說會在每次渲染時返回同一個 ref 物件
  • 當 ref 物件內容發生變化時,useRef 並不會通知你。變更 .current 屬性不會引發元件重新渲染
  • 可以在ref.current 屬性中儲存一個可變值的“盒子“。常見使用場景:儲存指向真實DOM / 儲存事件監聽的控制程式碼 / 記錄Function Component在某次渲染的值( eg:上一次state/props,定時器id.... )
// 儲存不變的引用型別
const { current: stableArray } = React.useRef( [1, 2, 3] )
<Comp arr={stableArray} />

// 儲存dom引用
const inputEl = useRef(null);
<input ref={inputEl} type="text" />

// 儲存函式回撥
const savedCallback = useRef();
useEffect(() => {
    savedCallback.current = callback;
}
複製程式碼

useMemo —— 記錄開銷大的值

// 此栗子來自文件
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
複製程式碼

useContext —— 功能強大的上下文

  • 接收一個 context (React.createContext 的返回值)並返回該 context 的當前值,當前的 context 值由上層元件中最先渲染的 <MyContext.Provider value={value}> 的 value決定
  • 當元件上層最近的 <MyContext.Provider> 更新時,該 Hook 會觸發重渲染,並使用最新傳遞給 MyContext provider 的 context value 值,如果重新呈現元件非常昂貴,那麼可以通過使用useMemo來優化它
// 此栗子來自文件
const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee"
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222"
  }
};

const ThemeContext = React.createContext(themes.light);

function App() {
  return (
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

function ThemedButton() {
  const theme = useContext(ThemeContext);
  return (
    <button style={{ background: theme.background, color: theme.foreground }}>
      I am styled by theme context!
    </button>
  );
}
複製程式碼

彩蛋

說是彩蛋,其實是補充說明~~

1. 一條重要的規則(程式碼不規範,親人兩行淚)

hooks除了要以use開頭,還有一條很很很很重要的規則,就是hooks只允許在react函式的頂層被呼叫(這裡牆裂推薦Hooks必備神器eslint-plugin-react-hooks)

考慮到出於研(gang)究(jing)精神的你可能會問,為什麼不能這麼用,我偏要的話呢?如果我是hooks開發者,我會毫不猶豫地說出門右轉,有請下一位開發者!當然如果你想知道為什麼這麼約定地話,還是值得探討一下的。其實這個規則就是保證了元件內的所有hooks可以按照順序被呼叫。那麼為什麼順序這麼重要呢,不可以給每一個hooks加一個唯一的標識,這樣不就可以為所欲為了嗎?我以前一直都這麼想過直到Dan給了我答案,簡單點說就是為了hooks最大的閃光點——custom-hooks

2. custom-hooks

給我的感覺就是custom-hooks是一個真正詮釋了React的程式設計模型的組合的魅力。你可以不看好它,但它確實有過人之處,至少它呈現出思想讓我越想越上頭~~以至於vue3.0也借鑑了他的經驗,推出了Vue Hooks。反手推薦一下react conf 2018的custom-hooks。

魅力

// 修改頁面標題
function useDocumentTitle(title) {
  useEffect (() => {
    document.title = title;
  }, [title]);
}

// 使用表單的input
function useFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);

  function handleChange(e) {
    setValue(e.target.value);
  }

  return {
    value,
    onChange: handleChange
  };
}
複製程式碼

寫在最後

最後丟擲兩個討論的小問題。

  1. React Hooks沒有缺點嗎?

    • 肯定是有的,給我最直觀的感受就是令人又愛又恨的閉包
    • 不斷地重複渲染會帶來一定的效能問題,需要人為的優化
  2. 上面說了寫了很多的setInterval的程式碼,可以考慮封裝成一個custom-hooks?

    • 可以考慮封裝成useInterva,關於封裝還是牆裂推薦Dan的 Making setInterval Declarative with React Hooks
    • 如果有一堆特定的功能hooks,是不是完全可以通過組裝各種hooks完成業務邏輯的開發,例如網路請求、繫結事件監聽等

本人能力有限,如果有哪裡說得不對的地方,歡迎批評指正!

對不聽系列

真的真的最後,怕你錯過,再次安利Dan Abramov的A Complete Guide to useEffect,一篇支稱整篇文章架構的深度好文!

相關文章