嚐鮮用 React Hook + Parcel 構建真心話大冒險簡單頁面

XGHeaven發表於2018-12-26

首發於我的 Blog

閱讀推薦:本人需要您有一定的 React 基礎,並且想簡單瞭解一下 Hook 的工作方式和注意點。但是並不詳細介紹 React Hook,如果想有進一步的瞭解,可以檢視官方文件。因為專案比較簡單,所以我會比較詳細的寫出大部分程式碼。建議閱讀文章之前請先閱讀目錄找到您關注的章節。

React Hook + Parcel

幾天前,我女票和我說他們新人培訓需要一個《真心話大冒險》的介面,想讓我幫她寫一個。我說好呀,正好想到最近的 React Hook 還沒有玩過,趕緊來試試,於是花了一個晚上的時間,其實是倆小時,一個小時搭建專案,一個小時寫。

Demo: souche-truth-or-dare.surge.sh (因為女票是大搜車的)

Demo

環境搭建

首先我們建立一個資料夾,做好初始化操作。

mkdir truth-or-dare
cd truth-or-dare
npm init -y
複製程式碼

安裝好依賴,react@next react-dom@next parcel-bundler emotion@9 react-emotion@9 babel-plugin-emotion@9

React Hook 截止發稿前(2018-12-26)還處於測試階段,需要使用 next 版本。

emotion 是一個比較完備的 css-in-js 的解決方案,對於我們這個專案來講是非常方便合適的。另外因為 emotion@10 的最新版本對 parcel 還有一定的相容性問題,見 issue。所以這裡暫時使用 emotion@9 的舊版本。

npm i react@next react-dom@next emotion@9 react-emotion@9
npm i parcel-bundler babel-plugin-emotion@9 -D
複製程式碼

建立 .babelrc 檔案或者在 package.json 中寫入 Babel 配置:

{
  "plugin": [
    ["emotion", {"sourceMap": true}]
  ]
}
複製程式碼

建立 src 資料夾,並建立 index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>真心話大冒險</title>
</head>
<body>
  <div id="app"></div>
  <script src="./index.jsx"></script>
</body>
</html>
複製程式碼

index.jsx 檔案

import * as React from 'react'
import { render } from 'react-dom'

render(<div>First Render</div>, document.getElementById('app'))
複製程式碼

最後新增如下 scriptspackage.json

{
  "start": "parcel serve src/index.html",
  "build": "rm -rf ./dist && parcel build src/index.html"
}
複製程式碼

最後我們就可以 npm start 就可以成功啟動開發伺服器了。在瀏覽器中開啟 localhost:1234 即可。

parcel 已經內建了 Hot Reload,所以不需要進行額外的配置,開箱即用。是不是覺得非常簡單,有了它,手動搭建專案不再困難。當然了,TS 也是開箱即用的,不過這次我這個專案真的很小,就不用 TS 了。

useState 第一個接觸的 Hook

我們建立一個 App.jsx 開始我們真正的編碼。先簡單來看一下

export default function App() {
  const [selected, setSelected] = useState('*')
  const [started, setStarted] = useState(false)

  return (
    <div>
      <div>{selected}</div>
      <button>{started ? '結束' : '開始'}</button>
    </div>
  )
}
複製程式碼

我們就完成了對 Hook 最簡單的使用,當然了現在還沒有任何互動效果,也許你並不明白這段程式碼有任何用處。

簡單講解一下 useState,這個函式接受一個引數,為初始值,可以是任意型別。它會返回一個 [any, (v: any) => void] 的元組。其中第一個 State 的值,另一個是一個 Setter,用於對 State 設定值。

這個 Setter 我們如何使用呢?只需要在需要的地方呼叫他就可以了。

<button onClick={() => setStarted(!started)}>{started ? '結束' : '開始'}</button>
複製程式碼

儲存,去頁面點選一下這個按鈕看看,是不是發現他會在 結束開始 之間切換?Setter 就是這麼用,非常簡單,如果用傳統的 Class Component 來理解的話,就是呼叫了 this.setState({started: !this.state.started}) 。不過和 setState 不同的是,Hook 裡面的所有資料比較都是 ===(嚴格等於)。

useState 還有很多用法,比如說 Setter 支援接收一個函式,用於傳入之前的值以及返回更新之後的值。

useEffect 監聽開始和結束事件

接下來,我們想要點選開始之後,螢幕上一直滾動,直到我點選結束。

如果這個需求使用 Class Component 來實現的話,是這樣的:

  1. 監聽按鈕點選事件
  2. 判斷是開始還是結束
    • 如果是開始,那麼就建立一個定時器,定時從資料當中隨機獲取一條真心話或大冒險並更新 selected
    • 如果是結束,那麼就刪除之前設定的定時器

非常直接,簡單粗暴。

用了 Hook 之後,當然也可以這樣做了,不過你還需要額外引入一個 State 來儲存 timer,因為函式元件無法持有變數。但是如果我們換一種思路:

  1. 監聽 started 變化
    • 如果是開始,那麼建立一個定時器,做更新操作
    • 如果是結束,那麼刪除定時器

好像突然變簡單了,讓我們想象這個用 Class Component 怎麼實現呢?

export default class App extends React.Component {
  componentDidUpdate(_, preState) {
    if (this.state.started !== preState.started) {
      if (this.state.started) {
        this.timer = setInterval(/* blahblah*/)
      } else {
        clearInterval(this.timer)
      }
    }
  }

  render() {
    // blahblah
  }
}
複製程式碼

好麻煩,而且邏輯比較繞,而且如果 componentDidUpdate 與 render 之間有非常多的程式碼的時候,就更難對程式碼進行分析和閱讀了,如果你後面維護這樣的程式碼,你會哭的。可是用 useEffect Hook 就不一樣了。畫風如下:

export default function App() {
  // 之前的程式碼
    
  // 當 started 變化的時候,呼叫傳進去的回撥
  useEffect(() => {
    if (started) {
      const timer = setInterval(() => {
        setSelected(chooseOne())
      }, 60)

      return () => clearInterval(timer)
    }
  }, [started])

  return (
    // 返回的 View
  )
}
複製程式碼

當用了 React Hook 之後,所有的邏輯都在一起了,程式碼清晰且便於閱讀。

useEffect 從字面意義上來講,就是可能會產生影響的一部分程式碼,有些地方也說做成副作用,其實都是沒有問題的。但是副作用會個人一種感覺就是這段程式碼是主動執行的而不是被動執行的,不太好理解。我覺得更好的解釋就是受到環境(State)變化影響而執行的程式碼。

為什麼這麼理解呢?你可以看到 useEffect 還有第二個引數,是一個陣列,React 會檢查這個陣列這次渲染呼叫和上次渲染呼叫(因為一個元件內可能會有多次 useEffect 呼叫,所以這裡加入了渲染限定詞)裡面的每一項和之前的是否變化,如果有一項發生了變化,那麼就呼叫回撥。

當理解了這個流程之後,或許你就能理解為什麼我這麼說。

當然了,第二個引數是可以省略的,省略之後就相當於預設監聽了全部的 State。(現在你可以這麼理解,但是當你進一步深入之後,你會發現不僅僅有 State,還有 Context 以及一些其他可能觸發狀態變化的 Hook,本文不再深入探究)

到現在,我們再來回顧一下關於定時器的流程,先看一下程式碼:

if (started) {
  const timer = setInterval(() => {
    setSelected(chooseOne())
  }, 60)

  return () => clearInterval(timer)
}
複製程式碼

理想的流程是這樣的:

  • 如果開始,那麼註冊定時器。——Done!
  • 如果是結束,那麼取消定時器。——Where?

咦,else 的分支去哪裡了?為啥在第一個分支返回了取消定時器的函式?

這就牽扯到 useEffect 的第二個特性了,他不僅僅支援做正向處理,也支援做反向清除工作。你可以返回一個函式作為清理函式,當 effect 被呼叫的時候,他會先呼叫上次 effect 返回的清除函式(可以理解成析構),然後再呼叫這次的 effect 函式。

於是我們輕鬆利用這個特性,可以在只有一條分支的情況下實現原先需要兩條分支的功能。

其他 Hook

在 Hook 中,上面兩個是使用非常頻繁的,當然還有其他的比如說 useContext/useReducer/useCallback/useMemo/useRef/useImperativeMethods/useLayoutEffect

你可以建立自己的 Hook,在這裡 React 遵循了一個約定,就是所有的 Hook 都要以 use 開頭。為了 ESLint 可以更好對程式碼進行 lint。

這些都屬於高階使用,感興趣的可以去研究一下,本片文章只是入門,不再過多講解。

我們來用 Emotion 加點樣式

css-in-js 大法好,來一頓 Duang, Duang, Duang 的特技就好了,程式碼略過。

收尾

重新修改 src/index.jsx 檔案,將 <div/> 修改為 <App/> 即可。

最後的 src/App.jsx 檔案如下:

import React, { useState, useEffect } from 'react'
import styled from 'react-emotion'

const lists = [
  '說出自己的5個缺點',
  '繞場兩週',
  '拍一張自拍放實習生群裡',
  '成功3個你說我猜',
  '記住10個在場小夥伴的名字',
  '大聲說出自己的名字“我是xxx”3遍',
  '拍兩張自拍放實習生群裡',
  '選擇另一位小夥伴繼續遊戲',
  '直接通過',
  '介紹左右兩個小夥伴',
]

function chooseOne(selected) {
  let n = ''
  do {
    n = lists[Math.floor(Math.random() * lists.length)]
  } while( n === selected)
  return n
}

const Root = styled.div`
  background: #FF4C19;
  height: 100vh;
  width: 100vw;
  text-align: center;
`

const Title = styled.div`
  height: 50%;
  font-size: 18vh;
  text-align: center;
  color: white;
  padding: 0 10vw;
  font-family:"Microsoft YaHei",Arial,Helvetica,sans-serif,"宋體";
`

const Button = styled.button`
  outline: none;
  border: 2px solid white;
  border-radius: 100px;
  min-width: 120px;
  width: 30%;
  text-align: center;
  font-size: 12vh;
  line-height: 20vh;
  margin-top: 15vh;
  color: #FF4C19;
  cursor: pointer;
`

export default function App() {
  const [selected, setSelected] = useState('-')
  const [started, setStarted] = useState(false)

  function onClick() {
    setStarted(!started)
  }

  useEffect(() => {
    if (started) {
      const timer = setInterval(() => {
        setSelected(chooseOne(selected))
      }, 60)

      return () => clearInterval(timer)
    }
  }, [started])

  return (
    <Root>
      <Title>{selected}</Title>
      <Button onClick={onClick}>{started ? '結束' : '開始'}</Button>
    </Root>
  )
}

複製程式碼

總結覆盤 —— 效能問題?

最近剛剛轉正答辯,突然發現覆盤這個詞還挺好用的,哈哈哈。

雖然這麼短時間的使用,還是有一些自己的思考,說出來供大家參考一下。

如果你仔細思考一下會發現,當使用 useEffect 的時候,其實每次都是建立了一個新的函式,但並不是說每次都會呼叫這個函式。如果你程式碼裡面 useEffect 使用的很多,而且程式碼還比較長,每次渲染都會帶來比較大的效能問題。

所以解決這個問題有兩個思路:

  1. 不要在 Hook 中做太多的邏輯,比如說可以讓 Hook 編寫一些簡單的展示元件,比如 Tag/Button/Loading 等,邏輯不復雜,程式碼量小,通過 Hook 寫在一起可以降低整個元件的複雜度。

  2. 將 Effect 拆分出去,並通過引數傳入。類似於這個樣子

    function someEffect(var1, var2) {
        // doSomething
    }
    
    export function App() {
    	// useState...
        useEffect(() => someEffect(var1, var2), [someVar])
        // return ....
    }
    複製程式碼

    雖然這也是建立了一個函式,但是這個函式建立的速度和建立一個幾十行幾百行的邏輯的函式相比,確實快了不少。其次不建議使用 .bind 方法,他的執行效率並沒有這種函式字面量快。

    這種方式不建議手動來做,可以交給 babel 外掛做這部分的優化工作。

其實作為一個開發者來說,不應該太多的關注這部分,但是效能就是程式設計師的 XX 點,我還是會下意識從效能的角度來思考。這裡只是提出了一點小小的優化方向,希望以後 React 官方也可以進一步做這部分的優化工作。

已經有的優化方案,可以檢視官方 FAQ

總結

經過這個簡短的使用,感覺用了 Hook 你可以將更多的精力放在邏輯的編寫上,而不是資料流的流動上。對於一些輕元件來說簡直是再合適不過了,希望早點能夠正式釋出正式使用上吧。

另外 parcel 提供了強大的內建功能,讓我們有著堪比 webpack 的靈活度卻有著比 webpack 高效的開發速度。

好的,一篇 1 小時寫程式碼,1 天寫文章的水文寫完了。以後如果有機會再深入嘗試。

相關文章