最近開了一個讀者回饋表單,無論是對文章的感想或是對部落格的感想,有什麼想回饋的都可以填表單跟我說:表單連結

從實際案例看 class 與 function component 的差異

前言

要學習一個新的東西,光用看的還真的沒什麼用,直接動手下去做才是比較好的方法。因為上一份工作快離職時 React hook 才剛出來,所以我之前從來沒寫過 hook,只有看過一些基本的教學而已。而前陣子開始工作之後,才終於開始寫 function component + hook。

雖然剛開始還是滿懷念 class 的寫法,但寫久之後覺得 hook 也挺不錯的。在使用 hook 的過程中也有碰到一些剛轉換的人常碰到的問題,仔細研究後發現這篇文章要提的案例還滿不錯的,如果能夠理解這個案例,應該就可以掌握到 class 與 function component 根本上的不同,因此寫了這篇來記錄一下心得。

話說如果你已經寫 function component 一陣子,hook 也用得滿習慣的,而且都有把官方文件還有 dan 哥的文章好好看過,基本上不會從這篇文章獲得任何新知識。這篇適合的對象是剛轉換到 function component,而且不太確定跟 class 的差異是什麼的人。

實際案例

這個案例是我在串接 Google reCAPTCHA 的時候所碰到的,所以讓我先來順便講一下 reCAPTCHA 的串接。

相信大家應該都對 reCAPTCHA 不陌生,因為在網路上滿常看到的。目前有分成兩個版本,v2 跟 v3,然後 v2 也有分幾個不同的型態,其中有一個叫做 checkbox 的版本,就是我們最常見的那個要你勾選「我不是機器人」的框框:

串接方法很簡單,首先你必須載入 reCAPTCHA 的 script:

<script src="https://www.google.com/recaptcha/api.js?onload=onloadCallback&render=explicit"
    async defer>
</script>

onload 這個參數需要傳給他 callback function 的名稱,在 script 載入完成以後會呼叫。而 render=explicit 則是告訴他說我們要自己呼叫程式碼去 render 出那個框框(另外一種 implicit 可以透過 data-xxx 這種形式把屬性放在 html 元素,讓 Google 自己去渲染出那個框框)。

當 script 載入完成以後,會去呼叫你提供的 callback function,也會多一個全域變數 grecaptcha 可以使用,再來你就可以用:

grecaptcha.render('html_element', {
    sitekey : 'your_site_key',
    callback: function(token) {
      console.log(token)
    }
  });
};

html_element 變成顯示 reCAPTCHA 的框框,並且在使用者點選時透過傳入的 callback function 拿到 token。

這邊我有做了一個小範例:codepen,畫面長得像這樣:

程式碼其實很簡單:

<div id="robot"></div>
Your token:
<div id="token"></div>
window.onloadCallback = function() {
  grecaptcha.render(document.querySelector('#robot'), {
    // test site key from https://developers.google.com/recaptcha/docs/faq
    sitekey : '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI',
    callback: function(token) {
      document.querySelector('#token').innerText = token
    }
  });
};

好,這個 reCAPTCHA 就是我們今天的主角。在介紹完使用方法以後,我們來看一下在 React 裡應該要怎麼來實作。

React 實作:Class component 版

在新增一個 component 的時候,我會先思考一件事,那就是我想要怎麼用它。這點非常重要,因為會決定這個元件長什麼樣子。

如果有一個 reCAPTCHA 的元件,我會希望能這樣用:

<ReCAPTCHA sitekey={siteKey} onChange={onChange} />

這個 component 應該要能做到:

  1. 自動幫我們載入需要的 script
  2. 自動產生一個 checkbox 的元素
  3. 當使用者打勾時,透過 onChange 把 token 傳回來

接著就讓我們來實作這個 component 吧!完整的程式碼會長這樣:

class ReCAPTCHA extends React.Component {
  constructor(props) {
    super(props);
    this.divRef = React.createRef();
    this.callbackName = "__recaptcha__cb";
  }

  componentDidMount() {
    // 檢查是否已經載入完成
    if (!window.grecaptcha) {
      return this.init();
    }
    this.handleLoad();
  }
  
  // 負責來執行 callback function
  handleCallback = token => {
    this.props.onChange(token);
  };

  handleLoad = () => {
    // 載入完成,渲染元素
    const { sitekey } = this.props;
    window.grecaptcha.render(this.divRef.current, {
      sitekey,
      callback: this.handleCallback
    });
  };

  init = () => {
    window[this.callbackName] = this.handleLoad;
    const script = document.createElement("script");
    script.src = `https://www.google.com/recaptcha/api.js?onload=${
      this.callbackName
    }&render=explicit`;
    script.async = true;
    document.body.appendChild(script);
  };

  render() {
    return <div ref={this.divRef} />;
  }
}

componentDidMount 的時候我們去檢查是不是已經有 grecaptcha 的存在,沒有的話就載入,有的話就直接呼叫 this.handleLoad,並且在裡面處理 render 的相關事項。而載入的部分則是動態產生 script 標籤插入到 document 裡面,我們就不用自己在 HTML 手動把 script 引入,方便很多。而 handleLoad 的地方其實就只是呼叫上面有寫過的 grecaptcha.render 而已:

handleLoad = () => {
  // 載入完成,渲染元素
  const { sitekey } = this.props;
  window.grecaptcha.render(this.divRef.current, {
    sitekey,
    callback: this.handleCallback
  });
};

做完這個 component 之後,上層去 render 它,然後傳入一個 onChange 的 callback function,最後介面會長這樣:

完整程式碼會長這樣:

import React, { useState } from "react";

class ReCAPTCHA extends React.Component {
  constructor(props) {
    super(props);
    this.divRef = React.createRef();
    this.callbackName = "__recaptcha__cb";
  }

  componentDidMount() {
    // 檢查是否已經載入完成
    if (!window.grecaptcha) {
      return this.init();
    }
    this.handleLoad();
  }

  handleCallback = token => {
    this.props.onChange(token);
  };

  handleLoad = () => {
    // 載入完成,渲染元素
    const { sitekey } = this.props;
    window.grecaptcha.render(this.divRef.current, {
      sitekey,
      callback: this.handleCallback
    });
  };

  init = () => {
    window[this.callbackName] = this.handleLoad;
    const script = document.createElement("script");
    script.src = `https://www.google.com/recaptcha/api.js?onload=${
      this.callbackName
    }&render=explicit`;
    script.async = true;
    document.body.appendChild(script);
  };

  render() {
    return <div ref={this.divRef} />;
  }
}

const sitekey = "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI";
export default function App() {
  const [token, setToken] = useState("");
  return (
    <div className="App">
      <ReCAPTCHA sitekey={sitekey} onChange={setToken} />
      <h2>Token</h2>
      <p>{token}</p>
    </div>
  );
}

這邊有完整的 codesandbox demo:https://codesandbox.io/s/practical-rgb-r785j?file=/src/App.js (備註:這個 component 其實有些問題沒有解掉,但因為重點不在撰寫 reCAPTCHA library,所以就不多提了)

這個 component 其實有一個重點,那就是在 componentDidMount 的時候我們去檢查 script 是不是已經載入完畢,沒有的話就先載入,載入完成以後執行 window.grecaptcha.render。而 window.grecaptcha.render 這個 function 只會被呼叫一次而已。除非 component 被 unmount 然後再被 mount,才會再呼叫一次 window.grecaptcha.render

而事實上如果你想在「同一個元素(this.divRef)」身上呼叫第二次 window.grecaptcha.render 也是不行的,會跳出一個錯誤提示:Uncaught Error: reCAPTCHA has already been rendered in this element,跟你說這個元件已經被 render 過了。

而這篇文章其實就跟這個行為有關,因為這個元件不能再被 render 一次,所以我們的重點是:「window.grecaptcha.render 只能呼叫一次,而且一旦設定好 callback function,就不能改變了」。

理解這個重點以後,reCAPTCHA 其實就可以退場了。因為這篇會出現 reCAPTCHA,就只是因為它的這個行為而已。我們其實可以自己模擬一次這個行為,然後改寫成一個 function:

import React, { useState } from "react";

let isCalled = false;
const grecaptcha = {
  render: function(element, { callback }) {
    if (isCalled) throw new Error("You can only call me once");
    isCalled = true;
    element.innerText = "click me if you are not robot";
    element.addEventListener("click", function() {
      callback("you got token!");
    });
  }
};

class ReCAPTCHA extends React.Component {
  constructor(props) {
    super(props);
    this.divRef = React.createRef();
  }

  componentDidMount() {
    this.handleLoad();
  }

  handleCallback = token => {
    this.props.onChange(token);
  };

  handleLoad = () => {
    grecaptcha.render(this.divRef.current, {
      callback: this.handleCallback
    });
  };

  render() {
    return <div ref={this.divRef} />;
  }
}

export default function App() {
  const [token, setToken] = useState("");
  return (
    <div className="App">
      <ReCAPTCHA onChange={setToken} />
      <h2>Token</h2>
      <p>{token}</p>
    </div>
  );
}

介面長這樣:

可以動的範例程式碼在這:https://codesandbox.io/s/simulate-grecaptcha-5z90f

其實就是自己簡單模擬 reCAPTCHA 的行為而已,我們的重點只有:「grecaptcha.render 只能被呼叫一遍」。

主角登場:React hook

前面鋪陳了這麼多 reCAPTCHA 的行為以及 class component 的實作,現在終於輪到 React hook 登場了,接著就讓我們直接把上面的範例改成用 hook 來做:

const ReCAPTCHA = ({ onChange }) => {
  const divRef = useRef();
  const handleCallback = token => {
    onChange(token);
  };
  const handleLoad = () => {
    grecaptcha.render(divRef.current, {
      callback: handleCallback
    });
  };

  useEffect(() => {
    handleLoad();
  }, []);

  return <div ref={divRef} />;
};

程式碼變得清爽好多,React hook 讚讚讚!

目前我們就只是照著之前 class component 的邏輯來改寫而已,在 componentDidMount 的時候執行 handleLoad,然後 handleLoad 裡面去呼叫 grecaptcha.render 並且設置 callback function,交由 handleCallback 來處理,最後再透過 props.onChange 把 token 傳回去。

試了一下,會發現功能完全沒有問題。這邊是完整程式碼:https://codesandbox.io/s/simulate-grecaptcha-react-hook-kxerd?file=/src/App.js:391-714

可是…真的沒問題嗎?

乍看之下是這樣,可是其實不然,這樣寫是有問題的,而這就是這篇文章真正要講的重點。

大家可以自己想想看會有什麼問題,再接著往下看。如果你看到這邊已經想到了,而且也知道怎麼解了,那代表你對 hook 有一定的熟悉程度,恭喜恭喜。

真正的問題

前面花了很多篇幅在介紹 reCAPTCHA 的使用以及「 grecaptcha.render 只能被呼叫一遍,所以 callback function 的綁定只能進行一遍」這件事情,因為這跟這篇文章要提出的一個重要問題有關,這個問題就是:

如果 props 的 onChange 換了,會發生什麼事?

你可能會想說:「咦?換掉就換掉啊,會怎樣嗎?」

我提供一個簡單的範例:

export default function App() {
  const [isOld, setIsOld] = useState(true);
  const oldFunction = () => console.log("old function");
  const newFunction = () => console.log("new function");

  return (
    <div className="App">
      <ReCAPTCHA onChange={isOld ? oldFunction : newFunction} />
      <button
        onClick={() => {
          console.log("Switch to new function");
          setIsOld(false);
        }}
      >
        change function
      </button>
    </div>
  );
}

這個範例會根據 state isOld 來決定傳入哪一個 function,預設傳入 oldFunction,點擊按鈕之後會把 isOld 設定成 false,就會傳入 newFunction,然後從 console 就可以看出來,最後被呼叫的到底是哪一個 function,我們改成這樣以後來試試看上面 hook 的範例:

跟你想的一樣嗎?我們明明就把 onChange 這個 props 換成了新的 function,為什麼被呼叫到的還是舊的?我這邊附上完整程式碼讓大家想一下:

import React, { useState, useRef, useEffect } from "react";

let isCalled = false;
const grecaptcha = {
  render: function(element, { callback }) {
    if (isCalled) throw new Error("You can only call me once");
    isCalled = true;
    element.innerText = "click me if you are not robot";
    element.addEventListener("click", function() {
      callback("you got token!");
    });
  }
};

const ReCAPTCHA = ({ onChange }) => {
  const divRef = useRef();
  const handleCallback = token => {
    onChange(token);
  };
  const handleLoad = () => {
    grecaptcha.render(divRef.current, {
      callback: handleCallback
    });
  };

  useEffect(() => {
    handleLoad();
  }, []);

  return <div ref={divRef} />;
};

export default function App() {
  const [isOld, setIsOld] = useState(true);
  const oldFunction = () => console.log("old function");
  const newFunction = () => console.log("new function");

  return (
    <div className="App">
      <ReCAPTCHA onChange={isOld ? oldFunction : newFunction} />
      <button
        onClick={() => {
          console.log("Switch to new function");
          setIsOld(false);
        }}
      >
        change function
      </button>
    </div>
  );
}

可以跑的範例:https://codesandbox.io/s/simulate-grecaptcha-react-hook-change-props-chl50?file=/src/App.js

大家在邊想的同時,我們可以邊來看之前的 class component,它會有這個問題嗎?

不會,運作地十分良好。

(一樣附上完整程式碼:https://codesandbox.io/s/change-props-onchange-jkm1n?file=/src/App.js)

可是為什麼 hook 會有問題,class 就不會?我們不是用同樣的邏輯來改寫的嗎?

我們來細看一下 class 的運作,自己先看這段重點程式碼模擬一遍:

handleCallback = token => {
  this.props.onChange(token);
};

handleLoad = () => {
  grecaptcha.render(this.divRef.current, {
    callback: this.handleCallback
  });
};

在呼叫 grecaptcha.render 時,我們把 callback function 綁定到 this.handleCallback,而這個 function 會呼叫 this.props.onChange(token),所以一定可以呼叫到最新的 props 裡面的 onChange 事件,完全沒有問題。

那 hook 呢?

const handleCallback = token => {
  onChange(token);
};
const handleLoad = () => {
  grecaptcha.render(divRef.current, {
    callback: handleCallback
  });
};

useEffect(() => {
  handleLoad();
}, []);

在元素第一次 render 完以後會去執行 useEffect 裡面的 handleLoad,而裡面會把 callback 綁定到 handleCallback,在裡面再去呼叫 onChange 這個 props,看起來也沒問題啊?

不,問題可大了。

function 跟 class component 最大的差異,就在於:「function component 會記住當下傳入的值」。這點或許聽起來有點難體會,但只要你把 mental model 建立好我相信就沒問題。你要牢記一件事,那就是:

Function component 的每一次 render,都是「重新」呼叫一次 function

聽起來有點廢話,但重點是「重新」這兩個字,以這個方式去思考,你就能理解 function component 的重點。我們用這個方式再重新看一遍上面的流程,底下我有附上每一個步驟的編號,請按照編號閱讀:

// 1. 第一次 render,onChange = oldFunction
// 2. 呼叫 ReCAPTCHA({ onChange: oldFunction })
// 3. 這邊的 onChange 會等於 oldFunction(這是重點,畫三顆星星必考)
const ReCAPTCHA = ({ onChange }) => {
  // 4. 建立 ref
  const divRef = useRef();

  // 5. 建立函式 handleCallback
  // 11. 當 callback 被觸發時,呼叫 onChange(oldFunction)
  // 這是重點,畫五顆星星必考
  const handleCallback = token => {
    onChange(token);
  };

  // 6. 建立函式 handleLoad
  // 10. 執行 handleLoad,把 callback 綁定到 handleCallback
  const handleLoad = () => {
    grecaptcha.render(divRef.current, {
      callback: handleCallback
    });
  };

  // 7. 宣告 useEffect
  // 9. render 完畢,執行 handleLoad
  useEffect(() => {
    handleLoad();
  }, []);

  // 8. render
  return <div ref={divRef} />;
};

當使用者點擊「change function」之後,流程是這樣的:

// 1. 第二次 render,onChange = newFunction
// 2. 呼叫 ReCAPTCHA({ onChange: newFunction })
// 3. 這邊的 onChange 會等於 newFunction
const ReCAPTCHA = ({ onChange }) => {
  // 4. 建立 ref
  const divRef = useRef();

  // 5. 建立函式 handleCallback
  const handleCallback = token => {
    onChange(token);
  };

  // 6. 建立函式 handleLoad
  const handleLoad = () => {
    grecaptcha.render(divRef.current, {
      callback: handleCallback
    });
  };

  // 7. 宣告 useEffect
  // 9. render 完畢,但因為不是第一次,所以不會執行 handleLoad
  useEffect(() => {
    handleLoad();
  }, []);

  // 8. render
  return <div ref={divRef} />;
};

這裡的重點有幾個:

  1. 第一次 render 裡的 handleCallback 跟第二次裡的 handleCallback,是兩個完全不同的 function,不是同一個
  2. 因此你綁定的是第一次的 handleCallback,就只會執行第一次的,而且第一次的 onChange 是 oldFunction
  3. 所以儘管你改變了 onChange,只有第二次的 handleCallback 會執行到新的 newFunction,但你綁定的 callback 是第一次的 handleCallback

這邊的關鍵在於:「第一次 render 裡的 function」跟「第二次 render 裡的 function」已經是完全不同的東西了。在使用 hook 時,有個 eslint 的提示會一直提醒你使用 useEffect 或是 useCallback 的時候要加上的 dependency array,就是為了要讓你能夠獲取到最新的值。

其實在寫上面那段 hook 的程式碼時,eslint 就有跳提醒了,那我們按照它講的來修修看:

const ReCAPTCHA = ({ onChange }) => {
  const divRef = useRef();

  // 當 onChange 改變時,就會產生新的 handleCallback
  const handleCallback = useCallback(
    token => {
      onChange(token);
    },
    [onChange]
  );  

  // 當 handleCallback 改變時,就會重新呼叫
  useEffect(() => {
    const handleLoad = () => {
      grecaptcha.render(divRef.current, {
        callback: handleCallback
      });
    };
    handleLoad();
  }, [handleCallback]);

  return <div ref={divRef} />;
};

看起來好像沒什麼問題,有把 dependency 都修好,就能確保 handleCallback 呼叫到的一定是最新的 onChange 事件,馬上來試試看:

完了,怎麼出錯了。

當我們 onChange 改變時,handleCallback 就會跟著變,然後連帶的 useEffect 那一段也會重新執行,所以 grecaptcha.render 就被呼叫了兩遍,就跳出了這個錯誤。還記得我前面特別強調這點嗎?這個問題之所以比較麻煩,就是因為 grecaptcha.render 只能呼叫一遍,所以我們這樣改是行不通的。

接著給大家一個小考驗,大家可以自己試試看開這個 codesandbox 來改,看看能不能改對:https://codesandbox.io/s/react-hook-change-props-fix-gi10h?file=/src/App.js

改對的標準是:

  1. 按下「click me if you are not robot」時,console 會印出 old function
  2. 按下「change function」不會出錯
  3. 再按「click me if you are not robot」時,console 會印出 new function

有達成這三點,你就成功了。

強烈建議大家立刻點開 codesandbox 去試試看,因為沒有試的話,看下面的範例你可能會沒什麼感覺。但如果你有試過,就會深有同感。若是你試了一段時間還是沒成功,可以接著看下面的段落,或許會發現你的錯誤解法。

為什麼你的解法行不通?

首先呢,handleLoad 一定只能呼叫一次,所以 useEffect 放的 dependency array 絕對是空陣列,這個沒有問題。而你要思考的就是怎麼樣去改傳入的 callback 以及 handleCallback

嘗試一

你可能試過這種解法,直接把 useEffect 的依賴改成空陣列,然後其他不動:

const ReCAPTCHA = ({ onChange }) => {
  const divRef = useRef();

  // 當 onChange 改變時,就會產生新的 handleCallback
  const handleCallback = useCallback(
    token => {
      onChange(token);
    },
    [onChange]
  );

  useEffect(() => {
    const handleLoad = () => {
      grecaptcha.render(divRef.current, {
        callback: handleCallback
      });
    };
    handleLoad();
  }, []);

  return <div ref={divRef} />;
};

看起來好像很合理,onChange 變的時候我就改變我的 handleCallback,確認可以在裡面呼叫到最新的 onChange,然後每一次 grecaptcha 改變時都會呼叫到我傳入的 function,也就是 handleCallback,十分合理。

不,你又忽略了前面強調的 mental model:

Function component 的每一次 render,都是「重新」呼叫一次 function

我寫一遍執行順序給你看,記得按照順序看:

// 1. 第一次執行,呼叫 ReCAPTCHA({ onChange: oldFunction })
// 5. 第二次執行,呼叫 ReCAPTCHA({ onChange: newFunction })
const ReCAPTCHA = ({ onChange }) => {
  const divRef = useRef();

  // 2. 第一次執行,產生 handleCallback1
  // 6. 第二次執行,產生 handleCallback2
  // 8. 當 grecaptcha 的 callback 觸發時,會呼叫到的是 handleCallback1
  // 而 handleCallback1 裡的 onChange 是 oldFunction
  // 因為在建立 handleCallback1 時,傳入的 onChange 是 oldFunction
  const handleCallback = useCallback(
    token => {
      onChange(token);
    },
    [onChange]
  );

  // 3. 第一次 render,執行這個 function
  // 4. 把 grecaptcha 的 callback 設成 handleCallback1
  // 7. 第二次 render,不執行這一段
  useEffect(() => {
    const handleLoad = () => {
      grecaptcha.render(divRef.current, {
        callback: handleCallback
      });
    };
    handleLoad();
  }, []);

  return <div ref={divRef} />;
};

或是看這張圖片可能比較容易理解:

每一次 render 就是重新呼叫一次 function,你第一次的 function call 會建立一個 handleCallback,當 props.onChange 改變以後,又會建立一個新的 handleCallback,這兩個同名,但是卻是不同的 function。

嘗試二

前面說過最大的問題是「當 onChange 改變時會產生不同的 function」,所以想要解決這個問題,就必須有某個「不會變動的東西」。

此時你可能會靈機一動,想說:那這種情況是不是就是 useRef 登場的時候了?

const ReCAPTCHA = ({ onChange }) => {
  const divRef = useRef();
  const handleCallback = useRef(onChange);

  // 當 onChange 改變時,去改變 handleCallback.current
  useEffect(() => {
    handleCallback.current = onChange
  }, [onChange])

  useEffect(() => {
    const handleLoad = () => {
      grecaptcha.render(divRef.current, {
        callback: handleCallback.current
      });
    };
    handleLoad();
  }, []);

  return <div ref={divRef} />;
};

handleCallback.current 傳給 callback,所以每次點擊時都會呼叫到 handleCallback.current,然後我在 useEffect 裡面再去隨著 onChange 更改 handleCallback.current,看起來十分合理。

不,還是不合理,請看底下的圖:

這其實是一個「重新賦值」的問題,我們先把 handleCallback.current 看成是一個變數 A 好了,我們在第一次 render 的時候,在 13 行把 callback 設成 A,然後在二次的 render 的時候,我們執行:handleCallback.current = newFunction,也就是 A = newFunction,我們把 A 重新賦值了,可是原本綁定到 callback 去的還是原本的 A,不會因為你把 A 重新賦值就改變。

嘗試三

這時你可能會想說,那既然問題好像是出在直接把 handleCallback.current 掛在 callback 上面,那我再宣告一個 function 不就好了嗎:

const ReCAPTCHA = ({ onChange }) => {
  const divRef = useRef();
  const cbRef = useRef(onChange);

  const handleCallback = () => {
    cbRef.current()
  } 

  useEffect(() => {
    cbRef.current = onChange
  }, [onChange])

  useEffect(() => {
    const handleLoad = () => {
      grecaptcha.render(divRef.current, {
        callback: handleCallback
      });
    };
    handleLoad();
  }, []);

  return <div ref={divRef} />;
};

每次點擊時都會去呼叫 handleCallback,然後在裡面呼叫 cbRef.current(),每當 onChange 改變時,我就去改變 cbRef.current,根本完美。

沒錯,你成功了!而且 handleCallback 其實可以用 useCallback 包起來,就不會每一次 render 時都產生一個新的 handleCallback

或甚至是你其實根本不需要宣告一個 function,直接用箭頭函式就行了:

const ReCAPTCHA = ({ onChange }) => {
  const divRef = useRef();
  const cbRef = useRef(onChange);

  useEffect(() => {
    cbRef.current = onChange
  }, [onChange])

  useEffect(() => {
    const handleLoad = () => {
      grecaptcha.render(divRef.current, {
        callback: () => {
          cbRef.current()
        }
      });
    };
    handleLoad();
  }, []);

  return <div ref={divRef} />;
};

最後的程式碼長這樣,大家可以自己玩玩看:https://codesandbox.io/s/react-hook-change-props-solution-ll8os?file=/src/App.js

回顧與思考

在兜了這麼多圈之後,終於找到了解法。可是為什麼以前寫 class component 的時候,從來沒有碰過這個問題?因為我們隨時都可以用 this.props.onChange 拿到最新的屬性。

可是 function component 並不是這樣的,每一次 render 就是一次 function call,而傳進來的 props 就會是「當時」的 props,不會因為 props 改變而改變。這個就是 function component 與 class component 最大的差別。

原本我一直不是很理解之前 dan 哥說的:「唯有拋下 class component,你才能真正理解 hook」是什麼意思,但我現在懂了。以前在 class component 時你會以那些 lifecycle 去思考,去想說「didMount 要做什麼」、「update 的時候要做什麼」,但 hook 的重點會放在「每一次 render」。

class component 是以 class 的 instance 為主體去思考,而 hook 是以 function 為主體去思考。以前在寫 class 的時候,你只會知道 render 這個 method 是每一次 render 都會執行到,其他的 lifecycle 不會。

但是 function component 就是「每一次 render 都會把整個 function 重新執行一遍」,是很不一樣的。最後再強調一次這點:

function component 的每一次渲染,都是一個新的 function call

總結

雖然說 React hook 看起來容易上手,程式碼也比較少,但我認為今天特地提的這個案例,並沒有讓 hook 在實戰的使用上變得更簡單,某種程度上反而更容易讓新手寫出 bug。或者更精確地說,會出 bug 的地方不一樣。

以前在寫 class 的時候,新手的第一個障礙是 this 的理解,第二個障礙是 props 與 state 永遠會拿到「最新的」而不是當時的。而 function 的障礙就是 closure,如果沒有正確的 mental model,很容易就會在 hook 裡面迷失,畢竟寫 class 跟寫 function component 真的完全不一樣。

原本我以為只是從 class 換到 functoon 只是換一種寫法,沒想到連整個思考模式都換了,衷心佩服 React 團隊的成員,一次次帶給前端這個領域一些全新的東西。

關於 function component 與 class component 的差異,誠心推薦大家去讀 dan 哥的文章,寫的真的很讚,可以先看這一篇:How Are Function Components Different from Classes? 來理解差別,然後再看這篇:A Complete Guide to useEffect 來了解 useEffect,看完之後再來看我文章提到的這個例子會更有感覺,而且可能會覺得:「咦?你這篇在寫什麼廢話,這不是很基本嗎」

這篇文章就差不多到這邊結束了,剛好在學習 hook 的過程中有一個實戰案例可以分享。

最後,特別感謝 Onedegree 的前端同事們跟我一起討論這個問題。

一個有趣的 styled components bug 簡易 Regular Expression 入門指南

評論