Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

vue-router(v4.0.6)之 createWebHashHistory, createWebHistory和 createMemoryHistory 实现简析 #1

Open
unproductive-wanyicheng opened this issue Apr 27, 2021 · 0 comments

Comments

@unproductive-wanyicheng
Copy link
Owner

unproductive-wanyicheng commented Apr 27, 2021

我们先看下大概的实现 createWebHashHistory:

/**
    * Creates a hash history. Useful for web applications with no host (e.g.
    * `file://`) or when configuring a server to handle any URL is not possible.
    *
    * @param base - optional base to provide. Defaults to `location.pathname +
    * location.search` If there is a `<base>` tag in the `head`, its value will be
    * ignored in favor of this parameter **but note it affects all the
    * history.pushState() calls**, meaning that if you use a `<base>` tag, it's
    * `href` value **has to match this parameter** (ignoring anything after the
    * `#`).
    *
    * @example
    * ```js
    * // at https://example.com/folder
    * createWebHashHistory() // gives a url of `https://example.com/folder#`
    * createWebHashHistory('/folder/') // gives a url of `https://example.com/folder/#`
    * // if the `#` is provided in the base, it won't be added by `createWebHashHistory`
    * createWebHashHistory('/folder/#/app/') // gives a url of `https://example.com/folder/#/app/`
    * // you should avoid doing this because it changes the original url and breaks copying urls
    * createWebHashHistory('/other-folder/') // gives a url of `https://example.com/other-folder/#`
    *
    * // at file:///usr/etc/folder/index.html
    * // for locations with no `host`, the base is ignored
    * createWebHashHistory('/iAmIgnored') // gives a url of `file:///usr/etc/folder/index.html#`
    * ```
    */
function createWebHashHistory(base) {
    // Make sure this implementation is fine in terms of encoding, specially for IE11
    // for `file://`, directly use the pathname and ignore the base
    // location.pathname contains an initial `/` even at the root: `https://example.com`
    base = location.host ? base || location.pathname + location.search : '';
    // allow the user to provide a `#` in the middle: `/base/#/app`
    if (base.indexOf('#') < 0)
        base += '#';
    if (!base.endsWith('#/') && !base.endsWith('#')) {
        warn(`A hash base must end with a "#":\n"${base}" should be "${base.replace(/#.*$/, '#')}".`);
    }
    return createWebHistory(base);
}

作者给出的注释里面描述了几种例子情况,很清晰的描述了对应情况产生的url,hash history模式下,正常情况尾部会追加一个 # 符号,得到格式化的 base 路径;
而这个经过hash化的 base 会传递给 createWebHistory ,也就是普通的 history 模式,2种模式归一成一种实现,只是参数 base 不同而已;而浏览器url中带有#符号也就是带有
hash路径的时候,我们改变hash url部分,不会引起浏览器去尝试加载对应的实际资源,但是我们可以通过添加事件来捕获这个变化,从而引发router-view组件的重新渲染,
从而正确更新dom视图;而普通history的url是一个不带hash的url,我们手动改变url后,浏览器会尝试去加载这个url对应的资源,这时候需要我们对服务器做一定配置,让服务器对指定
范围的url都返回单页应用的首页html,然后我们再添加捕获history的state改变事件,从而更新router-view的视图,效果和hash模式一样。
接下来看 createWebHistory 的实现:

/**
    * Creates an HTML5 history. Most common history for single page applications.
    *
    * @param base -
    */
function createWebHistory(base) {
    // 格式化base路径
    base = normalizeBase(base);
    // 创建router使用的包含 修改原生window的history属性的方法及属性集合 对象
    const historyNavigation = useHistoryStateNavigation(base);
    // 创建包含监听事件等属性方法的 historyListeners 对象
    const historyListeners = useHistoryListeners(base, historyNavigation.state, historyNavigation.location, historyNavigation.replace);
    // 模拟的go方法
    function go(delta, triggerListeners = true) {
        // 暂停触发state改变的监听者们
        if (!triggerListeners)
            historyListeners.pauseListeners();
        // 调用有原生history的方法
        history.go(delta);
    }
    // 创建history对象
    const routerHistory = assign({
        // it's overridden right after
        // 当前url 浏览器中我们看到的是哪个url,除去下面的base部分后面的路径,剩下的部分就等于这个值 2者保持同步
        // 初始化的时候这个会被 historyNavigation 的location覆盖
        location: '',
        // pathname + [search] + [#]
        base,
        go,
        // 根据base和参数得到完整的href
        createHref: createHref.bind(null, base),
    }, historyNavigation, historyListeners);

    // historyNavigation.location和state都是 {value: xxx} 类型的包裹体
    Object.defineProperty(routerHistory, 'location', {
        get: () => historyNavigation.location.value,
    });
    Object.defineProperty(routerHistory, 'state', {
        get: () => historyNavigation.state.value,
    });

    return routerHistory;
}

主体逻辑主要是合并几个对象成一个,先分别看几个组成部分:

1. normalizeBase
/**
    * Normalizes a base by removing any trailing slash and reading the base tag if
    * present.
    *
    * @param base - base to normalize
    */
function normalizeBase(base) {
    if (!base) {
        if (isBrowser) {
            // respect <base> tag
            // 从html标签中取base值
            const baseEl = document.querySelector('base');
            base = (baseEl && baseEl.getAttribute('href')) || '/';
            // strip full URL origin
            // 去除 协议 和 host 部分
            base = base.replace(/^\w+:\/\/[^\/]+/, '');
        }
        else {
            base = '/';
        }
    }
    // ensure leading slash when it was removed by the regex above avoid leading
    // slash with hash because the file could be read from the disk like file://
    // and the leading slash would cause problems
    // 不是 #/path/a 的情况下 path/a -> /path/a
    if (base[0] !== '/' && base[0] !== '#')
        base = '/' + base;
    // remove the trailing slash so all other method can just do `base + fullPath`
    // to build an href
    return removeTrailingSlash(base);
}

// 移除尾部的 /path/a/ -> /path/a 
const TRAILING_SLASH_RE = /\/$/;
const removeTrailingSlash = (path) => path.replace(TRAILING_SLASH_RE, '');
  1. useHistoryStateNavigation
function useHistoryStateNavigation(base) {
    const { history, location } = window;
    // private variables

    // 当前location的 url 我们通过router实例的push等操作修改url后 对应这个的value url 也会被修改 2者保持同步
    let currentLocation = {
        value: createCurrentLocation(base, location),
    };

    // state对象包裹体
    let historyState = { value: history.state };
    // build current history entry as this is a fresh navigation
    // 新开始一个页面的时候 state初始为null
    if (!historyState.value) {
        // 设置 currentLocation 的state 初始值
        changeLocation(currentLocation.value, {
            // location栈中上一个location url
            back: null,
            // location栈中当前location url
            current: currentLocation.value,
            // state栈中下一个location url
            forward: null,
            // the length is off by one, we need to decrease it
            // 当前location在栈中所处位置
            position: history.length - 1,
            // 是否是重定向
            replaced: true,
            // don't add a scroll as the user may have an anchor and we want
            // scrollBehavior to be triggered without a saved position
            // 滚动位置信息
            scroll: null,
        }, true);
    }
    function changeLocation(to, state, replace) {
        ...
    }
    function replace(to, data) {
        ...
    }
    function push(to, data) {
        ...
    }
    return {
        location: currentLocation,
        state: historyState,
        push,
        replace,
    };
}

可以看到 useHistoryStateNavigation 主要得到了 location对象并完成初始化,state对象并完成初始化, 声明了2个外部方法。返回体包含4个属性。
逻辑比较简单,再看它内部的方法:

createCurrentLocation:

/**
    * Creates a normalized history location from a window.location object
    * @param location -
    */
function createCurrentLocation(base, location) {
    const { pathname, search, hash } = location;
    // allows hash based url
    const hashPos = base.indexOf('#');
    if (hashPos > -1) {
        // prepend the starting slash to hash so the url starts with /#
        // 取出哈希路径 这个是哈希模式下需要的url
        let pathFromHash = hash.slice(1);
        if (pathFromHash[0] !== '/')
            pathFromHash = '/' + pathFromHash;
        return stripBase(pathFromHash, '');
    }
    // history模式下需要的url 不过需要去除 base部分 因为base是公共部分 我们只关注 base之后的路径部分
    const path = stripBase(pathname, base);
    return path + search + hash;
}

/**
    * Strips off the base from the beginning of a location.pathname in a non
    * case-sensitive way.
    *
    * @param pathname - location.pathname
    * @param base - base to strip off
    */
function stripBase(pathname, base) {
    // no base or base is not found at the beginning
    // base存在的话 必须要 目标pathname的0位置开始 也就是 startswith -1 或者 大于0 都无效
    if (!base || pathname.toLowerCase().indexOf(base.toLowerCase()))
        return pathname;
    return pathname.slice(base.length) || '/';
}

内部方法 changeLocation:

function changeLocation(to, state, replace) {
    /**
        * if a base tag is provided and we are on a normal domain, we have to
        * respect the provided `base` attribute because pushState() will use it and
        * potentially erase anything before the `#` like at
        * https://github.com/vuejs/vue-router-next/issues/685 where a base of
        * `/folder/#` but a base of `/` would erase the `/folder/` section. If
        * there is no host, the `<base>` tag makes no sense and if there isn't a
        * base tag we can just use everything after the `#`.
        */
    const hashIndex = base.indexOf('#');
    // hash模式下只取hash部分地址 
    const url = hashIndex > -1
        ? (location.host && document.querySelector('base')
            ? base
            : base.slice(hashIndex)) + to
        : createBaseLocation() + base + to;
    try {
        // BROWSER QUIRK
        // NOTE: Safari throws a SecurityError when calling this function 100 times in 30 seconds
        // 修改history的state 并且让浏览器url定位到我们设置的url上
        history[replace ? 'replaceState' : 'pushState'](state, '', url);
        // 同步state到我们的history
        historyState.value = state;
    }
    catch (err) {
        {
            warn('Error with push/replace State', err);
        }
        // Force the navigation, this also resets the call count
        // 失败情况下的方案 同样可以修改url
        location[replace ? 'replace' : 'assign'](url);
    }
}

// history模式下获取 协议+host
let createBaseLocation = () => location.protocol + '//' + location.host;

可以看到 changeLocation(to, state, replace) 就是把参数中的值同步到window的history中 且修改浏览器的url,是对真实history的操作封装

外部方法:replace

function replace(to, data) {
    // 构建目标state 注意assign的参数顺序
    // {} 一个新对象
    // 当前history的state
    // 新的计算出来的state buildState(historyState.value.back, to, historyState.value.forward, true)
    // 传入的state data
    // 之前的滚动信息{ position: historyState.value.position }
    // 以上参数从后往前覆盖
    const state = assign({}, history.state, buildState(historyState.value.back, 
    // keep back and forward entries but override current position
    to, historyState.value.forward, true), data, { position: historyState.value.position });

    // 调用 changeLocation 重定向覆盖即可
    changeLocation(to, state, true);
    // 更新我们的 currentLocation 的 url值
    currentLocation.value = to;
}

/**
    * Creates a state object
    * 返回一个新的state对象
    */
function buildState(back, current, forward, replaced = false, computeScroll = false) {
    return {
        back,
        current,
        forward,
        replaced,
        position: window.history.length,
        scroll: computeScroll ? computeScrollPosition() : null,
    };
}

再看下 push:

function push(to, data) {
    // 注意 assign 的几个参数顺序
    // {}
    // 我们的 historyState.value
    // 浏览器的 history.state
    // 由于是push操作 这个state是要替换当前state的 所以设置它的forward值 同时还要计算当前的滚动信息 用于回退的时候使用位置信息 { forward: to, scroll: computeScrollPosition(),}

    // Add to current entry the information of where we are going
    // as well as saving the current position
    const currentState = assign({}, 
    // use current history state to gracefully handle a wrong call to
    // history.replaceState
    // https://github.com/vuejs/vue-router-next/issues/366
    historyState.value, history.state, {
        forward: to,
        scroll: computeScrollPosition(),
    });
    // 重定向时候的限制
    if (!history.state) {
        warn(`history.state seems to have been manually replaced without preserving the necessary values. Make sure to preserve existing history state if you are manually calling history.replaceState:\n\n` +
            `history.replaceState(history.state, '', url)\n\n` +
            `You can find more information at https://next.router.vuejs.org/guide/migration/#usage-of-history-state.`);
    }
    // 替换当前的state 因为它的信息更新了 注意第三个参数replace为true
    changeLocation(currentState.current, currentState, true);
    // 计算新的to state
    // 注意buildState的三个参数
    // 更新 position 历史栈+1
    // 加入传入的data
    const state = assign({}, buildState(currentLocation.value, to, null), { position: currentState.position + 1 }, data);
    // 调用 changeLocation
    changeLocation(to, state, false);
    // 更新我们的 currentLocation 的url值
    currentLocation.value = to;
}

总结一下 useHistoryStateNavigation 做的事情:把我们对浏览器地址的改变操作 映射到实际的浏览器history中 并做了一些操作封装
得到了如下对象:

return {
    // 2个对象属性 由于是对象 被别人直接当做成参数使用的话 如果其他调用方有修改它们的value 会导致这里同步被修改
    location: currentLocation,
    state: historyState,
    // 对外暴露2个方法
    push,
    replace,
};

再看 const historyListeners = useHistoryListeners(base, historyNavigation.state, historyNavigation.location, historyNavigation.replace) 的实现:

function useHistoryListeners(base, historyState, currentLocation, replace) {
    // 事件监听者集合
    let listeners = [];
    // 待执行的移除监听者闭包函数集合
    let teardowns = [];
    // TODO: should it be a stack? a Dict. Check if the popstate listener
    // can trigger twice
    // 暂停state监听者触发
    let pauseState = null;
    // 事件handler
    const popStateHandler = ({ state, }) => {
        ...
    };
    // 暂停置位
    function pauseListeners() {
        pauseState = currentLocation.value;
    }
    // 添加监听者
    function listen(callback) {
        // setup the listener and prepare teardown callbacks
        listeners.push(callback);
        // 每个监听者对应一个销毁者 用户可以手动调用 单个 teardown
        const teardown = () => {
            const index = listeners.indexOf(callback);
            if (index > -1)
                listeners.splice(index, 1);
        };
        teardowns.push(teardown);
        return teardown;
    }
    // 事件handler
    function beforeUnloadListener() {
        const { history } = window;
        if (!history.state)
            return;
        history.replaceState(assign({}, history.state, { scroll: computeScrollPosition() }), '');
    }
    // 销毁所有资源
    function destroy() {
        for (const teardown of teardowns)
            teardown();
        teardowns = [];
        window.removeEventListener('popstate', popStateHandler);
        window.removeEventListener('beforeunload', beforeUnloadListener);
    }
    // setup the listeners and prepare teardown callbacks
    // 监听2个事件
    window.addEventListener('popstate', popStateHandler);
    window.addEventListener('beforeunload', beforeUnloadListener);
    // 对外暴露3个方法
    return {
        pauseListeners,
        listen,
        destroy,
    };
}

来看下重点的 popStateHandler:
这是 MDN 对这个事件的定义:

当活动历史记录条目更改时,将触发popstate事件。如果被激活的历史记录条目是通过对history.pushState()的调用创建的,或者受到对history.replaceState()的调用的影响,popstate事件的state属性包含历史条目的状态对象的副本。

需要注意的是调用history.pushState()或history.replaceState()不会触发popstate事件。只有在做出浏览器动作时,才会触发该事件,如用户点击浏览器的回退按钮(或者在Javascript代码中调用history.back()或者history.forward()方法)

const popStateHandler = ({ state, }) => {
    // 计算浏览器新地址 url
    const to = createCurrentLocation(base, location);
    // 我们的location url还是旧值
    const from = currentLocation.value;
    // 我们的旧state
    const fromState = historyState.value;
    let delta = 0;
    // 没有超出栈的底部的话 这个state就有值 到最底层后就是 null 了 需要replace一下
    if (state) {
        // 同步下值到我们的状态中
        currentLocation.value = to;
        historyState.value = state;
        // ignore the popstate and reset the pauseState
        // 暂停标志在这里起作用 对某个url设置了 暂停trigger话 后面就不执行了
        if (pauseState && pauseState === from) {
            pauseState = null;
            return;
        }
        // 计算间隔
        delta = fromState ? state.position - fromState.position : 0;
    }
    else {
        replace(to);
    }
    // console.log({ deltaFromCurrent })
    // Here we could also revert the navigation by calling history.go(-delta)
    // this listener will have to be adapted to not trigger again and to wait for the url
    // to be updated before triggering the listeners. Some kind of validation function would also
    // need to be passed to the listeners so the navigation can be accepted
    // call all listeners
    // trigger一下监听者们执行
    listeners.forEach(listener => {
        listener(currentLocation.value, from, {
            delta,
            // 下面的参数都是字符 表示跳转方向
            type: NavigationType.pop,
            direction: delta
                ? delta > 0
                    ? NavigationDirection.forward
                    : NavigationDirection.back
                : NavigationDirection.unknown,
        });
    });
};

var NavigationType;
(function (NavigationType) {
    NavigationType["pop"] = "pop";
    NavigationType["push"] = "push";
})(NavigationType || (NavigationType = {}));
var NavigationDirection;
(function (NavigationDirection) {
    NavigationDirection["back"] = "back";
    NavigationDirection["forward"] = "forward";
    NavigationDirection["unknown"] = "";
})(NavigationDirection || (NavigationDirection = {}));

总结一下:useHistoryListeners 做的事情:主要监听popstate事件 来同步浏览器地址到我们的状态中
// 监听2个事件
window.addEventListener('popstate', popStateHandler);
window.addEventListener('beforeunload', beforeUnloadListener);
// 对外暴露3个方法
return {
pauseListeners,
listen,
destroy,
};

然后再看下 go:

function go(delta, triggerListeners = true) {
    // 对应上文的暂停操作
    if (!triggerListeners)
        historyListeners.pauseListeners();
    // 其实就是原生的go 不过我们在上面监听了popstate事件了已经
    history.go(delta);
}

// 可以看到 createHref这个方法就是为了得到 去除#之前的字符的base + 目标地址location
// remove any character before the hash
const BEFORE_HASH_RE = /^[^#]+#/;
function createHref(base, location) {
    return base.replace(BEFORE_HASH_RE, '#') + location;
}

合并对象:

const routerHistory = assign({
    // it's overridden right after
    location: '',
    base,
    go,
    createHref: createHref.bind(null, base),
}, historyNavigation, historyListeners);

看下现在总共得到了哪些属性:
routerHistory =
{
location: '', // 初始值 会被下面的覆盖
base, // base路径值
go, // go方法
createHref,
// 2个对象属性 由于是对象 被别人直接当做成参数使用的话 如果其他调用方有修改它们的value 会导致这里同步被修改
// 覆盖上面的
location: currentLocation,
state: historyState,
// 对外暴露的几个个方法
push,
replace,
pauseListeners,
listen,
destroy,
}

然后就是2个getter的取值:
Object.defineProperty(routerHistory, 'location', {
get: () => historyNavigation.location.value,
});
Object.defineProperty(routerHistory, 'state', {
get: () => historyNavigation.state.value,
});

到此,createWebHashHistory 和 createWebHistory 的分析基本完成,它们主要是对base的格式化不同,内部实现中对浏览器url的取值截取部分也不同,其他整体逻辑基本相同。
都是内部自己维持 2个对象 location和history来维持对浏览器地址和history的抽象,然后把方法对自己对象的操作映射到实际的浏览器history中;同时都监听了popstate事件,通过截取不同的url和计算
state来更新自己的变量,维持数据同步。

另外,源码还有一部分关于 createMemoryHistory 的实现, 我们也顺便看一下:

const START = '';

/**
    * Creates a in-memory based history. The main purpose of this history is to handle SSR. It starts in a special location that is nowhere.
    * It's up to the user to replace that location with the starter location by either calling `router.push` or `router.replace`.
    *
    * @param base - Base applied to all urls, defaults to '/'
    * @returns a history object that can be passed to the router constructor
    */

function createMemoryHistory(base = '') {
    let listeners = [];
    // 模拟的历史记录队列
    let queue = [START];
    // 模拟的url位置 从 0 开始 默认就有一个初始值
    let position = 0;
    function setLocation(location) {
        // 直接位置+1
        position++;
        // 长度和位置相等 意味着 新push了一个进来 不然长度大1
        if (position === queue.length) {
            // we are at the end, we can simply append a new entry
            queue.push(location);
        }
        // 在中间位置上的push操作 会把当前位置上的后面的队列中的历史信息都清除 这符合history的对push操作的定义
        else {
            // we are in the middle, we remove everything from here in the queue
            queue.splice(position);
            queue.push(location);
        }
    }
    // trigger更新观察者 分析同上面的2个模式
    function triggerListeners(to, from, { direction, delta }) {
        const info = {
            direction,
            delta,
            type: NavigationType.pop,
        };
        for (let callback of listeners) {
            callback(to, from, info);
        }
    }
    // 构建类似上面的history的对象 方法都比较简单 分析同上
    const routerHistory = {
        // rewritten by Object.defineProperty
        location: START,
        state: {},
        base,
        createHref: createHref.bind(null, base),
        replace(to) {
            // remove current entry and decrement position
            queue.splice(position--, 1);
            setLocation(to);
        },
        push(to, data) {
            setLocation(to);
        },
        listen(callback) {
            listeners.push(callback);
            return () => {
                const index = listeners.indexOf(callback);
                if (index > -1)
                    listeners.splice(index, 1);
            };
        },
        destroy() {
            listeners = [];
        },
        go(delta, shouldTrigger = true) {
            const from = this.location;
            const direction = 
            // we are considering delta === 0 going forward, but in abstract mode
            // using 0 for the delta doesn't make sense like it does in html5 where
            // it reloads the page
            delta < 0 ? NavigationDirection.back : NavigationDirection.forward;
            position = Math.max(0, Math.min(position + delta, queue.length - 1));
            if (shouldTrigger) {
                triggerListeners(this.location, from, {
                    direction,
                    delta,
                });
            }
        },
    };
    Object.defineProperty(routerHistory, 'location', {
        get: () => queue[position],
    });
    return routerHistory;
}

总结一下,createMemoryHistory就是一个简易的history模拟实现。

至此,vue-router中的3种history模式的实现分析完成,在分析其他方法的文章会有对他们如何使用的解释。

@unproductive-wanyicheng unproductive-wanyicheng changed the title 测试issue是否可以用来写博客 123 Apr 30, 2021
@unproductive-wanyicheng unproductive-wanyicheng changed the title 123 vue-router(v4.0.6)之 createWebHashHistory, createWebHistory和 createMemoryHistory 实现简析 Apr 30, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant