浏览器历史记录是如何留下来的?
今天给朋友们分享的是我们编程导航知识星球的嘉宾 - 前端大佬神光的关于 图解 history api 和 React Router 实现原理的文章,希望能对大家有所帮助。
Router 是开发 React 应用的必备功能,那 React Router 是怎么实现的呢?
今天我们就来读一下 React Router 的源码吧!
首先,我们来学一下 History API,这是基础。
什么是 history 呢?
就是这个东西:
我打开了一个新的标签页、然后访问 baidu.com、sougou.com、taobao.com。
长按后退按钮,就会列出历史记录,这就是 history。
现在在这里:
history.length 是 5
点击两次后退按钮,或者执行两次 history.back()
就会回到这里:
这时候 history.length 依然是 5
因为前后的 history 都还保留着:
除了用 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) 这种。
比如当我执行 history.go(-2) 的时候,能直接从 taobao.com 跳到 sogou.com
你还可以通过 history.replaceState 来替换当前 history:
history.replaceState({aaa:1}, '', 'https://www.baidu.com?wd=光')
第一个参数是 state、第二个参数是 title,第三个是替换的 url。
不过第二个参数基本都不支持,state 倒是能拿到。
比如我在 https://www.baidu.com 那页 replaceState 为一个新的 url:
前后 history 都没变,只有当前的变了:
也就是这样:
当然,你还可以用 history.pushState 来添加一个新的 history:
history.pushState({bbb:1}, '', 'https://www.baidu.com?wd=东');
但有个现象,就是之后的 history 都没了:
也就是变成了这样:
为什么呢?
因为你是 history.pushState 的时候,和后面的 history 冲突了,也就是分叉了:
这时候自然只能保留一个分支,也就是最新的那个。
这时候 history.length 就是 3 了。
至此,history 的 length、go、back、forward、pushState、replaceState、state 这些 api 我们就用了一遍了。
还有个 history.scrollRestoration 是用来保留滚动位置的:
有两个值 auto、manual,默认是 auto,也就是会自动定位到上次滚动位置,设置为 manual 就不会了。
比如我访问百度到了这个位置:
打开个新页面,再退回来:
依然是在上次滚动到的位置。
这是因为它的 history.scrollRestoration 是 auto
我们把它设置为 manual 试试看:
这时候就算滚动到了底部,再切回来也会回到最上面。
此外,与 history 相关的还有个事件:popstate
当你在 history 中导航时,popstate 就会触发,比如 history.forwad、histroy.back、history.go。
但是 history.pushState、history.replaceState 这种并不会触发 popstate。
我们测试下:
history.pushState({aaa:1}, '', 'https://www.baidu.com?#/aaa');
history.pushState({bbb:2}, '', 'https://www.baidu.com?#/bbb');
我在 www.baidu.com 这个页面 pushState 添加了两个 history。
加上导航页一共 4 个:
然后我监听 popstate 事件:
window.addEventListener('popstate', event => {console.log(event)});
执行 history.back 和 history.forward 都会触发 popstate 事件:
事件包含 state,也可以从 target.location 拿到当前 url
但是当你 history.pushState、history.replaceState 并不会触发它:
也就是说添加、修改 history 不会触发 popstate,只有在 state 之间导航才会触发。
综上,history api 和 popstate 事件我们都过了一遍。
基于这些就可以实现 React Router。
有的同学说,不是还有个 hashchange 事件么?
确实,那个就是监听 hash 变化的。
基于它也可以实现 router,但很明显,hashchange 只能监听 hash 的变化,而 popstate 不只是 hash 变化,功能更多。
所以用 popstate 事件就足够了。
其实在 react router 里,就只用到了 popstate 事件,没用到 hashchange 事件:
接下来我们就具体来看下 React Router 是怎么实现的吧。
创建个 react 项目:
npx create-react-app react-router-test
安装 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
测试下:
子路由对应的组件在 处渲染。
当没有对应路由的时候,会返回错误页面:
那它是怎么实现的呢?
我们断点调试下:
创建调试配置文件 launch.json,然后创建 chrome 类型的调试配置:
在 createBrowserRouter 的地方打个断点:
点击 debug:
代码会在这里断住:
点击 step into 进入函数内部:
它调用了 createRouter:
这里传入了 history。
这个不是原生的 history api,而是包装了一层之后的:
关注 listen、push、replace、go 这 4 个方法就好了:
listen 就是监听 popstate 事件。
而 push、replace、go 都是对 history 的 api 的封装:
此外,history 还封装了 location 属性,不用自己从 window 取了。
然后 createRouter 里会对 routes 配置和当前的 location 做一次 match:
matchRoutes 会把嵌套路由拍平,然后和 location 匹配:
然后就匹配到了要渲染的组件以及它包含的子路由:
这样当组件树渲染的时候,就知道渲染什么组件了:
就是把 match 的这个结果渲染出来。
这样就完成了路由对应的组件渲染:
也就是这样的流程:
当点击 link 切换路由的时候:
会执行 navigate 方法:
然后又到了 matchRoutes 的流程:
match 完会 pushState 或者 replaceState 修改 history,然后更新 state:
然后触发了 setState,组件树会重新渲染:
也就是这样的流程:
router.navigate 会传入新的 location,然后和 routes 做 match,找到匹配的路由。
之后会 pushState 修改 history,并且触发 react 的 setState 来重新渲染,重新渲染的时候通过 renderMatches 把当前 match 的组件渲染出来。
而渲染到 Outlet 的时候,会从 context 中取出当前需要渲染的组件来渲染:
这就是 router 初次渲染和点击 link 时的渲染流程。
那点击前进后退按钮的时候呢?
这个就是监听 popstate,然后也做一次 navigate 就好了:
后续流程一样。
回过头来,其实 react router 的 routes 其实支持这两种配置方式:
效果一样。
看下源码就知道为什么了:
首先,这个 Route 组件就是个空组件,啥也没:
而 Routes 组件里会从把所有子组件的参数取出来,变成一个个 route 配置:
结果不就是和对象的配置方式一样么?
总结
我们学习了 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 是密不可分的。
最后,欢迎学编程的朋友们加入鱼皮的 编程知识星球 ,和上万名学编程的同学共享知识、交流进步,学习原创项目并享有答疑指导服务。
往期推荐