跳转至

16 单页面应用:如何理解和实现单页面应用开发?

你好,我是杨文坚。

今天,我们进入项目功能页面的开发。上一讲说过,运营搭建平台,后台核心功能是搭建功能。不过,搭建功能并不是简单的一个网页就能承载的。

因为搭建功能不是单一的功能点,而是一整个功能链路,包括:搭建的物料注册、物料列表管理、物料详情等物料管理的功能点,搭建页面的布局设计、搭建结果列表、搭建发布流程、搭建版本等搭建管理功能点,还有一些管理权限的控制功能点。最终涉及的功能链路,会是很庞大的功能点集合。

同时,每个功能点基本都是需要一个网页来承载,这就需要多个网页来覆盖整个搭建后台的功能链路。

现在问题来了,一个项目的后台功能链路,页面非常多,而且,操作一个功能链路时,要跳转多个页面,每次跳转都要重新经历HTML页面请求响应、JavaScript / CSS /图片等静态资源加载、服务端接口加载,会经历比较长时间的等待。

那么,有没有方式可以减少页面跳转时页面上HTTP的请求,提升不同页面在跳转或者切换过程中的加载和渲染速度呢?答案是有的,我们可以把功能链路的多页面形式变成单页面形式,也就是所谓的单页面应用。

什么是单页面应用?

单页面应用,英文的专业术语是Single-Page-Application,简称“SPA”,和“多页面应用”相对。所以,单页面应用一般要和多页面对比理解,我们来看一看。

在传统的多页面应用中,每次切换页面的跳转过程,一般分成五个阶段:

  • 第一阶段,浏览器URL改变发起新页面的HTTP请求;
  • 第二阶段,服务端接受到请求,通过路由响应请求的HTML结果;
  • 第三阶段,浏览器接受到HTML响应并加载HTML结果;
  • 第四阶段,浏览器等待加载HTML里依赖的资源(JavaScript、CSS、图片等文件);
  • 第五阶段,HTML页面静态资源加载完后,渲染对应内容。

而单页面应用,每次切换页面的过程,可以缩短成两个执行阶段:

  • 第一阶段,浏览器URL改变,发起新页面静态资源请求(JavaScript、CSS、图片等文件);
  • 第二阶段,新页面资源加载完后,渲染对应内容。

所以,在单页面应用跳转新页面过程中,其实并没有发起完整的新HTML页面请求,只是加载对应的资源,渲染修改指定的页面内容。所以,相比多页面应用,单页面应用,减少了服务端路由操作,也就减少了多个HTTP请求和响应的过程,最后就缩短了等待时间。

这就说明,单页面应用是通过浏览器层面控制URL,代替服务端路由进行页面跳转(或切换)的控制处理,减少HTTP请求,提升页面切换速度。这里,浏览器控制URL变化的能力,叫做浏览器路由,也叫前端路由,跟多页面应用的服务器路由是相对的。所以,单页面应用的核心能力就是浏览器路由能力。

那么浏览器路由的原理是什么?或者说要实现浏览器路由,有哪些技术方案呢?

实现浏览器路由有哪些技术方案?

浏览器路由的功能,就是当浏览器要控制URL变化时,保持页面不会重新请求HTTP加载。那么,浏览器有哪些特性可以实现类似的功能呢?

目前,浏览器实现类似功能有两种方式,一种是URL的hash值控制,另外是浏览器的history控制

先来看URL的hash控制方案。这里说的URL的hash,就是URL末尾带上#字符开头的字符,例如下述链接里,#title-123 就是URL里的hash值。

https://example.com/page/xxx.html?a=1&b=2#title-123

URL的hash,传统的作用是来标记页面某个位置的锚点。比如页面某个子标题的位置标记了这个hash值,例如“title-123”,URL带上这个hash值后,#title-123 会自动滑动到子标题位置。

不过,当页面不存在这个hash值标记的锚点位置时,URL带上的这个hash就没有什么作用了。即使强行修改URL这个hash值,只要页面不存在锚点位置,页面就不会发生任何变化,也不会发生任何页面跳转。

这时候,我们就可以利用这个URL的hash非锚点特性。当hash变化时候,用JavaScript监听这个变化,然后加载指定资源渲染页面,不需要重新请求服务端刷新整个页面。

怎么基于hash实现浏览器路由呢?看代码:

const viewDom = document.querySelector('#view');
const linksDom = document.querySelector('#links');
const viewMap = {
  '#/': '<h1>Default View</h1>',
  '#/home': '<h1>Home View</h1>',
  '#/about': '<h1>About View</h1>',
  '#/page/hello': '<h1>Page Hello View</h1>',
  '#/page/001': '<h1>Page 001 View</h1>',
  '#/page/002': '<h1>Page 002 View</h1>',
  '#/404': '<h1>404 View</h1>'
};

const renderLinks = () => {
  const linkList = Object.keys(viewMap);
  const htmlList = [];
  linkList.forEach((link) => {
    htmlList.push(
      `<li><a class="${
        link === location.hash ? 'active' : ''
      }" href="${link}">${link}</a></li>`
    );
  });
  linksDom.innerHTML = htmlList.join('');
};

const renderView = () => {
  // 获取当前URL的hash
  // 渲染对应hash的页面内容
  const hash = window.location.hash;
  const viewPath = hash.split('?')[0];
  const viewHtml = viewMap[viewPath] || viewMap['#/404'];
  viewDom.innerHTML = viewHtml;
};

window.addEventListener('hashchange', () => {
  // 监听URL的 hash 变化时候
  // 重新渲染页面内容
  renderView();
  renderLinks();
});

renderLinks();
renderView();

配套HTML代码:

<html>
  <head>
    <meta charset="utf-8" />
    <style>
      html,body { margin: 0; padding: 0 }
      #links {
        list-style: none;
        margin: 0;
        padding: 0 10px;
        flex-direction: row;
        display: flex;
        background: #3b889f;
      }
      #links li a {
        display: inline-block;
        padding: 10px 20px;
        font-size: 20px;
        color: #ffffff;
        text-decoration: none;
      }
      #links li a.active {
        background: #ffffff;
        color: #3b889f;
      }
      #view {
        padding: 20px;
        margin: 0;
      }
    </style>
  </head>
  <body>
    <ul id="links"></ul>
    <div id="view"></div>
    <script type="module" src="./spa-hash-router.js"></script>
  </body>
</html>

代码最终的实现效果如动图所示:

我们在切换页面操作时候,只修改URL的hash变化,在控制台的请求面板上并不会触发页面刷新的请求。这就是基于URL的hash值,来实现的浏览器路由能力。

不过,估计你也发现了,URL加了hash后有点长,不怎么优雅。那有更优雅的实现方式吗?

这时,我们就要提到另外一个浏览器路由实现方式了,基于浏览器的history特性,它也可以自定义浏览器URL变化,同时保持页面不会重新刷新加载。

history,顾名思义就是操作浏览器的访问历史。在浏览器里,访问历史就是记录或者控制URL的变化。按这个思路,history能通过新增一个“访问历史”来影响当前浏览器的URL变化,同时,这个变化的URL也是可以自定义的。

所以,我们可以通过history来实现浏览器路由,看代码:

const viewDom = document.querySelector('#view');
const linksDom = document.querySelector('#links');
const viewMap = {
  '/': '<h1>Default View</h1>',
  '/home': '<h1>Home View</h1>',
  '/about': '<h1>About View</h1>',
  '/page/hello': '<h1>Page Hello View</h1>',
  '/page/001': '<h1>Page 001 View</h1>',
  '/page/002': '<h1>Page 002 View</h1>',
  '/404': '<h1>404 View</h1>'
};

const renderLinks = () => {
  const linkList = Object.keys(viewMap);
  const htmlList = [];
  linkList.forEach((link) => {
    htmlList.push(
      `<li>
        <a class="${
          link === location.pathname ? 'active' : ''
        }" href="javascript:void(0)" data-href="${link}">${link}</a>
      </li>`
    );
  });
  linksDom.innerHTML = htmlList.join('');
};

const renderView = () => {
  const viewPath = window.location.pathname;
  const viewHtml = viewMap[viewPath] || viewMap['#/404'];
  viewDom.innerHTML = viewHtml;
};

// 人工注册history路由的pushState和replaceState监听
// 因为浏览器原生不支持
function registerHistoryListener(type) {
  const originFunc = window.history[type];
  const e = new Event(type);
  return function () {
    const result = originFunc.apply(this, arguments);
    e.arguments = arguments;
    window.dispatchEvent(e);
    return result;
  };
}

// 人工实现history路由的pushState事件监听
history.pushState = registerHistoryListener('pushState');
// 人工实现history路由的replaceState事件监听
history.replaceState = registerHistoryListener('replaceState');

// 监听history路由pushState
window.addEventListener('pushState', () => {
  renderLinks();
  renderView();
});
// 监听history路由replaceState
window.addEventListener('replaceState', () => {
  renderLinks();
  renderView();
});

linksDom.addEventListener('click', (e) => {
  // 链接点击之间时候
  // 拦截事件,然后做 history跳转操作
  const dom = e.target;
  const dataHref = dom.getAttribute('data-href');
  if (dataHref) {
    history.pushState({}, '', dataHref);
  }
});

renderLinks();
renderView();

代码的最终实现效果,如动图所示:

两种浏览器路由的实现原理和效果,我们都掌握了。该如何选择技术方案呢?

同样是浏览器路由的功能,技术原理不一样,就应该存在一定的技术差异和使用场景差异。

在实际开发中,hash路由和history路由最大的差异就是浏览器兼容的差异。hash路由是利用浏览器传统的URL hash锚点能力,这个特性,绝大部分浏览器都兼容,具体你可以通过 https://caniuse.com/ 这个网站来查询:

你看这张图片,连IE8这类浏览器都能兼容hash特性。但history特性的浏览器兼容性就比较弱了:

两种浏览器路由的兼容性差异,也导致了两者适用场景并不一样。如果单页面应用的业务需求要兼容IE8等低版本浏览器,那你就只能选择hash路由,如果对浏览器兼容要求不高,两者都可以。

除了浏览器这个兼容性差异,它们对页面所在服务端的要求也不同。hash路由只改变URL的hash值,当前页面强制浏览器刷新时,还能保持在当前页面。但是,history路由是真的改变了浏览器URL的路径path,当前页面强制浏览器刷新时,要走一遍服务器请求,查询当前URL是否存在,所以要保持history路由改变的URL存在,服务器路由就必须跟前端路由同步。

说了这么多,我们简单总结下,用原生JavaScript API实现的浏览器路由,达到实现单页面应用的功能,大概有这几个核心原理:

  • 利用hash或者history进行无刷新修改浏览器URL;
  • 监听URL变化,执行页面内容更新或切换;
  • 如果用history路由,要考虑服务器路由的同步和浏览器的兼容。

看到这里,你是不是觉得单页面应用的实现很简单?但是同样的,实际开发项目中,光靠这三点原理是满足不了实际功能需求的。

实际功能需求比较复杂多样,比如,要求在页面切换时,传递数据到下一个页面,如何设计这个浏览器路由的数据传递呢?再比如,切换的页面不存在,如何做统一的404页面呢?这些都需要做很多前端路由的封装工作,不是光靠我们前面的几句代码就能实现的。

那么,在Vue.js 3.x项目里,我们如何实现完善的单页面应用呢?

Vue.js 3.x如何实现完善的单页面应用?

Vue.js 3.x 官方提供了一个前端路由的模块 vue-router,方便开发者使用Vue.js开发单页面应用。接下来,我就给你演示一下如何基于 vue-router,实现常见的单页面应用的需求场景。

常见的功能场景主要有这么四种,我们会通过vue-router逐一实现:

  • hash路由(面向兼容IE8等低版本浏览器)
  • history路由(面向高版本浏览器和优雅URL路由)
  • 动态路由和嵌套路由(动态URL和多级URL)
  • 主动路由跳转(代码主动触发页面跳转)

1. hash路由

以hash路由形式,实现基本的单页面应用功能,具体代码如下所示:

import { createRouter, createWebHashHistory } from 'vue-router';
import { createApp } from 'vue';
// 主应用
import App from './app.vue';

// 不同路由的页面视图
import HomeView from './views/home.vue';
import ListView from './views/list.vue';
import AboutView from './views/about.vue';

// 定义路由
const router = createRouter({
  linkActiveClass: 'active',
  // hash 路由配置
  history: createWebHashHistory('/'),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/list',
      name: 'list',
      component: ListView
    },
    {
      path: '/about',
      name: 'about',
      component: AboutView
    }
  ]
});

const app = createApp(App);
app.use(router);

app.mount('#app');

主应用 app.vue 的配置,代码如下:

<template>
  <div class="app">
    <header class="header">
      <RouterLink to="/">Home</RouterLink>
      <RouterLink to="/list">List</RouterLink>
      <RouterLink to="/about">About</RouterLink>
    </header>
    <main class="content">
      <RouterView />
    </main>
  </div>
</template>

<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router';
</script>

代码中, RouterLink 是vue-router提供的单页面应用链接组件,RouterView是对应链接的视图,更多使用概念你可以查看官网。

具体的执行过程就是,创建hash路由到注册路由页面,再挂载到前端应用上。

2. history路由

我们再切换成history的方式看看。vue-router统一封装了hash路由和history路由的使用方式,开发者只要修改路由的选择方式,就可以直接切换两种路由形式:

// 定义路由
const router = createRouter({
  // 其它代码 ...  

  // hash 路由配置
  // history: createWebHashHistory('/'),

  // history 路由配置
  history: createWebHistory('/'),

  // 其它代码 ...  
}

我们前面说过,history路由需要浏览器路由做兼容的要求,因为今天的代码例子是基于Vite来做的开发服务,所有页面请求都在Vite开发服务器中,默认指向项目页面的index.html页面,这样等于变相实现了服务端路由对history前端路由URL的同步。

3. 动态路由和嵌套路由

至于动态路由和嵌套路由,vue-router 提供了很丰富的API,让开发者可以注册动态路由和路由嵌套,也就是在路由上携带参数和多级URL处理。

看路由配置代码,我用注释指明了动态路由和嵌套路由。动态路由是用“:”开头的变量,来标明路由中的动态数据。嵌套路由,是在路由里使用children来承载一个新的路由数组,作为当前路径后“接上”的子路由:

// 定义路由
const router = createRouter({
  // 其它配置 ...
  routes: [
    // 动态路由
    {
      path: '/detail/:id',
      component: DetailView,

      // 嵌套路由
      children: [
        { path: '', component: DetailItemView }
      ]
    }
  ]

这里,动态路由的“:id”数据,可以在对应View的组件里通过 $route.params.id 拿到对应数据,例如 /detail/A001 可以得到 $route.params.id = "/detail/A001"。同时,动态路由里的子路由页面,会渲染内部的component组件。

4. 主动路由跳转

我们再看最后一个常见功能需求,主动路由跳转。

虽然vue-router提供了 RouterLink 组件来承载路由连接,但是这个组件需要人为点击才能触发路由跳转,无法实现代码主动触发跳转。所以, vue-router 又提供另外一个 API,也就是useRouter 让开发者能主动操作路由实体,例如可以触发路由跳转等。

具体代码实现如下所示:

import { useRouter } from 'vue-router';

const router = useRouter();

const onClick = () => {
  router.push('/list');
};

上面这四个场景,覆盖了大部分的单页面应用业务需求场景。

不过,我们的单页面应用实现的还不是很完善。你有没有发现,上面所有的单页面应用里的“子页面”,都是跟“主页面”的代码耦合在一起,也就是说,前端项目打包时,所有子页面代码都会一起打包进去。如果我们后续扩展了更多子页面,也是直接打包进主页面代码,这会导致单页面应用构建结果体积非常大。

那怎么做才能优雅地扩展单页面应用,不导致主页面构建结果体积过大呢?

如何优雅扩展的单页面应用?

答案就是按需加载。我们可以把每个子页面资源独立打包,当主页面切换到子页面时,按需加载对应子页面的JavaScript和其他资源。

用到的技术也十分简单,利用ES Module的动态import模块的能力,通过import来动态加载模块。具体实现代码如下所示:

const router = createRouter({
  linkActiveClass: 'active',
  // hash 路由配置
  // history: createWebHashHistory('/'),

  // history 路由配置
  history: createWebHistory('/'),
  routes: [
    {
      path: '/',
      name: 'home',
      component: () => import('./views/home.vue')
    },
    {
      path: '/list',
      name: 'list',
      component: () => import('./views/list.vue')
    },
    {
      path: '/about',
      name: 'about',
      component: () => import('./views/about.vue')
    },
    {
      path: '/detail/:id',
      component: () => import('./views/detail.vue'),
      children: [
        { path: '', component: () => import('./views/detail-item.vue') }
      ]
    }
  ]
});

这里,import动态加载子模块后,Vite在生产模式构建时,会分割对应代码文件,最终生产环境也能实现单页面应用动态加载模块。如果你使用Webpack编译项目,也可以配置类似的构建结果,根据import来动态分割代码文件。

扩展单页面应用,除了动态加载子页面,也还有其它优化点,你可以围绕着“数据隔离”和“样式隔离”两个点来处理。

数据隔离,就是防止全局变量泛滥或者污染。在实际项目中,我们可以规范每个子页面开发过程,禁止操作window的全局变量。如果真的需要借助全局变量进行操作,你可以在每个子页面操作全局变量时,加个唯一的前缀,同时,离开页面时清理原有全局变量的内容,防止内存泄露。

样式隔离,就是防止样式污染。如果子页面是通过Vue.js模板语法开发的,你可以加上scoped属性保证每个子页面的样式都是唯一不冲突,或者,对每个子页面约定一个唯一的className前缀。

总结

通过今天的学习,相信你已经知道了如何开发Vue.js的单页面应用。

单页面应用,目前有两种主流的技术实现方式,一种是通过hash路由来实现,另一种是通过history路由来实现:

  • hash路由的浏览器兼容比较好,可以兼容到IE8浏览器,但是URL的格式就限制必须用#号的hash值来标识。
  • history路由对浏览器有一定要求,同时如果浏览器强制刷新,就需要服务端做服务端层面的路由支持,但是history路由的URL格式和正常的服务路由一致。

另外我们还总结了单页面路由的核心原理,你要重点看一下:

  • 利用hash或者history进行无刷新修改浏览器URL;
  • 监听URL变化执行页面内容更新或切换;
  • 如果用history路由,就要考虑服务器路由的同步和浏览器的兼容。

最后,我们通过Vue.js官方提供的vue-router模块,展示了如何实现单页面应用中常见的四种功能需求场景。通过这节课的学习,希望你充分明白单页面路由的技术原理,理解API背后是如何运行的,而不只是停留在vue-router的API使用。

思考题

如何处理单页面和多页面项目共存?

欢迎留言参与讨论,如果有疑问,也欢迎留言。我们下节课见。

完整的代码在这里

精选留言(5)
  • 请务必优秀 👍(0) 💬(2)

    可以讲一下路由鉴权这些吗?这些简单的就不要讲了

    2023-03-24

  • 伱是风ル 👍(0) 💬(1)

    都到企业实战章节了,咋又科普了一篇路由的内容呢,一定要写不应该放基础篇里。

    2023-02-13

  • ifelse 👍(0) 💬(0)

    学习打卡

    2024-09-17

  • ifelse 👍(0) 💬(0)

    学校的卡

    2024-09-17

  • 中欧校友 👍(0) 💬(0)

    单页面应用动态加载模块,生产环境还是需要和多页应用一样从服务端请求,浏览器渲染吗?这样单页应用的优势不是不太能体现了,是因为动态加载模块一般是局部模块,不是整体更新,体验好于多页应用吗?

    2023-08-02