iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 16
4
Modern Web

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

[Day 16 - 即時天氣] 定義並請求組件會使用到的資料 - useState 的更多使用

感謝 iT 邦幫忙與博碩文化,本系列文章已出版成書「從 Hooks 開始,讓你的網頁 React 起來」,首刷版稅將全額贊助 iT 邦幫忙鐵人賽,歡迎前往購書,鼓勵筆者撰寫更多優質文章。

昨天完成了整個即時天氣 App 的畫面後,現在我們就可以開始來串接資料了。首先從我們的版面來看,目前會需要的資料包括「城市名稱」、「天氣描述」、「當時溫度」、「風速」、「濕度」這幾個欄位:

Imgur

尋找合適的 API

這時候我們就需要回頭看中央氣象局的 API 是否有提供這些資料,因為我們目前要取得的是即時天氣資訊而非預報,因此從線上文件中可以在「觀測」的地方中尋找,其中比較適合我們需求的分別是下面這兩道 API:

Imgur

接著就可以在去細看這兩道 API 可以提供什麼資料,在 API 文件中都有非常詳細的說明:

在官方線上說明的 API 文件中,找到想要檢視的 API 後,點選「Try it out」,接著在 Authorization 欄位中填入第 13 天時註冊取得的授權碼後,就可以直接試打這些 API 了:

Imgur

實際檢視了這兩道 API 回傳的資料後,會發現局屬氣象站的地名通常都是某個城市名稱(例如,臺北、臺中、臺南、花蓮),而自動氣象站的地名比較多是地區名稱(例如,福山、萬丹、下營),因此我們先決定來使用「局屬氣象站-現在天氣觀測報告」這道 API。

局屬氣象站資料集說明檔中可以看到,這裡面提供了一些可以在即時天氣 App 中帶入的資料,分別是「觀測資料時間(obsTime)」、「風速(WDSD)」、「溫度(TEMP)」、「濕度(HUMD)」,看起來除了「氣象描述」之外,都可以取得。

關於「氣象描述」的部分在文件中有提到可以透過「H_Weather」取得,但實際上會發現回傳的資料大多是 null 或是 -99,即表示沒有資料。

除了「天氣描述」外,要用來決定天氣圖示的資料,像是「氣象描述」、「白天晚上」、「晴天或降雨」則無法從這些資料中看出來,這個部分或許之後要再靠其他的 API 支援。等到後面我們再來處理圖示這個部分。

定義組件中會用到的資料狀態 - useState

找到 API 可以提供的資料後,我們要先來定義在 React 組件中會用到的資料,要定義 React 中資料狀態的方法就是使用 useState,如果想要複習一下的話可以參考前幾天的 Day 06 - 計數器 - 醒醒啊!為什麼一動也不動 - useState 的使用

實作的步驟如下:

  1. 透過 importreact 套件中載入 useState
  2. 透過之前學到的 useState(<預設值>) 來定義資料狀態
  3. 將資料在透過 {} 代入 JSX 內
// ./src/WeatherApp.js

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

// ...

const WeatherApp = () => {
  // STEP 2:定義會使用到的資料狀態
  const [currentWeather, setCurrentWeather] = useState({
    observationTime: '2019-10-02 22:10:00',
    locationName: '臺北市',
    description: '多雲時晴',
    temperature: 27.5,
    windSpeed: 0.3,
    humid: 0.88,
  });

  // STEP 3:將資料帶入 JSX 中
  return (
    <Container>
      <WeatherCard>
        <Location>{currentWeather.locationName}</Location>
        <Description>
          {currentWeather.observationTime}
          {''}
          {currentWeather.description}
        </Description>
        <CurrentWeather>
          <Temperature>
            {currentWeather.temperature} <Celsius>°C</Celsius>
          </Temperature>
          <Cloudy />
        </CurrentWeather>
        <AirFlow>
          <AirFlowIcon />
          {currentWeather.windSpeed} m/h
        </AirFlow>
        <Rain>
          <RainIcon />
          {currentWeather.humid * 100} %
        </Rain>
        <Redo />
      </WeatherCard>
    </Container>
  );
};

export default WeatherApp;

可以稍微留意一下在 「Day 06 - 計數器 - 醒醒啊!為什麼一動也不動 - useState 的使用」這篇內容中,使用 useState 時,資料都是帶入一個數值或字串,但 useState 裡面也可以接一個物件,像是上面程式碼中這樣。

現在的畫面會像這樣:

Imgur

可以看到 observationTime 的呈現有點不太好看,另外溫度的部分也可以四捨五入捨棄掉小數點後面的值,讓我們來稍微調整一下:

  1. 優化時間呈現的部分,可以使用瀏覽器原生的 Intl 這個方法,這個方法的全名是 Internationalization API,它可以針對日期、時間、數字(貨幣)等資料進行多語系的呈現處理,相當方便,有興趣的話可以進一步參考 MDN 官方文件的說明。
  2. 優化溫度呈現的部分,則可以使用 Math.round() 做四捨五入即可。
// ./src/WeatherApp.js

// ...
const WeatherApp = () => {
  // ...
  return (
    <Container>
      <WeatherCard>
        {/* ... */}
        {/* STEP 1:優化時間呈現 */}
        <Description>
          {new Intl.DateTimeFormat('zh-TW', {
            hour: 'numeric',
            minute: 'numeric',
          }).format(new Date(currentWeather.observationTime))}
          {' '}
          {currentWeather.description}
        </Description>

        <CurrentWeather>
          <Temperature>
            {/* STEP 2:優化溫度呈現 */}
            {Math.round(currentWeather.temperature)} <Celsius>°C</Celsius>
          </Temperature>
          <Cloudy />
        </CurrentWeather>
        {/* ... */}
      </WeatherCard>
    </Container>
  );
};

現在的畫面將會長像這樣:

Imgur

透過 fetch API 發送 AJAX 請求

先來撰寫一段 AJAX 來像中央氣象局拉取資料,這裡我們使用瀏覽器原生的 fetch API 來發送請求,一般使用 fetch 發送 GET 請求時,只需要在 fetch(<requestURL>) 的方法中帶入 requestURL 作為參數,這個 fetch 會是一個 Promise,因此可以透過 .then 串連伺服器回傳的資料。

程式碼會像這樣:

fetch('<requestURL>')                  // 向 requestURL 發送請求
  .then((response) => response.json()) // 取得伺服器回傳的資料並以 JSON 解析
  .then((data) => console.log('data')); // 取得解析後的 JSON 資料

因此要發送請求,只需將 requestURL 的部分換成中央氣象局提供的 API 網址就可以了。

使用者點擊按鈕後向中央氣象局請求資料

可以有幾個不同時間點來向中央氣象局請求資料,一個是在畫面載入時就自動拉取一次,另一個是在使用者點擊「重新整理」按鈕時拉取資料。現在我們先做使用者主動點擊的方式。

我們只需先定義好 handleClick 方法,在 handleClick 內去呼叫中央氣象局 API,接著在 <Redo /> 按鈕綁上 onClick 事件,當事件被觸發時會呼叫 handleClick 方法:

// ./src/WeatherApp.js

// ...
const WeatherApp = () => {
  // ...

  // STEP 1:定義 handleClick 方法,並呼叫中央氣象局 API
  const handleClick = () => {
    fetch(
      'https://opendata.cwb.gov.tw/api/v1/rest/datastore/O-A0003-001?Authorization=你的授權碼&locationName=臺北'
    )
      .then((response) => response.json())
      .then((data) => {
        console.log('data', data);
      });
  };

  return (
    <Container>
      <WeatherCard>
        {/* ... */}
        {/* STEP 2:綁定 onClick 時會呼叫 handleClick 方法 */}
        <Redo onClick={handleClick}/>
      </WeatherCard>
    </Container>
  );
};

順利的話當我們點擊右下角的「重新整裡」按鈕時,就會向中央氣象局發送請求,並取得資料,你將可以在瀏覽器的 console 視窗中看到回傳的資料內容:

Imgur

將取得的資料帶回組件中

現在我們已經可以取得中央氣象局的觀測資料了,但是因為還沒被把這些資料內容帶回到 React 組件中,因此畫面並不會改變,這時候你可能已經想到了,要在改變資料的時候同時讓畫面重新渲染,就可以用 useState() 中回傳給我們的 setCurrentWeather 這個方法。

從回傳的資料來看,可以發現我們需要的資料藏的還蠻深的...。

Imgur

所以在使用 setCurrentWeather 來把這些資料帶回組件中時,需要先把用得到的資料取出來,稍微說明一下這裡的程式邏輯:

  1. 定義 locationData 把回傳的資料中會用到的部分取出來
  2. 因為風速(WDSD)、氣溫(TEMP)和濕度(HUMD)這些資料都存在 locationData.weatherElement 中,這裡透過陣列的 reduce 方法搭配 includes 可以把需要的資料取出來
  3. 把所有 React 組件中需要用到的資料存到 currentWeatherData
// STEP 1:定義 `locationData` 把回傳的資料中會用到的部分取出來
const locationData = data.records.location[0];

// STEP 2:將風速(WDSD)、氣溫(TEMP)和濕度(HUMD)的資料取出
const weatherElements = locationData.weatherElement.reduce(
  (neededElements, item) => {
    if (['WDSD', 'TEMP', 'HUMD'].includes(item.elementName)) {
      neededElements[item.elementName] = item.elementValue;
    }
    return neededElements;
  },
  {}
);

// STEP 3:要使用到 React 組件中的資料
const currentWeatherData = {
  observationTime: locationData.time.obsTime,
  locationName: locationData.locationName,
  description: '多雲時晴',
  temperature: weatherElements.TEMP,
  windSpeed: weatherElements.WDSD,
  humid: weatherElements.HUMD,
};

最後就把上面寫好的邏輯放到 handleClick 方法中:

// ./src/WeatherApp.js

const handleClick = () => {
  fetch(
    'https://opendata.cwb.gov.tw/api/v1/rest/datastore/O-A0003-001?Authorization=你的授權碼&locationName=臺北'
  )
    .then((response) => response.json())
    .then((data) => {
      // STEP 1:定義 `locationData` 把回傳的資料中會用到的部分取出來
      const locationData = data.records.location[0];

      // STEP 2:將風速(WDSD)、氣溫(TEMP)和濕度(HUMD)的資料取出
      const weatherElements = locationData.weatherElement.reduce(
        (neededElements, item) => {
          if (['WDSD', 'TEMP', 'HUMD'].includes(item.elementName)) {
            neededElements[item.elementName] = item.elementValue;
          }
          return neededElements;
        },
        {}
      );

      // STEP 3:要使用到 React 組件中的資料
      setCurrentWeather({
        observationTime: locationData.time.obsTime,
        locationName: locationData.locationName,
        description: '多雲時晴',
        temperature: weatherElements.TEMP,
        windSpeed: weatherElements.WDSD,
        humid: weatherElements.HUMD,
      });
    });
};

現在就可以點選重新整理來拉取資料拉!

Imgur

畫面調整與修改

從上圖中可以看到濕度的地方因為 JavaScript 小數點精度不足的緣故,0.55 * 100 會變成 55.00000000000001,所以這裡在小小修補一下兩個地方:

  1. 濕度的顯示的地方也加上 Math.round() 進行四捨五入,變成 {Math.round(currentWeather.humid * 100)} %
  2. 原本「最後觀測時間」是放在「地點」的下面,總覺得有些會讓人以為是當前時間的意思,因此我們一併把它搬到畫面右下角

調整的程式碼部份如下:

import React, { useState } from 'react';

// ...

const Redo = styled.div`
  position: absolute;
  right: 15px;
  bottom: 15px;
  font-size: 12px;
  display: inline-flex;
  align-items: flex-end;
  color: #828282;

  svg {
    margin-left: 10px;
    width: 15px;
    height: 15px;
    cursor: pointer;
  }
`;

const WeatherApp = () => {
  // ...

  return (
    <Container>
      <WeatherCard>
        {/* ... */}
        <Rain>
          <RainIcon />
          {/* 針對濕度進行四捨五入 */}
          {Math.round(currentWeather.humid * 100)} %
        </Rain>

        {/* 將最後觀測時間移到畫面右下角呈現 */}
        <Redo onClick={handleClick}>
          最後觀測時間:
          {new Intl.DateTimeFormat('zh-TW', {
            hour: 'numeric',
            minute: 'numeric',
          }).format(new Date(currentWeather.observationTime))}{' '}
          <RedoIcon />
        </Redo>
      </WeatherCard>
    </Container>
  );
};

export default WeatherApp;

完成後的畫面會像這樣:

Imgur

到目前為止我們還缺少一些資料,像是「降雨機率」、「天氣描述」還有天氣圖示,關於這幾個欄位將會在後面幾天再來處理。

關於今天內容完成的程式碼可以參考 Weather APP - fetch data with click @ CodeSandbox。

補充 useState 的更多說明

每次 setSomething 時都是用新的資料覆蓋舊的

useState 中的資料狀態可以帶入物件,像是上面程式碼中:

const [currentWeather, setCurrentWeather] = useState({
  observationTime: '2019-10-02 22:10:00',
  locationName: '臺北市',
  description: '多雲時晴',
  temperature: 27.5,
  windSpeed: 0.3,
  humid: 0.88,
});

但要特別留意的是,當我們使用物件時,如果有需要物件中的某個值時,不能只是在 setCurrentWeather 帶入想要變更的物件屬性,因為 setSomething 這種用法會完全傳入的值去覆蓋掉舊有的內容。

什麼意思呢?假設現在我只要修改 currentWeathertemperature 的值,我們不能這樣寫:

// ❌ 錯誤:不能只寫出要修改或添加的物件屬性
setCurrentWeather({
  temperature: 31,
});

console.log(currentWeather); //{ temperature: 31}

因為 setSomething 這種方法會有新給的資料全部覆蓋掉舊有的資料,因此 currentWeather 會變成剩下只有 temperature 這個屬性。

正確的做法應該要把舊的資料透過物件的解構賦值帶入新物件中,再去添加或修改想要變更的屬性,像是這樣:

// ✅ 正確:先透過解構賦值把舊資料帶入新物件中,再去添加或修改想要變更的資料
setCurrentWeather({
  ...currentWeather,
  temperature: 31,
});

console.log(currentWeather);
// {
//   observationTime: '2019-10-02 22:10:00',
//   locationName: '臺北市',
//   description: '多雲時晴',
//   temperature: 31,
//   windSpeed: 0.3,
//   humid: 0.88,
// }

要使用多次 useState 還是把所有資料都包在一個物件中只使用一次

一般來說,在一個 React Component 中多次呼叫 useState 並不會有太大的問題,因此不建議單純只是為了想要少用幾次 useState 而把所有不相關的資料都放到同一個物件中,因為這代表你將只會得到一個 setSomething 的方法,而你只要呼叫到這個方法,因為是用新的資料整個覆蓋掉舊的,因此即時有很多不需要更新的資料,但仍會被迫整個換掉。

因此官方建議,可以將有關聯的資料放在同一個物件中,而沒有關聯的資料,就另外在使用 useState 去定義資料狀態。

Should I use one or many state variables? @ Hooks FAQ

程式範例

Weather APP - fetch data with click

參考資源


上一篇
[Day 15 - 即時天氣] 就是這個畫面 - 使用 Emotion 為組件增添 CSS 樣式
下一篇
[Day 17 - 即時天氣] 頁面載入時就去請求資料 - useEffect 的基本使用
系列文
從 Hooks 開始,讓你的網頁 React 起來30

1 則留言

0
yanzhong
iT邦新手 4 級 ‧ 2021-02-08 23:39:10

大大你好 想請問文中提到useState 的set function
我沒使用...也確實有正確得到數值
想請問有copy跟沒copy會造成甚麼影響,還是其實我把資料都定義好,不要單獨set value值即可?!!!

function Weather() {
    const [weatherValue,setWeatherValue] = useState({
        temperature: "33",
        locationname:"新北市",
        description: "多雲",
    })

    const handleClick = ()=>{
        axios
        .get("https://opendata.cwb.gov.tw/api/v1/rest/datastore/F-D0047-069?Authorization=CWB-EAE0B711-693B-41E2-9771-5124D3581B6C&format=JSON")
        .then((res)=>{
            const data = res.data.records.locations
            console.log(data[0].location[16].locationName)
            setWeatherValue({
            temperature: data[0].location[16].weatherElement[3].time[0].elementValue[0].value,
            locationname: data[0].location[16].locationName,
            description: data[0].location[16].weatherElement[1].time[0].elementValue[0].value,
        })})}
    return (
        <div className="weather">
            <div className="weather__top">
                <div className="weather__left">
                    <View />
                </div>
                
                <div className="weather__right">
                    {weatherValue.temperature},
                    {weatherValue.locationname},
                    {weatherValue.description}
                    <button onClick={handleClick}>CLick</button>
                </div>
            </div>
        </div>
    )
}

我要留言

立即登入留言