深入浅出 SPA/MPA

概述

在 Web 应用架构设计中,单页应用(SPA)与多页应用(MPA)是目前两种主流的前端架构,他们各自适用于不同的业务场景。

作为一个前端开发,理解这两种模式的核心原理、技术实现及优劣势,对于我们未来选择合适的架构方案、优化应用性能有着非常重要的参考价值。

应用架构

单页和多页两种架构的差异,主要体现在页面和交互方式上。

单页应用

SPA,即 Single Page Application,是指整个应用仅以一个 HTML 页面为基础,所有功能模块和页面视图都在这个页面中动态渲染的 Web 应用(即通过JS 脚本实现dom的挂载等操作,让你的页面实现动态效果)。

再 SPA初始加载时,应用会一次性加载核心的 HTML、CSS 和 JavaScript 资源,后续的页面切换、数据更新等交互操作,都通过 JavaScript 动态修改 DOM 内容来完成,无需重新向服务器请求新的 HTML 文件(非核心、必要的资源,可以在后续使用到的时候自动拉取)。

核心特征

  1. 应用的生命周期内仅有一个 HTML 页面的存在,页面切换不会导致浏览器刷新。
  2. URL 的变化通过前端路由机制实现,不触发完整的页面加载流程(即HTML渲染过程)。
  3. 数据交互通过 ajax、Fetch 等异步请求方式完成,仅更新页面局部内容。

路由机制

路由机制决定了URL 变化如何对应到页面内容的更新,是两种应用模式最核心的差异。

单页应用的路由完全由前端控制,不需要服务器参与 URL的解析,路由核心原理是通过监听URL的变化,在前端生成对应的DOM结构渲染到指定位置处,实现主要有两种:

  1. Hash 模式
    利用 URL 中的 #,这个字符在浏览器中视为哈希[1],在这个字符后面的部分作为路由标识,这部分标识内容仅在客户端中有效,不会被发送到服务端,同时,我们可以通过 window.onhashchange 事件去监听哈希值的变化,然后生成对应的页面内容。
html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Hash 路由示例</title>
</head>
<body>
  <!-- 路由链接:点击可切换 hash -->
  <a href="#/home">首页</a> | 
  <a href="#/about">关于我们</a>

  <!-- 路由内容展示容器 -->
  <div id="app" style="margin-top: 20px; padding: 20px; border: 1px solid #ccc;"></div>

  <script>
    // Hash路由实现示例
    class HashRouter {
      constructor() {
        this.routes = {}; // 存储路由与对应视图渲染函数的映射
        this.init();
      }

      // 注册路由
      register(path, callback) {
        this.routes[path] = callback;
      }

      // 初始化路由监听
      init() {
        // 监听hash变化事件
        window.addEventListener('hashchange', () => {
          const currentPath = window.location.hash.slice(1) || '/';
          // 执行对应路由的渲染函数
          this.routes[currentPath]?.();
        });
        // 初始加载时触发一次路由处理
        window.dispatchEvent(new Event('hashchange'));
      }
    }

    // 使用示例
    const router = new HashRouter();
    router.register('/home', () => {
      document.getElementById('app').innerHTML = '<h1>首页内容</h1><p>这是首页的详细内容...</p>';
    });
    router.register('/about', () => {
      document.getElementById('app').innerHTML = '<h1>关于我们</h1><p>这是关于页面的详细内容...</p>';
    });
    // 可以添加一个默认路由(当hash为空时显示)
    router.register('/', () => {
      document.getElementById('app').innerHTML = '<h1>请选择一个页面</h1><p>点击上方链接切换页面</p>';
    });
  </script>
</body>
</html>

Hash 模式的优点是兼容性很好,支持所有现代浏览器,且不需要服务器特殊配置,但缺点也很容易看到,URL 里面有 # ,不好看,而且有时候路由没有实现好,刷新页面的时候,这个 # 会到处跑。

  1. History 模式
    基于 H5 标准提供的 History API ,主要是 pushState 和 replaceState 方法,通过修改浏览器的历史记录来改变 URL 而不触发页面刷新。同时,通过 popstate 事件监听浏览器的前进、后退操作,实现路由切换。
html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>History 路由示例</title>
  <style>
    .nav { margin: 20px 0; }
    button { margin-right: 10px; padding: 8px 16px; cursor: pointer; }
    #app { margin-top: 20px; padding: 20px; border: 1px solid #ccc; min-height: 100px; }
  </style>
</head>
<body>
  <!-- 路由切换入口:按钮或链接 -->
  <div class="nav">
    <button id="homeBtn">首页</button>
    <button id="aboutBtn">关于我们</button>
    <button id="userBtn">用户中心</button>
    <!-- 也可以用链接,但需要阻止默认跳转行为 -->
    <a href="/home" class="link">首页(链接)</a> |
    <a href="/about" class="link">关于我们(链接)</a>
  </div>

  <!-- 路由内容展示容器 -->
  <div id="app"></div>

  <script>
    // History路由实现示例
    class HistoryRouter {
      constructor() {
        this.routes = {}; // 存储路由与渲染函数的映射
        this.init();
      }

      // 注册路由:路径 -> 渲染函数
      register(path, callback) {
        this.routes[path] = callback;
      }

      // 手动跳转路由(修改URL并渲染)
      push(path) {
        // 用pushState修改URL,不触发页面刷新
        history.pushState({ path }, null, path);
        // 执行对应路由的渲染函数
        this.routes[path]?.();
      }

      // 初始化:监听历史记录变化(前进/后退按钮)
      init() {
        // 监听popstate事件(浏览器前进/后退时触发)
        window.addEventListener('popstate', (e) => {
          const path = e.state?.path || '/'; // 从状态中获取路径,默认 '/'
          this.routes[path]?.(); // 渲染对应内容
        });

        // 初始加载时渲染默认路由
        this.routes['/']?.();
      }
    }

    // 使用示例
    const router = new HistoryRouter();

    // 注册路由与对应渲染函数
    router.register('/', () => {
      document.getElementById('app').innerHTML = `
        <h1>首页</h1>
        <p>这是默认页面,点击上方按钮或链接切换路由</p>
      `;
    });

    router.register('/home', () => {
      document.getElementById('app').innerHTML = `
        <h1>首页内容</h1>
        <p>这里是首页的详细内容...</p>
      `;
    });

    router.register('/about', () => {
      document.getElementById('app').innerHTML = `
        <h1>关于我们</h1>
        <p>这里是关于页面的详细内容...</p>
      `;
    });

    router.register('/user', () => {
      document.getElementById('app').innerHTML = `
        <h1>用户中心</h1>
        <p>这里是用户中心的详细内容...</p>
      `;
    });

    // 绑定按钮点击事件(触发路由跳转)
    document.getElementById('homeBtn').addEventListener('click', () => {
      router.push('/home');
    });
    document.getElementById('aboutBtn').addEventListener('click', () => {
      router.push('/about');
    });
    document.getElementById('userBtn').addEventListener('click', () => {
      router.push('/user');
    });

    // 绑定链接点击事件(阻止默认跳转,用路由处理)
    document.querySelectorAll('.link').forEach(link => {
      link.addEventListener('click', (e) => {
        e.preventDefault(); // 阻止链接默认跳转行为
        const path = link.getAttribute('href'); // 获取链接的路径
        router.push(path); // 用路由跳转
      });
    });
  </script>
</body>
</html>

History 模式的优势是 URL 看起来不会那么奇怪,比较符合用户的使用习惯,但缺点是兼容性相对弱一些(但到现在了,其实可以忽略了,除非你想要兼容 IE),并且当用户直接访问非根路径的 URL 时,服务器需要将请求转发到单页应用的入口 HTML 文件,否则会返回 404 错误。

资源加载

单页应用采用初始拉取核心资源+按需获取其他资源的模式加载资源。

  • 初始拉取核心资源

    应用首次加载时,会下载一个基础的 HTML 文件(主要包含一个用于挂载应用的 DOM 节点,如 <div id="app"></div> )、全局 CSS 样式和打包后的 JavaScript 文件(该文件包含应用的核心逻辑、路由配置和所有页面组件)。

  • 按需获取其他资源

    在应用运行过程中,动态 import 相关组件代码,或者创建 <script><link> 来获取js和css资源。

    单页应用的数据是可以复用的,所有的页面都共用同一份数据,如用户信息存在 window.userInfo 后,所有的页面都可以这么去读取。

    这种加载方式的优势是公共资源仅需加载一次,减少了重复的网络请求,但缺点也很明显,即在初始加载时需要下载较大的 JavaScript 文件,可能导致首屏加载时间过长。

页面渲染

单页应用是使用客户端渲染方式:

  1. 访问应用时,服务器总是返回同一个初始的HTML。
  2. 初始的 HTML 文件的dom结构中仅提供一个应用挂载点。
  3. 应用加载后,JavaScript脚本根据路由生成对应的DOM结构,append到挂载点中。
  4. 页面数据变化或路由变化,仅会更新变化部分的结点(其实是所有变化的结点的最小公共祖先结点),避免重绘整个页面
  5. 整个过程都在浏览器中完成,仅数据从服务端中拉取。

这种渲染方式的优势是页面切换和数据更新时响应迅速,用户体验流畅。缺点则是首屏渲染需要等待 JavaScript 加载并执行完成,可能导致首屏加载时间较长,且对搜索引擎爬取内容不够友好。

适用场景

  • 交互密集型应用:如管理后台系统、CRM 系统、在线编辑器等应用用户交互频繁,需要流畅的操作体验。
  • 类原生 App 体验的应用:如 Web 版聊天工具、在线游戏、音乐播放器等需要模拟原生应用的操作体验的应用。
  • 移动端 H5 应用:在移动端网络环境不稳定的情况下,单页应用可以减少网络请求,节省流量,提升用户体验。

多页应用

MPA,即 Multiple Page Application,是由多个独立的 HTML 页面组成的 Web 应用,每个页面对应一个唯一的 URL。用户在页面间导航时,浏览器会向服务器请求对应 URL 的 HTML 文件,服务器返回完整的页面内容后,浏览器销毁当前页面的 DOM 结构,重新解析并渲染新页面。

核心特征

  1. 每个功能模块或页面视图对应一个独立的 HTML 文件。
  2. 页面切换通过传统的链接跳转或表单提交实现,会触发浏览器的全页刷新。
  3. 服务器需要为每个页面生成完整的 HTML 内容,包含页面所需的结构、样式和脚本引用。

路由机制

多页应用的路由由服务器控制,URL 直接映射到服务器上的 HTML 文件路径。当用户点击链接或提交表单时,浏览器向服务器发送对应 URL 的请求,服务器根据 URL 查找并返回相应的 HTML 文件,浏览器接收后重新渲染整个页面。

一般我们用 nginx 做相关的路由配置(但也可以用一个服务去处理不同的 URL 请求),示例如下:

nginx 复制代码
server {
  listen 80;
  root /var/www/html;

  # 访问根路径时返回index.html
  location / {
    try_files $uri $uri/ /index.html;
  }

  # 访问/about路径时返回about.html
  location /about {
    try_files $uri $uri/ /about.html;
  }

  # 访问/contact路径时返回contact.html
  location /contact {
    try_files $uri $uri/ /contact.html;
  }
}

这种路由方式简单直接,不需要在前端处理复杂的路由逻辑,但页面切换时一定会重新请求服务器,相对单页应用会存在一些延迟。

资源加载

每个页面的资源独立加载,即每个页面都需要加载自身对应的 HTML 文件、CSS 样式表和 JavaScript 脚本,即使一个资源在不同的页面上可以复用,在切换页面的时候,这些资源还是会重新拉取(这里不考虑浏览器缓存),会存在重复的资源请求。

此外,由于每个页面都是独立的,所以类似于 SPA 的数据共用就没办法做到,需要每次都重新获取。

页面渲染

  1. 根据URL,在服务器找到对应的 HTML 返回到浏览器。
  2. 浏览器解析渲染当前页面。
  3. 页面切换时,重新请求HTML并解析,同时销毁当前页面,即便页面的大部分布局都是一样的,也需要重新渲染。

但要注意的是,MPA的某个页面可以是SPA,在这个SPA的页面下,渲染逻辑还是遵循SPA的逻辑。

MPA的渲染方式的优势是首屏加载速度快,搜索引擎可以直接爬取页面内容,有利于 SEO,缺点是页面切换时需要重新渲染整个页面,可能出现白屏或闪烁,用户体验不够流畅。

适用场景

  • 内容展示型网站:如新闻网站、博客、电商商品页等,这类网站以内容展示为主,对 SEO 要求高很高。
  • 用户访问路径简单的应用:如企业官网、营销页面等,用户通常只需要浏览少数几个页面;
  • 对首屏加载速度要求高的应用:如活动页面、推广页面等,需要快速展示核心内容,吸引用户;
  • 小型项目或原型系统:开发周期短,无需复杂的架构设计,适合快速上线。

总结

虽然SPA 和 MPA 是两种架构,但是随着技术发展,这两种架构的边界变得模糊,并非选了 SPA 就不能选MPA,有时候我们会混合使用,架构的选型,永远是对用户体验、性能、开发效率以及SEO等内容的平衡,也是对业务场景的适配。

最后,这里汇总成一张表格,让你方便的看清两种架构的异同:

对比维度 单页应用(SPA) 多页应用(MPA)
架构本质 仅 1 个 HTML 页面作为载体,所有视图通过 JS 动态渲染 多个独立 HTML 页面,每个页面对应 1 个 URL,页面切换需重新请求 HTML
页面载体 单一入口 HTML(如index.html) 多个独立 HTML 文件(如home.html、about.html、user.html)
路由控制 前端 JS 控制(Hash 模式:URL 含#;History 模式:依赖 HTML5 History API) 服务器控制(URL 直接映射服务器上的 HTML 文件路径,如/home对应/home.html)
页面切换体验 无刷新切换(仅局部 DOM 更新),无白屏 / 闪烁,体验接近原生 App 全页刷新(浏览器销毁旧 DOM、重新解析新 HTML),可能出现白屏或闪烁
资源加载逻辑 初始加载:核心 JS/CSS 一次性加载;后续切换:仅加载当前路由组件(代码分割)+ 异步数据 每个页面独立加载:HTML + 当前页面专属 CSS/JS,公共资源(如 jQuery)需重复加载(依赖缓存)
首屏加载速度 较慢(需加载大量 JS/CSS,首屏渲染依赖 JS 执行) 较快(仅加载当前页面资源,服务器返回完整 HTML 可直接渲染)
后续交互速度 较快(公共资源已缓存,仅需请求数据 + 局部更新) 较慢(页面切换需重新加载 HTML/CSS/JS,即使公共资源也需验证缓存)
SEO 友好度 较差(初始 HTML 无实际内容,依赖 JS 渲染,搜索引擎爬虫可能无法抓取内容) 较好(服务器返回完整 HTML,包含真实内容,搜索引擎可直接爬取)
状态管理 需前端维护全局状态(如 Vuex、Redux),页面切换不丢失状态 状态绑定在当前页面,页面切换后状态重置(如需保存需依赖 Cookie/LocalStorage)
开发复杂度 较高(需处理路由、状态管理、代码分割等,依赖前端框架如 Vue/React) 较低(无需复杂前端架构,可直接用 HTML+CSS + 原生 JS,或后端模板如 PHP/JSP)
兼容性 依赖现代浏览器 API(如 History API),对老旧浏览器(如 IE8 及以下)兼容性差 兼容性好,支持老旧浏览器,无需依赖高级 JS API
适用场景 交互密集型应用(管理后台、在线编辑器、Web 版 App)、移动端 H5 应用 内容展示型应用(新闻网站、电商商品页、企业官网)、对 SEO 要求高的场景