React組件設計實踐總結04 - 組件的思惟

在 React 的世界裏」一切都是組件「, 組件能夠映射做函數式編程中的函數,React 的組件和函數同樣的靈活的特性不只僅能夠用於繪製 UI,還能夠用於封裝業務狀態和邏輯,或者非展現相關的反作用, 再經過組合方式組成複雜的應用. 本文嘗試解釋用 React 組件的思惟來處理常見的業務開發場景.javascript

系列目錄html


目錄前端




1. 高階組件

在很長一段時期裏,高階組件都是加強和組合 React 組件的最流行的方式. 這個概念源自於函數式編程的高階函數. 高階組件能夠定義爲: 高階組件是函數,它接收原始組件並返回原始組件的加強/填充版本:vue

const HOC = Component => EnhancedComponent;
複製代碼

首先要明白咱們爲何須要高階組件:java

React 的文檔說的很是清楚, 高階組件是一種用於複用組件邏輯模式. 最爲常見的例子就是 redux 的connect和 react-router 的 withRouter. 高階組件最初用於取代 mixin(瞭解React Mixin 的前世此生). 總結來講就是兩點:react

  • 邏輯複用. 把一些通用的代碼邏輯提取出來放到高階組件中, 讓更多組件能夠共享
  • 分離關注點. 在以前的章節中提到"邏輯和視圖分離"的原則. 高階組件能夠做爲實現該原則的載體. 咱們通常將行爲層或者業務層抽取到高階組件中來實現, 讓展現組件只關注於 UI

高階組件的一些實現方法主要有兩種:git

  • 屬性代理(Props Proxy): 代理傳遞給被包裝組件的 props, 對 props 進行操做. 這種方式用得最多. 使用這種方式能夠作到:github

    • 操做 props
    • 訪問被包裝組件實例
    • 提取 state
    • 用其餘元素包裹被包裝組件
  • 反向繼承(Inheritance Inversion): 高階組件繼承被包裝的組件. 例如:spring

    function myhoc(WrappedComponent) {
      return class Enhancer extends WrappedComponent {
        render() {
          return super.render();
        }
      };
    }
    複製代碼

    能夠實現:編程

    • 渲染劫持: 即控制被包裝組件的渲染輸出.
    • 操做 state: state 通常屬於組件的內部細節, 經過繼承的方式能夠暴露給子類. 能夠增刪查改被包裝組件的 state, 除非你知道你在幹什麼, 通常不建議這麼作.

實際上高階組件能作的不止上面列舉的, 高階組件很是靈活, 全憑你的想象力. 讀者能夠了解 recompose這個庫, 簡直把高階組件玩出花了.

總結一下高階組件的應用場景:

  • 操做 props: 增刪查改 props. 例如轉換 props, 擴展 props, 固定 props, 重命名 props
  • 依賴注入. 注入 context 或外部狀態和邏輯, 例如 redux 的 connnect, react-router 的 withRouter. 舊 context 是實驗性 API, 因此不少庫都不會將 context 保留出來, 而是經過高階組件形式進行注入
  • 擴展 state: 例如給函數式組件注入狀態
  • 避免重複渲染: 例如 React.memo
  • 分離邏輯, 讓組件保持 dumb

高階組件相關文檔在網上有不少, 本文不打算展開描述. 深刻了解高階組件

高階組件的一些規範:

  • 包裝顯示名字以便於調試

    function withSubscription(WrappedComponent) {
      class WithSubscription extends React.Component {
        /* ... */
      }
      WithSubscription.displayName = `WithSubscription(${getDisplayName(WrappedComponent)})`;
      return WithSubscription;
    }
    
    function getDisplayName(WrappedComponent) {
      return WrappedComponent.displayName || WrappedComponent.name || 'Component';
    }
    複製代碼
  • 使用 React.forwardRef 來轉發 ref

  • 使用'高階函數'來配置'高階組件', 這樣可讓高階組件的組合性最大化. Redux 的 connect 就是典型的例子

    const ConnectedComment = connect(
      commentSelector,
      commentActions,
    )(Comment);
    複製代碼

    當使用 compose 進行組合時就能體會到它的好處:

    // 🙅 不推薦
    const EnhancedComponent = withRouter(connect(commentSelector)(WrappedComponent));
    
    // ✅ 使用compose方法進行組合
    // compose(f, g, h) 和 (...args) => f(g(h(...args)))是同樣的
    const enhance = compose(
      // 這些都是單獨一個參數的高階組件
      withRouter,
      connect(commentSelector),
    );
    
    const EnhancedComponent = enhance(WrappedComponent);
    複製代碼
  • 轉發全部不相關 props 屬性給被包裝的組件

    render() {
      const { extraProp, ...passThroughProps } = this.props;
      // ...
      return (
        <WrappedComponent
          injectedProp={injectedProp}
          {...passThroughProps}
        />
      );
    }
    複製代碼
  • 命名: 通常以 with*命名, 若是攜帶參數, 則以 create*命名




2. Render Props

Render Props(Function as Child) 也是一種常見的 react 模式, 好比官方的 Context APIreact-spring 動畫庫. 目的高階組件差很少: 都是爲了分離關注點, 對組件的邏輯進行復用; 在使用和實現上比高階組件要簡單, 在某些場景能夠取代高階組件. 官方的定義是:

是指一種在 React 組件之間使用一個值爲函數的 prop 在 React 組件間共享代碼的簡單技術

React 並無限定任何 props 的類型, 因此 props 也能夠是函數形式. 當 props 爲函數時, 父組件能夠經過函數參數給子組件傳遞一些數據進行動態渲染. 典型代碼爲:

<FunctionAsChild>{() => <div>Hello,World!</div>}</FunctionAsChild>
複製代碼

使用示例:

<Spring from={{ opacity: 0 }} to={{ opacity: 1 }}>
  {props => <div style={props}>hello</div>}
</Spring>
複製代碼

某種程度上, 這種模式相比高階組件要簡單不少, 無論是實現仍是使用層次. 缺點也很明顯:

  • 可讀性差, 尤爲是多層嵌套狀況下
  • 組合性差. 只能經過 JSX 一層一層嵌套, 通常不宜多於一層
  • 適用於動態渲染. 由於侷限在 JSX 節點中, 當前組件是很難獲取到 render props 傳遞的數據. 若是要傳遞給當前組件仍是得經過 props, 也就是經過高階組件傳遞進來

再開一下腦洞. 經過一個 Fetch 組件來進行接口請求:

<Fetch method="user.getById" id={userId}>
  {({ data, error, retry, loading }) => (
    <Container>
      {loading ? (
        <Loader />
      ) : error ? (
        <ErrorMessage error={error} retry={retry} />
      ) : data ? (
        <Detail data={data} />
      ) : null}
    </Container>
  )}
</Fetch>
複製代碼

在 React Hooks 出現以前, 爲了給函數組件(或者說 dumb component)添加狀態, 一般會使用這種模式. 好比 react-powerplug

官方文檔




3. 使用組件的方式來抽象業務邏輯

大部分狀況下, 組件表示是一個 UI 對象. 其實組件不僅僅能夠表示 UI, 也能夠用來抽象業務對象, 有時候抽象爲組件能夠巧妙地解決一些問題.

舉一個例子: 當一個審批人在審批一個請求時, 請求發起者是不能從新編輯的; 反之發起者在編輯時, 審批人不能進行審批. 這是一個鎖定機制, 後端通常使用相似心跳機制來維護這個'鎖', 這個鎖能夠顯式釋放,也能夠在超過必定時間沒有激活時自動釋放,好比頁面關閉. 因此前端一般會使用輪詢機制來激活鎖.

通常的實現:

class MyPage extends React.Component {
  public componentDidMount() {
    // 根據一些條件觸發, 可能還要監聽這些條件的變化,而後中止加鎖輪詢. 這個邏輯實現起來比較囉嗦
    if (someCondition) {
      this.timer = setInterval(async () => {
        // 輪詢
        tryLock();
        // 錯誤處理,能夠加鎖失敗...
      }, 5000);
    }
  }

  public componentWillUnmount() {
    clearInterval(this.timer);
    // 頁面卸載時顯式釋放
    releaseLock();
  }

  public componentDidUpdate() {
    // 監聽條件變化,開始或中止鎖定
    // ...
  }
}
複製代碼

隨着功能的迭代, MyPage 會變得愈來愈臃腫, 這時候你開始考慮將這些業務邏輯抽取出去. 通常狀況下經過高階組件或者 hook 來實現, 但都不夠靈活, 好比條件鎖定這個功能實現起來就比較彆扭.

有時候考慮將業務抽象成爲組件, 可能能夠巧妙地解決咱們的問題, 例如 Locker:

/**
 * 鎖定器
 */
const Locker: FC<{ onError: err => boolean, id: string }> = props => {
  const {id, onError} = props
  useEffect(() => {
    let timer
    const poll = () => {
      timer = setTimeout(async () => {
        // ...
        // 輪詢,處理異常等狀況
      }, 5000)
    }

    poll()

    return () => {
      clearTimeout(timer)
      releaseLock()
    }
  }, [id])

  return null
};
複製代碼

使用 Locker

render() {
  return (<div>
    {someCondition && <Locker id={this.id} onError={this.handleError}></Locker>}
  </div>)
}
複製代碼

這裏面有一個要點:咱們將一個業務抽象爲了一個組件後,業務邏輯有了和組件同樣的生命週期。如今組件內部只需關心自身的邏輯,好比只關心資源請求和釋放(即 How),而什麼時候進行,什麼條件進行(即 When)則由父級來決定, 這樣就符合了單一職責原則。 上面的例子父級經過 JSX 的條件渲染就能夠動態控制鎖定, 比以前的實現簡單了不少




4. hooks 取代高階組件

我的以爲 hooks 對於 React 開發來講是一個革命性的特性, 它改變了開發的思惟和模式. 首先要問一下, "它解決了什麼問題? 帶來了什麼新的東西?"

hooks 首先是要解決高階組件或者 Render Props 的痛點的. 官方在'動機'上就說了:

    1. 很難在組件之間複用狀態邏輯:
    • 問題: React 框架自己並無提供一種將可複用的邏輯注入到組件上的方式/原語. RenderProps 和高階組件只是'模式層面(或者說語言層面)'的東西:

    • 此前的方案: 高階組件和 Render Props。這些方案都是基於組件自己的機制

      • 高階組件和 Render Props 會形成多餘的節點嵌套. 即 Wrapper hell
      • 須要調整你的組件結構, 會讓代碼變得笨重, 且難以理解
      • 高階組件複雜, 難以理解
      • 此前高階組件也要 ref 轉發問題等等
    • hooks 如何解決:

      • 將狀態邏輯從組件中脫離, 讓他能夠被單獨的測試和複用.
      • hooks 能夠在組件之間共享, 不會影響組件的結構

    1. 複雜的組件難以理解: 複雜組件的特色是有一大堆分散的狀態邏輯和反作用. 例如每一個生命週期函數經常包含一些互不相關的邏輯, 這些互不相關的邏輯會慢慢變成麪條式的代碼, 可是你發現很難再對它們進行拆解, 更別說測試它們
    • 問題:

      • 實際狀況,咱們很難將這些組件分解成更小的組件,由於狀態處處都是。測試它們也很困難。
      • 常常致使過度抽象, 好比 redux, 須要在多個文件中跳轉, 須要不少模板文件和模板代碼
    • 此前的解決方法: 高階組件和 Render Props 或者狀態管理器. 分割抽離邏輯和 UI, 切割成更小粒度的組件

    • hooks 如何解決: Hooks 容許您根據相關部分(例如設置訂閱或獲取數據)將一個組件分割成更小的函數,而不是強制基於生命週期方法進行分割。你還能夠選擇使用一個 reducer 來管理組件的本地狀態,以使其更加可預測


    1. 基於 class 的組件對機器和用戶都不友好:
    • 問題:
      • 對於人: 須要理解 this, 代碼冗長
      • 對於機器: 很差優化
    • hooks 如何解決: 函數式組件
    • 新的問題: 你要了解閉包

Hooks 帶來的新東西: hook 旨在讓組件的內部邏輯組織成可複用的更小單元,這些單元各自維護一部分組件‘狀態和邏輯’

migrate to hooks

圖片來源於twitter(@sunil Pai)

  • 一種新的組件編寫方式. 和此前基於 class 或純函數組件的開發方式不太同樣, hook 提供了更簡潔的 API 和代碼複用機制, 這使得組件代碼變得更簡短. 例如 👆 上圖就是遷移到 hooks 的代碼結構對比, 讀者也能夠看這個演講(90% Cleaner React).

  • 更細粒度的狀態控制(useState). 之前一個組件只有一個 setState 集中式管理組件狀態, 如今 hooks 像組件同樣, 是一個邏輯和狀態的聚合單元. 這意味着不一樣的 hook 能夠維護本身的狀態.

  • 無論是 hook 仍是組件,都是普通函數.

    • 從某種程度上看組件和 hooks 是同質的(都包含狀態和邏輯). 統一使用函數形式開發, 這使得你不須要在類、高階組件或者 renderProps 上下文之間切換, 下降項目的複雜度. 對於 React 的新手來講,各類高階組件、render props 各類概念拉高了學習曲線
    • 函數是一種最簡單的代碼複用單元, 最簡單也意味着更靈活。相比組件的 props,函數的傳參更加靈活; 函數也更容易進行組合, hooks 組合其餘 hook 或普通函數來實現複雜邏輯.
    • 本質上講,hooks 就是給函數帶來了狀態的概念
  • 高階組件之間只能簡單嵌套複合(compose), 而多個 hooks 之間是平鋪的, 能夠定義更復雜的關係(依賴).

  • 更容易進行邏輯和視圖分離. hooks 自然隔離 JSX, 視圖和邏輯之間的界限比較清晰, 這使得 hooks 能夠更專一組件的行爲.

  • 淡化組件生命週期概念, 將原本分散在多個生命週期的相關邏輯聚合起來

  • 一點點'響應式編程'的味道, 每一個 hooks 都包含一些狀態和反作用,這些數據能夠在 hooks 之間傳遞流動和響應, 見下文

  • 跨平臺的邏輯複用. 這是我本身開的腦洞, React hooks 出來以後尤雨溪就推了一個vue-hooks試驗項目, 若是後面發展順利, hooks 是可能被用於跨框架複用?


一個示例: 無限加載列表

Edit useList


通常 hooks 的基本代碼結構爲:

function useHook(options) {
  // ⚛️states
  const [someState, setSomeState] = useState(initialValue);
  // ⚛️derived state
  const computedState = useMemo(() => computed, [dependencies]);

  // ⚛️refs
  const refSomething = useRef();

  // ⚛️side effect
  useEffect(() => {}, []);
  useEffect(() => {}, [dependencies]);

  // ⚛️state operations
  const handleChange = useCallback(() => {
    setSomeState(newState)
  }, [])

  // ⚛️output
  return <div>{...}</div>
}
複製代碼

自定義 hook 和函數組件的代碼結構基本一致, 因此有時候hooks 寫着寫着原來越像組件, 組件寫着寫着越像 hooks. 我以爲能夠認爲組件就是一種特殊的 hook, 只不過它輸出 Virtual DOM.


一些注意事項:

  • 只能在組件頂層調用 hooks。不要在循環,控制流和嵌套的函數中調用 hooks
  • 只能從 React 的函數組件中調用 hooks
  • 自定義 hooks 使用 use*命名

總結 hooks 的經常使用場景:

  • 反作用封裝和監聽: 例如 useWindowSize(監聽窗口大小),useOnlineStatus(在線狀態)
  • 反作用衍生: useEffect, useDebounce, useThrottle, useTitle, useSetTimeout
  • DOM 事件封裝:useActive,useFocus, useDraggable, useTouch
  • 獲取 context
  • 封裝可複用邏輯和狀態: useInput, usePromise(異步請求), useList(列表加載)
    • 取代高階組件和 render Props. 例如使用 useRouter 取代 withRouter, useSpring 取代舊的 Spring Render Props 組件
    • 取代容器組件
    • 狀態管理器: use-global-hook, unstated
  • 擴展狀態操做: 原始的 useState 很簡單,因此有很大的擴展空間,例如 useSetState(模擬舊的 setState), useToggle(boolean 值切換),useArray, useLocalStorage(同步持久化到本地存儲)
  • 繼續開腦洞...: hooks 的探索還在繼續

學習 hooks:




5. hooks 實現響應式編程

Vue的非侵入性響應式系統是其最獨特的特性之一, 能夠按照 Javascript 的數據操做習慣來操做組件狀態, 而後自動響應到頁面中. 而 React 這邊則提供了 setState, 對於複雜的組件狀態, setState 會讓代碼變得的又臭又長. 例如:

this.setState({
  pagination: {
    ...this.state.pagination,
    current: defaultPagination.current || 1,
    pageSize: defaultPagination.pageSize || 15,
    total: 0,
  },
});
複製代碼

後來有了mobx, 基本接近了 Vue 開發體驗:

@observer
class TodoView extends React.Component {
  private @observable loading: boolean;
  private @observable error?: Error;
  private @observable list: Item[] = [];
  // 衍生狀態
  private @computed get completed() {
    return this.list.filter(i => i.completed)
  }

  public componentDidMount() {
    this.load();
  }

  public render() {
    /// ...
  }

  private async load() {
    try {
      this.error = undefined
      this.loading = true
      const list = await fetchList()
      this.list = list
    } catch (err) {
      this.error = err
    } finally {
      this.loading = false
    }
  }
}
複製代碼

其實 mobx 也有挺多缺點:

  • 代碼侵入性. 全部須要響應數據變更的組件都須要使用 observer 裝飾, 屬性須要使用 observable 裝飾, 以及數據操做方式. 對 mobx 耦合較深, 往後切換框架或重構的成本很高

  • 兼容性. mobx v5 後使用 Proxy 進行重構, Proxy 在 Chrome49 以後才支持. 若是要兼容舊版瀏覽器則只能使用 v4, v4 有一些, 這些坑對於不瞭解 mobx 的新手很難發現:

    • Observable 數組並不是真正的數組. 好比 antd 的 Table 組件就不認 mobx 的數組, 須要傳入到組件之間使用 slice 進行轉換
    • 向一個已存在的 observable 對象中添加屬性不會被自動捕獲

因而 hooks 出現了, 它讓組件的狀態管理變得更簡單直接, 並且它的思想也很接近 mobx 響應式編程哲學:

mobx


  1. 簡潔地聲明狀態

狀態 是驅動應用的數據. 例如 UI 狀態或者業務領域狀態

function Demo() {
  const [list, setList] = useState<Item[]>([]);
  // ...
}
複製代碼
  1. 衍生

任何 源自狀態而且不會再有任何進一步的相互做用的東西就是衍生。包括用戶視圖, 衍生狀態, 其餘反作用

function Demo(props: { id: string }) {
  const { id } = props;
  // 取代mobx的observable: 獲取列表, 在掛載或id變更時請求
  const [value, setValue, loading, error, retry] = usePromise(
    async id => {
      return getList(id);
    },
    [id],
  );

  // 衍生狀態: 取代mobx的computed
  const unreads = useMemo(() => value.filter(i => !i.readed), [value]);

  // 衍生反作用: value變更後自動持久化
  useDebounce(
    () => {
      saveList(id, value);
    },
    1000,
    [value],
  );

  // 衍生視圖
  return <List data={value} onChange={setValue} error={error} loading={loading} retry={retry} />;
}
複製代碼

因此說 hook 是一個革命性的東西, 它可讓組件的狀態數據流更加清晰. 換作 class 組件, 咱們一般的作法多是在 componentDidUpdate生命週期方法中進行數據比較, 而後命令式地觸發一些方法. 好比 id 變化時觸發 getList, list 變化時進行 saveList.

hook 彷佛在淡化組件生命週期的概念, 讓開發者更專一於狀態的關係, 以數據流的方式來思考組件的開發. Dan Abramov編寫有彈性的組件也提到了一個原則"不要阻斷數據流", 證明了筆者的想法:

不管什麼時候使用 props 和 state,請考慮若是它們發生變化會發生什麼。在大多數狀況下,組件不該以不一樣方式處理初始渲染和更新流程。這使它可以適應邏輯上的變化。

讀者能夠看一下awesome-react-hooks, 這些開源的 hook 方案都挺有意思. 例如rxjs-hooks, 巧妙地將 react hooks 和 rxjs 結合的起來:

function App(props: { foo: number }) {
  // 響應props的變更
  const value = useObservable(inputs$ => inputs$.pipe(map(([val]) => val + 1)), 200, [props.foo]);
  return <h1>{value}</h1>;
}
複製代碼



6. 類繼承也有用處

就如 react 官方文檔說的: "咱們的 React 使用了數以千計的組件,然而卻還未發現任何須要推薦你使用繼承的狀況。", React 偏向於函數式編程的組合模式, 面向對象的繼承實際的應用場景不多.

當咱們須要將一些傳統的第三方庫轉換成 React 組件庫時, 繼承就可能派上用場. 由於這些庫大部分是使用面向對象的範式來組織的, 比較典型的就是地圖 SDK. 以百度地圖爲例:

baidu overlay

百度地圖有各類組件類型: controls, overlays, tileLayers. 這些類型都有多個子類, 如上圖, overlay 有 Label, Marker, Polyline 等這些子類, 且這些子類有相同的生命週期, 都是經過 addOverlay 方法來渲染到地圖畫布上. 咱們能夠經過繼承的方式將他們生命週期管理抽取到父類上, 例如:

// Overlay抽象類, 負責管理Overlay的生命週期
export default abstract class Overlay<P> extends React.PureComponent<OverlayProps & P> {
  protected initialize?: () => void;
  // ...
  public componentDidMount() {
    // 子類在constructor或initialize方法中進行實例化
    if (this.initialize) {
      this.initialize();
    }

    if (this.instance && this.context) {
      // 渲染到Map畫布中
      this.context.nativeInstance!.addOverlay(this.instance);
      // 初始化參數
      this.initialProperties();
    }
  }

  public componentDidUpdate(prevProps: P & OverlayProps) {
    // 屬性更新
    this.updateProperties(prevProps);
  }

  public componentWillUnmount() {
    // 組件卸載
    if (this.instance && this.context) {
      this.context.nativeInstance!.removeOverlay(this.instance);
    }
  }
  // ...
  // 其餘通用方法
  private forceReloadIfNeed(props: P, prevProps: P) {
    ...
  }
}
複製代碼

子類的工做就變得簡單不少, 聲明本身的屬性/事件和實例化具體類:

export default class Label extends Overlay<LabelProps> {
  public static defaultProps = {
    enableMassClear: true,
  };

  public constructor(props: LabelProps) {
    super(props);
    const { position, content } = this.props;
    // 聲明支持的屬性和回調
    this.extendedProperties = PROPERTIES;
    this.extendedEnableableProperties = ENABLEABLE_PROPERTIES;
    this.extendedEvents = EVENTS;

    // 實例化具體類
    this.instance = new BMap.Label(content, {
      position,
    });
  }
}
複製代碼

代碼來源於 react-bdmap

固然這個不是惟一的解決方法, 使用高階組件和 hooks 一樣可以實現. 只不過對於本來就採用面向對象範式組織的庫, 使用繼承方式會更加好理解




7. 模態框管理

modal demo

模態框是應用開發中使用頻率很是高組件,尤爲在中後臺管理系統中. 可是在 React 中用着並非特別爽, 典型的代碼以下:

const Demo: FC<{}> = props => {
  // ...
  const [visible, setVisible] = useState(false);
  const [editing, setEditing] = useState();
  const handleCancel = () => {
    setVisible(false);
  };

  const prepareEdit = async (item: Item) => {
    // 加載詳情
    const detail = await loadingDeatil(item.id);
    setEditing(detail);
    setVisible(true);
  };

  const handleOk = async () => {
    try {
      const values = await form.validate();
      // 保存
      await save(editing.id, values);
      // 隱藏
      setVisible(false);
    } catch {}
  };

  return;
  <>
    <Table
      dataSource={list}
      columns={[
        {
          text: '操做',
          render: item => {
            return <a onClick={() => prepareEdit(item)}>編輯</a>;
          },
        },
      ]}
    />
    <Modal visible={visible} onOk={handleOk} onCancel={handleHide}>
      {/* 表單渲染 */}
    </Modal>
  </>;
};
複製代碼

上面的代碼太醜了, 不相關邏輯堆積在一個組件下 ,不符合單一職責. 因此咱們要將模態框相關代碼抽取出去, 放到EditModal中:

const EditModal: FC<{ id?: string; visible: boolean; onCancel: () => void; onOk: () => void }> = props => {
  // ...
  const { visible, id, onHide, onOk } = props;
  const detail = usePromise(async (id: string) => {
    return loadDetail(id);
  });

  useEffect(() => {
    if (id != null) {
      detail.call(id);
    }
  }, [id]);

  const handleOk = () => {
    try {
      const values = await form.validate();
      // 保存
      await save(editing.id, values);
      onOk();
    } catch {}
  };

  return (
    <Modal visible={visible} onOk={onOk} onCancel={onCancel}>
      {detail.value &&
        {
          /* 表單渲染 */
        }}
    </Modal>
  );
};

/**
 * 使用
 */
const Demo: FC<{}> = props => {
  // ...
  const [visible, setVisible] = useState(false);
  const [editing, setEditing] = useState<string | undefined>(undefined);
  const handleHide = () => {
    setVisible(false);
  };

  const prepareEdit = async (item: Item) => {
    setEditing(item.id);
    setVisible(true);
  };

  return;
  <>
    <Table
      dataSource={list}
      columns={[
        {
          text: '操做',
          render: item => {
            return <a onClick={() => prepareEdit(item)}>編輯</a>;
          },
        },
      ]}
    />
    <EditModal id={editing} visible={visible} onOk={handleHide} onCancel={handleHide}>
      {' '}
    </EditModal>
  </>;
};
複製代碼

如今編輯相關的邏輯抽取到了 EditModal 上,可是 Demo 組件還要維護模態框的打開狀態和一些數據狀態。一個複雜的頁面可能會有不少模態框,這樣的代碼會變得愈來愈噁心, 各類 xxxVisible 狀態滿天飛. 從實際開發角度上將,模態框控制的最簡單的方式應該是這樣的:

const handleEdit = item => {
  EditModal.show({
    // 🔴 經過函數調用的方式出發彈窗. 這符合對模態框的習慣用法, 不關心模態框的可見狀態. 例如window.confirm, wx.showModal().
    id: item.id, // 🔴 傳遞數據給模態框
    onOk: saved => {
      // 🔴 事件回調
      refreshList(saved);
    },
    onCancel: async () => {
      return confirm('確認取消'); // 控制模態框是否隱藏
    },
  });
};
複製代碼

這種方式在社區上也是有爭議的,有些人認爲這是 React 的反模式,@欲三更Modal.confirm 違反了 React 的模式嗎?就探討了這個問題。 以圖爲例:

modal confirm
圖片一樣出自欲三更文章

紅線表示時間驅動(或者說時機驅動), 藍線表示數據驅動。欲三更認爲「哪怕一個帶有明顯數據驅動特點的 React 項目,也存在不少部分不是數據驅動而是事件驅動的. 數據只能驅動出狀態,只有時機才能驅動出行爲, 對於一個時機驅動的行爲,你非得把它硬坳成一個數據驅動的狀態,你不以爲很奇怪嗎?」. 他的觀點正不正確筆者不作評判, 可是某些場景嚴格要求‘數據驅動’,可能會有不少模板代碼,寫着會很難受.

So 怎麼實現?

能夠參考 antd Modal.confirm的實現, 它使用ReactDOM.render來進行外掛渲染,也有人使用Context API來實現的. 筆者認爲比較接近理想的(至少 API 上看)是react-comfirm這樣的:

/**
 * EditModal.tsx
 */
import { confirmable } from 'react-confirm';
const EditModal = props => {
  /*...*/
};

export default confirmable(EditModal);

/**
 *  Demo.tsx
 */
import EditModal from './EditModal';

const showEditModal = createConfirmation(EditModal);

const Demo: FC<{}> = props => {
  const prepareEdit = async (item: Item) => {
    showEditModal({
      id: item.id, // 🔴 傳遞數據給模態框
      onOk: saved => {
        // 🔴 事件回調
        refreshList(saved);
      },
      onCancel: async someValues => {
        return confirm('確認取消'); // 控制模態框是否隱藏
      },
    });
  };

  // ...
};
複製代碼

使用ReactDOM.render外掛渲染形式的缺點就是沒法訪問 Context,因此仍是要妥協一下,結合 Context API 來實現示例:

Edit useModal

擴展




8. 使用 Context 進行依賴注入

Context 爲組件樹提供了一個傳遞數據的方法,從而避免了在每個層級手動的傳遞 props 屬性.

Context 在 React 應用中使用很是頻繁, 新的Context API也很是易用. Context 經常使用於如下場景:

  • 共享那些被認爲對於一個'組件樹'而言是「全局」的數據. 如當前認證的用戶, 主題, i18n 配置, 表單狀態
  • 組件配置. 配置組件的行爲, 如 antd 的 ConfigProvider
  • 跨組件通訊. 不推薦經過'事件'進行通訊, 而是經過'狀態'進行通訊
  • 依賴注入
  • 狀態管理器. Context 通過一些封裝能夠基本取代 Redux 和 Mobx 這些狀態管理方案. 後續有專門文章介紹

Context 的做用域是子樹, 也就是說一個 Context Provider 能夠應用於多個子樹, 子樹的 Provider 也能夠覆蓋父級的 Provider 的 value. 基本結構:

import React, {useState, useContext} from 'react'

export inteface MyContextValue {
  state: number
  setState: (state: number) => void
}

const MyContext = React.createContext<MyContextValue>(
  {
    state: 1,
    // 設置默認值, 拋出錯誤, 必須配合Provider使用
    setState: () => throw new Error('請求MyContextProvider組件下級調用')
  }
)

export const MyContextProvider: FC<{}> = props => {
  const [state, setState] = useState(1)
  return <MyContext.Provider value={{state, setState}}>{props.children}</MyContext.Provider>
}

export function useMyContext() {
  return useContext(MyContext)
}

export default MyContextProvider
複製代碼

Context 默認值中的方法應該拋出錯誤, 警告不規範的使用

擴展:




9. 不可變的狀態

對於函數式編程範式的 React 來講,不可變狀態有重要的意義.

  • 不可變數據具備可預測性。可不變數據可讓應用更好調試,對象的變動更容易被跟蹤和推導.

    就好比 Redux, 它要求只能經過 dispatch+reducer 進行狀態變動,配合它的 Devtool 能夠很好的跟蹤狀態是如何被變動的. 這個特性對於大型應用來講意義重大,由於它的狀態很是複雜,若是不加以組織和約束,你不知道是哪一個地方修改了狀態, 出現 bug 時很難跟蹤.

    因此說對於嚴格要求單向數據流的狀態管理器(Redux)來講,不可變數據是基本要求,它要求整個應用由一個單一的狀態進行映射,不可變數據可讓整個應用變得可被預測.

  • 不可變數據還使一些複雜的功能更容易實現。避免數據改變,使咱們可以安全保留對舊數據的引用,能夠方便地實現撤銷重作,或者時間旅行這些功能

  • 能夠精確地進行從新渲染判斷。能夠簡化 shouldComponentUpdate 比較。

實現不可變數據的流行方法:

筆者比較喜歡 immer,沒有什麼心智負擔, 按照 JS 習慣的對象操做方式就能夠實現不可變數據。




10. React-router: URL 即狀態

傳統的路由主要用於區分頁面, 因此一開始前端路由設計也像後端路由(也稱爲靜態路由)同樣, 使用對象配置方式, 給不一樣的 url 分配不一樣的頁面組件, 當應用啓動時, 在路由配置表中查找匹配 URL 的組件並渲染出來.

React-Router v4 算是一個真正意義上符合組件化思惟的路由庫, React-Router 官方稱之爲‘動態路由’, 官方的解釋是"指的是在應用程序渲染時發生的路由,而不是在運行應用程序以外的配置或約定中發生的路由", 具體說, <Route/>變成了一個普通 React 組件, 它在渲染時判斷是否匹配 URL, 若是匹配就渲染指定的組件, 不匹配就返回 null.

這時候 URL 意義已經不同了, URL 再也不是簡單的頁面標誌, 而是應用的狀態; 應用構成也再也不侷限於扁平頁面, 而是多個能夠響應 URL 狀態的區域(可嵌套). 由於思惟轉變很大, 因此它剛出來時並不受青睞. 這種方式更加靈活, 因此選擇 v4 不表明放棄舊的路由方式, 你徹底能夠按照舊的方式來實現頁面路由.

舉個應用實例: 一個應用由三個區域組成: 側邊欄放置多個入口, 點擊這些入口會加載對應類型的列表, 點擊列表項須要加載詳情. 三個區域存在級聯關係

router demo

首先設計可以表達這種級聯關係的 URL, 好比/{group}/{id}, URL 設計通常遵循REST 風格, 那麼應用的大概結構是這樣子:

// App
const App = () => {
  <div className="app">
    <SideBar />
    <Route path="/:group" component={ListPage} />
    <Route path="/:group/:id" component={Detail} />
  </div>;
};

// SideBar
const Sidebar = () => {
  return (
    <div className="sidebar">
      {/* 使用NavLink 在匹配時顯示激活狀態 */}
      <NavLink to="/message">消息</NavLink>
      <NavLink to="/task">任務</NavLink>
      <NavLink to="/location">定位</NavLink>
    </div>
  );
};

// ListPage
const ListPage = props => {
  const { group } = props.match.params;
  // ...

  // 響應group變化, 並加載指定類型列表
  useEffect(() => {
    load(group);
  }, [group]);

  // 列表項也會使用NavLink, 用於匹配當前展現的詳情, 激活顯示
  return <div className="list">{renderList()}</div>;
};

// DetailPage
const DetailPage = props => {
  const { group, id } = props.match.params;
  // ...

  // 響應group和id, 並加載詳情
  useEffect(() => {
    loadDetail(group, id);
  }, [group, id]);

  return <div className="detail">{renderDetail()}</div>;
};
複製代碼

擴展




11. 組件規範

擴展

相關文章
相關標籤/搜索