手寫 react-mini-router

前言

前端路由一直是一個很經(jīng)典的話題,不管是日常的使用還是面試中都會經(jīng)常遇到。本文通過實現(xiàn)一個簡單版的 react-router 來一起揭開路由的神秘面紗。

在這里,你可以學(xué)習(xí)到:

前端路由本質(zhì)上是什么。
前端路由里的一些坑和注意點。
hash 路由和 history 路由的區(qū)別。
Router 組件和 Route 組件分別是做什么的。

路由的本質(zhì)

簡單來說,瀏覽器端路由其實并不是真實的網(wǎng)頁跳轉(zhuǎn)(和服務(wù)器沒有任何交互),而是純粹在瀏覽器端發(fā)生的一系列行為,本質(zhì)上來說前端路由就是:

對 url 進行改變和監(jiān)聽,來讓某個 dom 節(jié)點顯示對應(yīng)的視圖。

僅此而已。新手不要被路由這個概念給嚇到。

路由的區(qū)別

一般來說,瀏覽器端的路由分為兩種:

1.hash 路由,特征是 url 后面會有 # 號,如baidu.com/#foo/bar/baz
2.history 路由,url 和普通路徑?jīng)]有差異。如baidu.com/foo/bar/baz。

我們已經(jīng)講過了路由的本質(zhì),那么實際上只需要搞清楚兩種路由分別是如何 改變 ,并且組件是如何 監(jiān)聽并完成視圖的展示 ,一切就真相大白了。

不賣關(guān)子,先分別談?wù)剝煞N路由用什么樣的 api 實現(xiàn)前端路由:

hash

hash
通過 location.hash = 'foo'這樣的語法來 改變 ,路徑就會由 baidu.com 變更為baidu.com/#foo

通過 window.addEventListener('hashchange') 這個事件,就可以 監(jiān)聽hash 值的變化。

history

其實是用了 history.pushState 這個 API 語法 改變 ,它的語法乍一看比較怪異,先看下 mdn 文檔里對它的定義:

history.pushState(state, title[, url])

其中 state 代表狀態(tài)對象,這讓我們可以給每個路由記錄創(chuàng)建自己的狀態(tài),并且它還會序列化后保存在用戶的磁盤上,以便用戶重新啟動瀏覽器后可以將其還原。

title 當前沒啥用。

url 在路由中最重要的 url 參數(shù)反而是個可選參數(shù),放在了最后一位。

通過 history.pushState({}, '', foo) ,可以讓 baidu.com 變化為 baidu.com/foo 。

為什么路徑更新后,瀏覽器頁面不會重新加載?

為什么路徑更新后,瀏覽器頁面不會重新加載?

這里我們需要思考一個問題,平常通過 location.href = 'baidu.com/foo'這種方式來跳轉(zhuǎn),是會讓瀏覽器重新加載頁面并且請求服務(wù)器的,但是 history.pushState的神奇之處就在于它可以讓 url 改變,但是不重新加載頁面,完全由用戶決定如何處理這次 url 改變。

因此,這種方式的前端路由必須在支持 histroy API 的瀏覽器上才可以使用。

為什么刷新后會 404?

本質(zhì)上是因為刷新以后是帶著 baidu.com/foo 這個頁面去請求服務(wù)端資源的,但是服務(wù)端并沒有對這個路徑進行任何的映射處理,當然會返回 404,處理方式是讓服務(wù)端對于"不認識"的頁面,返回index.html,這樣這個包含了前端路由相關(guān) js 代碼的首頁,就會加載你的前端路由配置表,并且此時雖然服務(wù)端給你的文件是首頁文件,但是你的 url 上是 baidu.com/foo ,前端路由就會加載/foo這個路徑相對應(yīng)的視圖,完美的解決了 404 問題。

history 路由的 監(jiān)聽也有點坑,瀏覽器提供了 window.addEventListener('popstate')事件,但是它只能監(jiān)聽到瀏覽器回退和前進所產(chǎn)生的路由變化,對于主動的 pushState 卻監(jiān)聽不到。解決方案當然有,下文實現(xiàn) react-router 的時候再細講~

實現(xiàn) react-mini-router

本文實現(xiàn)的 react-router 基于 history 版本,用最小化的代碼還原路由的主要功能,所以不會有正則匹配或者嵌套子路由等高階特性,回歸本心,從零到一實現(xiàn)最簡化的版本。

實現(xiàn) history

對于 history難用的官方 API,我們專門抽出一個小文件對它進行一層封裝,對外提供:

history.push
history.listen

這兩個 API,減輕用戶的心智負擔(dān)。

我們利用 觀察者模式 封裝了一個簡單的 listen API,讓用戶可以監(jiān)聽到 history.push 所產(chǎn)生的路徑改變。

// 存儲 history.listen 的回調(diào)函數(shù)
let listeners: Listener[] = [];
function listen(fn: Listener) {
  listeners.push(fn);
  return function() {
    listeners = listeners.filter(listener => listener !== fn);
  };
}
復(fù)制代碼

這樣外部就可以通過:

history.listen(location => {
  console.log('changed', location);
});
復(fù)制代碼

樣的方式感知到路由的變化了,并且在 location 中,我們還提供了 statepathname 、 search 等關(guān)鍵的信息。

實現(xiàn)改變路徑的核心方法 push 也很簡單:

function push(to: string, state?: State) {
  // 解析用戶傳入的 url
  // 分解成 pathname、search 等信息
  location = getNextLocation(to, state);
  // 調(diào)用原生 history 的方法改變路由
  window.history.pushState(state, '', to);
  // 執(zhí)行用戶傳入的監(jiān)聽函數(shù)
  listeners.forEach(fn => fn(location));
}
復(fù)制代碼

history.push('foo') 的時候,本質(zhì)上就是調(diào)用了 window.history.pushState 去改變路徑,并且通知 listen 所掛載的回調(diào)函數(shù)去執(zhí)行。

當然,別忘了用戶點擊瀏覽器后退前進按鈕的行為,也需要用 popstate 這個事件來監(jiān)聽,并且執(zhí)行同樣的處理:

// 用于處理瀏覽器前進后退操作
window.addEventListener('popstate', () => {
  location = getLocation();
  listeners.forEach(fn => fn(location));
});
復(fù)制代碼

接下來我們需要實現(xiàn) RouterRoute 組件,你就會看到它們是如何和這個簡單的 history 庫結(jié)合使用了。

實現(xiàn) Router

Router 的核心原理就是通過 Providerlocationhistory 等路由關(guān)鍵信息傳遞給子組件,并且在路由發(fā)生變化的時候要讓子組件可以感知到:

import React, { useState, useEffect, ReactNode } from 'react';
import { history, Location } from './history';
interface RouterContextProps {
  history: typeof history;
  location: Location;
}

export const RouterContext = React.createContext<RouterContextProps | null>(
  null,
);

export const Router: React.FC = ({ children }) => {
  const [location, setLocation] = useState(history.location);
  // 初始化的時候 訂閱 history 的變化
  // 一旦路由發(fā)生改變 就會通知使用了 useContext(RouterContext) 的子組件去重新渲染
  useEffect(() => {
    const unlisten = history.listen(location => {
      setLocation(location);
    });
    return unlisten;
  }, []);

  return (
    <RouterContext.Provider value={{ history, location }}>
      {children}
    </RouterContext.Provider>
  );
};
復(fù)制代碼

注意看注釋的部分,我們在組件初始化的時候利用 history.listen 監(jiān)聽了路由的變化,一旦路由發(fā)生改變,就會調(diào)用 setLocation 去更新location 并且通過 Provider 傳遞給子組件。

并且這一步也會觸發(fā)Providervalue 值的變化,通知所有用useContext 訂閱了 historylocation 的子組件去重新render 。

實現(xiàn) Route

Route 組件接受 pathchildren 兩個prop,本質(zhì)上就決定了在某個路徑下需要渲染什么組件,我們又可以通過 RouterProvider 傳遞下來的 location信息拿到當前路徑,所以這個組件需要做的就是判斷當前的路徑是否匹配,渲染對應(yīng)組件。

import { ReactNode } from 'react';
import { useLocation } from './hooks';

interface RouteProps {
  path: string;
  children: ReactNode;
}

export const Route = ({ path, children }: RouteProps) => {
  const { pathname } = useLocation();
  const matched = path === pathname;

  if (matched) {
    return children;
  }
  return null;
};
復(fù)制代碼

這里的實現(xiàn)比較簡單,路徑直接用了全等,實際上真正的實現(xiàn)考慮的情況比較復(fù)雜,使用了 path-to-regexp這個庫去處理動態(tài)路由等情況,但是核心原理其實就是這么簡單。

實現(xiàn) useLocation、useHistory

這里就很簡單了,利用 useContext 簡單封裝一層,拿到 Router 傳遞下來的 historylocation即可。

import { useContext } from 'react';
import { RouterContext } from './Router';

export const useHistory = () => {
  return useContext(RouterContext)!.history;
};

export const useLocation = () => {
  return useContext(RouterContext)!.location;
};
復(fù)制代碼

實現(xiàn)驗證 demo

至此為止,以下的路由 demo 就可以跑通了:

import React, { useEffect } from 'react';
import { Router, Route, useHistory } from 'react-mini-router';

const Foo = () => 'foo';
const Bar = () => 'bar';

const Links = () => {
  const history = useHistory();

  const go = (path: string) => {
    const state = { name: path };
    history.push(path, state);
  };

  return (
    <div className="demo">
      <button onClick={() => go('foo')}>foo</button>
      <button onClick={() => go('bar')}>bar</button>
    </div>
  );
};

export default () => {
  return (
    <div>
      <Router>
        <Links />
        <Route path="foo">
          <Foo />
        </Route>
        <Route path="bar">
          <Bar />
        </Route>
      </Router>
    </div>
  );
};
復(fù)制代碼

結(jié)語

前端路由的原理,其實它只是對瀏覽器提供 API 的一個封裝,以及在框架層去聯(lián)動做對應(yīng)的渲染,換個框架 vue-router 也是類似的原理。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。