iT邦幫忙

2022 iThome 鐵人賽

DAY 20
0
Modern Web

一次打破 React 常見的學習門檻與觀念誤解系列 第 20

[Day 20] 每一次 render 都有自己的 effects

  • 分享至 

  • xImage
  •  

接續上一章節的概念,我們已經了解到了每一次 render 都有自己的 props 與 state 以及 event handlers,那麼 useEffect 又是如何呢?


每一次 render 都有自己的 effects

我們來看看這個 React 官方文件裡介紹 effect 的經典範例,會在每次 render 時執行一個以 counter state 來更新 document title 的 effect:

function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

類似的情況又出現了,這個 effect 是怎麼知道 count 的值是什麼呢?

你會發現他的概念基本上與上一章節中提到的 event handlers 其實是相同的:

  • count 是一個由 state 中取出的值,每次 render 裡自己的 count 變數都是永遠不變的
  • 我們傳入 useEffect 的 effect function 是一個每次 render 過程中都會重新建立的函式,它在裡面使用了本次 render 裡的 count 變數
  • 由於 closure 的特性,這個 effect function 所記得的 count 永遠都會是該次 render 的版本,而不會隨著後續的 component re-render 而有所改變

所以其實我們會為了每次 render 都建立一個新的、專屬版本的 effect function,它們會因為 closure 而各自「捕捉」自己所用到的 props 或 state:

function Example() {
  const [count, setCount] = useState(0);
  
  // 注意看這裡,其實是傳入一個 inline 的 function 給 useEffect 當作 effect function,
  // 因此每次 re-render 執行到這裡時都會產生一個新的 effect function,
  // 所以同一個 useEffect 的 effect function 其實是會在不同次 render 之間是不同的
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });
  
  // ...
}

我們一樣用逐次 render 模擬的方式來觀察這個概念:

// 在第一次 render 時
function Counter() {
  const count = 0; // 從 useState 回傳

  useEffect(
    // 在第一次 render 時的 effect 函式
    () => {
      document.title = `You clicked ${0} times`;
    }
  );
  // ...
}

// 經過一次點擊,我們的 component function 再次被執行
function Counter() {
  const count = 1; // 從 useState 回傳

  useEffect(
    // 在第二次 render 時的 effect 函式
    () => {
      document.title = `You clicked ${1} times`;
    }
  );
  // ...
}

// 經過另一次點擊,我們的 component function 再次被執行
function Counter() {
  const count = 2; // 從 useState 回傳

  useEffect(
    // 在第三次 render 時的 effect 函式
    () => {
      document.title = `You clicked ${2} times`;
    }
  );
  // ..
}

概念上來說,你可以想像 effects 是 render 輸出結果的副產物,每個 effect 都是專屬於特定的一次 render,它們會「記得」該次 render 版本的 props 與 state。


每一次 render 都有自己的 effect cleanup function

某些 effect 可能會需要做 cleanup 來避免一些意外的問題,例如執行訂閱動作的 effect 就會需要做取消訂閱的 cleanup 動作。我們來看看一個簡單的範例:

useEffect(() => {
  OrderAPI.subscribeStatus(props.id, handleChange);
  return () => {
    OrderAPI.unsubscribeStatus(props.id, handleChange);
  };
});

這是一個關於訂閱訂單狀態的 effect,我們假設首次 render 時 props 是 { id: 1 },然後第二次 render 時 props 是 { id: 2 }

// 第一次 render 時,props 是 { id: 1 }
function Example(props) {
  // ...
  useEffect(
    // 第一次 render 時產生的 effect function
    () => {
      OrderAPI.subscribeStatus(1, handleChange);
      // 清理第一次 render 的 effect
      return () => {
        OrderAPI.unsubscribeStatus(1, handleChange);
      };
    }
  );
  // ...
}

// 第二次 render 時,props 是 { id: 2 }
function Example(props) {
  // ...
  useEffect(
    // 第二次 render 時產生的 effect function
    () => {
      OrderAPI.subscribeStatus(2, handleChange);
      // 清理第二次 render 的 effect
      return () => {
        OrderAPI.unsubscribeStatus(2, handleChange);
      };
    }
  );
  // ...
}

我們需要先稍微解釋一下 effect 與 cleanup 在 render 流程中發生的時間點與流程:

  1. React 以本次 render 的 props 與 state 產生對應的 UI,也就是 React elements
  2. 瀏覽器完成畫面的繪製,我們可以在瀏覽器畫面中看到結果
  3. 清理前一次 render 的 effect 而執行「前一次 render 的 cleanup
    • 當然,如果是首次 render 的話就不會有這個環節
  4. 執行本次 render 的 effect

從上面的流程中可以看到,其實 effect 會在瀏覽器更新完實際畫面之後才會發生,這可以讓你的 App 的效能更好,因為大部分的 effect 其實都沒必要阻擋畫面的更新,所以大多情況下應該要讓瀏覽器的畫面繪製更優先完成。

另外,你還會發現 cleanup 其實並不是在 effect 執行完成之後就會立刻進行,而是下一次 render 的 effect 準備要執行前才會執行前一次 render 的 cleanup,所以我們可以將上面的範例拆解成像這樣的細流:

  1. 首次 render 時,props 是 { id: 1 }
    1. 以 props { id: 1 } 產生對應的 React elements
    2. 瀏覽器完成畫面的繪製,我們可以在瀏覽器中看到對應 props { id: 1 } 的畫面結果
    3. 執行本次 render 時 props 是 { id: 1 } 的 effect
  2. 第二次 render 時,props 是 { id: 2 }
    1. 以 props { id: 2 } 產生對應的 React elements
    2. 瀏覽器完成畫面的繪製,我們可以在瀏覽器中看到對應 props { id: 2 } 的畫面結果
    3. 清理前一次 render 的 effect:執行前一次 render 時 props 是 { id: 1 } 的 cleanup function
    4. 執行本次 render 時 props 是 { id: 2 } 的 effect

所以當我們第二次 render 時,會執行第一次 render 的 effect cleanup function。類似問題又來了,這個 cleanup function 中取得的 props.id 會是 1 還是最新的值 2

相信在經過這麼多次類似的情況之後,你應該可以舉一反三的的思考了:cleanup function 也是一次 render 流程的結果的副產物,它在每次的 render 中都會重新被產生,並依賴該次 render 中 props 與 state,而這些 props 與 state 都是永遠不變的,因此無論這個 cleanup function 多久之後才被執行,它所找到的 props 與 state 永遠是固定的。

因此在上面的範例中,雖然第二次 render 時才會執行第一次 render 的 effect cleanup,但這個 cleanup function 已經在第一次 render 建立的時候透過 closure 永遠的「記住」了第一次 render 中的 props { id: 1 }

// 第一次 render 的 props 是 { id: 1 } 時的 effect cleanup function
return () => {
  OrderAPI.unsubscribeStatus(1, handleChange);
};

講到這邊,我們終於可以將前面的這些篇章做一個完整的總結:

Component 的每次 render 都有它自己的 props 與 state,它們的值是永遠不變的。而 render 中所定義產生的各種函式 — 包括 event handlers、effects、cleanups 等等,都會「捕捉並記得」這次 render 中的 props 與 state,因此無論這些函式在多久之後才被執行,它所找到的 props 與 state 永遠是固定的。


Immutable data 使得 closure 變得可靠而美好

在前面的篇幅中,我們深入的解析了關於 function component 的 render 中資料與函式的關聯。你會發現這個概念其中的關鍵點有兩個,一個是 immutable 的資料,另一個則是 closure,而想要達到這樣的效果則兩者缺一不可。

讓我們來以目前為止的理解再次回顧一下之前篇章提到過的「class component 與 function component 的關鍵區別」,幫助我們更清晰的內化這個觀念:

class BuyProductButton extends React.Component {
  showSuccessAlert = () => {
    alert(`購買商品「${this.props.productName}」成功!`); 
  };

  handleClick = () => {
    setTimeout(this.showSuccessAlert, 3000);
  };
  
  // ...
}

可以看到,在 class component 中如果我們以 this.props.xxxx 的方式來取得 props 資料的話,其實我們無法保證 showSuccessAlert 這個方法在任何時間點執行的結果都一致。由於當 props 有更新時 React 會 mutate this 這個物件,所以如果因為一些非同步的情況而導致執行 showSuccessAlert 方法時 this.props 已經被 React 替換過的話,就會發生「錯誤的取得了最新的資料」的情況。

所以,這個問題的根源在於「this 並不是一種保證 immutable 的固定資料」,它隨時有可能被外力修改其內容,因此我們無法保證使用到了 this 的函數在不同時間點多次的執行結果會一直維持不變。此時因為 closure 的特性而「記得」某個函式外的變數資料的行為其實是很難預測的,因為你很難完全掌握所依賴的資料會何時在其它地方被隨意修改,自然也很難保證函式的執行結果會是固定、可預期的。

而 function component 為什麼完全不會遇到這種問題?

function BuyProductButton(props) {
  const showSuccessAlert = () => {
    alert(`購買商品「${props.productName}」成功!`); 
  };

  const handleClick = () => {
    setTimeout(showSuccessAlert, 3000);
  };

  // ...
}

在 function component 中,props 與 state 都是透過注入的方式在每次 render 時重新取得的(props 是透過參數,state 是透過呼叫 useState 的回傳值),每次 render 之間的 props 與 state 都是獨立且永遠不變的。以上面這個例子來說,每次 render 時接收到的 props 參數物件都是不同的 instance,所以當這個 component re-render 且 props 改變時,它也不會 mutate 上一次 render 時的那個 props 參數物件,而是會傳入一個全新的 props 參數物件。

而我們的 showSuccessAlert 方法在 function component 的每次 render 時則都會重新產生一個該次 render 的專屬版本,依賴該次 render 的專屬 props。因此這個函式無論在何時何地被執行,它所記得的 props.productName 都永遠依舊是它產生時的那次 render 的版本。

所以 function component 之所以可以完美的解決了這個問題的關鍵,就在於「render 內的各種函式中以 closure 所記住並依賴的資料是 immutable 的,它們永遠不會發生改變」,此時這些函式的執行效果就反而變成了穩定且可預期的。它能夠做到讓 component 裡的函式也變成單向資料流的一部份,也就是說「當原始資料發生改變時,函式的執行效果才會連帶發生改變」,包含 event handlers、effects、cleanups…等等函式皆是如此。

這也是為什麼我一直不斷強調掌握 closure 特性對於學習 React 的重要性,因為它在 React 的開發中幾乎是隨處可見!以上解析的這些設計與觀念並不是 React 本身的什麼獨創技術或黑魔法,只是依賴了 JavaScript 本身一直都有的基礎特性而已。

因此當你的函式內依賴的外部變數是 immutable 的時候,closure 的特性其實會是可靠又美好的,讓開發者對於資料流的感知變得更簡單直覺,因為函式的執行效果總是固定且可預期的。


參考資料

  • A Complete Guide to useEffect - Overreacted
    • 本文所講的觀念蠻多的參考了這篇 Dan Abramov 的個人 blog 文章,吸收內化後再加上我自己的理解以及整理來解釋這些概念。非常推薦所有英文程度 ok 的 React 開發者去他的 blog 中閱讀原文

2024/2 更新 - 實體書平裝版本預購

在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~

《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》

目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:

天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695

博客來(平裝版):
https://www.books.com.tw/products/0010982322

momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845


上一篇
[Day 19] 每一次 render 都有自己的 props、state 以及 event handlers
下一篇
[Day 21] useEffect 其實不是 function component 的生命週期 API
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言