iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 8
3
Modern Web

從 Hooks 開始,讓你的網頁 React 起來系列 第 8

[Day 08 - 計數器] 一個不夠,給我一次來十個 - JSX 中迴圈的使用

今天會學到的內容

  • 將事件處理器建立成實名函數
  • 在事件處理器中帶入參數
  • 透過 JSX 重複渲染某一的組件

⚠️ 警告:密集恐懼症者慎入本篇內容。如果你連 iPhone 11 Pro 的三鏡頭都會覺得不適或感到恐懼的話,可能無法閱讀本文

內文開始

看到自己桌面上滿滿的計數器截圖,可以感受到讀者一定想說鐵人賽都快三分之一了,怎麼還在計數器,PJ 也太混了吧!為了因應觀眾需求,今天是我們看到計數器的最後一天,就讓我們準備跟它說再見吧。

什麼!準備要跟計數器說再見你覺得有點難過?你怕自己以後忘記怎麼寫計數器,所以覺得要重複一直做這個計數器的練習讓自己更熟練,你打算做 14 次練習,每次都會做一個新的,你心裡 OS:「嘿嘿嘿~練習 14 次總是不會忘記了吧!」。

下圖是你內心想像的畫面:

Imgur

沒料到,今天的練習要打破你的想法了!(不過如果你還是想要重複寫 14 次也是不反對拉...,熟能生巧是真的)

還記得我們曾經說過,使用組件(component)的好處在於可以快速地重複使用已經寫好的組件,而且每個組件的狀態都是獨立的,也就是說,你不會因為點了「第一個」計數器的向上按鈕,使得剩下其他計數器的數字也都加一,而是只有「第一個」計數器的數字會改變而已。

現在就讓我們開始今天的練習吧,學會今天的內容後,下次碰到需要重複的東西時,相信你都可以很有信心的說:「我要一次打十個!」

圖片來自電影葉問

將程式碼做整理

在開始戰鬥前,先讓我們整理一下昨天完成的程式碼,因為使用了 visibility: hidden 來隱藏箭頭的樣式比較好看,不會讓畫面排版有抖動的情況,因此就繼續沿用昨天 Day 7 - Counter with useState - conditional inline-style 的這個 CodePen,你可以 Fork 這份程式碼再繼續修改。

把 event handler 抽成一個函式

在昨天的程式碼中,我們是把使用者點擊按鈕時要做的事直接放在 onClick={}{} 內去執行:

Imgur

因為這裡 onClick 後只需呼叫 setCount 這個方法,因此並不會有什麼大問題,但若現在 onClick 後需要做更多的事情時,直接把這個事件處理器(event handlers)寫在 JSX 的行內可能就會變得比較難管理。因此,為了程式碼管理上的方便,有時會把事件處理器先定義成一個函式,在 onClick 後去呼叫這個函式即可。

可以把 onClick 裡面的函式拉出來,分別取名為 handleIncrementhandleDecrement,像是這樣:

// ...
const Counter = () => {
  const [count, setCount] = useState(5);

  const handleIncrement = () => {
    setCount(count + 1);
  };

  const handleDecrement = () => {
    setCount(count - 1);
  };

  return (
    // ...
  );
};

由於目前這兩個函式裡面並沒有做任何其他的操作,因此在箭頭函式中可以在 => 後直接呼叫 setCount 方法,就可以把上面的內容精簡成:

// ...
const Counter = () => {
  const [count, setCount] = useState(5);

  const handleIncrement = () => setCount(count + 1);
  const handleDecrement = () => setCount(count - 1);

  return (
    // ...
  );
};

最後在把 handleIncrementhandleDecrement 放到 onClick 內的 {} 內即可。

在功能不變的情況下,整個程式碼又變的更精簡:

Imgur

完整的程式碼可參考 Day 8 - Multiple Counters - clean code with event handlers @ CodePen

在 event handlers 中帶入參數

雖然現在程式碼看起來又乾淨了不少,但你可能會想說,看起來 handleIncrementhandleDecrement 做的事好像差不多,那可不可以把它們包在一起,寫成一個稱作 handleClick 的函式,接著 handleClick 中帶入一個名為 type 的參數,當 typeincrement 的時候就呼叫 setCount(count + 1);當 typedecrement 的時候就呼叫 setCount(count - 1),像是這樣:

const Counter = () => {
  const [count, setCount] = useState(5);

  const handleClick = (type) => {
    if (type === 'increment') {
      setCount(count + 1);
    }
    if (type === 'decrement') {
      setCount(count - 1);
    }
  };

  return (
    // ...
  );
};

函式後加上小括號會直接呼叫該函式

這麼做當然也是可以的,但要特別留意在放入 onClick 中的內容,很多時候因為在呼叫 handleClick 這個方法的同時又要帶入參數,在 onClick 的地方一不小心可能會寫成這樣 onClick={handleClick('increment')}

Imgur

但當你這麼做時,程式是無法正確執行的:

Imgur

為什麼錯誤訊息會炸的亂七八糟呢?當我們寫成 onClick={handleClick('increment')} 時,是什麼意思呢?

和剛剛我們寫 onClick={handleIncrement} 不同,當我們寫 onClick={handleClick('increment')} 時,我們預期的的是「當使用者點擊按鈕時,會去執行 handleClick('increment') 這個方法」。但實際上,因為 handleClick 後面直接加上了小括號 ('increment'),因此當 JavaScript 執行到這裡的時候,這個 handleClick 函式就已經被執行了

Imgur

所以實際上畫面在渲染的時候,就執行了 handleClick 這個函式,這時候就呼叫到了 setCount();當 setCount 被呼叫到時,React 發現就會去檢查 count 的值,發現 count 不一樣之後,又會去更新畫面,於是就進入了無限迴圈...

Imgur

這也就是為什麼在錯誤訊息中會看到「Uncaught Invariant Violation: Too many re-renders. React limits the number of renders to prevent an infinite loop.」,因為它陷入無窮迴圈,畫面一直重複渲染。

要解決這樣的問題需要把 handleClick() 包在一個函式中,讓它不會在畫面渲染時馬上被執行,寫法上可以這麼做:

Imgur

這樣的話,畫面渲染的時候 handleClick 就不會馬上被執行,而是在使用者點擊按鈕的時候才會去執行 () => handleClick('increment') 這個函式。

此部分完整的程式碼如下,或參考 Day 8 - Multiple Counters - event handlers with parameters @ CodePen:

const { useState } = React;

const Counter = () => {
  const [count, setCount] = useState(5);

  const handleClick = (type) => {
    if (type === 'increment') {
      setCount(count + 1);
    }
    if (type === 'decrement') {
      setCount(count - 1);
    }
  };

  return (
    <div className="container">
      <div
        className="chevron chevron-up"
        onClick={() => handleClick('increment')}
        style={{
          visibility: count >= 10 && 'hidden',
        }}
      />

      <div className="number">{count}</div>

      <div
        className="chevron chevron-down"
        onClick={() => handleClick('decrement')}
        style={{
          visibility: count <= 0 && 'hidden',
        }}
      />
    </div>
  );
};

ReactDOM.render(<Counter />, document.getElementById('root'));

進階寫法:讓函式執行後回傳另一個函式(可跳過)

這個寫法比較進階一點,如果你對於 JavaScript 還不是那麼熟悉的話,建議可以先跳過這段。

如果你覺得既然都已經把事件處理器抽成函式,卻又要在 onClick={}{} 內多包一個函式很多餘的話,也可以把 handleClick 改成這樣,讓 handleClick 被執行的時候實際上是回傳一個已經帶有 type 的函式:

// handleClick('increment') 執行的時候實際上是回傳一個 type 為 increment 的函式
const handleClick = (type) => {
  return function() {
    if (type === 'increment') {
      setCount(count + 1);
    }
    if (type === 'decrement') {
      setCount(count - 1);
    }
  };
};

那麼在 onClick 中就可以寫像這樣:

return (
  <div className="container">
    <div
      className="chevron chevron-up"
      onClick={handleClick('increment')}
      style={{
        visibility: count >= 10 && 'hidden',
      }}
    />

    {/* ... */}

    <div
      className="chevron chevron-down"
      onClick={handleClick('decrement')}
      style={{
        visibility: count <= 0 && 'hidden',
      }}
    />
  </div>
);

之所以可以這樣寫,是因為當畫面渲染的時候,雖然 handleClick('increment') 會馬上被執行沒錯,但 handleClick('increment') 執行後並不是馬上去呼叫 setCount 方法,實際上是回傳了另一個 typeincrement 的函式到 {} 內。這個被回傳的函式一樣會在按鈕的點擊事件被促發時被呼叫到。

上面提到的 handleClick 方法,由於是一個函式直接回傳另一個函式,因此在箭頭函式中,甚至可以精簡成這樣:

const handleClick = (type) => () => {
  if (type === 'increment') {
    setCount(count + 1);
  }
  if (type === 'decrement') {
    setCount(count - 1);
  }
};

意思一樣是當 handleClick 執行後會回傳 (type) => 後面 () => { ... } 的這個函式。

Imgur

如果你覺得都是 setCount() 只是一個是 + 1,另一個是 - 1,甚至可以再精簡成這樣:

const handleClick = type => () =>
    setCount(type === 'increment' ? count + 1 : count - 1);

handleClick 被呼叫的時候,會回傳一個函式,這個函式才是事件處理器,而這個事件處理器也只是根據 type 的不同去執行 setCount

如果你覺得一下還沒辦法接受這種寫法,看得頭昏腦脹的話,你可以先把這整段略過,等之後越來越熟練後,就會比較容易理解了。完整的程式碼如下,也可以參考 Day 8 - Multiple Counters - event handlers with parameters (Advanced) @ CodePen:

Imgur

一次打十個吧 - 在 JSX 中使用迴圈

上面把程式碼整理完成後,我們就用剛剛上面第一個完成的程式碼 Day 8 - Multiple Counters - clean code with event handlers 來繼續說明。

手動產生多個計數器

其實作法不難,因為 React 中每個組件其實都是各自獨立的,因此當我們想要一次產出非常多的計數器時,只需要寫很多次 <Counter />,讓我們先產生 7 個計數器就好,像下面這樣:

const Counter = () => {
  // ...
}

ReactDOM.render(
  <div>
    <Counter />
    <Counter />
    <Counter />
    <Counter />
    <Counter />
    <Counter />
    <Counter />
  </div>,
  document.getElementById("root")
);

提醒:記得因為一個 JSX 元素最多只能有一個最外層的元素,因此當我們要渲染很多的 <Counter /> 時,為了要讓外層只有一個元素,可以加上一個額外的 <div> 把所有 <Counter /> 包起來。

同時一併在最外層的 <div> 透過行內樣式(inline-style)添加 CSS 樣式,像是這樣:

// ...

ReactDOM.render(
  <div
    style={{
      display: 'flex',
      flexWrap: 'wrap',
    }}
  >
    <Counter />
    <Counter />
    <Counter />
    <Counter />
    <Counter />
    <Counter />
    <Counter />
  </div>,
  document.getElementById('root')
);

同時在 CSS 程式編輯區的 .container 中,把每個計數器透過 min-width 設定最小寬度:

.container {
  display: flex;
  align-items: center;
  flex-direction: column;
  min-width: 200px;
}

imgur

這時候你可以試著玩玩看,應該會發現每個計數器都是獨立的,彼此的數字不會互相干擾。沒錯,組件就是這麼方便:

Imgur

使用迴圈重複產生多個計數器

你可能會說,既然 JSX 本質上都是 JavaScript 了,難道還得要手動複製貼上 <Counter /> ,不能用迴圈的方式,看要幾個有幾個嗎?

當然是可以的!既然 JSX 本質上就是 JavaScript ,那麼你當然可以使用 JavaScript 學到的方式來重複產生多個計數器。

當在 JavaScript 中要重複執行某一個內容或動作時,很直覺的會想到可以用 for 迴圈。首先你可能會很直覺的這麼寫:

// ❌ 錯誤寫法:for 不是 expressions 不能直接放在 JSX 的 {} 內
// ...
ReactDOM.render(
  <div
    style={{
      display: 'flex',
      flexWrap: 'wrap',
    }}
  >
    {
      for (let i = 0; i < 10; i ++) {
        <Counter />
      }
    }
  </div>,
  document.getElementById('root')
);

但這麼做程式並沒有辦法正確執行,原因在 for 迴圈 本身是個 statements 而非 expressions,執行的時候並不會有回傳值,因此不能直接放到 JSX 中的 {} 內去執行。那麼實際上可以怎麼做呢?

在 React 中,當我們要做重複渲染多個組件時,最常使用到的是透過陣列的 map 方法,因為 map 這個方法會有回傳值,所以可以直接在 JSX 中使用。

實際的做法會像這樣:

  1. 透過 Array.from() 先建立一個帶有 n 個元素的陣列
  2. 在 JSX 中將這個陣列使用 map 方法,並且每次都回傳 <Counter /> 元素

透過 Array.from 一次產生帶有 n 個元素的陣列

在建立帶有多個元素的陣列時,經常會使用到 Array.from() 這個方法,下面列出常用的方式:

// 產生元素數目為 10,元素值都為 undefined 的陣列
Array.from({ length: 10 });    // [undefined, undefined, ..., undefined]

// 產生元素數目為 10,元素值為 0 ~ 9 的陣列
Array.from({ length: 10 }, (_, index) => index); // [0, 1, 2, ..., 8, 9]

因此透過 Array.from() 我們可以先建立一個帶有 n 個元素的陣列,並取名為 counters

// STEP 1: 建立元素數目為 14,內容為 [0, 1, ..., 13]
const counters = Array.from({ length: 14 }, (_, index) => index);

Array.from() 詳細的用法可以參考 MDN 的說明

透過陣列的 map 方法來執行迴圈

接下來,就可以在 JSX 中透過在 {} 內使用 counters.map 的方式,就可以產生帶有多個 <Counter /> 的陣列,像是這樣:

// ...
ReactDOM.render(
  <div
    style={{
      display: 'flex',
      flexWrap: 'wrap',
    }}
  >
    {/* STEP 2: 使用 map 產生多個 <Counter /> */}
    {counters.map((item) => (
      <Counter />
    ))}
  </div>,
  document.getElementById('root')
);

實際上 counters.map 會產生帶有許多 <Counter /> 組件的陣列,而 JSX 在解析的時候,會就會把這個陣列中的 React 組件渲染出來:

// ...
ReactDOM.render(
  <div
    style={{
      display: 'flex',
      flexWrap: 'wrap',
    }}
  >
    {/* STEP 2: 使用 map 產生多個 <Counter /> */}
    {[
      <Counter />,
      <Counter />,
      <Counter />,
      //...
    ]}
  </div>,
  document.getElementById('root')
);

現在,你只需要更改 Array.from({ length: n })n 的數目,就可以根據你的需要產生不同數量的計數器。只要你的電腦夠強,你要一次打 100 個也沒問題拉!

完整的程式碼如下,或參考 Day 8 - Multiple Counters Finished @ CodePen:

const { useState } = React;

// STEP 1: 建立元素數目為 14,內容為 [0, 1, ..., 13]
const counters = Array.from({ length: 14 }, (_, index) => index);

const Counter = () => {
  const [count, setCount] = useState(5);

  const handleIncrement = () => setCount(count + 1);
  const handleDecrement = () => setCount(count - 1);

  return (
    <div className="container">
      <div
        className="chevron chevron-up"
        onClick={handleIncrement}
        style={{
          visibility: count >= 10 && 'hidden',
        }}
      />

      <div className="number">{count}</div>

      <div
        className="chevron chevron-down"
        onClick={handleDecrement}
        style={{
          visibility: count <= 0 && 'hidden',
        }}
      />
    </div>
  );
};

ReactDOM.render(
  <div
    style={{
      display: 'flex',
      flexWrap: 'wrap',
    }}
  >
    {/* STEP 2: 使用 map 產生多個 <Counter /> */}
    {counters.map((item) => (
      <Counter />
    ))}
  </div>,
  document.getElementById('root')
);

程式範例

參考資源


上一篇
[Day 07 - 計數器] 幫計數器設個最大最小值吧 - JSX 中條件渲染的使用
下一篇
[Day 09 - 網速換算器] 網速傻傻分不清楚 Mbps? Mb/s? 來寫個單位換算器吧
系列文
從 Hooks 開始,讓你的網頁 React 起來30
0
阿展展展
iT邦好手 1 級 ‧ 2019-10-09 05:11:43

看到這種一排數字的計數器就會想要排列成 "87878787"
(對不起我去面壁思過......

你的做法非常正確??
我覺得 "55665566" 也是不錯的選擇XDD

56不能亡 (拭淚

0
Chanxz
iT邦新手 5 級 ‧ 2019-12-27 11:18:54

想請教一個問題,這裏的Count應該不能共用這個count變量的吧?只是為了舉例這樣寫的吧?

你好,不太懂你的意思,能否多說明一些呢?

0
sunbu
iT邦新手 5 級 ‧ 2020-01-29 17:10:32

講解 for 不是 expression 那邊程式碼的註解寫成 if 囉~

非常謝謝你!已經修正了!

0
hk7math
iT邦新手 5 級 ‧ 2020-02-23 23:06:07
[...Array(5).keys()]

上面這個好像比下面簡單

Array.from({ length: 10 }, (_, index) => index)

真的耶!學習了!非常感謝!!

我要留言

立即登入留言