iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 23
2
Modern Web

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

[Day 23 - 即時天氣] 實作資料載入中的提示狀態

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

keywords: useState, css-in-js, Emotion.js

昨天下班前,公司的設計師看了終於表示滿意:「沒錯,白天就是要是用太陽,晚上就是要用月亮。」於是我也心滿意足的離開了。

沒料到今天一早到公司的時候,設計又跑來跟我說,她覺得用起來還是不太對勁,作為一個著重使用者體驗的設計師,果然就是有異於常人敏銳的觀察力和感受力。她說:「這個不行!點了右下角的更新按鈕後,為什麼畫面一點動靜都沒有!這樣誰知道是在更新呢?」,我想了一想,好像真的有道理,決定就把這個功能做給她。

定義與處理「載入中」的資料狀態

由於現今網站許多都是透過 AJAX 去向後端伺服器拉取資料回來呈現,拉取資料的過程中必然需要消耗一些時間,因此處理「載入中」的狀態算是現在每個網站都需要考慮到的。

以 Instagram 為例,一開始進入網頁的時候,會先看到一個「空畫面」:

Imgur

接著會出現一個「空殼」,可以注意到右下角有一個載入中的圖示:

Imgur

回到我們的即時天氣 App 中可以怎麼樣做呢?

定義「載入中」的資料狀態 - isLoading

在這裡因為我們有向兩道不同的 API 發送請求,因此需要等到兩個 API 的資料都拿回來之後,才算是「資料載入」完畢。現在先讓我們在 WeatherApp 的組件中添加一個名為 isLoading 的資料狀態。

一樣可以先打開並複製一個昨天在 CodeSandbox 上完成的程式碼 - Weather APP - dynamic weather icon with sunrise and sunset data。在 WeatherApp.js 中,在原本 useState 的地方,預設值內加入一個 isLoading 的資料狀態:

  • isLoading 的預設值設為 true,表示一進來的時候就正在拉取資料

Imgur

這裡也可以把 isLoading 拆成另一個 state,但這裡考量到載入完成指的就是天氣資料(weatherElement)是否已經載入完成,因此多數時候是 isLoadingweatherElement 是會一起變動的。

資料取得後修改 isLoading 的狀態

接著在 fetchData 的地方,當兩道 API 的資料都載入完成後,就透過 setWeatherElementisLoading 的狀態改成 false,表示載入完成:

Imgur

點擊重新整理時,再次修改 isLoading 狀態 - prevState 的使用

現在,當使用者初次進來網站時,一開始的 isLoading 會是 true,也就是資料正在載入中;待 fetchData 的資料都回來之後,isLoading 會變成 false

但還有一個情況是當使用者點擊右下角的重新整理時,也需要把 isLoading 的狀態改成 true,這時候我們可以在 fetchData 實際開始向 API 拉取資料(fetchingData)前,先把 isLoading 的狀態設成 true

但要注意的是,像下圖這種寫法是會產生錯誤的:

Imgur

我們需要在 fetchingData() 之前去將 isLoading 改成 true 這部分的邏輯是沒有錯的,但這麼做之所以會導致錯誤是因為在「Day 16 - 定義並請求組件會使用到的資料 - useState 的更多使用」時,我們曾提到「每次 setSomething 時都是用新的資料覆蓋舊的」,所以這裡如果直接用:

setWeatherElement({ isLoading: true })

那麼整個 weatherElement 的資料狀態都會被覆蓋掉,變成只剩下 { isLoading: true }。好在,透過 useState 產生的 setSomething 這個方法中,參數不只可以帶入物件,還可以帶入函式,透過這個函式就可以取得上一次的資料狀態,慣例上我們會把前一次的資料狀態取名為 prevState

// 在 setState 中如果是帶入函式的話,可以取得前一次的資料狀態
setState(prevState => {
  // Object.assign would also work
  return {...prevState, ...updatedValues};
});

因此,這裡的 setWeatherElement 中,只需要帶入函式就可以取得原本的資料狀態,再透過物件的解構賦值把原有資料帶進去,更新 isLoading 的狀態改成 true 就可以了,程式碼會修改成下面這樣:

// ./src/WeatherApp.js

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

  const fetchData = useCallback(() => {
    // const fetchingData = () => {...}

    setWeatherElement((prevState) => ({
      ...prevState,
      isLoading: true,
    }));

    fetchingData();
  }, []);

  // ...
};

到這一步後,一開始畫面載入或使用者點選「更新按鈕」時 isLoading 會是 true,資料載入完畢後 isLoading 會變成 false。如果你不確定是不是有正確修改的話,可以在 return JSX 的地方,使用 console.log(weatherElement.isLoading) 看一下:

Imgur

從瀏覽器的開發者工具中可以看到:

  • 一開始網頁載入時,畫面一共會渲染三次,isLoading 的狀態分別是 true (預設值)-> true (拉資料前)-> false(拉完資料後)
  • 當使用者點選更新按鈕後,畫面會渲染兩次,isLoading 的狀態分別是 true(拉資料前) -> false(拉完資料後)

Imgur

撰寫載入中要顯示的樣式

現在透過 isLoading 的狀態,我們已經可以清楚知道什麼時候是正在拉取資料,什麼時候已經取得資料,因此就可以來撰寫載入中要顯示的樣式了。

這裡載入中的提示很簡單,當資料在載入中的時候,右下角的「更新按鈕」就改成顯示「載入中」的圖示,並搭配旋轉,這個「載入中」的圖示已經放在之前從 Dropbox 下載過的天氣圖示中,因此如果你先前已經載入並上傳這些圖示到 CodeSandbox 的話,就不用在重新下載與上傳一次。

根據載入狀態切換顯示圖示

現在就可以根據 isLoading 的狀態來切換要顯示的是「更新圖示」或「載入中圖示」,切換圖示的方式就和「Day 07 - 幫計數器設個最大最小值吧 - JSX 中條件渲染的使用」一樣:

  1. ./src/images 資料夾中載入 loading 圖示,並取名為 LoadingIcon
  2. 使用三元判斷式來做到條件渲染,當 isLoadingtrue 時顯示 LoadingIcon 否則顯示 RedoIcon
// ./src/WeatherApp.js
// ...

// STEP 1:從 `images` 資料夾中載入 loading 圖示,並取名為 `LoadingIcon`
import { ReactComponent as LoadingIcon } from './images/loading.svg';
// ...

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

  return (
    <Container>
      <WeatherCard>
        {/* ... */}
        <Redo onClick={fetchData}>
          最後觀測時間:
          {new Intl.DateTimeFormat('zh-TW', {
            hour: 'numeric',
            minute: 'numeric',
          }).format(new Date(weatherElement.observationTime))}{' '}

          {/* STEP 2:當 isLoading 的時候顯示 LoadingIcon 否則顯示 RedoIcon */}
          {weatherElement.isLoading ? <LoadingIcon /> : <RedoIcon />}
        </Redo>
      </WeatherCard>
    </Container>
  );
};

export default WeatherApp;

現在,只要資料在載入中,右下角的圖示就會變成 LoadingIcon,載入完畢後就會變回原本的 RedoIcon:

Imgur

如果你的網路速度很快的話,可能會發現你幾乎看不到 Loading 圖示,這時候你可以如上圖所示,在 Network 面板把自己的網速暫時調慢試試看。

增加圖示旋轉的效果

很明顯這種效果設計師是不會滿意的,一點都沒有「載入中」的感覺,放個「C」還以為是想測試使用者的視力勒,因此我們得要加上一點旋轉的效果來讓它看起來更像是在「載入中」。

只需要透過 CSS 的 animation 就可以讓圖示旋轉,在最上面透過 Emotion 定義 styled components 的地放,撰寫 Redo CSS 樣式的地方,只需要在這裡面加上 animation 的效果就可以了:

  1. 使用 @keyframes 定義旋轉的動畫效果,並取名為 rotate
  2. 針對 svg 圖示透過 animation 屬性套用 rotate 動畫效果
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;
    /* STEP 2:使用 rotate 動畫效果在 svg 圖示上 */
    animation: rotate infinite 1.5s linear;
  }

  /* STEP 1:定義旋轉的動畫效果,並取名為 rotate */
  @keyframes rotate {
    from {
      transform: rotate(360deg);
    }
    to {
      transform: rotate(0deg);
    }
  }
`;

這時候畫面會像這樣:

Imgur

看起來稍微好一點,但是不對啊!哪有更新的圖示也會變成跟著一起轉的...。

只有在載入資料時才旋轉 - 把資料透過 props 傳入 Styled Component 內

還記得曾經在「Day 15 - 就是這個畫面 - 使用 Emotion 為組件增添 CSS 樣式」提過,透過 CSS-in-JS 的這種寫法,我們可以在 CSS 中使用 JavaScript,並且可以把資料透過 props 傳到 Styled Component 內,讓 CSS 可以根據這個資料來調整套用的樣式。

因此這裡可以這樣做:

  1. 是否正在載入中的 isLoading 資料狀態透過 props 帶入 <Redo> 中,也就是 <Redo isLoading={...} >
  2. Redo 這個 Styled Component 定義的地方把傳入的 props 取出,直接透過物件的解構賦值取出 isLoading,並以此判斷是否要執行動畫,0s 的話表示載入完畢,則不旋轉
// ...

const Redo = styled.div`
  /* ... */
  svg {
    margin-left: 10px;
    width: 15px;
    height: 15px;
    cursor: pointer;
    animation: rotate infinite 1.5s linear;

    /* STEP 2:取得傳入的 props 並根據它來決定動畫要不要執行 */
    animation-duration: ${({ isLoading }) => (isLoading ? '1.5s' : '0s')};
  }

  @keyframes rotate {
    from {
      transform: rotate(360deg);
    }
    to {
      transform: rotate(0deg);
    }
  }
`;

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

  return (
    <Container>
      <WeatherCard>
        {/* ... */}
        {/* STEP 1:把 isLoading 的資料狀態透過 props 傳入 Styled Component */}
        <Redo onClick={fetchData} isLoading={weatherElement.isLoading}>
          最後觀測時間:
          {new Intl.DateTimeFormat('zh-TW', {
            hour: 'numeric',
            minute: 'numeric',
          }).format(new Date(weatherElement.observationTime))}{' '}

          {weatherElement.isLoading ? <LoadingIcon /> : <RedoIcon />}
        </Redo>
      </WeatherCard>
    </Container>
  );
};

export default WeatherApp;

到目前為止,畫面就差不多完成了!

Imgur

整理程式碼

現在稍微來整理一下專案的程式碼。

將名稱從 Redo 改成 Refresh

在程式開發的過程中,語意化是非常重要的一件事,它可以幫助接手的開發者(甚至是自己)都更容易了解自己在寫些什麼。這裡一開始我們把右下角重新整理的按鈕取名為「Redo」有些不太精確,Redo 比較像是回到上一步的感覺,因此改成叫做「Refresh」應該會比較容易理解。

更動的部分如下圖所示:

Imgur

使用物件的解構賦值將會用的資料從 state 取出

在 WeatherApp 組件最後回傳 JSX 的地方,使用了非常多 weatherElement.observationTimeweatherElement.locationNameweatherElement.temperature、...,這樣寫起來非常重複,因為一直要多寫 weatherElement.ooo

Imgur

因此在 React 中對於物件類型的資料,經常會使用物件的解構賦值先把要使用到的資料取出來,像是這樣:

  const {
    observationTime,
    locationName,
    temperature,
    windSpeed,
    description,
    weatherCode,
    rainPossibility,
    comfortability,
    isLoading,
  } = weatherElement;

如此,在 return 的地方就可以直接使用這些變數,而不需要在前面多加上 weatherElement.ooo,修改後的程式碼會變得更加精簡:

// ./src/WeatherApp.js

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

  const {
    observationTime,
    locationName,
    temperature,
    windSpeed,
    description,
    weatherCode,
    rainPossibility,
    comfortability,
    isLoading,
  } = weatherElement;

  // ...

  const moment = useMemo(() => getMoment(locationName), [locationName]);

  // ...
  return (
    <Container>
      <WeatherCard>
        <Location>{locationName}</Location>
        <Description>
          {description} {comfortability}
        </Description>
        <CurrentWeather>
          <Temperature>
            {Math.round(temperature)} <Celsius>°C</Celsius>
          </Temperature>
          <WeatherIcon
            currentWeatherCode={weatherCode}
            moment={moment || 'day'}
          />
        </CurrentWeather>
        <AirFlow>
          <AirFlowIcon />
          {windSpeed} m/h
        </AirFlow>
        <Rain>
          <RainIcon />
          {Math.round(rainPossibility)} %
        </Rain>
        <Refresh onClick={fetchData} isLoading={isLoading}>
          最後觀測時間:
          {new Intl.DateTimeFormat('zh-TW', {
            hour: 'numeric',
            minute: 'numeric',
          }).format(new Date(observationTime))}{' '}
          {isLoading ? <LoadingIcon /> : <RefreshIcon />}
        </Refresh>
      </WeatherCard>
    </Container>
  );
};

今天完整的程式碼一樣會放在 CodeSandbox 上 Weather APP - add loading state

範例程式碼

Weather APP - add loading state

參考資源


上一篇
[Day 22 - 即時天氣] 讓白天和晚上使用不同天氣圖示
下一篇
[Day 24 - 即時天氣] 跟風一下,在 React 中透過 Emotion 實作深色主題!
系列文
從 Hooks 開始,讓你的網頁 React 起來30

尚未有邦友留言

立即登入留言