Serenader

Learning by sharing

React Router 的跳转中断与自定义 UI

起因

在我最近几年的开发经验里面,我所负责的业务基本上都是移动端的页面。这几年来,有一个越来越明显的感受是,现在的产品经理越来越希望移动端的 HTML 页面能做到跟 Native 应用相似的用户体验。举个例子,产品经理会希望前端页面能够直接唤起手机里面的某个应用,比如点击页面的“发送给 WhatsApp 好友”,就唤起 WhatsApp 应用,并且进入选择分享对象的界面,而且还要能自动带上分享的内容。当然这种场景现在确实是可以做到的,比如可以使用 WhatsApp 的私有协议 whatsapp://send 来实现,或者在安卓手机下,还可以使用更加规范的 intent 协议
但是也有些场景确实前端页面是做不到的,比如有时候产品经理还希望前端页面能够判断当前手机是否有安装某款应用,有的话就唤起这个应用,没有的话就直接跳转到 Google Play 或者直接下载 apk 文件。就目前来看,前端页面是不具备查询当前设备是否已安装某款应用的能力的。这并不是开发人员水平差,而是浏览器本身就不提供这样的 API ,我们没有太多的选择(当然这种场景现在也有比较成熟的解决方案,那就是尝试先唤起目标应用,设置唤起超时时间,超时后则认为当前设备没有安装目标应用)。
最近我又碰到一个需求,产品经理希望用户在前端页面里面编辑某些数据,当用户有改动页面内容时,如果用户点击了页面的其他链接,或者按了手机的返回键,页面能够弹出一个提示框,提示用户当前未保存内容,是否要离开。这需求我并不是第一次遇到,而且说实在的,这种场景也确实挺常见的。但是我们更多的是在 Native 应用里面体验到这样的交互。
要在前端实现这样的功能,我脑海里想到的第一个方案是能不能阻止掉默认的返回事件,即拦截手机的返回键。但是很不幸,并没有这样的接口。如果是移动端 Native 应用的话,应该非常容易处理这种事情。当然也不是说浏览器就不能实现这样的场景,实际上可以在浏览器上监听 beforeunload 事件,只要给 beforeunload 事件设置了 returnValue ,那么当用户想关闭当前页面的时候浏览器就会弹出提示框。
下面的截图就是使用这种方案在桌面浏览器以及移动端浏览器所表现出来的形式。
/image/eb56fe85-531f-4cbb-9cc1-642bb47e7237/66174277-e3ce-4f1f-823c-6fd57a1d830b_Untitled.png
桌面 Chrome 的离开页面提示
/image/eb56fe85-531f-4cbb-9cc1-642bb47e7237/1420f0c3-30ba-419b-9994-47a152d65c4d_Untitled.png
安卓 Chrome 的离开页面提示
Click to run embed content:
https://codesandbox.io/embed/agitated-benz-1o3ox
可以从这个 demo 中体验具体效果
上面的截图看起来能够满足我们的目的,但是其实有以下两个问题:
  • 产品经理往往希望这个弹窗提示的 UI 能够自定义,从截图来看,浏览器默认的弹窗谈不上好看。但是最重要的是目前浏览器的这个弹窗提示并不支持自定义的文案展示。
  • 对于单页应用而言,beforeunload 并不能在前端路由跳转的时候起作用。
所以这种方案往往最后是被否决了。我记得第一次碰到这种需求时,当时因为技术实现原因就不了了之了。
而这次我抽空花了点时间稍微研究了一下,发现原来 React Router 是支持这么一个功能的!

React Router 的 <Prompt />

在 React Router 的官方文档里面有这么一个 demo:Preventing transition,里面提到了使用 <Prompt /> 组件来实现页面跳转的提示,核心的代码如下:
<Prompt
  when={isBlocking}
  message={location =>
    `Are you sure you want to go to ${location.pathname}`
  }
/>
isBlockingtrue 时,页面跳转或者按了返回键就会触发显示弹窗,提示文案就是 message 的内容。
React Router 的弹窗提示默认使用 window.confirm 来实现,但是经过一番研究发现它是支持用户渲染自定义的 UI 的,只需要在 Router 里面设置 getUserConfirmation 。以 BrowserRouter 为例,具体实现方式可以参考下面的代码:
import React, { useState } from "react";
import ReactDOM from "react-dom";
import {
  BrowserRouter as Router,
  Route,
  Switch,
  Link,
  Prompt
} from "react-router-dom";

function Home() {
  const [value, setValue] = useState("");
  return (
    <div>
      <Prompt when={!!value} message="are you sure?" />
      <input value={value} onChange={e => setValue(e.target.value)} />
    </div>
  );
}

function About() {
  return <div>About</div>;
}

function Nav() {
  return (
    <div>
      <Link to="/">home</Link>
      <Link to="/about">About</Link>
    </div>
  );
}

function getUserConfirmation(message, callback) {
  const el = document.querySelector("#confirm");
  function decide(result) {
    callback(result);
    ReactDOM.unmountComponentAtNode(el);
  }
  function ConfirmModal() {
    return (
      <div>
        <p>{message}</p>
        <button onClick={() => decide(false)}>cancel</button>
        <button onClick={() => decide(true)}>ok</button>
      </div>
    );
  }

  ReactDOM.render(<ConfirmModal />, el);
}

export default function App() {
  return (
    <div className="App">
      <Router getUserConfirmation={getUserConfirmation}>
        <Nav />
        <Switch>
          <Route path="/" exact>
            <Home />
          </Route>
          <Route path="/about">
            <About />
          </Route>
        </Switch>
      </Router>
    </div>
  );
}
Click to run embed content:
https://codesandbox.io/embed/agitated-keldysh-3wxhs
在这个 demo 里面,如果用户在 Home 页面的输入框输入内容的话,无论是点击顶部的链接,或者点击浏览器的返回键,页面都会先展示我们自己绘制的确认弹窗,只有当用户点击了 “OK” 之后页面才会真正跳转,否则页面会一直停留在当前页。这就完美地实现了产品经理所需要的效果。
我想再提一次这个方案的一个优点,那就是页面跳转的中断是异步的!页面是要停留在当前页面还是前往或者返回其他页面,只有在 getuserConfirmation 里面的 callback 执行了才会作出决定。所以这里面就可以做很多有意思的事情了,比方说在弹出确认框之前请求渲染弹窗渲染所需要的数据等。
上面的 demo 虽然可以完美实现产品经理所要的效果,但是有个缺点就是,这个弹窗是全局的,无论是哪个页面需要中断页面跳转,都只能用同一个弹窗,只是文案不一样而已。
那么能不能对 React Router 的这个功能进一步封装,做到按需渲染不同的弹窗呢?答案当然是可以的。在我准备自己造轮子之前搜索了一下,果然有类似的库,能够实现更灵活的配置,比如这个 @allpro/react-router-pause 。感兴趣的读者可以自行研究。

Behind the Magic

解决方案找到了,那么问题本来也就解决了。但是我非常好奇 React Router 是怎么做到的?如果只是拦截页面链接的点击那么我可以理解,但是拦截浏览器的返回按钮,或者拦截手机的返回键是怎么做到的呢?难道是有什么神秘的 API 可以像 onClick 事件那样把返回的事件给 preventDefault 掉吗?
为了得到答案,我又更深入地学习了解了它的工作原理。后来经过了一系列的源码阅读,我终于搞清楚它的工作原理了。原来这一切都跟浏览器的 history API 紧密相关。
上面说的 history API 其实是指两方面的 history API ,一方面是 HTML5 规范中的 history API,另一方面是指 React Router 底层所依赖的 npm 包 history 的 API。
浏览器的原生 history 对象大家应该不陌生,它拥有 pushStatereplaceState 等方法,调用这些方法可以更改页面当前的 URL ,但是浏览器并不会真正去加载更改后的 URL 的内容。也就是说,调用这些方法实际上只是改变了 history 的一些内部状态而已。并且浏览器会将 pushState 的 URL 变化记录到历史记录里面,因此你可以通过浏览器的前进、返回按钮来更改页面的 URL 。同样的,此时浏览器的前进、返回也都只是 URL 地址发生变化,而不会真正去加载 URL 的内容。与此同时,当用户前进、后退页面的时候浏览器还会发出一个 onpopstate 的事件,告知页面当前发生了页面切换。这里有个很重要的点需要知道,那就是通过浏览器的 history API 来操作页面 URL 时,此时页面的 URL 与页面内容是没有直接关联的。如果只是简单通过 pushState 来更改页面 URL ,而不做其他操作的话,那么页面展示的内容还是原来的内容,不会发生任何改变。
而 React Router 依赖的 history 库其底层是基于浏览器的 history API 的,只不过它对 history API 进行了封装。在原生 history API 中,如果你手动进行 pushState 或者 replaceState 的话,是不会触发 onpopstate 事件的。也就是说,通过代码进行页面切换时是不会触发任何事件回调的。只有当按了浏览器的返回、前进按钮才会触发事件回调。而在 history 库里面,如果你通过调用 history.listen 方法来监听页面 URL 切换,并且只通过它提供的 pushreplace 方法来进行页面切换时,那么无论是通过代码进行页面切换,还是通过点击浏览器的返回、前进按钮,它都能够触发事件回调。history 库内部维护了它自己内部的 URL 状态,无论是代码调用 pushreplace ,或者浏览器触发了 onpopstate 事件,都会改变它内部的 URL 状态。而状态一旦发生改变,也就会触发 history.listen 的回调函数。
React Router 正是通过这种方式来实现前端路由的。在 Router 组件初始化阶段,它会设置 history 的监听器,并且会通过 React Context 将当前的 URL 状态存起来。当页面 URL 发生变化时,触发了监听回调,此时会再更新 React Context 中的状态。而像 Route 组件则是直接读取 Context 里面的 URL 状态,并且以此来判断此时此刻的 URL 是否命中了自身的 path ,如果命中则渲染当前 Route 的内容。不命中则返回 null 。React Router 所提供的 Link 组件在底层也都是直接调用 history 库的 API ,以此来实现前端路由跳转。
以上就是 React Router 的工作原理。简单地说其实就是 React Router 根据 history 库的 listen 回调来设置当前页面所要展示的内容而已。那么关键就在于 history 库内部的 URL 状态是否发生变化了。如果状态改变了,自然就会触发 React Router 的 rerender 。而我们这次所说的通过 <Prompt /> 组件来阻止页面的切换,本质上也就是用来延迟更改 history 库内部的状态而已。
在 history 库里面,每次 URL 发生变化,无论是通过代码触发 pushState 或者 replaceState,还是浏览器的返回、前进触发的 onpopstate 事件,它通过调用内部的 transitionManager.confirmTransitionTo 来判断当前是否真的需要进行状态改变 。而这个方法内部会判断当前 prompt 私有变量是否为空,是的话则表示不需要进行任何中断,直接改变状态。否则的话会先展示用户设置的 prompt ,并且提供结果回调,让用户自行选择是否切换到新页面。如果用户选择是,那么跟上面一样,直接更改 history 的状态。如果用户选了否,那么分两种情况,如果用户是通过点击页面的其他链接,比方说点击了 React Router 的 <Link /> 标签,那么 history 库会直接忽略这次的状态变化,并且不调用浏览器 history 的 pushState 。另外一种情况是当用户点击了浏览器的前进、后退按钮,由于我们并不能拦截浏览器的返回或者前进,此时页面的 URL 是已经发生了变化,但是在 history 内部,它的状态并没有发生改变,那么它就会尝试还原当前的页面 URL 为上一个页面的 URL,使得表现出来的效果就是,即使用户点了浏览器的返回按钮,用户如果没有确认要切换页面的话,页面仍然停留在上一个页面,并且 URL 保持原来的地址。
而上面提到的 prompt 私有变量是什么呢?其实就跟我们使用的 <Prompt /> 组件有关了。当 <Prompt /> 组件的 when 属性为 true 时,此时在 onMount 阶段它会调用 history 库的 history.block 方法,而这个方法内部就是给 history 库的 prompt 私有变量设置新的值。因此这时候只要切换页面时,就会中断 history 内部状态的改变,让用户作出选择,直到用户做出了选择之后状态才发生改变,也因此页面的跳转得到了中断。
以上面的那段 demo 代码为例,具体的过程大致可以分为这几步:
  1. React Router 初始化阶段设置了 history 库的监听
  2. React Router 在初始阶段,以及在 history 监听回调触发时,会设置内部的 Context 值,使得依赖这些 Context 值的子组件进行重新渲染
  3. 我们在页面中使用了 <Prompt /> 组件,使得 history 库内部的 prompt 私有变量不为空
  4. 当我们在 Home 页输入内容,并且点击其他页面的链接,或者点击浏览器的返回按钮时,history 库内部的 transitionManager 接收到要进行页面切换的请求,执行 transitionManager.confirmTransitionTo 方法来判断是否可以切换页面
  5. confirmTransitionTo 方法内部判断到 prompt 不为空,那么就会调用我们传入的 getUserConfirmation 方法
  6. 我们传入的 getUserConfirmation 会在 DOM 中绘制确认弹窗,因此页面会显示出确认弹窗,并且如果用户没有进一步操作的话页面不会发生变化
  7. 当用户点击 OK 或者 Cancel 时,会调用 getUserConfirmation 的回调,并且传入结果
  8. getUserConfirmation 的回调实际上就是 transitionManager.confirmTransitionTo 所传入的匿名函数,当这个函数接收到结果时,再根据结果来判断是否要进行 history 状态更新
  9. 如果状态有更新的话,则触发 history.listen 的监听回调,如果没有更新则不触发,并且尝试还原当前页面 URL 为之前的 URL
  10. history.listen 的回调触发之后,也就触发了 React Router 的状态更新,因此也就引起了它的子组件的更新,比如 Route 组件的更新,这时候就会进行页面切换
如果上面的这个过程你还是不能理解的话,那么解答这个问题的最好办法就是去查看源码了:

总结

在理清楚 React Router 的工作原理之后,之前的疑惑也就理清楚了。原来并不是有什么神秘 API 可以阻止浏览器的返回、前进事件,只不过是浏览器返回、前进时的事件跟页面内容展示没有直接关系而已,而是 React Router 把这两者给结合起来。这当中 history 库又充当着两者之间的桥梁,它既负责帮我们处理监听页面 URL 的变化,同时又赋予了我们手动干预页面切换的能力。
不得不说这种方案确实非常巧妙,实现方式优雅,并且最终效果非常理想。
当然不得不说这种方案也有局限性,那就是它只能用在 SPA 应用上。传统的网页间跳转是没办法实现这样的功能的。并且这种方案也不适用于用户直接关闭页面,或者刷新页面。要想处理这部分操作,还是得靠拦截 beforeunload 事件。