history (version 4.10.1) 源码解读

584 阅读4分钟

history 是spa应用路由系统的重要模块, 是实现前端路由匹配的基础。

history支持三种不同的路由方式,分别是 BrowserHistory、HashHistory和MemoryHistory

在分析具体的实现方式之前, 有一些知识点需要简单的提一下。前端要想根据不同的路由渲染不同的UI组件,首先要做到的就是能够监听到路由的变化,幸运的是浏览器提供给了我们这种能力,那就是在浏览器的事件系统中监听 popstate 事件 和 hashchange 事件,分别对应着 BrowserHistory 和 HashHistory 的实现, 但是有一点需要注意的是通过pushState 和 popState方式引起的路由变化 是不能被上述事件监听到的,这就需要我们做一些额外的处理。

想要监听history的变化,可以调用history模块提供的 listen 方法,源码如下:

function listen(listener) {
    const unlisten = transitionManager.appendListener(listener);    
    checkDOMListeners(1);    
    return () => {      
        checkDOMListeners(-1);      
        unlisten();    
    };  
}

checkDOMListeners 我们后边再说, 我们先关注一下 transitionManager.appendListener 这个方法的调用,不难理解,就是收集监听事件并在路由改变的时候触发listener回调的执行,在createTransitionManager.js 中有一段关于listener收集的源码是这样写的:

let listeners = [];
function appendListener(fn) {    
    let isActive = true;    
    function listener(...args) {      
        if (isActive) fn(...args);    
    }    
    listeners.push(listener);    
    return () => {      
        isActive = false;      
        listeners = listeners.filter(item => item !== listener);    
    };  
}

 可以看到源码巧妙的利用了闭包的特性,实现了listener 的收集,并且返回了可以用做监听移除的函数。同时isActive的使用可以让我们在调用监听移除函数的时候就可以阻止后续监听的触发。

在 createTransitionManager.js 中另外两个结合使用的是 setPrompt 和 confirmTransitionTo提供的 getUserConfirmation 功能,主要的作用就是在路由变化的时候允许根据用户的反馈判断是否取消本地路由的变更(revert), 我们来看一下源码

 function confirmTransitionTo(    
    location,    
    action,    
    getUserConfirmation,    
    callback  
 ) {
    // prompt 通过 setPrompt 方法指定 如果未指定 表明不需要拦截    
    if (prompt != null) {      
        const result =            
            typeof prompt === 'function' ? prompt(location, action) : prompt;      
        if (typeof result === 'string') {        
            if (typeof getUserConfirmation === 'function') {          
                getUserConfirmation(result, callback);// 把对路由跳转与否的控制权交给用户        
            } else {          
                warning(            
                    false,            
                    'A history needs a getUserConfirmation function in order to use a prompt message'          
                );          
                callback(true);        
            }      
         } else {        
            // Return false from a transition hook to cancel the transition.        
            callback(result !== false);      
         }    
     } else {      
        callback(true);    
     }  
}

然后我们看回 checkDOMListeners 这个方法

监听我们已经提到的两个事件分别执行不同的操作, 监听调用checkDOMListeners(1), 取消监听调用checkDOMListeners(-1)

 let listenerCount = 0;  
 function checkDOMListeners(delta) {    
    listenerCount += delta;    
    if (listenerCount === 1 && delta === 1) {      
        window.addEventListener(PopStateEvent, handlePopState);      
        if (needsHashChangeListener)        
            window.addEventListener(HashChangeEvent, handleHashChange);    
    } else if (listenerCount === 0) {      
        window.removeEventListener(PopStateEvent, handlePopState);      
        if (needsHashChangeListener)        
            window.removeEventListener(HashChangeEvent, handleHashChange);    
    }  
  }

继续看一下 handlePopState 对于路由改变时的处理

如果不存在路由的拦截的情况就是调用setState去通知路由改变了

 function setState(nextState) {    
    Object.assign(history, nextState);    
    history.length = globalHistory.length;    
    transitionManager.notifyListeners(history.location, history.action);  
 }

 function 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);          
                    }        
                }      
        );    
    }  
}

因为调用pushState和replaceState方法是不会触发我们监听的popState事件的,所以history模块封装了push和replace的方法。

封装的主要目的就是调用setState方法去通知路由的改变

function push(path, state) {    
    warning(      
        !(        
            typeof path === 'object' &&        
            path.state !== undefined &&        
            state !== undefined      
         ),      
        'You should avoid providing a 2nd state argument to push when the 1st ' +        
        'argument is a location-like object that already has state; it is ignored'    
   );    
    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;        
            if (canUseHistory) {          
                globalHistory.pushState({ key, state }, null, href);          
                if (forceRefresh) {            
                    window.location.href = href;          
                } else {            
                    const prevIndex = allKeys.indexOf(history.location.key);            
                    const nextKeys = allKeys.slice(0, prevIndex + 1);            
                    nextKeys.push(location.key);            
                    allKeys = nextKeys;            
                    setState({ action, location });          
                }        
            } else {          
                warning(            
                    state === undefined,            
                    'Browser history cannot push state in browsers that do not support HTML5 history'          
                );          
                window.location.href = href;        
            }      
        }    
     );  
}

至此关于BrowserHistory的相关实现已经没有了,下边我们来实践一下路由的拦截功能

首先我们创建一个React的函数组件并且调用history的block方法来定义我们的prompt函数

import React, { useEffect } from 'react';
import { withRouter } from 'dva/router';
export default withRouter(({ history }) => {  
    console.log(history);  
    useEffect(() => {    
        const unblock = history.block((location, action) => {      
            return '拦截路由';    
        });    
        return () => {      
            unblock();    
        };  
    }, []);  
    return <div>history</div>;
});

然后在BrowserRouter的属性中添加  getUserConfirmation 属性来进行路由拦截

结果就是我们在自己创建的组件内部不能向任何路由进行跳转

<Router      
    getUserConfirmation={(result, callback) => {        
        console.log(result);        
        if (result === '拦截路由') {          
            console.log(result);          
            callback(false);        
        } else {          
            callback(true);        
        }}}    
>

当然如果利用浏览器自身的前进和后退按钮,上述设置就是不生效的。