天天看點

深入揭秘前端路由本質,手寫 mini-router

前言

前端路由一直是一個很經典的話題,不管是日常的使用還是面試中都會經常遇到。本文通過實作一個簡單版的

react-router

來一起揭開路由的神秘面紗。

通過本文,你可以學習到:

  • 前端路由本質上是什麼。
  • 前端路由裡的一些坑和注意點。
  • hash 路由和 history 路由的差別。
  • Router 元件和 Route 元件分别是做什麼的。
深入揭秘前端路由本質,手寫 mini-router

路由的本質

簡單來說,浏覽器端路由其實并不是真實的網頁跳轉(和伺服器沒有任何互動),而是純粹在浏覽器端發生的一系列行為,本質上來說前端路由就是:

對 url 進行改變和監聽,來讓某個 dom 節點顯示對應的視圖。

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

路由的差別

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

  1. hash 路由,特征是 url 後面會有

    #

    号,如

    baidu.com/#foo/bar/baz

  2. history 路由,url 和普通路徑沒有差異。如

    baidu.com/foo/bar/baz

我們已經講過了路由的本質,那麼實際上隻需要搞清楚兩種路由分别是如何 改變,并且元件是如何監聽并完成視圖的展示,一切就真相大白了。

不賣關子,先分别談談兩種路由用什麼樣的 api 實作前端路由:

hash

通過

location.hash = 'foo'

這樣的文法來改變,路徑就會由

baidu.com

變更為

baidu.com/#foo

window.addEventListener('hashchange')

這個事件,就可以監聽到

hash

值的變化。

history

其實是用了

history.pushState

這個 API 文法改變,它的文法乍一看比較怪異,先看下 mdn 文檔裡對它的定義:

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

其中

state

代表狀态對象,這讓我們可以給每個路由記錄建立自己的狀态,并且它還會序列化後儲存在使用者的磁盤上,以便使用者重新啟動浏覽器後可以将其還原。

title

目前沒啥用。

url

在路由中最重要的 url 參數反而是個可選參數,放在了最後一位。

history.pushState({}, '', foo)

,可以讓

baidu.com

變化為

baidu.com/foo

為什麼路徑更新後,浏覽器頁面不會重新加載?

這裡我們需要思考一個問題,平常通過

location.href = 'baidu.com/foo'

這種方式來跳轉,是會讓浏覽器重新加載頁面并且請求伺服器的,但是

history.pushState

的神奇之處就在于它可以讓 url 改變,但是不重新加載頁面,完全由使用者決定如何處理這次 url 改變。

是以,這種方式的前端路由必須在支援

histroy

API 的浏覽器上才可以使用。

為什麼重新整理後會 404?

本質上是因為重新整理以後是帶着

baidu.com/foo

這個頁面去請求服務端資源的,但是服務端并沒有對這個路徑進行任何的映射處理,當然會傳回 404,處理方式是讓服務端對于"不認識"的頁面,傳回

index.html

,這樣這個包含了前端路由相關

js

代碼的首頁,就會加載你的前端路由配置表,并且此時雖然服務端給你的檔案是首頁檔案,但是你的 url 上是

baidu.com/foo

,前端路由就會加載

/foo

這個路徑相對應的視圖,完美的解決了 404 問題。

history

路由的監聽也有點坑,浏覽器提供了

window.addEventListener('popstate')

事件,但是它隻能監聽到浏覽器回退和前進所産生的路由變化,對于主動的

pushState

卻監聽不到。解決方案當然有,下文實作

react-router

的時候再細講~

實作 react-mini-router

本文實作的

react-router

基于

history

版本,用最小化的代碼還原路由的主要功能,是以不會有正則比對或者嵌套子路由等高階特性,回歸本心,從零到一實作最簡化的版本。

實作 history

對于

history

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

  1. history.push

  2. history.listen

這兩個 API,減輕使用者的心智負擔。

我們利用

觀察者模式

封裝了一個簡單的

listen

API,讓使用者可以監聽到

history.push

所産生的路徑改變。

// 存儲 history.listen 的回調函數
let listeners: Listener[] = [];
function listen(fn: Listener) {
  listeners.push(fn);
  return function() {
    listeners = listeners.filter(listener => listener !== fn);
  };
}
           

這樣外部就可以通過:

history.listen(location => {
  console.log('changed', location);
});
           

這樣的方式感覺到路由的變化了,并且在

location

中,我們還提供了

state

pathname

search

等關鍵的資訊。

實作改變路徑的核心方法

push

也很簡單:

function push(to: string, state?: State) {
  // 解析使用者傳入的 url
  // 分解成 pathname、search 等資訊
  location = getNextLocation(to, state);
  // 調用原生 history 的方法改變路由
  window.history.pushState(state, '', to);
  // 執行使用者傳入的監聽函數
  listeners.forEach(fn => fn(location));
}
           

history.push('foo')

的時候,本質上就是調用了

window.history.pushState

去改變路徑,并且通知

listen

所挂載的回調函數去執行。

當然,别忘了使用者點選浏覽器後退前進按鈕的行為,也需要用

popstate

這個事件來監聽,并且執行同樣的處理:

// 用于處理浏覽器前進後退操作
window.addEventListener('popstate', () => {
  location = getLocation();
  listeners.forEach(fn => fn(location));
});
           

接下來我們需要實作

Router

Route

元件,你就會看到它們是如何和這個簡單的

history

庫結合使用了。

實作 Router

Router 的核心原理就是通過

Provider

location

history

等路由關鍵資訊傳遞給子元件,并且在路由發生變化的時候要讓子元件可以感覺到:

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 的變化
  // 一旦路由發生改變 就會通知使用了 useContext(RouterContext) 的子元件去重新渲染
  useEffect(() => {
    const unlisten = history.listen(location => {
      setLocation(location);
    });
    return unlisten;
  }, []);

  return (
    <RouterContext.Provider value={{ history, location }}>
      {children}
    </RouterContext.Provider>
  );
};
           

注意看注釋的部分,我們在元件初始化的時候利用

history.listen

監聽了路由的變化,一旦路由發生改變,就會調用

setLocation

去更新

location

并且通過

Provider

傳遞給子元件。

并且這一步也會觸發

Provider

value

值的變化,通知所有用

useContext

訂閱了

history

location

的子元件去重新

render

實作 Route

Route

元件接受

path

children

兩個

prop

,本質上就決定了在某個路徑下需要渲染什麼元件,我們又可以通過

Router

Provider

傳遞下來的

location

資訊拿到目前路徑,是以這個元件需要做的就是判斷目前的路徑是否比對,渲染對應元件。

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;
};
           

這裡的實作比較簡單,路徑直接用了全等,實際上真正的實作考慮的情況比較複雜,使用了

path-to-regexp

這個庫去處理動态路由等情況,但是核心原理其實就是這麼簡單。

實作 useLocation、useHistory

這裡就很簡單了,利用

useContext

簡單封裝一層,拿到

Router

history

location

即可。

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

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

export const useLocation = () => {
  return useContext(RouterContext)!.location;
};
           

實作驗證 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>
  );
};
           

結語

通過本文的學習,相信小夥伴們已經搞清楚了前端路由的原理,其實它隻是對浏覽器提供 API 的一個封裝,以及在架構層去關聯做對應的渲染,換個架構

vue-router

也是類似的原理。

本文源碼位址:https://github.com/sl1673495/react-mini-router

深入揭秘前端路由本質,手寫 mini-router

繼續閱讀