前端路由实现方式

1,792 阅读2分钟

当前端单页应用越来越复杂时,我们需要一个前端路由将页面切分为多个子页面,通过路由动态匹配和加载子页面。现在各种框架都有对应的前端路由实现,其实现原理大致相同。这篇文章也来实现一个简单前端路由,用来展示其原理

分析需求

先来分析前端路由功能有哪些需求,首先我们需要将 url 映射到子页面,也就是一个 url 对应一个子页面。当改变 url 不能引起页面刷新,同时动态匹配和加载对应的子页面,卸载之前的子页面。当用户点击 a 标签时,能触发加载子页面。总结下来就是下面3个需求

主动修改地址栏,不引起页面刷新

地址栏的修改有两种方式,一种是用户手动修改地址栏中的地址,一种是在脚本中调用 DOM API。当 url 被修改时,能不引起页面刷新的方式也有两种,一种是只修改 url 中的 hash 部分,一种是通过调用 history.pushState。

监听地址栏变化

我们如果能捕获到 url 修改事件,拿到修改后的结果,我们就能执行子页面的切换动作,展示路由后的子页面。window 上的 hashchange 和 popstate 事件可以用来监听 url 的改变。

劫持点击事件

当用户点击 a 标签后,我们需要判断这个链接是想打开子页面还是在其他窗口或标签中打开。如果是打开子页面,可以获取到跳转 url 传入上边的事件监听函数内执行子页面跳转

设计实现

根据需求来看,我们需要实现一个 Router 对象,用来保存所有 url 和对应的子页面信息。一个 History 对象,用来模拟浏览器 history 的功能,提供子页面切换功能,同时监听 url 的变化。主要代码如下

<a href="/page1">page1</a>
<a href="/page2">page2</a>
<a href="/page3">page3</a>
<div id="app"></div>

<script>
    const router = (function Router() {
        const app = document.querySelector('#app')
        // url 和子页面的映射关系,这里用字符串代表子页面内容
        const routerMap = {
            '/page1': '页面1',
            '/page2': '页面2',
        }

        return {
            matchUrl: function(url) {
                // 此处可以做路由拦截器,返回 false 代表路由失败
                app.innerHTML = routerMap[url] || '404'
            }
        }
    })()

    const myhistory = (function History(router, modal = 'hash') {
        // 保存当前 url
        let currentUrl = '/'

        // 获取地址栏中的 url
        function getUrl() {
            let url = ''
            const location = window.location

            if (modal == 'hash') {
                url = location.hash.replace('#', '')
            } else { // history
                url = location.href.replace(location.origin, '')
            }

            return url.startsWith('/') ? url : ('/' + url)
        }

        // 当 url 发生变化时,跳转到对应子页面
        // 该函数既是 hashchange 等事件的响应函数,也是点击模式下的跳转实现
        function urlChange(e, replace = false, clickModel = false) {
            console.log('urlChange', e, clickModel)
            // 点击链接跳转时,使用传入的参数 e 当作子页面的 url,事件响应函数时从地址栏获取 url
            const url = clickModel ? e : getUrl()

            if (currentUrl == url) return

            if (router.matchUrl(url) !== false) {
                // 子页面跳转成功时,保存状态
                currentUrl = url
                // 点击模式下,设置地址栏地址为新地址
                clickModel && setUrl(url, replace)
            }
        }

        // 设置地址栏地址为新地址
        function setUrl(url, replace = false) {
            if (modal == 'hash') {
                if (replace) {
                    window.history.go(-1)
                }
                window.location.hash = url
            } else {
                if (replace) {
                    window.history.replaceState(null, document.title, url)
                } else {
                    window.history.pushState(null, document.title, url)
                }
                urlChange()
            }
        }

        // 页面初始化时载入初始子页面
        urlChange()

        // 绑定事件
        if (modal == 'hash') {
            window.addEventListener('hashchange', urlChange)
        } else {
            window.addEventListener('popstate', urlChange)
        }

        return {
            // 导航到新 url,在浏览记录中入栈一个新 url
            nav: function(url) {
                urlChange(url, false, true)
            },
            // 导航到新 url,替换浏览记录中的第一个 url
            replace: function(url) {
                urlChange(url, true, true)
            }
        }
    })(router, 'hash');

    // 拦截链接的点击事件
    (function intercept() {
        document.addEventListener('click', function(e) {
            if (e.target.nodeName.toLowerCase() == 'a') {
                e.preventDefault()

                myhistory.nav(e.target.getAttribute('href'))
            }
        })
    })()
</script>

代码主要展示了前端路由的实现原理,还可以添加路由拦截钩子,用来阻止当前跳转。Router 对象增加匹配规则,动态参数等功能。拦截链接的点击事件可以增加过滤条件,特殊情况下不使用前端路由等