程式碼簡潔之道:編寫乾淨的 React Components & JSX

破曉L發表於2020-07-20
不同團隊編寫出來的 React 程式碼也不盡相同,水平有個有高低,就像十個讀者就有十種哈姆雷特,但是以下八點可能是你編寫 React 程式碼的基本準則。

這篇 效能優化小冊 - React 搜尋優化:防抖、快取、LRU 文章提到,最近要做 React 專案的一些重構和優化等相關工作,過了這麼久來總結一下(借鑑網上的一些文章和自己的實踐,提取一些 React 程式碼優化上的共性)。

一、 可選的 props 和空物件 {}

遵循單一目的元件哲學,避免過於複雜的多行元件,並儘可能地將元件分解。

假設我們有一個簡單的 <userCard/> 元件,此元件的唯一用途是接收一個 user 物件並顯示相應的使用者資料。

程式碼如下:

import React from 'react';
import propsTypes from 'props-types';

const UserCard = ({ user }) => {
  return (
    <ul>
      <li>{user.name}</li>
      <li>{user.age}</li>
      <li>{user.email}</li>
    </ul>
  )
}

UserCard.propsTypes = {
  user: propsTypes.object
}
UserCard.defaultTypes = {
  user: {}
}

這個元件需要一個 user props 並且它是元件的唯一資料來源,但是這個 props 不是必須的(沒有設定 isRequired),所以設定了一個預設值為 {},以避免 Can not access property‘ name’ of... errors 等錯誤。

那麼,如果沒有為 <UserCard> 元件在等待渲染時提供一個回退值 (fallback),並且 UserCard 沒有任何資料也沒有任何邏輯執行,就沒有理由呈現這個元件。

那麼,props 什麼時候是必需的,什麼時候不是必需的呢?

有時候你只需要問問自己,怎樣實現才是最合理的。

假設我們有一個轉換貨幣的元件 <CurrencyConverter/>,它有三個 props

  • value - 我們要轉換的數值。
  • givenCurrency - 我們正在轉換的貨幣。
  • targetCurrency - 我們要轉換成的貨幣。

然而,如果我們的 value 值不足,那麼進行任何轉換都毫無意義,那時根本不需要呈現元件。

因此,value props 肯定是必需的。

二、 條件在父元件中呈現

我們有時候在一個子元件中經常會看到類似如下程式碼:

import React, { useState } from "react";
import PropTypes from "prop-types";
// 子元件
export const UserCard = ({ user }) => {
  const keys = Object.keys(user)
  return (
    keys.length ?
      <ul>
        <li>{user.name}</li>
        <li>{user.age}</li>
        <li>{user.email}</li>
      </ul>
    : "No Data"
  );
};

我們看到一個元件帶有一些邏輯,卻徒勞地執行,只是為了顯示一個 spinner 或一條資訊。

針對這種情況,請記住,在父元件內部完成此操作總是比在元件本身內部完成更為乾淨。

按著這個原則,子元件和父元件應該像這樣:

import React, { useState } from "react";
import PropTypes from "prop-types";

// 子元件
export const UserCard = ({ user }) => {
  return (
    <ul>
      <li>{user.name}</li>
      <li>{user.age}</li>
      <li>{user.email}</li>
    </ul>
  );
};

UserCard.propTypes = {
  user: PropTypes.object.isRequired
};

// 父元件
export const UserContainer = () => {
  const [user, setUser] = useState(null);
  // do some apiCall here
  return (
    <div>
      {user && <UserCard user={user} />}
    </div>
  );
};

通過這種方式,我們將 user 初始化為 null,然後簡單的執行一個 falsy 檢測,如果 user 不存在,!user 將返回 true

如果設定為 {} 則不然,我們必須通過 Object.keys() 檢查物件 key 的長度,通過不建立新的物件引用,我們節省了一些記憶體空間,並且只有在獲得了所需的資料之後,我們才渲染子元件 <UserCard/>

如果沒有資料,顯示一個 spinner 也會很容易做到。

export const UserContainer = () => {
  const [user, setUser] = useState(null); 
  // do some apiCall here
  return (
    <div>
      {user ? <UserCard user={user} /> : 'No data available'}
    </div>
  );
};

子元件 <UserCard/> 只負責顯示使用者資料,父元件 <UserContainer/> 是用來獲取資料並決定呈現什麼的。這就是為什麼父元件是顯示回退值(fallback)最佳位置的原因。

三、不滿足時,及時 return

即使我們使用的是正常的程式語言,巢狀也是一團糟,更不用說 JSX(它是 JavaScript、 HTML 的混合體)了。

你可能經常會看到使用 JSX 編寫的類似的程式碼:

const NestedComponent = () => {
  // ...
  return (
    <>
      {!isLoading ? (
        <>
          <h2>Some heading</h2>
          <p>Some description</p>
        </>
      ) : <Spinner />}
    </>
  )
}

該怎麼做才是更合理的呢?

const NestedComponent = () => {
  // ...
  if (isLoading) return <Spinner />
  return (
    <>
      <h2>Some heading</h2>
      <p>Some description</p>
    </>
  )
}

我們處理 render 邏輯時,在處理是否有可用的資料,頁面是否正在載入,我們都可以選擇提前 return

這樣我們就可以避免巢狀,不會把 HTML 和 JavaScript 混合在一起,而且程式碼對於不同技術水平或沒有技術背景的人來說也是可讀的。

四、在 JSX 中儘量少寫 JavaScript

JSX 是一種混合語言,可以寫JS程式碼,可以寫表示式,可以寫 HTML,當三者混合起來後,使用 JSX 編寫的程式碼可讀性就會差很多。

雖然有經驗的人可以理解元件內部發生了什麼,但並不是每個人都能理解。

const CustomInput = ({ onChange }) => {
  return (
    <Input onChange={e => {
      const newValue = getParsedValue(e.target.value);
      onChange(newValue);
    }} />
  )
}

我們正在處理一些外部對 input 的一些輸入,使用自定義處理程式解析該輸入 e.target.value,然後將解析後的值傳給 <CustomInput/> 元件接收的 onChange prop。雖然這個示例可以正常工作,但是會讓程式碼變得很難理解。

在實際專案中會有更多的元素和更復雜的 JS 邏輯,所以我們將邏輯從元件中抽離出來,會使 return() 更清晰。

const CustomInput = ({ onChange }) => {
  const handleChange = (e) => {
    const newValue = getParsedValue(e.target.value);
    onChange(newValue);
  };
  return (
    <Input onChange={handleChange} />
  )
}

當在 render 中返回 JSX 時,不要使用內聯的 JavaScript 邏輯。

五、userCallback & userEffect

隨著 v16.8 Hooks 問世後,人們開始大量使用函式元件,當使用函式進行編寫元件時,如果需要在內部執行 API 介面的呼叫,需要用到 useEffect 生命週期鉤子。

useEffect 用於處理元件中的 effect,通常用於請求資料,事件處理,訂閱等相關操作。

在最初版本的文件指出,防止 useEffect 出現無限迴圈,需要提供空陣列 [] 作為 useEffect 依賴項,將使鉤子只能在元件的掛載和解除安裝階段執行。因此,我們會看到在很多使用 useEffect 的地方將 [] 作為依賴項傳入。

使用 useEffect 出現無限迴圈的原因是,useEffect 在元件 mount 時執行,但也會在元件更新時執行。因為我們在每次請求資料之後基本上都會設定本地的狀態,所以元件會更新,因此 useEffect 會再次執行,因此出現了無限迴圈的情況。

然而,這種處理方式就會出現 react-hooks/exhaustive-deps 規則的警告,因此程式碼中常常會通過註釋忽略此警告。

// eslint-disable-next-line react-hooks/exhaustive-deps 
import React, { useState, useEffect } from 'react'

import { fetchUserAction } from '../api/actions.js'

const UserContainer = () => {
  const [user, setUser] = useState(null);
  
  const handleUserFetch = async () => {
    const result = await fetchUserAction();
    setUser(result);
  };
  
  useEffect(() => {
    handleUserFetch();
    // 忽略警告
    // eslint-disable-next-line react-hooks/exhaustive-deps 
  }, []);
  
  if (!user) return <p>No data available.</p>
  
  return <UserCard data={user} />
};

最初,很多人認為這個警告毫無意義,從而選擇進行忽略,而不去試圖探索它是如何產生的。

其實,有些人沒有意識到,handleUserFetch() 方法在元件每次渲染的時候都會重新建立(元件有多少次更新就會建立多少次)。

關於 react-hooks/exhaustive-deps 詳細的討論,可以看下這個 issue

useCallback 的作用在於利用 memoize 減少無效的 re-render,來達到效能優化的作用。

這就是為什麼我們需要在 useEffect 中呼叫的方法上使用 useCallback的原因。通過這種方式,我們可以防止 handleUserFetch() 方法重新建立(除非其依賴項發生變化) ,因此這個方法可以用作 useEffect 鉤子的依賴項,而不會導致無限迴圈。

上邊的例子應該這樣重寫:

import React, { useState, useEffect, useCalllback } from 'react'

import { fetchUserAction } from '../api/actions.js'

const UserContainer = () => {
  const [user, setUser] = useState(null);
  
  // 使用 useCallback 包裹
  const handleUserFetch = useCalllback(async () => {
    const result = await fetchUserAction();
    setUser(result);
  }, []);
  
  useEffect(() => {
    handleUserFetch();
  }, [handleUserFetch]); /* 將 handleUserFetch 作為依賴項傳入 */
  
  if (!user) return <p>No data available.</p>
  
  return <UserCard data={user} />
};

我們將 handleUserFetch 作為 useEffect 的依賴項,並將它包裹在 useCallback 中。如果此方法使用外部引數,例如 userId (在實際開發中,可能希望獲取特定的使用者) ,則此引數可以作為 useCallback 的依賴項傳入。只有 userId 發生變化時,依賴它的 handleUserFetch 才重寫改變。

六、抽離獨立邏輯

假設我們在元件中有一個方法,它可以處理元件的一些變數,併為我們返回一個輸出。

例如:

const UserCard = ({ user }) => {
  const getUserRole = () => {
    const { roles } = user;
    if (roles.includes('admin')) return 'Admin';
    if (roles.includes('maintainer')) return 'Maintainer';
    return 'Developer';
  }
  
  return (
    <ul>
      <li>{user.name}</li>
      <li>{user.age}</li>
      <li>{user.email}</li>
      <li>{getUserRole()}</li>
    </ul>
  );
}

這個方法和前一個例子中的方法一樣,在元件每次渲染時都會重新建立,(但是沒必要使用 useCallback 進行包裹,因為它沒有被作為一個依賴項傳入)。

元件內部定義的許多邏輯可以從元件中抽離,因為它的實現並不真正與元件相關。

改進後的程式碼:

const getUserRole = (roles) => {
  if (roles.includes('admin')) return 'Admin';
  if (roles.includes('maintainer')) return 'Maintainer';
  return 'Developer';
}

const UserCard = ({ user }) => {
  return (
    <ul>
      <li>{user.name}</li>
      <li>{user.age}</li>
      <li>{user.email}</li>
      <li>{getUserRole(user.roles)}</li>
    </ul>
  );
}

通過這種方式,可以在一個單獨的檔案中定義函式,並在需要時匯入,進而可能會達到複用的目的。

早期將邏輯從元件中抽象出來,可以讓我們擁有更簡潔的元件和易於重用的實用函式。

七、不要使用內聯樣式

CSS 作用域在 React 中是通過 CSS-in-JS 的方案實現的,這引入了一個新的面向元件的樣式範例,它和普通的 CSS 撰寫過程是有區別的。另外,雖然在構建時將 CSS 提取到一個單獨的樣式表是支援的,但 bundle 裡通常還是需要一個執行時程式來讓這些樣式生效。當你能夠利用 JavaScript 靈活處理樣式的同時,也需要權衡 bundle 的尺寸和執行時的開銷。 -- 來自 Vue 官網

以前,網頁開發有一個原則,叫做「關注點分離」,主要是以下三種技術分離:

  • HTML 語言:負責網頁的結構,又稱語義層。
  • CSS 語言:負責網頁的樣式,又稱
  • JavaScript 語言:負責網頁的邏輯和互動,又稱邏輯層或互動層。

對 CSS 來說,就是不要寫內聯樣式(inline style),如下:

<div style="width: 100%; height: 20px;">

但是元件化(Vue、React)流行以後,打破了這個原則,它要求把 HTML、CSS、JavaScript 寫在一起。

使用 React 編寫樣式可以這麼做:

const style = {
  fontSize: "14px"
}
const UserCard = ({ user }) => {
  return (
    <ul style={style}>
      <li>{user.name}</li>
      <li>{user.age}</li>
      <li>{user.email}</li>
      <li>{getUserRole(user.roles)}</li>
    </ul>
  );
}

React 這麼做有利於元件的隔離,每個元件包含了所有需要用到的程式碼,不依賴外部,元件之間沒有耦合,很方便複用。

這裡,本文不建議在 React 中使用內聯樣式基於兩點:

  1. 他會讓你的 HTML 結構變得臃腫。

  1. 如果樣式過多,維護起來很麻煩,無法通過外部修改 CSS。

const style1 = {
  fontSize: "14px"
}
const style2 = {
  fontSize: "12px",
  color: "red"
}
const style = {...}
const UserCard = ({ user }) => {
  return (
    <ul style={style}>
      <li style={style2}>{user.name}</li>
      <li style={color: "#333"}>{user.age}</li>
      <li style={color: "#333"}>{user.email}</li>
      <li style={color: "#333"}>{getUserRole(user.roles)}</li>
    </ul>
  );
}

看到這裡,有人可能會反駁:「你可以使用 props 有條件地對 CSS 內嵌樣式進行樣式化」,這是可行的,然而,你的元件不應該只有 10 個處理 CSS 的 props,而不做其他事情。

如果非要在元件中編寫 CSS,建議使用 style-components CSS-in-JS 庫。

styled-components 編寫的元件樣式存在於 style 標籤內,而且選擇器名字是一串隨機的雜湊字串,實現了區域性 CSS 作用域的效果(scoping styles),各個元件的樣式不會發生衝突。

如果不借助管理 CSS 的類庫,把 CSS 和 JS 混合在一起,如果做的好,可以有效的做到元件隔離。如果做的不好,這個元件不僅會變得臃腫難以理解,你的 CSS 也會變得越來越難以維護。

八、編寫有效的 HTML

很多人對 HTML 技術的關注度都是不夠的,但是編寫 HTML 和 CSS 仍然是我們前端工程師的必備工作。

React 是有趣的,Hooks 也是有趣的,但我們最終關心的是渲染 HTML 和使它看起來更友好。

對於 HTML 元素來說,如果它是一個按鈕,它應該是一個 <button>,而不是一個可點選的 div;如果它不進行表單提交,那麼它應該是 type="button";如果它應該基於文字大小自適應,它不應該有一個固定的寬度,等等。

對一些人來說,這是最基本的,但對於一部分人來說,情況並非如此。

我們經常會看到類似的表單提交程式碼:

import React, { useState } from 'react';

const Form = () => {
  const [name, setName] = useState('');  
  const handleChange = e => {
    setName(e.target.value);
  }
  const handleSubmit = () => {
    // api call here
  } 
  return (
    <div>
      <input type="text" onChange={handleChange} value={name} />
      <button type="button" onClick={handleSubmit}>
        Submit
      </button>
    </div>
  )
}

這個示例所做的事是在 <input/> 上更新 name 值,並且在 <button/> 上繫結 click 事件,通過點選呼叫 handleSubmit 來提交資料。

這個功能對於通過使用按鈕進行提交的使用者是可以的,但是對於使用 Enter 鍵提交表單的使用者來說就不行了。

對於 form 表單支援 Enter 提交,可以這麼做,無需對 Enter 進行監聽:

<form onsubmit="myFunction()">
  Enter name: <input type="text">
  <input type="submit">
</form>

詳細 https://www.w3schools.com/jsref/event_onsubmit.asp

在 React 中 使用 onSubmit 是等效的:

import React, { useState } from 'react';

const Form = () => {
  const [name, setName] = useState('');
  
  const handleChange = e => {
    setName(e.target.value);
  }
  
  const handleSubmit = e => {
    e.preventDefault();
    // api call here
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input type="text" onChange={handleChange} value={name} />
      <button type="submit">
        Submit
      </button>
    </form>
  )
}

現在,這個表單提交適用於任何觸發場景,而不僅僅是一個只支援通過按鈕點選的表單。

總結

  1. 遵循單一目的元件哲學,避免過於複雜的多行元件,並儘可能地將元件分解。
  2. 請記住,在父元件內部完成此操作總是比在元件本身內部完成更為乾淨。
  3. 當在 render 中返回 JSX 時,不要使用內聯的 JavaScript 邏輯。
  4. 元件內部定義的許多邏輯可以從元件中抽離,因為它的實現並不真正與元件相關。
  5. 早期將邏輯從元件中抽象出來,可以讓我們擁有更簡潔的元件和易於重用的實用函式。
  6. 如果不借助管理 CSS 的類庫,把 CSS 和 JS 混合在一起,如果做的好,可以有效的做到元件隔離。如果做的不好,這個元件不僅會變得臃腫難以理解,你的 CSS 也會變得越來越難以維護。
  7. React 是有趣的,Hooks 也是有趣的,但最終我們關心的是渲染 HTML 和使它看起來更友好。

參考:

  1. https://itnext.io/write-clean...
  2. https://zhuanlan.zhihu.com/p/...(介紹 useCallback)
  3. https://stackoverflow.com/que...(react-hooks-exhaustive-deps)
  4. https://zhuanlan.zhihu.com/p/...(介紹 CSS-in-Js)

相關文章