咱们是袋鼠云数栈 UED 团队,致力于打造优良的一站式数据中台产品。咱们始终保持工匠精力,摸索前端路线,为社区积攒并流传教训价值。

本文作者:霜序

前言

在前一篇文章中,咱们具体的说了 react-router@3.x 降级到 @6.x 须要留神的问题以及变更的应用形式。

react-router 版本更新十分快,然而它的底层实现原理确是万变不离其中,在本文中会从前端路由登程到 react-router 原理总结与分享。

前端路由

在 Web 前端单页面利用 SPA(Single Page Application)中,路由是形容 URL 和 UI 之间的映射关系,这种映射是单向的,即 URL 的扭转会引起 UI 更新,无需刷新页面

如何实现前端路由

实现前端路由,须要解决两个外围问题

  1. 如何扭转 URL 却不引起页面刷新?
  2. 如何监测 URL 变动?

在前端路由的实现模式有两种模式,hash 和 history 模式,别离答复上述两个问题

hash 模式

  1. hash 是 url 中 hash(#) 及前面的局部,罕用锚点在页面内做导航,扭转 url 中的 hash 局部不会引起页面的刷新
  2. 通过 hashchange 事件监听 URL 的扭转。扭转 URL 的形式只有以下几种:通过浏览器导航栏的后退后退、通过<a>标签、通过window.location,这几种形式都会触发hashchange事件

history 模式

  1. history 提供了 pushStatereplaceState 两个办法,这两个办法扭转 URL 的 path 局部不会引起页面刷新
  2. 通过 popchange 事件监听 URL 的扭转。须要留神只在通过浏览器导航栏的后退后退扭转 URL 时会触发popstate事件,通过<a>标签和pushState/replaceState不会触发popstate办法。但咱们能够拦挡<a>标签的点击事件和pushState/replaceState的调用来检测 URL 变动,也是能够达到监听 URL 的变动,绝对hashchange显得稍微简单

JS 实现前端路由

基于 hash 实现

因为三种扭转 hash 的形式都会触发hashchange办法,所以只须要监听hashchange办法。须要在DOMContentLoaded后,解决一下默认的 hash 值

// 页面加载完不会触发 hashchange,这里被动触发一次 hashchange 事件,解决默认hashwindow.addEventListener('DOMContentLoaded', onLoad);// 监听路由变动window.addEventListener('hashchange', onHashChange);// 路由变动时,依据路由渲染对应 UIfunction onHashChange() {  switch (location.hash) {    case '#/home':      routerView.innerHTML = 'This is Home';      return;    case '#/about':      routerView.innerHTML = 'This is About';      return;    case '#/list':      routerView.innerHTML = 'This is List';      return;    default:      routerView.innerHTML = 'Not Found';      return;  }}

hash 实现 demo

基于 history 实现

因为 history 模式下,<a>标签和pushState/replaceState不会触发popstate办法,咱们须要对<a>的跳转和pushState/replaceState做非凡解决。

  • <a>作点击事件,禁用默认行为,调用pushState办法并手动触发popstate的监听事件
  • pushState/replaceState能够重写 history 的办法并通过派发事件可能监听对应事件
var _wr = function (type) {  var orig = history[type];  return function () {    var e = new Event(type);    e.arguments = arguments;    var rv = orig.apply(this, arguments);    window.dispatchEvent(e);    return rv;  };};// 重写pushstate事件history.pushState = _wr('pushstate');function onLoad() {  routerView = document.querySelector('#routeView');  onPopState();  // 拦挡 <a> 标签点击事件默认行为  // 点击时应用 pushState 批改 URL并更新手动 UI,从而实现点击链接更新 URL 和 UI 的成果。  var linkList = document.querySelectorAll('a[href]');  linkList.forEach((el) =>    el.addEventListener('click', function (e) {      e.preventDefault();      history.pushState(null, '', el.getAttribute('href'));      onPopState();    }),  );}// 监听pushstate办法window.addEventListener('pushstate', onPopState());// 页面加载完不会触发 hashchange,这里被动触发一次 popstate 事件,解决默认pathnamewindow.addEventListener('DOMContentLoaded', onLoad);// 监听路由变动window.addEventListener('popstate', onPopState);// 路由变动时,依据路由渲染对应 UIfunction onPopState() {  switch (location.pathname) {    case '/home':      routerView.innerHTML = 'This is Home';      return;    case '/about':      routerView.innerHTML = 'This is About';      return;    case '/list':      routerView.innerHTML = 'This is List';      return;    default:      routerView.innerHTML = 'Not Found';      return;  }}

history 实现 demo

React-Router 的架构

  • history 库给 browser、hash 两种 history 提供了对立的 API,给到 react-router-dom 应用
  • react-router 实现了路由的最外围能力。提供了<Router><Route>等组件,以及配套 hook
  • react-router-dom 是对 react-router 更上一层封装。把 history 传入<Router>并初始化成<BrowserRouter><HashRouter>,补充了<Link>这样给浏览器间接用的组件。同时把 react-router 间接导出,缩小依赖

History 实现

history

在上文中说到,BrowserRouter应用 history 库提供的createBrowserHistory创立的history对象扭转路由状态和监听路由变动。

❓ 那么 history 对象须要提供哪些性能讷?

  • 监听路由变动的listen办法以及对应的清理监听unlisten办法
  • 扭转路由的push办法
// 创立和治理listeners的办法export const EventEmitter = () => {  const events = [];  return {    subscribe(fn) {      events.push(fn);      return function () {        events = events.filter((handler) => handler !== fn);      };    },    emit(arg) {      events.forEach((fn) => fn && fn(arg));    },  };};

BrowserHistory

const createBrowserHistory = () => {  const EventBus = EventEmitter();  // 初始化location  let location = {    pathname: '/',  };  // 路由变动时的回调  const handlePop = function () {    const currentLocation = {      pathname: window.location.pathname,    };    EventBus.emit(currentLocation); // 路由变动时执行回调  };  // 定义history.push办法  const push = (path) => {    const history = window.history;    // 为了放弃state栈的一致性    history.pushState(null, '', path);    // 因为push并不触发popstate,咱们须要手动调用回调函数    location = { pathname: path };    EventBus.emit(location);  };  const listen = (listener) => EventBus.subscribe(listener);  // 解决浏览器的后退后退  window.addEventListener('popstate', handlePop);  // 返回history  const history = {    location,    listen,    push,  };  return history;};

对于 BrowserHistory 来说,咱们的解决须要减少一项,当咱们触发 push 的时候,须要手动告诉所有的监听者,因为 pushState 无奈触发 popState 事件,因而须要手动触发

HashHistory

const createHashHistory = () => {  const EventBus = EventEmitter();  let location = {    pathname: '/',  };  // 路由变动时的回调  const handlePop = function () {    const currentLocation = {      pathname: window.location.hash.slice(1),    };    EventBus.emit(currentLocation); // 路由变动时执行回调  };  // 不必手动执行回调,因为hash扭转会触发hashchange事件  const push = (path) => (window.location.hash = path);  const listen = (listener: Function) => EventBus.subscribe(listener);  // 监听hashchange事件  window.addEventListener('hashchange', handlePop);  // 返回的history上有个listen办法  const history = {    location,    listen,    push,  };  return history;};

在实现 hashHistory 的时候,咱们只是对hashchange进行了监听,当该事件产生时,咱们获取到最新的 location 对象,在告诉所有的监听者 listener 执行回调函数

React-Router@6 丐版实现

  • 绿色为 history 中的办法
  • 紫色为 react-router-dom 中的办法
  • 橙色为 react-router 中的办法

Router

️ 基于 Context 的全局状态下发。Router 是一个 “Provider-Consumer” 模型

Router 做的事件很简略,接管navigatorlocation,应用 context 将数据传递上来,可能让子组件获取到相干的数据

function Router(props: IProps) {  const { navigator, children, location } = props;  const navigationContext = React.useMemo(() => ({ navigator }), [navigator]);  const { pathname } = location;  const locationContext = React.useMemo(    () => ({ location: { pathname } }),    [pathname],  );  return (    <NavigationContext.Provider value={navigationContext}>      <LocationContext.Provider value={locationContext} children={children} />    </NavigationContext.Provider>  );}

HashRouter

基于不同的 history 调用 Router 组件。并且在 history 产生扭转的时候,监听 history,可能在 location 产生扭转的时候,执行回调扭转 location。

在上面的代码中,可能发现监听者为 setState 函数,在上述 hashHistory 中,如果咱们的 location 产生了扭转,会告诉到所有的监听者执行回调,也就是咱们这里的 setState 函数,即咱们可能拿到最新的 location 信息通过 LocationContext 传递给子组件,再去做对应的路由匹配

function HashRouter({ children }) {  let historyRef = React.useRef();  if (historyRef.current == null) {    historyRef.current = createHashHistory();  }  let history = historyRef.current;  let [state, setState] = React.useState({    location: history.location,  });  React.useEffect(() => {    const unListen = history.listen(setState);    return unListen;  }, [history]);  return (    <Router children={children} location={state.location} navigator={history} />  );}

Routes/Route

咱们可能发现在 v6.0 的版本 Route 组件只是一个工具人,并没有做任何事件。

function Route(_props: RouteProps): React.ReactElement | null {  invariant(    false,    `A <Route> is only ever to be used as the child of <Routes> element, ` +      `never rendered directly. Please wrap your <Route> in a <Routes>.`,  );}

实际上解决所有逻辑的组件是 Routes,它外部实现了依据路由的变动,匹配出一个正确的组件。

const Routes = ({ children }) => {  return useRoutes(createRoutesFromChildren(children));};

useRoutes 为整个 v6 版本的外围,分为路由上下文解析、路由匹配、路由渲染三个步骤

<Routes>  <Route path="/home" element={<Home />}>    <Route path="1" element={<Home1 />}>      <Route path="2" element={<Home2 />}></Route>    </Route>  </Route>  <Route path="/about" element={<About />}></Route>  <Route path="/list" element={<List />}></Route>  <Route path="/notFound" element={<NotFound />} />  <Route path="/navigate" element={<Navigate to="/notFound" />} /></Routes>

上述 Routes 代码中,通过 createRoutesFromChildren 函数将 Route 组件结构化。能够把 <Route> 类型的 react element 对象,变成了一般的 route 对象构造,如下图

useRoutes

useRoutes 才是真正解决渲染关系的,其代码如下:

// 第一步:获取相干的 pathnamelet location = useLocation();let { matches: parentMatches } = React.useContext(RouteContext);// 第二步:找到匹配的路由分支,将 pathname 和 Route 的 path 做匹配const matches = matchRoutes(routes, location);// 第三步:渲染真正的路由组件const renderedMatches = _renderMatches(matches, parentMatches);return renderedMatches;

matchRoutes

matchRoutes 中通过 pathname 和路由的 path 进行匹配

因为咱们在 Route 中定义的 path 都是相对路径,所以咱们在 matchRoutes 办法中,须要对 routes 对象遍历,对于 children 外面的 path 须要变成残缺的门路,并且须要将 routes 扁平化,不在应用嵌套构造

const flattenRoutes = (  routes,  branches = [],  parentsMeta = [],  parentPath = '',) => {  const flattenRoute = (route) => {    const meta = {      relativePath: route.path || '',      route,    };    const path = joinPaths([parentPath, meta.relativePath]);    const routesMeta = parentsMeta.concat(meta);    if (route.children?.length > 0) {      flattenRoutes(route.children, branches, routesMeta, path);    }    if (route.path == null) {      return;    }    branches.push({ path, routesMeta });  };  routes.forEach((route) => {    flattenRoute(route);  });  return branches;};

当咱们拜访/#/home/1/2的时候,取得的 matches 如下

咱们失去的 match 程序是从 Home → Home1 → Home2

\_renderMatches

\_renderMatches 才会渲染所有的 matches 对象

const _renderMatches = (matches, parentMatches = []) => {  let renderedMatches = matches;  return renderedMatches.reduceRight((outlet, match, index) => {    let matches = parentMatches.concat(renderedMatches.slice(0, index + 1));    const getChildren = () => {      let children;      if (match.route.Component) {        children = <match.route.Component />;      } else if (match.route.element) {        children = match.route.element;      } else {        children = outlet;      }      return (        <RouteContext.Provider          value={{            outlet,            matches,          }}        >          {children}        </RouteContext.Provider>      );    };    return getChildren();  }, null);};

\_renderMatches 这段代码咱们可能明确 outlet 作为子路由是如何传递给父路由渲染的。matches 采纳从右往左的遍历程序,将上一项的返回值作为后一项的 outlet,那么子路由就作为 outlet 传递给了父路由

Outlet

实际上就是外部渲染 RouteContext 的 outlet 属性

function Outlet(props) {  return useOutlet(props.context);}function useOutlet(context?: unknown) {  let outlet = useContext(RouteContext).outlet; // 获取上一级 RouteContext 下面的 outlet  if (outlet) {    return (      <OutletContext.Provider value={context}>{outlet}</OutletContext.Provider>    );  }  return outlet;}

Link

在 Link 中,咱们应用<a>标签来做跳转,然而 a 标签会使页面从新刷新,所以须要阻止 a 标签的默认行为,调用 useNavigate 办法进行跳转

function Link({ to, children, onClick }) {  const navigate = useNavigate();  const handleClick = onClick    ? onClick    : (event) => {        event.preventDefault();        navigate(to);      };  return (    <a href={to} onClick={handleClick}>      {children}    </a>  );}

Hooks

function useLocation() {  return useContext(LocationContext).location;}function useNavigate() {  const { navigator } = useContext(NavigationContext);  const navigate = useCallback(    (to: string) => {      navigator.push(to);    },    [navigator],  );  return navigate;}

本文所有的代码链接可点击查看

参考链接
  • react router v6 应用详解以及局部源码解析(新老版本比照) - 掘金
  • 「React 进阶」react-router v6 通关指南 - 掘金
  • 一文读懂 react-router 原理

最初

欢送关注【袋鼠云数栈UED团队】~
袋鼠云数栈UED团队继续为宽广开发者分享技术成绩,相继参加开源了欢送star

  • 大数据分布式任务调度零碎——Taier
  • 轻量级的 Web IDE UI 框架——Molecule
  • 针对大数据畛域的 SQL Parser 我的项目——dt-sql-parser
  • 袋鼠云数栈前端团队代码评审工程实际文档——code-review-practices
  • 一个速度更快、配置更灵便、应用更简略的模块打包器——ko