利用 React 高階元件實現一個麵包屑導航

Duang發表於2020-08-21

什麼是 React 高階元件

React 高階元件就是以高階函式的方式包裹需要修飾的 React 元件,並返回處理完成後的 React 元件。React 高階元件在 React 生態中使用的非常頻繁,比如react-router 中的 withRouter 以及 react-reduxconnect 等許多 API 都是以這樣的方式來實現的。

<!-- more -->

使用 React 高階元件的好處

在工作中,我們經常會有很多功能相似,元件程式碼重複的頁面需求,通常我們可以通過完全複製一遍程式碼的方式實現功能,但是這樣頁面的維護可維護性就會變得極差,需要對每一個頁面裡的相同元件去做更改。因此,我們可以將其中共同的部分,比如接受相同的查詢操作結果、元件外同一的標籤包裹等抽離出來,做一個單獨的函式,並傳入不同的業務元件作為子元件引數,而這個函式不會修改子元件,只是通過組合的方式將子元件包裝在容器元件中,是一個無副作用的純函式,從而我們能夠在不改變這些元件邏輯的情況下將這部分程式碼解耦,提升程式碼可維護性。

自己動手實現一個高階元件

前端專案裡,帶連結指向的麵包屑導航十分常用,但由於麵包屑導航需要手動維護一個所有目錄路徑與目錄名對映的陣列,而這裡所有的資料我們都能從 react-router 的路由表中取得,因此我們可以從這裡入手,實現一個麵包屑導航的高階元件。

首先我們看看我們的路由表提供的資料以及目標麵包屑元件所需要的資料:

// 這裡展示的是 react-router4 的route示例
let routes = [
  {
    breadcrumb: '一級目錄',
    path: '/a',
    component: require('../a/index.js').default,
    items: [
      {
        breadcrumb: '二級目錄',
        path: '/a/b',
        component: require('../a/b/index.js').default,
        items: [
          {
            breadcrumb: '三級目錄1',
            path: '/a/b/c1',
            component: require('../a/b/c1/index.js').default,
            exact: true,
          },
          {
            breadcrumb: '三級目錄2',
            path: '/a/b/c2',
            component: require('../a/b/c2/index.js').default,
            exact: true,
          },
      }
    ]
  }
]

// 理想中的麵包屑元件
// 展示格式為 a / b / c1 並都附上鍊接
const BreadcrumbsComponent = ({ breadcrumbs }) => (
  <div>
    {breadcrumbs.map((breadcrumb, index) => (
      <span key={breadcrumb.props.path}>
        <link to={breadcrumb.props.path}>{breadcrumb}</link>
        {index < breadcrumbs.length - 1 && <i> / </i>}
      </span>
    ))}
  </div>
);

這裡我們可以看到,麵包屑元件需要提供的資料一共有三種,一種是當前頁面的路徑,一種是麵包屑所帶的文字,一種是該面包屑的導航連結指向。

其中第一種我們可以通過 react-router 提供的 withRouter 高階元件包裹,可使子元件獲取到當前頁面的 location 屬性,從而獲取頁面路徑。

後兩種需要我們對 routes 進行操作,首先將 routes 提供的資料扁平化成麵包屑導航需要的格式,我們可以使用一個函式來實現它。

/**
 * 以遞迴的方式展平react router陣列
 */
const flattenRoutes = arr =>
  arr.reduce(function(prev, item) {
    prev.push(item);
    return prev.concat(
      Array.isArray(item.items) ? flattenRoutes(item.items) : item
    );
  }, []);

之後將展平的目錄路徑對映與當前頁面路徑一同放入處理函式,生成麵包屑導航結構。

export const getBreadcrumbs = ({ flattenRoutes, location }) => {
  // 初始化匹配陣列match
  let matches = [];

  location.pathname
    // 取得路徑名,然後將路徑分割成每一路由部分.
    .split('?')[0]
    .split('/')
    // 對每一部分執行一次呼叫`getBreadcrumb()`的reduce.
    .reduce((prev, curSection) => {
      // 將最後一個路由部分與當前部分合並,比如當路徑為 `/x/xx/xxx` 時,pathSection分別檢查 `/x` `/x/xx` `/x/xx/xxx` 的匹配,並分別生成麵包屑
      const pathSection = `${prev}/${curSection}`;
      const breadcrumb = getBreadcrumb({
        flattenRoutes,
        curSection,
        pathSection,
      });

      // 將麵包屑匯入到matches陣列中
      matches.push(breadcrumb);

      // 傳遞給下一次reduce的路徑部分
      return pathSection;
    });
  return matches;
};

然後對於每一個麵包屑路徑部分,生成目錄名稱並附上指向對應路由位置的連結屬性。

const getBreadcrumb = ({ flattenRoutes, curSection, pathSection }) => {
  const matchRoute = flattenRoutes.find(ele => {
    const { breadcrumb, path } = ele;
    if (!breadcrumb || !path) {
      throw new Error(
        'Router中的每一個route必須包含 `path` 以及 `breadcrumb` 屬性'
      );
    }
    // 查詢是否有匹配
    // exact 為 react router4 的屬性,用於精確匹配路由
    return matchPath(pathSection, { path, exact: true });
  });

  // 返回breadcrumb的值,沒有就返回原匹配子路徑名
  if (matchRoute) {
    return render({
      content: matchRoute.breadcrumb || curSection,
      path: matchRoute.path,
    });
  }

  // 對於routes表中不存在的路徑
  // 根目錄預設名稱為首頁.
  return render({
    content: pathSection === '/' ? '首頁' : curSection,
    path: pathSection,
  });
};

之後由 render 函式生成最後的單個麵包屑導航樣式。單個麵包屑元件需要為 render 函式提供該面包屑指向的路徑 path, 以及該面包屑內容對映content 這兩個 props。

/**
 *
 */
const render = ({ content, path }) => {
  const componentProps = { path };
  if (typeof content === 'function') {
    return <content {...componentProps} />;
  }
  return <span {...componentProps}>{content}</span>;
};

有了這些功能函式,我們就能實現一個能為包裹元件傳入當前所在路徑以及路由屬性的 React 高階元件了。傳入一個元件,返回一個新的相同的元件結構,這樣便不會對元件外的任何功能與操作造成破壞。

const BreadcrumbsHoc = (
  location = window.location,
  routes = []
) => Component => {
  const BreadComponent = (
    <Component
      breadcrumbs={getBreadcrumbs({
        flattenRoutes: flattenRoutes(routes),
        location,
      })}
    />
  );
  return BreadComponent;
};
export default BreadcrumbsHoc;

呼叫這個高階元件的方法也非常簡單,只需要傳入當前所在路徑以及整個 react router 生成的 routes 屬性即可。
至於如何取得當前所在路徑,我們可以利用 react router 提供的 withRouter 函式,如何使用請自行查閱相關文件。
值得一提的是,withRouter 本身就是一個高階元件,能為包裹元件提供包括 location 屬性在內的若干路由屬性。所以這個 API 也能作為學習高階元件一個很好的參考。

withRouter(({ location }) =>
  BreadcrumbsHoc(location, routes)(BreadcrumbsComponent)
);

Q&A

如果react router 生成的 routes 不是由自己手動維護的,甚至都沒有存在本地,而是通過請求拉取到的,儲存在 redux 裡,通過 react-redux 提供的 connect 高階函式包裹時,路由發生變化時並不會導致該面包屑元件更新。使用方法如下:

function mapStateToProps(state) {
  return {
    routes: state.routes,
  };
}

connect(mapStateToProps)(
  withRouter(({ location }) =>
    BreadcrumbsHoc(location, routes)(BreadcrumbsComponent)
  )
);

這其實是 connect 函式的一個bug。因為 react-redux 的 connect 高階元件會為傳入的引數元件實現 shouldComponentUpdate 這個鉤子函式,導致只有 prop 發生變化時才觸發更新相關的生命週期函式(含 render),而很顯然,我們的 location 物件並沒有作為 prop 傳入該引數元件。

官方推薦的做法是使用 withRouter 來包裹 connectreturn value,即

withRouter(
  connect(mapStateToProps)(({ location, routes }) =>
    BreadcrumbsHoc(location, routes)(BreadcrumbsComponent)
  )
);

其實我們從這裡也可以看出,高階元件同高階函式一樣,不會對元件的型別造成任何更改,因此高階元件就如同鏈式呼叫一樣,可以任意多層包裹來給元件傳入不同的屬性,在正常情況下也可以隨意調換位置,在使用上非常的靈活。這種可插拔特性使得高階元件非常受 React 生態的青睞,很多開源庫裡都能看到這種特性的影子,有空也可以都拿出來分析一下。

相關文章