手写history里的监听和阻塞

287 阅读2分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第23天,点击查看活动详情

前言

在前面的篇章中《手写history里的createBrowserHistory》我们介绍了react-router的核心库history,包括history的主要属性方法以及基本实现原理。

在上一篇章中我们并没有完善history的监听器内容,在本篇章我们主要是实现history里的listen和block方法。

listen

在history库里,我们先看看listen的作用:

import React from "react";
import { createBrowserHistory } from "history"

const history = createBrowserHistory()

history.listen(({ action, location }) => {
  console.log(
    `The current URL is ${location.pathname}${location.search}${location.hash}`
  );
  console.log(`The last navigation action was ${action}`);
});

window.h = history

export default function App() {
  console.log(history)
  return <>
    APP
  </>
}

image.png

在代码里我们给全局变量h赋予了history的引用,我们在控制台操作push方法,往地址栏push一个新的地址:

image.png

基于监听器的原理,我们可以通过队列任务实现如下监听器代码:

class ListenerManager {
  // 维护监听器队列
  listeners = [];
  
  // 添加一个监听器,返回一个用于取消监听的方法
  addListener(listener) {
      this.listeners.push(listener);
      const unListen = () => {
          const index = this.listeners.indexOf(listener)
          this.listeners.splice(index, 1);
      }
      return unListen;
  }
  
  // 依次执行队列里的监听器任务
  triggerListener(location, action){
      for (const listener of this.listeners) {
          listener(location, action);
      }
  }
}

通过上述的代码我们实现了简单的监听器。

block

history提供了block的方法,通过调用history.block(blocker: Blocker),我们可以实现阻止导航离开当前页面。如提示用户知道如果他们离开当前页面将丢失一些未保存的更改等。

测试代码如下:

import React from "react";
import { createBrowserHistory } from "history"

const history = createBrowserHistory()

let unblock = history.block((tx) => {
  let url = tx.location.pathname;
  if (window.confirm(`Are you sure you want to go to ${url}?`)) {
    // Unblock the navigation.
    unblock();

    // Retry the transition.
    tx.retry();
  }
});

window.h = history

export default function App() {
  console.log(history)
  return <>
    APP
  </>
}

同样的,我们在控制台调用push方法往地址栏push一个新地址:

image.png

image.png

基于上述功能效果,我们可以实现如下的阻塞器:

class BlockManager {
    // 该属性是否有值,决定了是否有阻塞
    prompt = null; 

    constructor(getUserConfirmation) {
        this.getUserConfirmation = getUserConfirmation;
    }

    block(prompt) {
        if (typeof prompt !== "string" && typeof prompt !== "function") {
            throw new TypeError("block must be string or function");
        }
        this.prompt = prompt;
        return () => {
            this.prompt = null;
        }
    }

    // 阻塞之后要执行的回调
    triggerBlock(location, action, callback) {
        if (!this.prompt) {
            callback();
            return;
        }
        
        //阻塞消息
        let message; 
        if (typeof this.prompt === "string") {
            message = this.prompt;
        }
        else if (typeof this.prompt === "function") {
            message = this.prompt(location, action);
        }

        //调用getUserConfirmation
        this.getUserConfirmation(message, result => {
            if (result === true) {
                callback();
            }
        })
    }
}

通过上述的代码我们实现了简单的阻塞器。

完善history

基于之前的history代码,我们可以在其内部引入监听器和阻塞器,实现对listen和block的封装。

import ListenerManager from "./ListenerManager";
import BlockManager from "./BlockManager";

/**
 * 创建一个history api的history对象
 * @param {*} options 
 */
export default function createBrowserHistory(options = {}) {
    const {
        basename = "",
        forceRefresh = false,
        keyLength = 6,
        getUserConfirmation = (message, callback) => callback(window.confirm(message))
    } = options;
    
    // 创建监听器实例
    const listenerManager = new ListenerManager();
    
    // 创建阻塞器实例
    const blockManager = new BlockManager(getUserConfirmation);

    function go(step) {
        window.history.go(step);
    }

    function goBack() {
        window.history.back();
    }

    function goForward() {
        window.history.forward();
    }

    function push(path, state) {
        changePage(path, state, true);
    }

    function replace(path, state) {
        changePage(path, state, false);
    }

    function changePage(path, state, isPush) {
        let action = "PUSH";
        if (!isPush) {
            action = "REPLACE"
        }
        const pathInfo = handlePathAndState(path, state, basename);
        const location = createLoactionFromPath(pathInfo);
        blockManager.triggerBlock(location, action, () => {
            if (isPush) {
                window.history.pushState({
                    key: createKey(keyLength),
                    state: pathInfo.state
                }, null, pathInfo.path);
            }
            else {
                window.history.replaceState({
                    key: createKey(keyLength),
                    state: pathInfo.state
                }, null, pathInfo.path);
            }
            listenerManager.triggerListener(location, action);
            history.action = action;
            history.location = location;
            if (forceRefresh) {
                window.location.href = pathInfo.path;
            }
        })
        
        function createKey(keyLength) {
            return Math.random().toString(36).substr(2, keyLength);
        }
        
        function handlePathAndState(path, state, basename) {
            if (typeof path === "string") {
                return {
                    path,
                    state
                }
            }else if (typeof path === "object") {
                let pathResult = basename + path.pathname;
                let { search = "", hash = "" } = path;
                if (search.charAt(0) !== "?") {
                    search = "?" + search;
                }
                if (hash.charAt(0) !== "#") {
                    hash = "#" + hash;
                }
                pathResult += search;
                pathResult += hash;
                return {
                    path: pathResult,
                    state: path.state
                }
            }else {
                throw new TypeError("path must be string or object");
            }
        }
        
        function createLoactionFromPath(pathInfo, basename) {
            let pathname = pathInfo.path.replace(/[#?].*$/, "");
            let reg = new RegExp(`^${basename}`);
            pathname = pathname.replace(reg, "");
            var questionIndex = pathInfo.path.indexOf("?");
            var sharpIndex = pathInfo.path.indexOf("#");
            
            let search;
            if (questionIndex === -1 || questionIndex > sharpIndex) {
                search = "";
            } else {
                search = pathInfo.path.substring(questionIndex, sharpIndex);
            }

            let hash;
            if (sharpIndex === -1) {
                hash = "";
            } else {
                hash = pathInfo.path.substr(sharpIndex);
            }

            return {
                hash,
                pathname,
                search,
                state: pathInfo.state
            }
        }
   }

    function addDomListener() {
        // opstate事件,仅能监听前进、后退、用户对地址hash的改变
        // 法监听到pushState、replaceState
        window.addEventListener("popstate", () => {
            const location = createLocation(basename);
            const action = "POP";
            blockManager.triggerBlock(location, action, () => {
                listenerManager.triggerListener(location, "POP");
                history.location = location;
            })
        })
    }

    addDomListener();

    function listen(listener) {
        return listenerManager.addListener(listener);
    }

    function block(prompt) {
        return blockManager.block(prompt);
    }

    function createHref(location) {
        return basename + location.pathname + location.search + location.hash;
    }
    
    function createLocation(basename = "") {
        let pathname = window.location.pathname;
        const reg = new RegExp(`^${basename}`);
        pathname = pathname.replace(reg, "");
        const location = {
            hash: window.location.hash,
            search: window.location.search,
            pathname
        };

        let state, historyState = window.history.state;

        if (historyState === null) {
            state = undefined;
        }else if (typeof historyState !== "object") {
            state = historyState;
        }else {
            if ("key" in historyState) {
                location.key = historyState.key;
                state = historyState.state;
            }else {
                state = historyState;
            }
        }
        location.state = state;
        return location;
    }

    const history = {
        action: "POP",
        createHref,
        block,
        length: window.history.length,
        go,
        goBack,
        goForward,
        push,
        replace,
        listen,
        location: createLocation(basename)
    };

    return history;
}

小结

history作为react-router的核心库,我们完成的是其大致的实现,该库更多的是在此基础上对细节进行完善处理,我们只需要理解其基本实现原理即可。