理解 react-router 中的 history


react-router 的分析,目前准备主要集中在三点:
a. history 的分析。
b. historyreact-router 的联系。
c. react-router 内部匹配及显示原理。
这篇文章准备着重理解 history.
推荐:★★★☆

索引

引子

  • 一段显而易见出现在各大 react v16+ 项目中的代码是这样的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import React, {Component} from 'react'
    import { render } from 'react-dom'
    import { Router, Route } from 'react-router'
    import { createBrowserHistory } from 'history'
    const history = createBrowserHistory()

    const App = () => (
    <Router history={history}>
    <div id="app">
    {/* something */}
    </div>
    </Router>
    )

    render(<App/>, document.body.querySelector('#app'))
  • react v16+ 版本里,通常 react-router 也升级到了 4 以上。

  • react-router v4+ 通常是配合 history v4.6+ 使用的。

  • 下面就先从 history 开始,让我们一步一步走近 react-router 的神秘世界。

history核心

  • history源码
  • history 在内部主要导出了三个方法:
    • createBrowserHistory, createHashHistory, createMemoryHistory.

    • 它们分别有着自己的作用:

      • createBrowserHistory 是为现代主流且支持 HTML5 history 浏览器提供的 API.
      • createHashHistory 是为不支持 history 功能的浏览器提供的 API.
      • createMemoryHistory 则是为没有 DOM 环境例如 nodeReact-Native 或测试提供的 API.
    • 我们就先从最接地气的 createBrowserHistory 也就是我们上文中使用的方法开始看起。

走进createBrowserHistory

  • 话不多说,直接走进 createBrowserHistory源码

    1
    2
    3
    4
    /**
    * Creates a history object that uses the HTML5 history API including
    * pushState, replaceState, and the popstate event.
    */
  • 在该方法的注释里,它说明了是它基于 H5 的 history 创建的对象,对象内包括了一些常用的方法譬如

    • pushState,replaceState,popstate 等等。

history 对象

  • 那么它具体返回了什么内容呢,下面就是它目前所有的方法和属性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const globalHistory = window.history;
    const history = {
    length: globalHistory.length, // (number) The number of entries in the history stack
    action: "POP", // (string) The current action (`PUSH`, `REPLACE`, or `POP`)
    location: initialLocation, // (object) The current location. May have the following properties.
    createHref,
    push, // (function) Pushes a new entry onto the history stack
    replace, // (function) Replaces the current entry on the history stack
    go, // (function) Moves the pointer in the history stack by `n` entries
    goBack, // (function) Equivalent to `go(-1)`
    goForward, // (function) Equivalent to `go(1)`
    block, // (function) Prevents navigation
    listen
    }
  • globalHistory.length 显而易见是当前存的历史栈的数量。

  • createHref 根据根路径创建新路径,在根路径上添加原地址所带的 search, pathname, path 参数, 推测作用是将路径简化。

  • location 当前的 location, 可能含有以下几个属性。

    • path - (string) 当前 url 的路径 path.
    • search - (string) 当前 url 的查询参数 query string.
    • hash - (string) 当前 url 的哈希值 hash.
    • state - - (object) 存储栈的内容。仅存在浏览器历史和内存历史中。
  • block 阻止浏览器的默认导航。用于在用户离开页面前弹窗提示用户相应内容。the history docs

  • 其中,go/goBack/goForward 是对原生 history.go 的简单封装。

  • 剩下的方法相对复杂些,因此在介绍 push, replace 等方法之前,先来了解下 transitionManager. 因为下面的很多实现,都用到了这个对象所提供的方法。

transitionManager 方法介绍

  • 首先看下该对象返回了哪些方法:

    1
    2
    3
    4
    5
    6
    const transitionManager = {
    setPrompt,
    confirmTransitionTo,
    appendListener,
    notifyListeners
    }
  • 在后续 popstate 相关的方法中,它就应用了 appendListener 和与之有关的 notifyListeners 方法,我们就先从这些方法看起。

  • 它们的设计体现了常见的订阅-发布模式,前者负责实现订阅事件逻辑,后者负责最终发布逻辑。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    let listeners = [];
    /**
    * [description 订阅事件]
    * @param {Function} fn [description]
    * @return {Function} [description]
    */
    const appendListener = fn => {
    let isActive = true;
    // 订阅事件,做了函数柯里化处理,它实际上相当于运行了 `fn.apply(this, ...args)`
    const listener = (...args) => {
    if (isActive) fn(...args);
    };
    // 将监听函数一一保存
    listeners.push(listener);
    return () => {
    isActive = false;
    listeners = listeners.filter(item => item !== listener);
    };
    };
    /**
    * [发布逻辑]
    * @param {[type]} ..args [description]
    */
    const notifyListeners = (..args) => {
    listeners.forEach(listener => listener(..args))
    }
  • 介绍了上面两个方法的定义,先别急。后续再介绍它们的具体应用。

  • 然后来看看另一个使用的较多的方法 confirmTransitionTo.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    const confirmTransitionTo = (
    location,
    action,
    getUserConfirmation,
    callback
    ) => {
    if (prompt != null) {
    const result =
    typeof prompt === "function" ? prompt(location, action) : prompt;
    if (typeof result === "string") {
    if (typeof getUserConfirmation === "function") {
    getUserConfirmation(result, callback);
    } else {
    callback(true);
    }
    } else {
    // Return false from a transition hook to cancel the transition.
    // 如果已经在执行,则暂时停止执行
    callback(result !== false);
    }
    } else {
    callback(true);
    }
    };
  • 实际上执行的就是从外部传进来的 callback 方法,只是多了几层判断来做校验,而且传入了布尔值来控制是否需要真的执行回调函数。

transitionManager 调用

  • 再然后我们来看看上述方法 appendListener, notifyListeners 的具体应用。前者体现在了 popstate 事件的订阅中。

  • 那么就先简单谈谈 popstate 事件。

    • 当做出浏览器动作时,会触发 popstate 事件, 例如点击浏览器的回退。也就是说,popstate 本身并不是像 pushStatereplaceState 一样是 history 的方法。
    • 不能使用 history.popState 这样的方式来调用。
    • 而且,直接调用 history.pushStatehistory.replaceState 不会触发 popstate 事件。
  • 在事件监听方法 listen 中涉及了 popstate 的使用,在源码中可以看到以下两个方法 listencheckDOMListeners.

  • 它们就是上述订阅事件的具体调用方。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    // 首先自然是初始化
    const transitionManager = createTransitionManager();
    const PopStateEvent = "popstate";
    const HashChangeEvent = "hashchange";

    // 当 URL 的片段标识符更改时,将触发 hashchange 事件(跟在 # 后面的部分,包括 # 符号)
    // https://developer.mozilla.org/zh-CN/docs/Web/Events/hashchange
    // https://developer.mozilla.org/zh-CN/docs/Web/API/Window/onhashchange
    const checkDOMListeners = delta => {
    listenerCount += delta;
    if (listenerCount === 1) {
    // 其实也是最常见最简单的订阅事件, handlePopState 对应的内容在下文有说明
    window.addEventListener(PopStateEvent, handlePopState);
    if (needsHashChangeListener)
    window.addEventListener(HashChangeEvent, handleHashChange);
    } else if (listenerCount === 0) {
    window.removeEventListener(PopStateEvent, handlePopState);
    if (needsHashChangeListener)
    window.removeEventListener(HashChangeEvent, handleHashChange);
    }
    };

    /**
    * [订阅事件的具体调用方]
    * @param {Function} listener [description]
    * @return {Function} [description]
    */
    const listen = listener => {
    // 返回一个解绑函数
    const unlisten = transitionManager.appendListener(listener);
    checkDOMListeners(1);
    // 返回的函数负责取消
    return () => {
    checkDOMListeners(-1);
    unlisten();
    };
    };
  • 简言之,调用 listen 就是给 window 绑定了相应方法,再次调用之前 listen 返回的函数则是取消。

  • 然后来看看发布事件的具体调用方 setState它在 createBrowserHistory.js 中定义,在 popstatepushreplace 中均有调用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /**
    * 在该方法中发布
    * @param {*} nextState [入参合并到 history]
    */
    const setState = nextState => {
    Object.assign(history, nextState);
    history.length = globalHistory.length;
    // 执行所有的监听函数
    transitionManager.notifyListeners(history.location, history.action);
    };
  • 以上是 setState 的定义。我们来看看它在 popstate 中的使用。

    • 上文有许多代码,以此关键代码为例:
    • window.addEventListener(PopStateEvent, handlePopState);
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    const handlePopState = (event) => {
    handlePop(getDOMLocation(event.state))
    }

    let forceNextPop = false

    const handlePop = (location) => {
    if (forceNextPop) {
    forceNextPop = false
    setState()
    } else {
    const action = 'POP'

    transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
    if (ok) {
    setState({ action, location })
    } else {
    revertPop(location)
    }
    })
    }
    }
  • 浏览器注册了 popstate 事件,对应的 handlePopState 的方法则最终调用了 setState 方法。

  • 翻译成白话就是浏览器回退操作的时候,会触发 setState 方法。它将在下文以及后一篇博文里起到重要作用。

下面的方法则应用了 confirmTransitionTo.
  • push, replace 这两个上文提到的重要方法,是原生方法的扩展。它们都用到了上述分析过的方法,都负责实现跳转,因此内部有较多逻辑相同。

  • 后面会以 push 为例, 它其实就是对原生的 history.pushState 的强化。

  • 那么这里就先从原生的 history.pushState 开始熟悉了解。

  • history.pushState 接收三个参数,第一个为状态对象,第二个为标题,第三个为 Url.

    • 状态对象:一个可序列化的对象,且序列化后小于 640k. 否则该方法会抛出异常。(暂时不知这对象可以拿来做什么用,或许 react-router 用来标识页面的变化,以此渲染组件)
    • 标题(目前被忽略):给页面添加标题。目前使用空字符串作为参数是安全的,未来则是不安全的。Firefox 目前还未实现它。
    • **URL(可选)**:新的历史 URL 记录。直接调用并不会加载它,但在其他情况下,重新打开浏览器或者刷新时会加载新页面。
    • 一个常见的调用是 history.pushState({ foo: 'bar'}, 'page1', 'bar.html').
    • 调用后浏览器的 url 会立即更新,但页面并不会重新加载。例如 www.google.com 变更为 www.google.com/bar.html. 但页面不会刷新。
    • 注意,此时并不会调用 popstate 事件。只有在上述操作后,访问了其他页面,然后点击返回,或者调用 history.go(-1)/history.back() 时,popstate 会被触发。
    • 让我们在代码中更直观的看吧。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 定义一个 popstate 事件
    window.onpopstate = function(event) {
    console.info(event.state)
    }
    const page1 = { page: 'page1' }
    const page2 = { page: 'page2' }
    history.pushState(page1, 'page1', 'page1.html')
    // 页面地址由 www.google.com => www.google.com/page1.html
    // 但不会刷新或重新渲染
    history.pushState(page2, 'page2', 'page2.html')
    // 页面地址由 www.google.com/page2.html => www.google.com/page2.html
    // 但不会刷新或重新渲染
    // 此时执行
    history.back() // history.go(-1)
    // 会触发 popstate 事件, 打印出 page1 对象
    // { page: 'page1' }
  • 介绍完 pushState 后,看看 history 中是怎样实现它的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    const push = (path, state) => {
    const action = "PUSH";
    const location = createLocation(path, state, createKey(), history.location);
    // 过渡方法的应用
    transitionManager.confirmTransitionTo(
    location,
    action,
    getUserConfirmation,
    ok => {
    // 布尔值,用于判断是否需要执行
    if (!ok) return;
    const href = createHref(location);
    const { key, state } = location;
    // 在支持 history 的地方则使用 history.pushState 方法实现
    if (canUseHistory) {
    globalHistory.pushState({ key, state }, null, href);
    if (forceRefresh) {
    window.location.href = href
    } else {
    // 如果是非强制刷新时,会更新状态,后续在 react-router 中起到重要作用
    // 上文提到过的发布事件调用处
    setState({ action, location })
    }
    } else {
    window.location.href = href;
    }
    }
    );
    };
  • 关键代码:**globalHistory.pushState({ key, state }, null, href);** 和上文分析的一致。

  • pushStatepush 方法讲完,replaceStatereplace 也就很好理解了。

  • replaceState 只是把推进栈的方式改为替换栈的行为。它接收的参数与 pushState 完全相同。只是方法调用后执行的效果不同。

  • 补:本来如果仅仅是介绍当前的 history. 我之前以为找到 pushState 这个核心就已经足够了。但当我继续深入,探究 react-router 原理的时候,才发现这里遗漏了重要的一点。那就是 setState 方法。

  • 那么这个方法具体做了什么呢。在上文中已经做了简单介绍,这里再重申一遍:就是将当前 state 存入 history, 同时发布事件,也就是调用之前订阅时的保存的所有方法。参数则是 [history.location, history.action]. 或许现在,我们可能对该方法的重要性没有那么深的理解,当你再结合后一篇分析 react-router 的文章,就知道它起的作用了。

history在react-router中

  • 这篇文章快完成的时候,我才发现 react-router 仓库里是有 history 的介绍的。此时我一脸茫然。这里面内容虽然不多,却非常值得参考。这里做部分翻译和理解,当作对上文的补充。
  • 原地址
history is mutable
  • 在原文档中,说明了 history 对象是可变的。因此建议在 react-router 中获取 location 时可以使用 Routeprops 的方式来替代 history.location 的方式。这样的方式会确保你的流程处于 React 的生命周期中。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Comp extends React.Component {
    componentWillReceiveProps(nextProps) {
    // 正确的打开方式
    const locationChanged = nextProps.location !== this.props.location

    // 错误的打开方式,因为 history 是可变的,所以这里总是不等的 // will *always* be false because history is mutable.
    const locationChanged = nextProps.history.location !== this.props.history.location
    }
    }

    <Route component={Comp}/>
  • 更多内容请查看the history documentation.

小结

  • 一句话形容 history 这个库。它是一个对 HTML5 原生 history 的拓展,它对外输出三个方法,用以在支持原生 api 的环境和不兼容的环境,还有 node 环境中调用。而该方法返回的就是一个增强的 history api.
  • 写这篇文章的时候,第一次有感受到技术栈拓展的无穷魅力。从最初试图分析 react-router,到发现它依赖的主要的库 history. 再进行细化,到 history 主要提供的对象方法。里面涉及的发布订阅设计模式、思路、以及具体的实现使用了柯里化方式。一步一步探究下去可以发现很多有趣的地方。似乎又唤起往日的技术热情。
  • 下一篇文章将会继续介绍 react-router.

占位坑

  • 下面两个方法返回的内容和 createBrowserHistory 基本一致,只是具体的实现有部分差别。有时间补上。
  • createHashHistory
  • createMemoryHistory

参考

react-router 的实现原理
react-router 源代码学习笔记
Javascript设计模式之发布-订阅模式


升级 MacOs Mojave 后的兼容问题解决 单测入门笔记

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×