程式碼簡潔之道:編寫乾淨的 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)

相關文章