本文目录

[[toc]]

SPA

SPA 原理

监听 Router 变化后,对局部 DOM 树进行 patch ,仅更新改动部分内容。

Router 一般有两种模式:

  • hash: 主要监听 url 的 hash 部分的变化,触发 DOM patch
  • history: 监听 url path 部分的编号,触发 DOM patch

hash Router

// 定义 Router
class HashRouter {
  constructor() {
    this.routes = {}
    window.addEventListener('hashchange', () => this.updateView())
  }

  route(path, callback) {
    this.routes[path] = callback
  }

  push(path) {
    window.location.hash = path
  }

  updateView() {
    const path = window.location.hash.slice(1) || '/'
    this.routes[path]?.()
  }
}

// 使用示例
const router = new HashRouter()
router.route('/home', () => document.getElementById('view').innerHTML = 'Home Page')
router.route('/about', () => document.getElementById('view').innerHTML = 'About Page')
router.updateView() // 初始化加载

history Router

// 定义 Router
class HistoryRouter {
  constructor() {
    this.routes = {}
    // 绑定事件监听
    window.addEventListener('popstate', () => this.updateView())
  }

  route(path, callback) {
    this.routes[path] = callback
  }

  push(path) {
    history.pushState({}, '', path)
    this.updateView()
  }

  updateView() {
    const path = window.location.pathname
    this.routes[path]?.() || console.warn('Route not found')
  }
}

// 使用示例
const router = new HistoryRouter()
router.route('/home', () => document.getElementById('view').innerHTML = 'Home Page')
router.route('/about', () => document.getElementById('view').innerHTML = 'About Page')
router.updateView()

SPA 与 MPA 的区别

单页面应用(SPA)多页面应用(MPA)
组成一个主页面和多个页面片段多个主页面
刷新方式局部刷新整页刷新
url 模式hash / historyhistory
SEO效果较差, SEO 抓取不到站点内容SEO 友好
数据传递容易通过 url 、 cookie 、 localStorage 等传递
页面切换速度快,用户体验良好切换加载资源,速度慢,用户体验差
维护成本相对容易相对复杂

SPA优缺点

优点:

  • 具有桌面应用的即时性、网站的可移植性和可访问性
  • 用户体验好、快,内容的改变不需要重新加载整个页面
  • 良好的前后端分离,分工更明确

缺点:

  • 不利于搜索引擎的抓取
  • 首次渲染速度相对较慢

SEO 优化

  • SSR: 转为 SSR 渲染,访问页面时通过 Node.js 生成 HTML ,页面切换采用 history 模式重新渲染
  • 静态化: 构建后进行二次构建,将页面在服务端渲染为 HTML 后导出
  • 针对爬虫处理: 通过 Nginx rewrite 将爬虫请求转发到服务端的无头浏览器中,将无头浏览器渲染结果返回给爬虫

SSR

发展历史

前后端不分离

前端作为一种模板语法,在后端通过字符串替换等方式,将数据直接插入模板中,渲染为完整的 HTML 后,返回给客户端。

路由交给后端处理,后端根据请求路径获取不同的模板并获取数据,渲染为不同页面的 HTML。

---
title: 前后端不分离用户访问时序图
---
sequenceDiagram
    participant 浏览器
    participant 服务器

    浏览器->>服务器: 请求 url
    服务器-->>浏览器: 返回 HTML

前后端分离

HTML 中只包含最小结构,前端通过 Ajax 方式获取数据,拿到数据后由前端通过 DOM API 动态更新页面。

路由交给前端处理,前端监听 hash / history 变化,请求不同页面的 JS 并执行,从而获取数据、更新 DOM 节点。

---
title: 前后端分离用户访问时序图
---
sequenceDiagram
    participant 浏览器
    participant 服务器

    浏览器->>服务器: 请求 url
    服务器-->>浏览器: 返回最小 HTML
    Note over 浏览器: 使用 JS 渲染页面 DOM
    浏览器->>服务器: 请求数据
    服务器-->>浏览器: 返回数据
    Note over 浏览器: 组装数据,更新页面

服务端渲染

在前后端分离的基础上,增加 Node.js 层,使用 Node.js 渲染首屏 HTML ,客户端拿到的不再是最小结构,而是完整的首屏 HTML 结构,应用启动后还是 SPA 的更新方式。

路由方案由 Node.js 处理,同页面内的 DOM 更新由前端实现,跨页面更新由 Node.js 实现。

---
title: 服务端渲染用户访问时序图
---
sequenceDiagram
    participant 浏览器
    participant 服务器

    浏览器->>服务器: 请求 url
    Note over 服务器: 渲染首屏页面
    服务器-->>浏览器: 返回渲染后的首屏页面 HTML
    Note over 浏览器: 展示首屏页面
    Note over 浏览器: 激活 SPA

使用场景

  • SEO: 首屏 HTML 会完整返回, SEO 友好。
  • 用户体验好: 首屏返回给用户时,已经渲染完成了,用户无需等待即可看到首屏。

缺点

  • 复杂度高: 项目除了前端部分还有 Node.js 部分,整体复杂度较高
  • 历史库不兼容: 部分语法在 Node.js 渲染的时候是不支持的,需要添加对应的条件判断或者重新选库
  • 新的性能问题:
    • 渲染成本高: 为了防止污染,每个请求都需要创建实例去渲染 HTML
    • 缓存命中低: 需要根据不同的用户以及权限,进行条件渲染,缓存不能完全复用
  • 服务器负载增大: 所有用户都需要在服务端完成渲染、缓存等步骤,相比较 SPA 增加了服务器开销

实现方式

---
title: SSR 实现流程图
---
graph LR
  subgraph Source
    A[Code] --> B[Server entry]
    A --> C[Client entry]
  end

  B --> D[Webpack 构建]
  C --> D
  D --> E[Server Bundle]
  D --> F[Client Bundle]

  subgraph Node Server
    E --> G[Bundle Renderer]
  end

  subgraph Browser
    F -->|Hydrate| I[HTML]
  end

  G -->|Render| I[HTML]

虚拟 DOM 的优势

跨平台

虚拟 DOM 是对真实 DOM 的抽象,这一层抽象使得框架可以基于虚拟 DOM 实现跨平台渲染,比如:

  • Web 平台:渲染为真实 DOM
  • 小程序:渲染为小程序原生组件
  • Native:渲染为原生组件

性能优化

虚拟 DOM 通过 diff 算法,可以精确地定位到需要更新的 DOM 节点,避免不必要的 DOM 操作,提升性能。

批量更新

虚拟 DOM 可以将多次 DOM 更新合并成一次,减少重排重绘。

按需更新

通过 diff 算法,可以精确地知道哪些节点需要更新,哪些节点可以复用,避免不必要的 DOM 操作。

开发体验

虚拟 DOM 提供了声明式的开发方式,开发者只需要关注数据的变化,而不需要手动操作 DOM。

无虚拟 DOM 解决方案

优势

  • 更小的运行时体积:没有虚拟 DOM 相关的代码
  • 更好的性能:直接操作 DOM,没有 diff 的开销
  • 更简单的代码:不需要处理虚拟 DOM 的复杂性
  • 更精确的更新:只更新真正需要更新的节点

劣势

  • 编译时开销:需要更多的编译时间
  • 跨平台困难:直接操作 DOM 使得跨平台变得困难
  • 调试困难:编译后的代码难以调试

重复 key 导致页面渲染异常

复现代码

<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api'

const keys = ['a', 'b', 'c', 'd', 'e', 'f']

interface Data {
  key: string
  label: string
}

const count = 10

export default defineComponent({
  setup() {
    const list = ref<Data[]>([])
    let page = 1

    function updateList() {
      const newList = Array.from({ length: count }).map((_, idx) => {
        const curIdx = (page - 1) * count + idx
        const key = keys[curIdx % keys.length]

        page++

        return {
          key,
          label: `${key}-${idx}-${curIdx}`,
        }
      })

      list.value = newList
    }
    updateList()

    return {
      list,
      updateList,
    }
  },
})
</script>

<template>
  <div>
    <button @click="updateList">
      update list
    </button>
    <div v-for="item in list" :key="item.key">
      {{ item.label }}
    </div>
  </div>
</template>

现象

假设交互为从 1 - N 按照顺序依次翻页,在第 (n - 1) 页存在 key 为 aVNode n1 个,在第 n 页存在 key 为 aVNode n2 个。

n1 < n2 时, vue 会报错,提示 VNode 不存在

分析

Vue 默认 keyVNode 在一个 scope 中是一一对应的,在 patchdiff 算法中,当 key 发生匹配后,会复用节点,并且将 keyVNodescope 缓存中删去,避免同一节点重复复用。

由于 keyVNode 一一对应,只要有 key 按道理应该能取到 VNode 的,所以当发生重复 key 的时候,会提示 VNode 不存在。