浏览器历史记录是如何留下来的?

程序员鱼皮

共 7484字,需浏览 15分钟

 · 2023-08-17

今天给朋友们分享的是我们编程导航知识星球的嘉宾 - 前端大佬神光的关于 图解 history api 和 React Router 实现原理的文章,希望能对大家有所帮助。

Router 是开发 React 应用的必备功能,那 React Router 是怎么实现的呢?

今天我们就来读一下 React Router 的源码吧!

首先,我们来学一下 History API,这是基础。

什么是 history 呢?

就是这个东西:

2cfba9ddd30894fcb88d737d77c194ef.webp

我打开了一个新的标签页、然后访问 baidu.com、sougou.com、taobao.com。

长按后退按钮,就会列出历史记录,这就是 history。

现在在这里:

5f00d1f6775d56142aec591c2a561ba1.webp

history.length 是 5

3b8b49fd15182401d40ac495da10c212.webp

点击两次后退按钮,或者执行两次 history.back()

3cfbb4a757bdee27ea243b0df08675ae.webp

就会回到这里:

b6811302a3ec487adfd2118e70082e48.webp

这时候 history.length 依然是 5

25a91b2afc73bd63d5cc5dbefa38e623.webp

因为前后的 history 都还保留着:

06525c9b7e2ac2fc581391ba8b7398e8.webp 2887b788ae33b5fb1f26ec3cdc4245b2.webp

除了用 history.back、history.forward 在 history 之间切换外,还可以用 history.go

参数值是 delta:

history.go(0) 是刷新当前页面。

history.go(1) 是前进一个,相当于 history.forward()

history.go(-1) 是后退一个,相当于 history.back()

当然,你还可以 history.go(-2)、histroy.go(3) 这种。

fa7304b195f2d710035945e4b7cf5272.webp

比如当我执行 history.go(-2) 的时候,能直接从 taobao.com 跳到 sogou.com

b019adad28467efa0ead20982e1a33d7.webp

你还可以通过 history.replaceState 来替换当前 history:

9911e6ccbe5c143d70c96f0abe143897.webp
      
      history.replaceState({aaa:1}, '''https://www.baidu.com?wd=光')

第一个参数是 state、第二个参数是 title,第三个是替换的 url。

不过第二个参数基本都不支持,state 倒是能拿到。

比如我在 https://www.baidu.com 那页 replaceState 为一个新的 url:

51af7c26fbdc0579e835996c60cf189a.webp

前后 history 都没变,只有当前的变了:

4ce4cff96b7c56daa86939212305efc8.webp

也就是这样:

578c30511480bb471325f8b7a5b345c6.webp

当然,你还可以用 history.pushState 来添加一个新的 history:

      
      history.pushState({bbb:1}, '''https://www.baidu.com?wd=东');
bcd529b4d35ddfe3294af9a3e15a9c38.webp

但有个现象,就是之后的 history 都没了:

793cfb441fe71d348c62b2db386982b2.webp 0bc661373d8b221e8596ac46b4982243.webp

也就是变成了这样:

d4d344ba5bba417e8eac512b67818148.webp

为什么呢?

因为你是 history.pushState 的时候,和后面的 history 冲突了,也就是分叉了:

631f6e3320b5a701aa5a1978ef981869.webp

这时候自然只能保留一个分支,也就是最新的那个。

这时候 history.length 就是 3 了。

73171fed32c5c618cc2b574f92007da3.webp

至此,history 的 length、go、back、forward、pushState、replaceState、state 这些 api 我们就用了一遍了。

还有个 history.scrollRestoration 是用来保留滚动位置的:

有两个值 auto、manual,默认是 auto,也就是会自动定位到上次滚动位置,设置为 manual 就不会了。

比如我访问百度到了这个位置:

9e869595d5c839cb2a1a0870adec20fe.webp

打开个新页面,再退回来:

ffeeb95ade864a5a44406268b80e5a6c.webp


依然是在上次滚动到的位置。

这是因为它的 history.scrollRestoration 是 auto

80b631057dfaf6dd5d603ef9064e74b3.webp

我们把它设置为 manual 试试看:

03b29776d0d34251d0db0d458b86543e.webp

49e85379cbc5ab3435f66a014f386270.webp

这时候就算滚动到了底部,再切回来也会回到最上面。

此外,与 history 相关的还有个事件:popstate

当你在 history 中导航时,popstate 就会触发,比如 history.forwad、histroy.back、history.go。

但是 history.pushState、history.replaceState 这种并不会触发 popstate。

我们测试下:

7fe406f2cee311032fb3d4175a92456c.webp
      
      history.pushState({aaa:1}, '''https://www.baidu.com?#/aaa');

history.pushState({bbb:2}, '''https://www.baidu.com?#/bbb');

我在 www.baidu.com 这个页面 pushState 添加了两个 history。

加上导航页一共 4 个:

57d023974a6775f1cdf62b9722d13a13.webp

然后我监听 popstate 事件:

0c99bd8190a87d6d4254932f2523b4ad.webp
      
      window.addEventListener('popstate', event => {console.log(event)});

执行 history.back 和 history.forward 都会触发 popstate 事件:

af396bf88037c9104f903766ca97b696.webp

事件包含 state,也可以从 target.location 拿到当前 url

08994130c7dcba4b76e925c134408ab7.webp

但是当你 history.pushState、history.replaceState 并不会触发它:

8c4a2a5ef088051df59345b0cf92bddd.webp

也就是说添加、修改 history 不会触发 popstate,只有在 state 之间导航才会触发。

综上,history api 和 popstate 事件我们都过了一遍。

基于这些就可以实现 React Router。

有的同学说,不是还有个 hashchange 事件么?

确实,那个就是监听 hash 变化的。

b76742a9b6969e6de6b9e84be66b3b14.webp 1749a55d5a3a3c14b353419d2a62c7eb.webp

基于它也可以实现 router,但很明显,hashchange 只能监听 hash 的变化,而 popstate 不只是 hash 变化,功能更多。

所以用 popstate 事件就足够了。

其实在 react router 里,就只用到了 popstate 事件,没用到 hashchange 事件:

4ff91e94bcccfa0627bc28ddfb228d9e.webp 8bbee4556ae292e213a4c07a690ebf4d.webp

接下来我们就具体来看下 React Router 是怎么实现的吧。

创建个 react 项目:

      
      npx create-react-app react-router-test
6d66a44f2925b102b6f1e3b021ac8792.webp

安装 react-router 的包:

      
      npm install react-router-dom

然后在 index.js 写如下代码:

      
      import React from 'react';
import ReactDOM from 'react-dom/client';
import {
  createBrowserRouter,
  Link,
  Outlet,
  RouterProvider,
from "react-router-dom";

function Aaa() {
  return <div>
    <p>aaa</p>
    <Link to={'/bbb/111'}>to bbb</Link>
    <br/>
    <Link to={'/ccc'}>to ccc</Link>
    <br/>
    <Outlet/>
  </div>
;
}

function Bbb() {
  return 'bbb';
}

function Ccc() {
  return 'ccc';
}

function ErrorPage() {
  return 'error';
}

const routes = [
  {
    path"/",
    element<Aaa/>,
    errorElement<ErrorPage/>,
    children: [
      {
        path"bbb/:id",
        element<Bbb />,
      },
      {
        path"ccc",
        element<Ccc />,
      }    
    ],
  }
];
const router = createBrowserRouter(routes);

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<RouterProvider router={router} />);

通过 react-router-dom 包的 createBrowserRouter 创建 router,传入 routes 配置。

然后把 router 传入 RouterProvider。

有一个根路由 /、两个子路由 /bbb/:id 和 /ccc

把开发服务跑起来:

      
      npm run start

测试下:

45073e27e9ace00fd0b92a64fb62db81.webp

子路由对应的组件在  处渲染。

当没有对应路由的时候,会返回错误页面:

ebb294e3c6be95a2ef07cadb13465a55.webp

那它是怎么实现的呢?

我们断点调试下:

5c793811f67386ea7652e7aae746c619.webp

创建调试配置文件 launch.json,然后创建 chrome 类型的调试配置:

c862e9b55f7ddf39a0615eb755ee4f8e.webp

在 createBrowserRouter 的地方打个断点:

39b21721026ac16a387406073d06a0f9.webp

点击 debug:

1556b0eae3a982e812d9725eb99ee149.webp

代码会在这里断住:

f3815609c0de1a1b99227e4032c44c92.webp

点击 step into 进入函数内部:

它调用了 createRouter:

886d91b99840a3f3fca5ed6524bd71ed.webp

这里传入了 history。

这个不是原生的 history api,而是包装了一层之后的:

关注 listen、push、replace、go 这 4 个方法就好了:

509d212605f8629523634695de8c4056.webp f6375cd953e9feadfb4ff9e12f46def6.webp

listen 就是监听 popstate 事件。

而 push、replace、go 都是对 history 的 api 的封装:

e8da8bdfc0bc9c24192c93a4aa3b2ab1.webp 08219851393983a1491dcf42c1c263b3.webp

此外,history 还封装了 location 属性,不用自己从 window 取了。

然后 createRouter 里会对 routes 配置和当前的 location 做一次 match:

78dac80cc695058477396aa71fc67b92.webp 7fbe37f0caf04e290cb1c9b8117f3200.webp

matchRoutes 会把嵌套路由拍平,然后和 location 匹配:

a65c198a705f04b47e7100b29d89accd.webp

然后就匹配到了要渲染的组件以及它包含的子路由:

3313fd79f47a35e18e78571592a688f4.webp

这样当组件树渲染的时候,就知道渲染什么组件了:

2f5674a2dcb088ab28f25e8601a39b7c.webp

就是把 match 的这个结果渲染出来。

这样就完成了路由对应的组件渲染:

1460dcef01df59d81f44193b79e7f9ed.webp

也就是这样的流程:

388337d6417ead4078d5a381c40640e6.webp

当点击 link 切换路由的时候:

f16dc2f3c314ec62b4fc1b29677e9cf7.webp

会执行 navigate 方法:

9ff27ca400c201803b0afc1b381f7ad0.webp 7f098cdeda8eee759ce6865ef0ebbfb3.webp

然后又到了 matchRoutes 的流程:

107622f0f251ec7514aaf947fdf0a4a8.webp

match 完会 pushState 或者 replaceState 修改 history,然后更新 state:

c1b8365d346c78a53cb9c6a6d874c634.webp

然后触发了 setState,组件树会重新渲染:

a230e5399ff9d89ec099dac7082b1a35.webp 80c228dd7e0d509c7b6c1aeb6eb527b9.webp

也就是这样的流程:

5d6e1e3b2dacf4060684a9c9e069c658.webp

router.navigate 会传入新的 location,然后和 routes 做 match,找到匹配的路由。

之后会 pushState 修改 history,并且触发 react 的 setState 来重新渲染,重新渲染的时候通过 renderMatches 把当前 match 的组件渲染出来。

而渲染到 Outlet 的时候,会从 context 中取出当前需要渲染的组件来渲染:

d2b4c16c2e23261951964ff5fe704ee0.webp e05f7fa45ff3b7a89b5dbe8c35521beb.webp

这就是 router 初次渲染和点击 link 时的渲染流程。

那点击前进后退按钮的时候呢?

这个就是监听 popstate,然后也做一次 navigate 就好了:

62c7055ce86df0a452d61c6f65d704fe.webp 9c4f1dd775e6ea35f95c971b256ad8fb.webp 5376dcf630b06981df9ba1f1285ed83c.webp

后续流程一样。

277f712531c9821c7155901d54b1c432.webp

回过头来,其实 react router 的 routes 其实支持这两种配置方式:

4029e25602cbe73598a1102fa4105a8f.webp 2d70ae1d49e0335fc12a6d85b43a9604.webp

效果一样。

看下源码就知道为什么了:

首先,这个 Route 组件就是个空组件,啥也没:

6a65fe5e60b81019300f032fe12a4b76.webp

而 Routes 组件里会从把所有子组件的参数取出来,变成一个个 route 配置:

2d9e302e1d7bc6e12604d393f3cdd363.webp b995267e1f753adfd120ccab51a4c75f.webp

结果不就是和对象的配置方式一样么?

总结

我们学习了 history api 和 React Router 的实现原理。

history api 有这些:

  • length:history 的条数
  • forward:前进一个
  • back:后退一个
  • go:前进或者后退 n 个
  • pushState:添加一个 history
  • replaceState:替换当前 history
  • scrollRestoration:保存 scroll 位置,取值为 auto 或者 manual,manual 的话就要自己设置 scroll 位置了

而且还有 popstate 事件可以监听到 history.go、history.back、history.forward 的导航,拿到最新的 location。

这里要注意 pushState、replaceState 并不能触发 popstate 事件。也就是 history 之间导航(go、back、forward)可以触发 popstate,而修改 history (push、replace)不能触发。

React Router 就是基于这些 history api 实现的。

首次渲染的时候,会根据 location 和配置的 routes 做匹配,渲染匹配的组件。

之后点击 link 链接也会进行 location 和 routes 的匹配,然后 history.pushState 修改 history,之后通过 react 的 setState 触发重新渲染。

前进后退的时候,也就是执行 history.go、history.back、history.forward 的时候,会触发 popstate,这时候也是同样的处理,location 和 routes 的匹配,然后 history.pushState 修改 history,之后通过 react 的 setState 触发重新渲染。

渲染时会用到 Outlet组件 渲染子路由,用到 useXxx 来取一些匹配信息,这些都是通过 context 来传递的。

这就是 React Router 的实现原理,它和 history api 是密不可分的。


最后,欢迎学编程的朋友们加入鱼皮的  编程知识星球  ,和上万名学编程的同学共享知识、交流进步,学习原创项目并享有答疑指导服务。

往期推荐

我的学习小圈子

实习不少于 3 个月,以后年薪就能过 30 w?

接入支付服务,一定要知道的小知识

鱼皮原创实战项目教程【系列】

DNS 解析一个地址,会返回多个 IP 吗?

老子写个代码而已,凭什么还要我写文档?

浏览 17
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报