iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 21
3
Modern Web

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

[Day 21 - 即時天氣] 處理天氣圖示以及 useMemo 的使用

昨天學到了 useCallback 能讓 React 組件內所定義的函式在 dependencies 不變的情況被「保存」下來,如此便可以把希望被覆用的函式放到 useEffect 中使用。

今天終於要來處理這個即時天氣 App 中最軟的一塊,就是天氣圖示的部分,這個功能的實作其實並不困難,但因為圖示很多而有些麻煩:

Imgur

就讓我們開始吧!

首先,一樣可以在 CodeSandbox 上打開或複製昨天完成的專案 Weather APP - fetch data with useCallback,繼續接著做下去。

建立並使用 WeatherIcon 組件

因為有很多不同的天氣型態需要判斷並對應到不同的天氣圖示,把這些判斷邏輯都寫在 <WeatherApp /> 組件中會顯得有些雜亂,所以我們把天氣圖示的呈現拆成另一個 React 組件:

  1. ./src 中新增一支名為 WeatherIcon.js 的檔案
  2. 把原本撰寫在 WeatherApp.js 中關於 <CloudyIcon /> 的這塊拆到 WeatherIcon 的組件內

變更的部分如下圖:

Imgur

另外因為之後會有許多不同的天氣圖示,所以透過 max-height 限制一下圖片的最大高度為 110pxWeatherIcon.js 的程式內容如下:

// ./src/WeatherIcon.js
import React from 'react';
import styled from '@emotion/styled';
import { ReactComponent as CloudyIcon } from './images/cloudy.svg';

const IconContainer = styled.div`
  flex-basis: 30%;

  svg {
    max-height: 110px;
  }
`;

const WeatherIcon = () => {
  return (
    <IconContainer>
      <CloudyIcon />
    </IconContainer>
  );
};

export default WeatherIcon;

WeatherApp.js 中只需把 <WeatherIcon /> 這個組件載入並放到 JSX 中:

// ./src/WeatherApp.js

// ...
// STEP 1:載入 WeatherIcon
import WeatherIcon from './WeatherIcon.js';

// ...
const WeatherApp = () => {
  const [weatherElement, setWeatherElement] = useState({/* ... */});

  // ...
  useEffect(() => {/* ... */}, [fetchData]);

  return (
    <Container>
      <WeatherCard>
        {/* ... */}
        <CurrentWeather>
          {/* ... */}
          {/* STEP 2:使用 WeatherIcon */}
          <WeatherIcon />
        </CurrentWeather>
        {/* ... */}
      </WeatherCard>
    </Container>
  );
};

export default WeatherApp;

如此就把 <WeatherIcon /> 拆成一個獨立的組件了,畫面也不會有任何變動。

定義天氣代碼要對應到的天氣圖示

中央氣象局 API 透過 fetchWeatherForecast 取回的資料中,可以取得天氣的類型,接下來需要在 <WeatherIcon /> 中去判斷不同的天氣類型需要顯示什麼樣的天氣圖示。這是一個有些繁瑣的工作,不過我大致上都整理好了...。

從中央氣象局提供的「預報XML產品預報因子欄位中文說明表 」這份文件中,可以看到所有的**天氣代碼(分類代碼)**一共有 42 種:

Imgur

上傳並載入 SVG 天氣圖示

天氣的圖示一樣會使用 IconFinder 上免費的 The Weather is Nice Today 圖示,把需要用到的圖示先過濾出來放在下圖的左半部,考量到希望即時天氣 App 能夠在白天和晚上顯示不同的圖示,因此最後只選出有用黃色框起來部分的圖示:

Imgur

在這裡已經把專案中會用到的圖示部份轉成 SVG 上傳到 Dropbox 上可供下載。只需把所有這些 SVG 圖示透過拖曳的方式,拉到 CodeSandbox 的 ./src/images 資料夾內即可:

Imgur

接著就可以在 WeatherIcon.js 中把剛剛上傳的這些圖片都當作 React 組件匯入:

// ./src/WeatherIcon.js
import React from 'react';
import styled from '@emotion/styled';
import { ReactComponent as DayThunderstorm } from './images/day-thunderstorm.svg';
import { ReactComponent as DayClear } from './images/day-clear.svg';
import { ReactComponent as DayCloudyFog } from './images/day-cloudy-fog.svg';
import { ReactComponent as DayCloudy } from './images/day-cloudy.svg';
import { ReactComponent as DayFog } from './images/day-fog.svg';
import { ReactComponent as DayPartiallyClearWithRain } from './images/day-partially-clear-with-rain.svg';
import { ReactComponent as DaySnowing } from './images/day-snowing.svg';
import { ReactComponent as NightThunderstorm } from './images/night-thunderstorm.svg';
import { ReactComponent as NightClear } from './images/night-clear.svg';
import { ReactComponent as NightCloudyFog } from './images/night-cloudy-fog.svg';
import { ReactComponent as NightCloudy } from './images/night-cloudy.svg';
import { ReactComponent as NightFog } from './images/night-fog.svg';
import { ReactComponent as NightPartiallyClearWithRain } from './images/night-partially-clear-with-rain.svg';
import { ReactComponent as NightSnowing } from './images/night-snowing.svg';

const IconContainer = styled.div`
  flex-basis: 30%;

  svg {
    max-height: 110px;
  }
`;

const WeatherIcon = () => {
  return (
    <IconContainer>
      <DayClear />
    </IconContainer>
  );
};

export default WeatherIcon;

提示:如果對於如何將 SVG 匯入 React 組件中仍不清楚,可回頭參考 Day 15 - 就是這個畫面 - 使用 Emotion 為組件增添 CSS 樣式

定義天氣代碼會對應到的到天氣圖示

之所以會需要定義「天氣代碼」到「天氣型態」的對應表,是因為在「預報XML產品預報因子欄位中文說明表 」中,不同的「天氣代碼(分類代碼)」會對應到相同或相似的「天氣型態」,以下圖為例,可以看到「天氣代碼」為 15, 16, 17, 18 時,對應到的天氣型態都屬於「雷雨」:

Imgur

因此,我們需要一個對應表來把這些「天氣代碼」對應到「天氣型態」,這裡我把這張表取名為 weatherTypes,並放到 ./src/WeatherIcon.js 中:

// ./src/WeatherIcon.js

import React from 'react';

// ...
const weatherTypes = {
  isThunderstorm: [15, 16, 17, 18, 21, 22, 33, 34, 35, 36, 41],
  isClear: [1],
  isCloudyFog: [25, 26, 27, 28],
  isCloudy: [2, 3, 4, 5, 6, 7],
  isFog: [24],
  isPartiallyClearWithRain: [
    8, 9, 10, 11, 12,
    13, 14, 19, 20, 29, 30,
    31, 32, 38, 39,
  ],
  isSnowing: [23, 37, 42],
};

// ...
const WeatherIcon = () => {/* ... */};

export default WeatherIcon;

從這個 weatherTypes 表中可以看到,如果天氣代碼是屬於 15, 16, 17, 18, ... 這其中一種的話,都屬於雷陣雨(isThunderstorm);如果天氣代碼是 1 的話則表示晴天(isClear)。

能夠將「天氣代碼」對應到特定的「天氣型態」後,因為所有的天氣圖示中都有分成白天(day)和晚上(night),所以會再定義一個能夠將「天氣型態」對應到「天氣圖示」的變數,稱作 weatherIcons,一樣放到 ./src/WeatherIcon.js 中:

// ./src/WeatherIcon.js

import React from 'react';
// ...

const weatherTypes = {
  // ...
};

const weatherIcons = {
  day: {
    isThunderstorm: <DayThunderstorm />,
    isClear: <DayClear />,
    isCloudyFog: <DayCloudyFog />,
    isCloudy: <DayCloudy />,
    isFog: <DayFog />,
    isPartiallyClearWithRain: <DayPartiallyClearWithRain />,
    isSnowing: <DaySnowing />,
  },
  night: {
    isThunderstorm: <NightThunderstorm />,
    isClear: <NightClear />,
    isCloudyFog: <NightCloudyFog />,
    isCloudy: <NightCloudy />,
    isFog: <NightFog />,
    isPartiallyClearWithRain: <NightPartiallyClearWithRain />,
    isSnowing: <NightSnowing />,
  },
};

// ...
const WeatherIcon = () => {
  // ...
};

export default WeatherIcon;

整個對應的關係會像這樣:

Imgur

透過 weatherTypesweatherIcons 這兩個變數,就可以找出某一「天氣代碼」需要對應顯示哪一張「天氣圖示」。舉例來說,如果從 API 取得的「天氣代碼」是 1,那麼透過 weatherTypes 這個變數,就可以知道這個「天氣代碼」對應到的「天氣型態」是屬於「晴天(isClear)」;如果當時是晚上(night),那麼從 weatherIcons 中就可以透過 weatherIcons.night.isClear 去找到要顯示的圖示。

建立根據天氣代碼找出對應天氣型態的函式

⚠️ 提示:這個部分會用到較多處理陣列的方法,包含 Array.prototype.reduceArray.prototype.includes 的方法,若對於這個部分較不熟悉的話,可以先大概看過。只需知道這裡建立的 weatherCode2Type 函式,可以將「天氣代碼」轉換成「天氣型態」。

先來定義一個名為 weatherCode2Type 函式用來將「天氣代碼」轉換成「天氣型態」,這個函式的流程像這樣:

  • 假設從 API 取得的天氣代碼(currentWeatherCode)是 1
  • 使用 Object.entriesweatherTypes 這個物件的 key 和 value 轉成陣列,把 key 取做 weatherType,把 value 取做 weatherCodes
  • 針對該陣列使用 find 方法來跑迴圈,搭配 includes 方法來檢驗 API 回傳的「天氣代碼」,會對應到哪一種「天氣型態」
  • 找到的陣列會長像這樣 ['isClear', [1]],因此可以透過透過陣列的賦值,取出陣列的第一個元素,並取名為 weatherType 後回傳
// 假設從 API 取得的天氣代碼是 1
const currentWeatherCode = 1;

// 使用迴圈來找出該天氣代碼對應到的天氣型態
const weatherCode2Type = (weatherCode) => {
  const [weatherType] =
    Object.entries(weatherTypes).find(([weatherType, weatherCodes]) =>
      weatherCodes.includes(Number(weatherCode))
    ) || [];

  return weatherType;
};

console.log(weatherCode2Type(currentWeatherCode)); // isClear

weatherCode2Type 的方法中,當 currentWeatherCode 是 1 個時候,我們會知道該天氣型態會是 isClear,接下來就只需要判斷是白天還是晚上來從 weatherIcons 中找出對應的 SVG 圖示。如果是晚上的話,就會使用 weatherIcons.night.isClear 這張天氣圖示。

這個方法的邏輯稍微有些複雜,如果對於陣列的處理還不是那麼熟悉的話,可以先大概看過。只需知道這裡建立的 weatherCode2Type 函式,可以將「天氣代碼」轉換成「天氣型態」,這部分的程式碼有放在 repl.it 上,也可以在上面測試玩玩看,會對於 weatherCode2Type 這個方法比較理解:

Imgur

weatherCode2Type 程式碼範例 @ Repl.it

組件間資料的傳遞

將需要的資料傳入子層組件

現在 API 是在 WeatherApp 這個組件中呼叫,而 WeatherIcon 需要知道 API 回傳的天氣代碼(weatherElement.weatherCode),因此我們要透過 props 從 WeatherApp 組件把資料傳入 WeatherIcon 組件。

目前因為我們從 API 只能取得「天氣代碼」,還沒有辦法得知當時是白天(day)還是晚上(night),因此把 moment 先固定為晚上(night)。如下圖所示:

Imgur

提示:若對於資料如何從父組件傳遞到子組件還不太清楚的話,可以回頭參考 Day 11 - 那個...資料可以分享給我嗎 - 將資料傳入組件

取得傳入組件的資料

現在 <WeatherIcon /> 就可以根據傳入的 props 來判斷要顯示的天氣圖示了:

  1. react 中載入 useState
  2. 在函式的參數中直接透過解構賦值將所需的資料從 props 取出(等同於 props.currentWeatherCodeprops.moment
  3. 透過 useState 定義當前使用的圖示名稱,預設值先設成 isClear,記得在程式的最上發要把 useState 的方法匯入
  4. 從定義好的 weatherIcons 中找出對應的 SVG 圖示
// ./src/WeatherIcon.js

// STEP 1:載入 useState
import React, { useState } from 'react';

// ...
// STEP 2:使用解構賦值將所需的資料從 props 取出
const WeatherIcon = ({ currentWeatherCode, moment}) => {
  // STEP 3:透過 useState 定義當前使用的圖示名稱,預設值設為 `isClear`
  const [currentWeatherIcon, setCurrentWeatherIcon] = useState('isClear');

  return (
    // STEP 3:從 weatherIcons 中找出對應的圖示
    <IconContainer>{weatherIcons[moment][currentWeatherIcon]}</IconContainer>
  );
};

export default WeatherIcon;

到目前為止,如果程式沒有問題的話,你應該會看到如下圖的畫面。這裡因為 moment 先固定為 nightcurrentWeatherIcon 預設值是 isClear,所以會產生的是「晚上晴朗(night-clear.svg)」的圖示:

Imgur

根據天氣代碼呈現天氣圖示 - 留意 useEffect 的使用

最後一步就是根據天氣代碼來呈現對應的天氣圖示,在這裡只需要把我們剛剛撰寫好的 weatherCode2Type 方法,放到 useEffect 中,在根據「天氣代碼」找到對應的「天氣型態」後,在透過 setCurrentWeatherIcon 去更改天氣圖示就可以了:

  1. react 中載入 useEffect
  2. useEffect 中放入 weatherCode2Type 方法,這裡因為 weatherCode2Type 這個函式並沒有需要在其他地方被重複使用,因此可以直接在 useEffect 內定義該函式就可以
  3. 透過 setCurrentWeatherIcon 修改組件內的資料狀態
  4. 當我們這樣寫好了之後,你會發現畫面並不會更新!! 第四步將會是這裡的重點!!
// ./src/WeatherIcon.js

// STEP 1:載入 useEffect
import React, { useState, useEffect } from 'react';

// ...
const WeatherIcon = ({ currentWeatherCode, moment }) => {
  const [currentWeatherIcon, setCurrentWeatherIcon] = useState('isClear');

  useEffect(() => {
    // STEP 2:因為 weatherCode2Type 方法沒有要覆用,直接放到 `useEffect` 內即可
   const weatherCode2Type = (weatherCode) => {
      const [weatherType] =
        Object.entries(weatherTypes).find(([weatherType, weatherCodes]) =>
          weatherCodes.includes(Number(weatherCode))
        ) || [];

      return weatherType;
    };
    const currentWeatherIcon = weatherCode2Type(currentWeatherCode);

    // STEP 3:透過 `setCurrentWeatherIcon` 修改組件內的資料狀態
    setCurrentWeatherIcon(currentWeatherIcon);

    // STEP 4:...
  }, []);

  return (
    <IconContainer>{weatherIcons[moment][currentWeatherIcon]}</IconContainer>
  );
};

export default WeatherIcon;

可以想想看畫面為什麼不會更新嗎?回顧一下之前提到關於 useEffect 最重要的那句話!!

「組件渲染完後,如果 dependencies 有改變,才會呼叫 useEffect 內的 function」

你想到了嗎?在這裡由外面組件傳進來的 currentWeatherCode 在還沒有從 API 拉取到資料前,會使用 useState 中所定義的預設值,也就是 0。等到透過 AJAX 取得中央氣象局的資料回應後,會呼叫 setWeatherElement 這個方法,進而更新 currentWeatherCode 的值,然後 <WeatherIcon currentWeatherCode={weatherElement.weatherCode}/> 就可以取得最新的天氣代碼。

可是為什麼畫面沒有更新呢?再回顧一次那句關於 useEffect 最重要的話:

「組件渲染完後,如果 dependencies 有改變,才會呼叫 useEffect 內的 function」

在上面的程式碼中, useEffect 的 dependencies 陣列中並沒有放入任何元素,也就是說,這個 useEffect 內的函式只會執行一次,就不會再被呼叫到了,這也就是為什麼雖然外部已經傳進來新的 currentWeatherCode,但是因為 useEffect 內的 setCurrentWeatherIcon 並沒有再被呼叫到,因此畫面也就沒有被更新到了。

要解決這個問題很簡單,就是把在 useEffect 中有相依到的資料狀態放到 dependencies 中就可以了,在這裡也就是 currentWeatherCode,根據 useEffect 的原則,因為 currentWeatherCode 已經放到 dependencies 這陣列中,所以一旦它有改變,useEffect 內的函式就會再次被呼叫到,進而呼叫到 setCurrentWeatherIcon 並觸發畫面重新渲染。

所以在上面程式碼中的第四步,就是要在 dependencies 中放入 [currentWeatherCode]),像下面這樣:

// ./src/WeatherIcon.js

// STEP 1:載入 useEffect
import React, { useState, useEffect } from 'react';
// ...

const WeatherIcon = ({ currentWeatherCode, moment }) => {
  const [currentWeatherIcon, setCurrentWeatherIcon] = useState('isClear');

  useEffect(() => {
    // STEP 2:因為 weatherCode2Type 方法沒有要覆用,直接放到 `useEffect` 內即可
    const weatherCode2Type = (weatherCode) => {
      const [weatherType] =
        Object.entries(weatherTypes).find(([weatherType, weatherCodes]) =>
          weatherCodes.includes(Number(weatherCode))
        ) || [];

      return weatherType;
    };
    const currentWeatherIcon = weatherCode2Type(currentWeatherCode);

    // STEP 3:透過 `setCurrentWeatherIcon` 修改組件內的資料狀態
    setCurrentWeatherIcon(currentWeatherIcon);

    // STEP 4:...
  }, [currentWeatherCode]);

  return (
    <IconContainer>{weatherIcons[moment][currentWeatherIcon]}</IconContainer>
  );
};

export default WeatherIcon;

如此畫面就會根據 currentWeatherCode 的不同而更新了。

這個部分的完整程式碼一樣可以在 CodeSandbox 上檢視 Weather APP - dynamic weather icon with useEffect

保存複雜運算的資料結果 - useMemo 使用

在上面的程式碼中,我們把用來找出天氣型態的這個 weatherCode2Type 方法放到了 useEffect 中,這麼做在程式運作上一樣並不會有什麼問題。

另一種做法是我們也可以把這類帶有複雜運算的過程,放到 useMemo 中,這個 useMemo 和我們昨天提到的 useCallback 功能非常類似,useCallback 是用來在 dependencies 沒有改變的情況下,把某個 function 保存下來;useMemo 則是會在 dependencies 沒有改變的情況下,把某個運算的結果保存下來,它的用法如下:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

只要 dependencies 的值沒有改變,useMemo 就會直接使用上一次計算過的結果而不會重新在運算一次。放到即時天氣 App 中可以這麼寫:

  1. react 中載入 useMemo
  2. 將原本轉換天氣狀態的 weatherCode2Type 函式放到組件外,避免每次組件重新選染時,該方法都被重新定義
  3. 透過 useMemo 取得並保存 weatherCode2Type 計算的結果,回傳的結果取名為 theWeatherIcon
  4. useMemo 的 dependencies 中放入 currentWeatherCode,當 currentWeatherCode 的值有變化的時候,useMemo 就會重新計算取值
  5. useEffect 的 dependencies 中放入 theWeatherIcon,當 theWeatherIcon 的值有變化時,才會再次執行 setCurrentWeatherIcon 來觸發畫面更新
// ./src/WeatherIcon.js

// STEP 1:載入 useMemo
import React, { useState, useEffect, useMemo } from 'react';
// ...

// STEP 2:把 weatherCode2Type 函式搬到組件外
const weatherCode2Type = (weatherCode) => {
  const [weatherType] =
    Object.entries(weatherTypes).find(([weatherType, weatherCodes]) =>
      weatherCodes.includes(Number(weatherCode))
    ) || [];

  return weatherType;
};

const WeatherIcon = ({ currentWeatherCode, moment }) => {
  const [currentWeatherIcon, setCurrentWeatherIcon] = useState('isClear');

  // STEP 3:透過 useMemo 保存計算結果,記得要在 dependencies 中放入 currentWeatherCode
  const theWeatherIcon = useMemo(() => weatherCode2Type(currentWeatherCode), [
    currentWeatherCode,
  ]);

  // STEP 4:在 useEffect 中去改變 currentWeatherIcon,記得定義 dependencies
  useEffect(() => {
    setCurrentWeatherIcon(theWeatherIcon);
  }, [theWeatherIcon]);

  return (
    <IconContainer>{weatherIcons[moment][currentWeatherIcon]}</IconContainer>
  );
};

export default WeatherIcon;

關於 useMemo 的使用有一點需要留意的是, useMemo 會在組件渲染時(rendering)被呼叫,因此不應該在這個時間點進行任何會有副作用(side effect)的操作;若需要有副作用的操作,則應該使用的是 useEffect 而不是 useMemo

補充:useCallback(fn, deps) 等同於 useMemo(() => fn, deps)

到目前為止,雖然天氣圖示還沒辦法根據白天或晚上而改變,但已經可以隨著天氣型態的不同而改變了。今天完整的程式碼一樣可以到 CodeSandbox 上查看 Weather APP - dynamic weather icon with useMemo

程式範例

參考資源


上一篇
[Day 20 - 即時天氣] 在 useEffect 中使用呼叫需被覆用的函式 - useCallback 的使用
下一篇
[Day 22 - 即時天氣] 讓白天和晚上使用不同天氣圖示
系列文
從 Hooks 開始,讓你的網頁 React 起來30

尚未有邦友留言

立即登入留言