前言
前端路由一直是一個很經(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
中,我們還提供了 state
、 pathname
、 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) Router
和 Route
組件,你就會看到它們是如何和這個簡單的 history
庫結(jié)合使用了。
實現(xiàn) Router
Router 的核心原理就是通過 Provider
把location
和 history
等路由關(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ā)Provider
的 value
值的變化,通知所有用useContext
訂閱了 history
和 location
的子組件去重新render
。
實現(xiàn) Route
Route
組件接受 path
和 children
兩個prop
,本質(zhì)上就決定了在某個路徑下需要渲染什么組件,我們又可以通過 Router
的 Provider
傳遞下來的 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
傳遞下來的 history
和 location
即可。
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
也是類似的原理。