1 前后端开发模式的演变

jQuery时对大部分Web项目,前端不能控制路由,要依赖后端项目的路由系统。通常,前端项目也部署在后端项目的模板里,项目执行示意图:

jQuery前端都要学会在后端模板如JSP里写代码。此时,前端工程师无需了解路由。对每次的页面跳转,都由后端负责重新渲染模板。

前端依赖后端,且前端无需负责路由,有很多

优点

如开发速度快、后端也承担部分前端任务,所以至今很多内部管理系统还这样。

缺点

如:

  • 前后端项目无法分离
  • 页面跳转由于需重新刷新整个页面、等待时间较长,让交互体验下降

为提高页面交互体验,很多前端做不同尝试。前端开发模式变化,项目结构也变化。目前前端开发中,用户访问页面后代码执行的过程:

  • 用户访问路由后,无论URL地址,都直接渲染一个前端的入口文件index.html,然后在index.html文件中加载JS、CSS
  • 之后,js获取当前页面地址及当前路由匹配的组件
  • 再去动态渲染当前页面

用户在页面上点击时,也不需刷新页面,而直接通过JS重新计算出匹配的路由渲染。

前后两个示意图中,绿色的部分表示的就是前端负责的内容。后面这架构下,前端获得路由的控制权,在js中控制路由系统。也因此,页面跳转时就不需刷新页面,网页浏览体验提高。 这种所有路由都渲染一个前端入口文件的方式,是单页面应用程序(SPA)的雏形。

通过js动态控制数据去提高用户体验的方式并不新奇,Ajax让数据获取不需刷新页面,SPA应用让路由跳转也不需要刷新页面。这种开发模式在jQuery时代就出来,浏览器路由的变化可以通过pushState来操作,这种纯前端开发应用的方式,以前称Pjax (pushState+ Ajax)。之后,这种开发模式在MVVM框架时代放异彩,现在大部分使用Vue/React/Angular应用都这种架构。

SPA应用相比于模板的开发方式,对前端更友好,如:

  • 前端对项目控制权更大
  • 交互体验更丝滑
  • 前端项目终于可独立部署

完成了前后端系统完全分离。

2 前端路由的实现原理

通过URL区分路由的机制实现:

  • hash模式,通过URL中#后面的内容做区分,hash-router
  • history模式,路由看起来和正常URL一致

对应vue-router的函数:

  • createWebHashHistory
  • createWebHistory

2.1 hash 模式

单页应用在页面交互、页面跳转上都是无刷新的,极大提高用户访问网页的体验。 为实现单页应用,前端路由的需求也变重要。

类似服务端路由,前端路由实现也简单,就是匹配不同 URL 路径,进行解析,然后动态渲染出区域 HTML 内容。但URL每次变化都会造成页面的刷新。解决思路:改变 URL 时保证页面的不刷新。

2014年前,大家通过 hash 实现前端路由,URL hash 中的 # 类似下面这种 # :

http://www.xxx.com/#/login

之后,在进行页面跳转操作时,hash 值变化并不会导致浏览器页面刷新,只会触发hashchange事件。在下面的代码中,通过对hashchange事件的监听,就可在fn函数内部进行动态地页面切换。

window.addEventListener('hashchange',fn)

2.2 history 模式

2014年后HTML5标准发布,浏览器多API:pushState 和 replaceState。可改变 URL 地址,并且浏览器不会向后端发送请求,就能用另外一种方式实现前端路由。

监听popstate事件,可监听到通过pushState修改路由的变化。并且在fn函数中,我们实现了页面的更新

window.addEventListener('popstate', fn)

3 手写vue-router

  • src/router新建grouter文件夹
  • 在grouter文件夹新建index.js

手写Vuex的基础,在index.js写代码。

先用Router类去管理路由,并用createWebHashHistory返回hash模式相关的监听代码及返回当前URL和监听hashchange事件的方法

import {ref,inject} from 'vue'
const ROUTER_KEY = '__router__'

function createRouter(options){
    return new Router(options)
}

function useRouter(){
    return inject(ROUTER_KEY)
}
function createWebHashHistory(){
    function bindEvents(fn){
        window.addEventListener('hashchange',fn)
    }
    return {
        bindEvents,
        url:window.location.hash.slice(1) || '/'
    }
}
class Router {
    constructor(options) {
        this.history = options.history
        this.routes = options.routes
        this.current = ref(this.history.url)

        this.history.bindEvents(()=>{
            this.current.value = window.location.hash.slice(1)
        })
    }
   	// 通过Router类install方法注册Router实例
    install(app) {
        app.provide(ROUTER_KEY,this)
    }
}
// 暴露createRouter方法创建Router实例
// 暴露useRouter方法,获取路由实例
export {createRouter,createWebHashHistory,useRouter}

回到src/router/index.js:

import {createRouter, createWebHashHistory} from './grouter/index'

const router = createRouter({
  history: createWebHashHistory(),
  // 使用routes作为页面参数传递给createRouter函数
  routes
})

在createRouter创建的Router实例上,current返回当前路由地址,并用ref包裹成响应式数据。

注册两个内置组件:

  • router-view:就是current变化时,去匹配current地址对应组件,然后动态渲染到router-view。
  • router-link

实现RouterView组件

grouter下新建RouterView.vue。

<template>
 		4. 在template内部使用component组件动态渲染
    <component :is="comp"></component>
</template>
<script setup>

import {computed } from 'vue'
import { useRouter } from '../grouter/index'
// 1. 先用useRouter获取当前路由的实例
let router = useRouter()

// 3. 最后通过计算属性返回comp变量
const comp = computed(()=>{
  	// 2. 通过当前的路由,即router.current.value值,在用户路由配置route中计算出匹配的组件
    const route = router.routes.find(
        (route) => route.path === router.current.value
    )
    return route?route.component : null
})
</script>

实现router-link组件

grouter下新建RouterILink.vue。template是渲染个a标签,只是将其href属性前加个#, 实现hash的修改。

<template>
    <a rel="nofollow" :href="'#'+props.to">
        <slot />
    </a>
</template>

<script setup>
import {defineProps} from 'vue'
let props = defineProps({
    to:{type:String,required:true}
})

</script>

回到grouter/index.js,注册router-link、router-view组件,hash模式mini-vue-router就实现了。

import {ref,inject} from 'vue'
import RouterLink from './RouterLink.vue'
import RouterView from './RouterView.vue'
class Router{
    ....
    install(app){
        app.provide(ROUTER_KEY,this)
        app.component("router-link",RouterLink)
        app.component("router-view",RouterView)
    }
}

vue-router还需处理路由懒加载、路由的正则匹配等。

4 vue-router实战路由匹配

vue-router支持动态路由。某用户页面使用User组件,但每个用户信息不一,需给每个用户配置单独的路由入口,就可按下面代码样式配置路由。

冒号开头的id就是路由的动态部分,同时匹配/user/dasheng和/user/javaedge, 详见 官方文档的路由匹配语法部分

const routes = [
  { path: '/users/:id', component: User },
]

有些页面,仅管理员可访问,普通用户访问提示无权限。得用vue-router的 路由守卫功能 ,即访问路由页面之前进行权限认证,做到页面级控制,只允许某些用户访问。

项目庞大后,如果首屏加载文件太大,就可能影响性能。可用vue-router的 动态导入功能,把不常用的路由组件单独打包,当访问到这个路由的时候再进行加载,这也是vue项目常见优化方式。

5 总结

前后端开发模式演进:前端项目经历的从最初的嵌入到后端内部发布,再到如今前后端分离,也见证了前端SPA发展。

前端路由实现的两种方式,即通过监听不同的浏览器事件,实现hash、history模式。之后,根据这原理,手写vue-router,通过createRouter创建路由实例,并在app.use函数内部执行router-link和router-view组件的注册,最后在router-view组件内部动态的渲染组件。

Vue Router 路由实现步骤

路由配置

import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/user/login.vue')
  },
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue'),
    meta: { requiresAuth: true } // 需要登录权限
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

路由守卫

router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token')
  
  if (to.meta.requiresAuth && !token) {
    // 需要登录但未登录,重定向到登录页
    next({ 
      path: '/login',
      query: { redirect: to.fullPath }
    })
  } else {
    next()
  }
})

登录组件中使用路由

<script setup>
import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()

const login = async () => {
  try {
    await doLogin()
    // 登录成功后跳转
    const redirect = route.query.redirect || '/'
    router.push(redirect)
  } catch (error) {
    handleError(error)
  }
}

const logout = async () => {
  localStorage.removeItem('token')
  router.push('/login')
}
</script>

主应用挂载路由

import { createApp } from 'vue'
import router from './router'
import App from './App.vue'

const app = createApp(App)
app.use(router)
app.mount('#app')

路由视图渲染

<template>
  <router-view></router-view>
</template>

路由流程:

  1. 配置路由表
  2. 设置路由守卫
  3. 组件中注入路由
  4. 应用挂载路由
  5. 视图渲染组件

使用方式:

  • 声明式: <router-link to="/login">
  • 编程式: router.push('/login')

FAQ

60行代码实现hash模式的迷你vue-router,支持history模式的迷你vue-router咋实现?

实现支持 history 模式 的迷你 Vue Router 的核心是利用 HTML5 提供的 pushStatereplaceState API,以及监听 popstate 事件来响应浏览器的回退、前进等操作。以下是支持 history 模式的迷你 Vue Router 的实现步骤:


实现 history 模式的 createWebHistory 方法

src/router/grouter/index.js 中修改或新增以下代码,用于返回 history 模式相关的监听逻辑:

function createWebHistory() {
    function bindEvents(fn) {
        window.addEventListener('popstate', fn);
    }

    function push(url) {
        history.pushState(null, '', url); // 修改浏览器地址但不刷新页面
    }

    return {
        bindEvents,
        push,
        url: window.location.pathname || '/', // 获取当前路径
    };
}

修改 Router 类

扩展 Router 类,支持 history 模式的路由变化处理:

class Router {
    constructor(options) {
        this.history = options.history;
        this.routes = options.routes;
        this.current = ref(this.history.url);

        this.history.bindEvents(() => {
            this.current.value = window.location.pathname;
        });
    }

    // 编程式导航(例如 router.push('/path'))
    push(url) {
        this.history.push(url);
        this.current.value = url;
    }

    install(app) {
        app.provide(ROUTER_KEY, this);
        app.component('router-link', RouterLink);
        app.component('router-view', RouterView);
    }
}

修改 RouterLink 组件

支持 history 模式的 RouterLink 组件不需要 # 前缀,使用编程式导航:

<template>
    <a @click.prevent="navigate">{{ $slots.default() }}</a>
</template>

<script setup>
import { defineProps, inject } from 'vue';

const props = defineProps({
    to: { type: String, required: true },
});

const router = inject('__router__');

function navigate() {
    router.push(props.to);
}
</script>

注册 Vue Router

src/router/index.js 中注册使用 createWebHistory 的路由实例:

import { createRouter, createWebHistory } from './grouter/index';

const routes = [
    { path: '/', component: Home },
    { path: '/about', component: About },
];

const router = createRouter({
    history: createWebHistory(),
    routes,
});

export default router;

配置 Web 服务

要支持 history 模式,需要配置服务器以处理所有的路径。以 Nginx 为例,配置如下:

server {
    listen 80;
    server_name yourdomain.com;

    location / {
        root /path/to/your/app;
        index index.html;
        try_files $uri /index.html;
    }
}

hash V.S history模式

特点 Hash 模式 History 模式
URL 格式 http://example.com/#/path http://example.com/path
浏览器刷新处理 不需后端额外支持 需服务器配置支持
SEO 不友好 更友好
实现复杂度 简单 较复杂

本文已收录在Github关注我,紧跟本系列专栏文章,咱们下篇再续!

作者简介:魔都架构师,多家大厂后端一线研发经验,在分布式系统设计、数据平台架构和AI应用开发等领域都有丰富实践经验。

各大技术社区头部专家博主。具有丰富的引领团队经验,深厚业务架构和解决方案的积累。

负责:

  • 中央/分销预订系统性能优化
  • 活动&券等营销中台建设
  • 交易平台及数据中台等架构和开发设计
  • 车联网核心平台-物联网连接平台、大数据平台架构设计及优化
  • LLM Agent应用开发
  • 区块链应用开发
  • 大数据开发挖掘经验
  • 推荐系统项目

目前主攻市级软件项目设计、构建服务全社会的应用系统。

本文由博客一文多发平台 OpenWrite 发布!