前言
前端路由一直是一個很經典的話題,不管是日常的使用還是面試中都會經常遇到。本文通過實作一個簡單版的
react-router
來一起揭開路由的神秘面紗。
通過本文,你可以學習到:
- 前端路由本質上是什麼。
- 前端路由裡的一些坑和注意點。
- hash 路由和 history 路由的差別。
- Router 元件和 Route 元件分别是做什麼的。
路由的本質
簡單來說,浏覽器端路由其實并不是真實的網頁跳轉(和伺服器沒有任何互動),而是純粹在浏覽器端發生的一系列行為,本質上來說前端路由就是:
對 url 進行改變和監聽,來讓某個 dom 節點顯示對應的視圖。
僅此而已。新手不要被路由這個概念給吓到。
路由的差別
一般來說,浏覽器端的路由分為兩種:
- hash 路由,特征是 url 後面會有
号,如#
。baidu.com/#foo/bar/baz
- 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,我們專門抽出一個小檔案對它進行一層封裝,對外提供:
-
history.push
-
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