梳理SPA项目Router原理和运行机制 [共2500字-阅读时长10min]

背景

SPA单页面应用实际是只有一个HTML文件,路由的切换都是通过JS动态切换组件的显示与隐藏,MPA应用拥有多个HTML文件。

Vue-Router和React-Router的原理类似,本文通过React-Router举例

路由模式分类

  1. history模式
  1. hash模式

前置知识history

History提供了浏览器会话历史的管理能力。通过history对象,开发者可以:

  1. 通过使用go, backforward在会话历史中导航,目标会话历史条目如果是通过传统方式跳转的,如直接修改window.location.href则会刷新页面。如果是通过pushStatereplaceState修改的则不会修改刷新页面。
  1. 通过使用pushStatereplaceState添加和替换当前的会话历史,不会刷新页面。但是新url和旧url必须是同源的。

pushStatereplaceState是HTML5新特性。

API 定义
history.pushState(data, title [, url]) pushState主要用于往历史记录堆栈顶部添加一条记录 。各参数解析如下:①data 会在onpopstate事件触发时作为参数传递过去;②title 为页面标题,当前所有浏览器都会忽略此参数;③url为页面地址,可选,缺少时表示为当前页地址
history.replaceState(data, title [, url]) 更改当前的历史记录,参数同上; 上面的pushState是添加,这个更改
history.state 用于存储以上方法的data数据,不同浏览器的读写权限不一样
window.onpopstate 修改当前会话历史条目时候,都会触发这个回调函数

注意⚠️的是 :用history.pushState() 或者 history.replaceState() 不会触发popstate事件。(区分出监听路由和改变路由的区别)

hash模式原理

一句话总结:监听url中hash的变化,来做页面组件的显示与隐藏。特点是在url中有#美观程度低。

关于hash需要知道的三点是:

  1. url中的hash变化不会导致页面重新加载。
  1. url中的hash变化会被window.history记录下来,当使用浏览器的页面的前进后退功能,是可以看到url中hash的变化的。
  1. 当通过url向服务端请求资源的时候,url中hash是不会传递给服务端的。hash路由模式是纯前端实现的路由跳转。

改变路由

通过window.location.hash可以获取到urlhash值,对应着项目的页面路径。

监听路由

通过hashchange事件监听urlhash的变化,也就是项目页面路径的变化。

javascript 复制代码
window.addEventListener("hashchange",callback)

history模式原理

一句话总结:利用HTML5推出的pushStatereplaceState改变url路径,而不会触发页面的刷新,通过监听popState事件来实现项目的页面组件切换。

改变路由

1.pushState:向会话历史中压入一个新的会话历史记录,相当于入栈。

2.replaceState:更改当前的会话记录历史 ,相当于更新栈顶元素。

监听路由

popState事件 这个名字起的不好,他是一个监听事件,当会话历史记录发生变化 就会回调出发(前进和后退都会触发),单看名字好像是一个可以主动调用的方法,极具迷惑性。

javascript 复制代码
window.addEventListener("popstate",callback);

改变路由和监听路由的作用

  1. 改变路由 ,开发者通过React-Router提供的组件或者API主动的进行页面切换时使用,强调的是开发者主动。
  1. 监听路由 ,当用户使用手动修改浏览器地址栏中URL,手动点击浏览器的前进后退按钮的方式改变 URL的时候,项目需要监听到这些URL路径改变的操作,从而做出响应,强调的是外部变化,被动响应 。

React-Router基础的路由结构

xml 复制代码
<Router>

  <Switch>

    <Route exact path="/" component={Home} />

    <Route path="/about" component={About} />

    <Redirect from="/old" to="/new" />

    <Route path="/new" component={NewPage} />

    <Route component={NotFound} />

  </Switch>

</Router>

Router组件

javascript 复制代码
// 在 Router 组件内部:

componentDidMount() {

  // 1. 监听 history 变化

  this.unlisten = props.history.listen(location => {

    // 2. URL 变了!更新状态

    this.setState({ location: newLocation });

  });

}

Router组件的主要作用:

  1. 根据选择的路由模式,添加改变路由的监听事件hash采用hashchangehistory模式采用popState事件。
  1. 将变化后的location通过Context传递给子组件

总结 :监听路径的变化,然后将变化的location传递给子组件,相当于做了一层【事件委托】,避免子组件都要进行监听。

Switch组件

ini 复制代码
// Switch 内部逻辑:

// 1. 从 Router 获取最新的 location

const location = context.location; // { pathname: '/about' }



// 2. 按顺序检查每个子组件

React.Children.forEach(children, child => {

  // 检查顺序:

  // 1. <Route exact path="/" />       ❌ 不匹配(不是 exact /)

  // 2. <Route path="/about" />        ✅ 匹配!停止检查

  // 3. <Redirect from="/old" to="/new" />  不检查

  // 4. <Route path="/new" />         不检查

  // 5. <Route component={NotFound} />不检查

});

Switch组件的主要作用是:

  1. 所有的Route组件都要直接放在Switch组件的children
  1. 遍历所有的Route组件,根据从Router组件接收到的新location信息,和Route组件配置的path进行匹配,找到当前URL对应的Route组件

总结 :根据当前的URL找到匹配的Route组件。

Route组件

php 复制代码
// Route 内部逻辑:

// 1. 从 Switch 收到 computedMatch(匹配信息)

const match = this.props.computedMatch;



// 2. 匹配成功,准备渲染

if (match) {

  // 根据配置决定渲染方式:

  if (this.props.component) {

    // 使用 component 方式:创建 About 组件

    return React.createElement(About, {

      history: context.history,

      location: context.location,

      match: match  // { path: '/about', url: '/about', isExact: true }

    });

  }

  // 如果是 render 或 children 方式,也类似

}

Route组件的作用:

根据配置在组件上的参数来决定最终需要渲染的组件。

ini 复制代码
// 场景1:只想在匹配时显示组件
<Route path="/home" component={Home} />


// 场景2:匹配时要显示,但需要传额外参数
<Route 
  path="/user/:id" 
  render={(props) => <User userId={extraId} {...props} />} 
/>

组件结构作用分层

代码层面解析路由跳转

背景示例:

xml 复制代码
// 应用结构

<Router>

  <Switch>

    <Route path="/home" component={Home} />

    <Route path="/about" component={About} />

  </Switch>

</Router>

初始状态,显示Home页面

URL:/home

用户点击链接切换到About

javascript 复制代码
<Link to="/about">About</Link> // 也可以使用redirect和useNavigate

// 点击后调用 history.push('/about')

详细步骤分析

第1步:history.push 改变 URL

php 复制代码
// history.push 内部简化代码:

function push(path) {

  // 1. 改变浏览器 URL(不刷新页面)

  window.history.pushState({}, '', path);

  

  // 2. 创建新的 location 对象

  const location = createLocation(path);

  

  // 3. ✅ 关键:调用 setState

  setState({

    location: location,

    action: 'PUSH'

  });

}

第2步:setState 的内部操作

javascript 复制代码
// setState 函数内部:

function setState(nextState) {

  // 1. 更新 history 内部的状态

  Object.assign(history, nextState);

  

  // 2. ✅ 核心:通知所有监听器

  listeners.forEach(listener => {

    // 每个 listener 都是一个回调函数

    listener(history.location, history.action);

  });

}

第3步:Router 监听器被调用

kotlin 复制代码
// 在 Router 组件构造函数中:

this.unlisten = props.history.listen(location => {

  // ✅ 当 setState 通知监听器时,这个函数被调用

  this.setState({ location: location });

});

第4步:Router 的 setState 触发重新渲染

scala 复制代码
// React 内部:当调用 this.setState 时

class Router extends React.Component {

  this.setState({ location: newLocation }, () => {

    // setState 完成后,React 会自动调用 render 方法

    this.render();

  });

  

  render() {

    // ✅ Router 重新渲染,使用新的 location

    return (

      <RouterContext.Provider value={{

        history: this.props.history,

        location: this.state.location, // ✅ 这里是新的 location

        match: /* ... */

      }}>

        {this.props.children}

      </RouterContext.Provider>

    );

  }

}

第5步:Context 值变化触发子组件更新

ini 复制代码
// Switch 组件内部:

<RouterContext.Consumer>

  {(context) => {

    // ✅ context.location 现在是新的 location

    const location = context.location; // { pathname: '/about' }

    

    // Switch 重新计算匹配

    let match = null;

    React.Children.forEach(this.props.children, child => {

      if (match == null) {

        // 检查每个 Route 是否匹配

        const path = child.props.path;

        if (path === '/about') {

          match = true; // ✅ 匹配!

        }

      }

    });

    

    // 渲染匹配的 Route

    if (match) {

      return React.cloneElement(foundChild, {

        location,

        computedMatch: match

      });

    }

  }}

</RouterContext.Consumer>

第6步:Route 组件渲染新页面

kotlin 复制代码
// Route 组件收到新的 match 后:

render() {

  if (this.props.computedMatch) {

    // ✅ 匹配成功,渲染对应的组件

    return React.createElement(this.props.component, {

      history: context.history,

      location: context.location,

      match: this.props.computedMatch

    });

  }

  return null; // 不匹配的 Route 不渲染

}

JavaScript

****从 setState 到 DOM 更新的完整链条

markdown 复制代码
// 完整的调用链条:

history.setState()

    ↓

调用 Router 的监听函数 (listener)

    ↓

Router.setState({ location })

    ↓

React 调度 Router 重新渲染

    ↓

Router.render() 调用

    ↓

Context.Provider value 更新

    ↓

Switch (Consumer) 检测到变化

    ↓

Switch.render() 重新计算匹配

    ↓

匹配的 Route 重新渲染

    ↓

Route 创建/更新组件实例

    ↓

组件的 render 方法调用

    ↓

React 更新 Virtual DOM

    ↓

ReactDOM 更新实际 DOM

    ↓

页面显示新内容

一句话总结:setState 通过改变状态,触发 React 的重新渲染机制,配合 Context 将变化传播到整个组件树,最终实现页面的无缝切换。

为什么History模式需要服务端的支持,而Hash模式不需要

在页面刷新的时候,相当于使用浏览器地址栏中的URL发送了一次GET请求,请求项目的HTML文件。Hash模式下,路由跳转是通过Hash值变化来实现的,而在请求的时候Hash是不会传递给服务端的,所以使用www.baidu.com#/123向服务端请求资源和www.baidu.com 是等价的。所以能被nginx配置的静态资源代理拦截并正常匹配返回HTML文件

而History模式,则是通过改变URL的路径来实现路由跳转的,使用www.baidu.com/abc和使用www.baidu.com/123 向服务端请求资源是完全不同的。此时nginx配置的静态资源代理拦截到这个请求后会去服务器找/abc/123路径中的资源,但是肯定是找不到的,所以会返回404给浏览器,要解决这个404的问题就在nginx加一个404找不到,重定向到默认HTML文件的配置。

本文参考:

  1. 「源码解析 」这一次彻底弄懂react-router路由原理 个人理解,单页面应用是使用一个html下,一次性加载js, - 掘金
  2. 浅谈前端路由原理hash和history🎹序言 众所周知, hash 和 history 在前端面试中是很常考的一道题 - 掘金
相关推荐
粥里有勺糖8 小时前
视野修炼-技术周刊第128期 | Bun 被收购
前端·javascript·github
用户12039112947268 小时前
彻底搞定大模型流式输出:从二进制碎块到“嘚嘚嘚”打字机效果,让底层逻辑飞起来
前端·javascript·面试
CPU NULL8 小时前
Vue 3 前端调试与开发指南
前端·javascript·vue.js
2401_860494708 小时前
React Native鸿蒙跨平台开发:error SyntaxError:Unterminated string constant.解决bug错误
javascript·react native·react.js·ecmascript·bug
幼儿园技术家9 小时前
多方案统一认证体系对比
前端
十一.3669 小时前
83-84 包装类,字符串的方法
前端·javascript·vue.js
over69710 小时前
深入解析:基于 Vue 3 与 DeepSeek API 构建流式大模型聊天应用的完整实现
前端·javascript·面试
用户40993225021210 小时前
Vue3计算属性如何通过缓存特性优化表单验证与数据过滤?
前端·ai编程·trae
接着奏乐接着舞10 小时前
react useMeno useCallback
前端·javascript·react.js