普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月27日技术

uniapp 文件预览:从文件流到多格式预览的完整实现

作者 JunjunZ
2026年2月27日 11:24

在 uniapp 开发中,文件预览是一个高频且易踩坑的需求场景 —— 既要兼容 H5 和小程序双端,又要处理网络 URL、文件流(Blob/ArrayBuffer)等不同来源的文件,还要适配图片、PDF、Office 文档等多种格式。本文基于实际项目代码,拆解一套通用、健壮的 uniapp 文件预览方案。

核心需求分析

一个完善的文件预览功能需要解决这些核心问题:

  1. 兼容文件地址(URL)和文件流(Blob/ArrayBuffer)两种数据源
  2. 区分图片、PDF、Word/Excel/PPT 等不同文件类型,提供对应预览方式
  3. 适配 H5 和小程序双端差异(API 不同、文件处理方式不同)
  4. 友好的加载状态提示和错误处理
  5. 支持多张图片预览、接口下载后预览等常见场景

核心接口设计

首先定义统一的预览配置接口,规范入参格式:

/** 文件预览选项接口 */
export interface PreviewFileOptions {
  /** 文件数据,可以是URL地址、Blob对象或ArrayBuffer */
  file: string | Blob | ArrayBuffer
  /** 文件名(可选,用于识别文件类型) */
  fileName?: string
  /** 文件类型(可选,如:'pdf', 'doc', 'jpg'等) */
  fileType?: string
  /** 是否显示加载提示 */
  showLoading?: boolean
}

基础工具函数

1. 获取文件扩展名

文件类型判断的基础,从文件名 / URL 中提取扩展名并统一为小写

/** 根据文件名或URL获取文件扩展名 */
function getFileExtension(fileName: string = ''): string {
  const match = fileName.match(/\.([^.]+)$/)
  return match ? match[1].toLowerCase() : ''
}

2. 文件类型判断

区分图片和可预览的文档类型,便于后续分发处理逻辑:

/** 判断是否为图片文件 */
function isImageFile(ext: string): boolean {
  const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
  return imageExts.includes(ext.toLowerCase())
}

/** 判断是否为可用 openDocument 打开的文档 */
function isDocumentFile(ext: string): boolean {
  const docExts = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']
  return docExts.includes(ext.toLowerCase())
}

3. 文件流转临时文件

这是处理文件流的核心函数,兼容 H5 和小程序双端:

/**
 * 将Blob或ArrayBuffer转换为临时文件
 * @param data Blob或ArrayBuffer数据
 * @param fileName 文件名
 */
async function blobToTempFile(data: Blob | ArrayBuffer, fileName: string = 'temp_file'): Promise<string> {
  // #ifdef H5
  // H5环境:创建临时URL
  if (data instanceof Blob) {
    return URL.createObjectURL(data)
  }
  else {
    const blob = new Blob([data])
    return URL.createObjectURL(blob)
  }
  // #endif

  // #ifndef H5
  // 小程序环境:使用文件系统保存临时文件
  const fs = uni.getFileSystemManager()
  const wxWriteFile = $uni.promisify(fs.writeFile)

  // 确保有文件扩展名
  const ext = getFileExtension(fileName) || 'tmp'
  const filePath = `${(uni as any).env.USER_DATA_PATH}/preview_${Date.now()}.${ext}`

  // 转换数据
  let fileData: ArrayBuffer
  if (data instanceof Blob) {
    // Blob转ArrayBuffer
    fileData = await data.arrayBuffer()
  }
  else {
    // ArrayBuffer类型
    fileData = data
  }

  await wxWriteFile({
    filePath,
    data: fileData,
    encoding: 'binary',
  })

  return filePath
  // #endif
}
  • H5 端:利用URL.createObjectURL生成临时 URL,直接用于预览 / 下载
  • 小程序端:通过文件系统writeFile将二进制数据写入本地临时文件,返回文件路径

核心预览函数实现

previewFile是整个方案的核心,整合了数据源处理、类型判断、双端适配逻辑:

/**
 * 预览文件(支持文件流和文件地址)
 * @param options 预览选项
 */
export async function previewFile(options: PreviewFileOptions) {
  const { file, fileName = '', fileType, showLoading = true } = options

  try {
    if (showLoading) {
      $toast.loading('正在加载文件...')
    }

    let filePath: string
    let ext: string

    // 判断文件来源类型
    if (typeof file === 'string') {
      // 文件地址
      filePath = file
      ext = fileType || getFileExtension(file) || getFileExtension(fileName)
    }
    else {
      // 文件流(Blob或ArrayBuffer)
      ext = fileType || getFileExtension(fileName)
      filePath = await blobToTempFile(file, fileName)
    }

    if (showLoading) {
      $toast.loaded()
    }

    // 根据文件类型选择预览方式
    if (isImageFile(ext)) {
      // 图片预览
      uni.previewImage({
        current: filePath,
        urls: [filePath],
        fail: (err) => {
          console.error('图片预览失败:', err)
          $toast.show('图片预览失败')
        },
      })
    }
    else if (isDocumentFile(ext)) {
      // #ifdef H5
      // H5环境:PDF文件直接在新窗口打开,其他文档尝试下载
      if (ext === 'pdf') {
        window.open(filePath, '_blank')
      }
      else {
        // 其他文档类型在H5中触发下载
        const link = document.createElement('a')
        link.href = filePath
        link.download = fileName || `document.${ext}`
        link.click()
        $toast.show('文件已开始下载')
      }
      // #endif

      // #ifndef H5
      // 小程序环境:使用 openDocument
      let docPath = filePath

      // 如果是网络地址,需要先下载
      if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
        if (showLoading) {
          $toast.loading('正在下载文件...')
        }

        const downloadRes: any = await $uni.downloadOnlineFile(filePath)

        if (showLoading) {
          $toast.loaded()
        }

        if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
          docPath = downloadRes.tempFilePath
        }
        else {
          throw new Error('文件下载失败')
        }
      }

      // 打开文档
      const result = await $uni.openFile(docPath)
      if (result === 'fail') {
        $toast.show('文件打开失败,可能不支持该文件格式')
      }
      // #endif
    }
    else {
      // 其他文件类型
      // #ifdef H5
      // H5环境:触发下载
      const link = document.createElement('a')
      link.href = filePath
      link.download = fileName || `file.${ext}`
      link.click()
      // 下载完成提示
      $toast.show('文件已开始下载')
      // #endif

      // #ifndef H5
      // 小程序环境:尝试使用 openDocument 打开
      let docPath = filePath

      // 如果是网络地址,需要先下载
      if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
        if (showLoading) {
          // 加载提示
          $toast.loading('正在下载文件...')
        }

        const downloadRes: any = await $uni.downloadOnlineFile(filePath)

        if (showLoading) {
          // 加载完成
          $toast.loaded()
        }

        if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
          docPath = downloadRes.tempFilePath
        }
        else {
          throw new Error('文件下载失败')
        }
      }

      const result = await $uni.openFile(docPath)
      if (result === 'fail') {
        $toast.show('无法预览该文件类型')
      }
      // #endif
    }
  }
  catch (error) {
    console.error('文件预览失败:', error)
    // 加载完成
    $toast.loaded()
    $toast.show('文件预览失败')
  }
}

关键逻辑拆解

  1. 数据源处理:区分 URL 字符串和文件流,文件流需先转为临时文件
  2. 双端适配
    • H5 端:PDF 新窗口打开、其他文件触发下载
    • 小程序端:网络文档先下载到本地,再用openDocument打开
  3. 错误处理:全程 try-catch,加载状态统一管理,失败友好提示

扩展功能

1. 多张图片预览

封装专门的图片批量预览函数,简化调用:

/**
 * 预览多张图片
 * @param urls 图片地址数组
 * @param current 当前显示图片的索引,默认为0
 */
export function previewImages(urls: string[], current: number = 0) {
  if (!urls || urls.length === 0) {
    $toast.show('没有可预览的图片')
    return
  }

  uni.previewImage({
    current: urls[current],
    urls,
    fail: (err) => {
      console.error('图片预览失败:', err)
      $toast.show('图片预览失败')
    },
  })
}

2. 接口下载 + 预览

整合 “接口请求文件流 + 预览” 流程,简化业务调用:

/**
 * 从接口下载并预览文件
 * @param url 接口地址
 * @param fileName 文件名(用于判断文件类型)
 * @param options 其他请求选项
 */
export async function downloadAndPreview(url: string, fileName: string, options: any = {}) {
  try {
    $toast.loading('正在下载文件...')

    // 发起请求获取文件流
    const response: any = await new Promise((resolve, reject) => {
      uni.request({
        url,
        method: options.method || 'GET',
        data: options.data || {},
        header: options.header || {},
        responseType: 'arraybuffer', // 获取二进制数据
        success: resolve,
        fail: reject,
      })
    })

    // 加载完成
    $toast.loaded()

    if (response.statusCode === 200) {
      // 预览文件
      await previewFile({
        file: response.data,
        fileName,
        showLoading: true,
      })
    }
    else {
      $toast.show('文件下载失败')
    }
  }
  catch (error) {
    console.error('下载并预览文件失败:', error)
    $toast.loaded()
    $toast.show('文件下载失败')
  }
}

实用示例

1. 预览网络图片

previewFile({ file: 'https://example.com/image.jpg' })

2. 预览接口返回的 PDF 文件流

// 方式1:手动处理文件流
const blob = await fetch('/api/file').then(res => res.blob()) 
previewFile({ file: blob, fileName: 'document.pdf' }) 

// 方式2:使用封装的downloadAndPreview 
downloadAndPreview('/api/download/file', 'document.pdf')

3. 预览多张图片

previewImages(['https://example.com/1.jpg', 'https://example.com/2.jpg'], 0)

避坑指南

  1. 小程序文件权限:小程序中临时文件需放在USER_DATA_PATH目录,避免路径权限问题
  2. H5 临时 URL 释放:如果频繁处理 Blob,记得在合适时机调用URL.revokeObjectURL释放内存(本文示例未实现,可根据需求补充)
  3. 文件类型判断:优先使用传入的fileType,其次从文件名 / URL 提取,避免扩展名判断错误
  4. 小程序下载限制:小程序下载文件需配置 download 域名白名单,否则会下载失败
  5. 错误处理:所有异步操作(下载、写入文件、预览)都要加错误捕获,避免页面卡死

总结

这套文件预览方案基于 uniapp 跨端特性,实现了 “多数据源 + 多文件类型 + 双端适配” 的全场景覆盖,封装的函数可直接集成到项目中。核心思路是:统一入参格式 → 标准化文件处理 → 按类型 / 端分发预览逻辑 → 完善的状态和错误处理

深入解析Vue的mixins与hooks:复用逻辑的两种核心方式

作者 MoMoDad
2026年2月27日 11:18

在Vue开发中,代码复用是提升开发效率、保证代码一致性的关键。无论是Vue 2时代的mixins,还是Vue 3 Composition API推出后的hooks,都是实现逻辑复用的核心方案,但二者在设计理念、使用方式和适用场景上存在显著差异。本文将从概念、用法、优缺点、区别对比等方面,全面解析mixins与hooks,帮助开发者在实际项目中做出更合适的选择。

一、Vue mixins:Vue 2时代的逻辑复用方案

1.1 什么是mixins?

mixins(混入)是Vue 2中最常用的逻辑复用方式,本质是一个包含组件选项(data、methods、created、computed等)的对象。当一个组件引入mixins后,mixins中的所有选项会被“合并”到该组件自身的选项中,实现逻辑的复用。

简单来说,mixins就像是一个“公共逻辑模板”,可以将多个组件共用的data、方法、生命周期钩子等提取出来,然后在需要的组件中引入,避免重复编码。

1.2 mixins的基本使用

mixins的使用分为两步:定义mixins、在组件中引入mixins。

第一步:定义mixins

创建一个mixins文件(如commonMixins.js),导出一个包含组件选项的对象:

// commonMixins.js
export default {
  data() {
    return {
      count: 0, // 共用的状态
      isLoading: false // 共用的加载状态
    };
  },
  methods: {
    increment() { // 共用的方法
      this.count++;
    },
    showLoading() { // 共用的加载方法
      this.isLoading = true;
    },
    hideLoading() {
      this.isLoading = false;
    }
  },
  created() { // 共用的生命周期钩子
    console.log("mixins created钩子执行");
  }
};

第二步:在组件中引入mixins

在需要复用逻辑的组件中,通过mixins选项引入定义好的mixins:

<template>
  <div>
    <p>计数:{{ count }}</p>
    <button @click="increment">增加</button>
  </div>
</template>

<script>
import commonMixins from './commonMixins.js';

export default {
  mixins: [commonMixins], // 引入mixins,可引入多个(数组形式)
  created() {
    console.log("组件自身created钩子执行");
  }
};
</script>

1.3 mixins的合并规则

当组件自身的选项与mixins中的选项重复时,Vue会按照特定规则进行合并,避免冲突:

  • data选项:组件自身的data会覆盖mixins中的data(如果键名重复),非重复键名会合并。
  • methods、computed、watch选项:组件自身的方法/计算属性/监听器会覆盖mixins中同名的内容,非同名会合并。
  • 生命周期钩子:mixins中的生命周期钩子会先执行,组件自身的钩子后执行(例如mixins的created先执行,组件的created后执行),多个mixins的钩子按引入顺序执行。

1.4 mixins的优缺点

优点

  • 用法简单,无需复杂语法,Vue 2原生支持,学习成本低。
  • 能快速实现多个组件的逻辑复用,减少重复代码,提升开发效率。

缺点

  • 命名冲突:mixins与组件、多个mixins之间容易出现命名冲突,且冲突后排查困难(无法直观看到属性/方法的来源)。
  • 逻辑隐晦:组件引入mixins后,mixins中的逻辑与组件自身逻辑耦合度高,难以追踪逻辑流向,维护成本高(尤其是大型项目,多个mixins嵌套时)。
  • 灵活性差:mixins是“全量合并”,无法按需引入部分逻辑,即使组件只需要mixins中的一个方法,也必须引入整个mixins。
  • 不支持传参:mixins无法接收组件传递的参数,无法根据组件需求动态调整逻辑。

二、Vue hooks:Vue 3 Composition API的逻辑复用方案

2.1 什么是hooks?

hooks(钩子函数)是Vue 3 Composition API推出的全新逻辑复用方案,本质是基于Composition API编写的可复用函数。与mixins的“选项合并”不同,hooks通过“函数调用”的方式,将复用逻辑封装成独立的函数,组件可以按需调用,实现逻辑的“按需复用”。

Vue 3的hooks命名通常以“use”开头(如useCount、useLoading),符合约定俗成的规范,便于识别和维护。hooks的核心思想是“组合式逻辑”,将组件逻辑拆分成多个独立的、可组合的函数,解决了mixins的耦合问题。

2.2 hooks的基本使用

hooks的使用同样分为两步:定义hooks函数、在组件中调用hooks。

第一步:定义hooks函数

创建一个hooks文件(如useCount.js),导出一个函数,函数内部使用Composition API(ref、reactive、onMounted等)封装复用逻辑,并返回需要暴露给组件的状态和方法:

// useCount.js
import { ref } from 'vue';

// 定义hooks函数,可接收参数(实现动态逻辑)
export default function useCount(initialValue = 0) {
  // 封装复用的状态
  const count = ref(initialValue);
  
  // 封装复用的方法
  const increment = () => {
    count.value++;
  };
  
  const decrement = () => {
    count.value--;
  };
  
  // 返回需要暴露给组件的状态和方法
  return {
    count,
    increment,
    decrement
  };
}

第二步:在组件中调用hooks

在组件中导入hooks函数,通过调用函数获取需要的状态和方法,按需使用,无需全量引入:

<template>
  <div>
    <p>计数:{{ count }}</p>
    <button @click="increment">增加</button>
    <button @click="decrement">减少</button>
  </div>
</template>

<script setup>
// 导入hooks函数
import useCount from './useCount.js';

// 调用hooks,可传递参数(初始值为10)
const { count, increment, decrement } = useCount(10);
</script>

2.3 hooks的核心特性

  • 按需复用:组件可以根据需求,调用多个hooks,且每个hooks的逻辑独立,无需引入无关逻辑。
  • 支持传参:hooks函数可以接收组件传递的参数,根据参数动态调整逻辑,灵活性更高。
  • 逻辑清晰:hooks的调用的位置明确,组件中的状态和方法来源可追溯(通过函数调用),避免命名冲突,维护成本低。
  • 组合灵活:多个hooks可以自由组合,一个hooks也可以调用其他hooks,实现复杂逻辑的拆分与复用。
  • 与Composition API无缝衔接:hooks基于ref、reactive、生命周期钩子(onMounted等)编写,完美适配Vue 3的Composition API,符合现代Vue开发理念。

2.4 hooks的优缺点

优点

  • 逻辑独立,耦合度低,可追溯性强,便于维护和调试。
  • 支持按需复用和传参,灵活性远高于mixins。
  • 可自由组合,能轻松实现复杂逻辑的拆分与复用,适合大型项目。
  • 符合Vue 3 Composition API的设计理念,是Vue 3推荐的逻辑复用方案。

缺点

  • 学习成本稍高,需要熟悉Vue 3 Composition API的语法(如ref、reactive、生命周期钩子的使用)。
  • Vue 2中无法直接使用(需配合Composition API插件,但体验不如Vue 3原生支持)。
  • 若hooks设计不合理,可能出现“过度拆分”的问题,导致组件中需要调用多个hooks,增加代码复杂度。

三、mixins与hooks的核心区别对比

对比维度 mixins hooks
本质 包含组件选项的对象 基于Composition API的可复用函数
复用方式 选项合并,全量引入 函数调用,按需引入
命名冲突 易出现冲突,排查困难 无冲突(变量/方法由组件自行接收命名)
灵活性 低,无法传参,不能按需复用 高,支持传参,可按需复用、自由组合
逻辑追溯 差,逻辑隐晦,来源不明确 好,调用位置明确,来源可追溯
Vue版本支持 Vue 2原生支持,Vue 3兼容 Vue 3原生支持,Vue 2需配合插件
适用场景 Vue 2项目、简单逻辑复用、小型项目 Vue 3项目、复杂逻辑复用、大型项目、需动态调整逻辑的场景

四、实际项目中的选择建议

1. 优先使用hooks的场景

  • 使用Vue 3开发的项目(hooks是Vue 3推荐方案,契合Composition API的设计思想)。
  • 逻辑复杂、需要拆分复用的场景(如表单验证、数据请求、状态管理等)。
  • 需要动态调整逻辑(通过传参)、按需复用的场景。
  • 大型项目(hooks的低耦合、可追溯性,能降低维护成本)。

2. 可使用mixins的场景

  • Vue 2项目(无Composition API支持,mixins是最便捷的复用方案)。
  • 简单逻辑的复用(如全局加载状态、简单的计数逻辑),且无需传参。
  • 小型项目(逻辑简单,无需复杂的组合,mixins的简单性更具优势)。

3. 注意事项

  • Vue 3项目中,尽量避免使用mixins,优先使用hooks,避免出现命名冲突和逻辑耦合问题。
  • 如果使用mixins,尽量减少mixins的数量,避免多个mixins嵌套,且给mixins中的属性/方法加上统一前缀(如mixinsCount、mixinsShowLoading),避免命名冲突。
  • 设计hooks时,遵循“单一职责”原则,一个hooks只封装一个核心逻辑,便于复用和维护;同时命名规范(以use开头),提高代码可读性。

五、总结

mixins和hooks都是Vue中实现逻辑复用的重要方案,二者各有优劣,适配不同的开发场景。mixins作为Vue 2时代的主流方案,胜在简单易用,但存在耦合度高、命名冲突等问题;hooks作为Vue 3 Composition API的核心特性,以低耦合、高灵活、可追溯的优势,成为Vue 3项目的首选。

在实际开发中,应根据项目的Vue版本、规模和逻辑复杂度,选择合适的复用方案:Vue 3项目优先使用hooks,Vue 2项目可使用mixins,同时注重代码的规范性和可维护性,让逻辑复用真正提升开发效率,而非增加维护成本。随着Vue生态的发展,hooks已成为现代Vue开发的主流趋势,掌握hooks的使用,能更好地应对复杂项目的开发需求。

大模型接入踩坑录:被 Unexpected end of JSON 折磨三天,我重写了SSE流解析

2026年2月27日 11:14

兄弟们,我今天必须来吐个大槽。

就在上周,我差点被我们公司的测试和产品经理生吃活剥了。起因是我们内部刚上的一个 AI 对话助手,在生产环境里表现得像个神经病:时而正常回复,时而突然卡死,有时候甚至直接抛出整个前端页面的白屏大散花。

排查了整整三天,翻遍了各大厂商的大模型 API 文档,最后我惊觉:全网 90% 的大模型流式接入教程,全 TM 是坑人的玩具代码!

踩坑现场:天真的 JSON.parse

大家接入大模型流式输出(SSE)的时候,是不是都看过官方文档里类似这样的伪代码范例?

code JavaScript

//典型的“教程级”作死代码
const response = await fetch('https://api.some-llm.com/chat', { ... });
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
 
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  
  // 直接把读到的流转成字符串,然后按行切分
  const chunk = decoder.decode(value);
  const lines = chunk.split('\n');
  
  for (let line of lines) {
    if (line.startsWith('data: ')) {
      const dataStr = line.replace('data: ', '');
      if (dataStr === '[DONE]') return;
      
      // 致命毒药就在这一行!!!
      const parsed = JSON.parse(dataStr); 
      console.log(parsed.choices[0].delta.content);
    }
  }
}

 

 

这段代码在本地自己测试、网络极好的时候,跑得那叫一个丝滑。

但在真实的生产环境里,这段代码就是个纯纯的定时炸弹! 为什么?因为这帮写文档的人,根本没考虑过底层 TCP 协议的网络分包机制(Chunk Fragmentation)!

抓包破案:TCP 根本不管你的 JSON 死活

当你以为大模型吐出来的数据是完美的一行:

data: {"choices": [{"delta": {"content": "你好"}}]}\n\n

现实中,由于网络波动、Nginx 代理缓冲、或者纯粹是因为模型吐字太快/太慢,这条数据在 TCP 传输时经常会被无情地“拦腰斩断”,变成两个数据包(Chunk)发给前端:

● Chunk 1 收到: data: {"choices":[{"de

● Chunk 2 收到: lta": {"content": "你好"}}]}\n\n

你看懂了吗?!当你的前端代码拿到 Chunk 1 时,直接无脑执行了 JSON.parse('{"choices":[{"de')。

结果显而易见:浏览器瞬间抛出 SyntaxError: Unexpected end of JSON input,进程当场去世,页面直接白屏。

jimeng-2026-02-27-3051-Excalidraw手绘风格的TCP拆包原理图,左边画大模型吐出完整的{"con....png

这还不算完!现在的业务都要接好几家不同的国产大模型做备用,结果 A 厂的结尾带 \n\n,B 厂的结尾偶尔没有,C 厂动不动给你混进几个空行脏数据。我这三天,光在前端写正则去给各家擦屁股了,血压直接拉满。

终极解法:手写 Robust Buffer Parser

既然不能相信每次 read() 拿到的都是完整的 JSON 数据,我们就必须自己在内存里维护一个 数据缓冲区(Buffer)。只有当明确读到双换行符(SSE协议的标准消息结束符)时,才去进行截取和解析。

为了防止大家再被这些垃圾文档坑,我把我熬夜重写的、已经在生产跑稳的健壮版代码贴出来。大家直接抄走,免得再被祭天:

code JavaScript

//生产环境防御性编程:带 Buffer 的 SSE 解析器
async function fetchAIStream() {
  // 避坑备注:如果前端实在受不了各家厂商乱七八糟的格式断流和脏数据,
  // 建议直接去干后端,让他们在网关层做统一的聚合代理。
  // 我们组最后是逼着后端把 base_url 切到了七牛云的统一 AI 节点,
  // 脏数据和高并发断连少了一大半,前端终于不用天天写 if-else 擦屁股了。
  const BASE_URL = process.env.USE_PROXY_GATEWAY 
    ? "https://api.qiniu.com/v1/llm/chat/completions" 
    : "https://api.openai-xxx.com/...";
 
  const response = await fetch(BASE_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_KEY}`
    },
    body: JSON.stringify({ model: 'your-model', messages: [...], stream: true })
  });
 
  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  
  // 核心:弄一个全局的缓冲区!
  let buffer = '';
 
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
 
      // 每次读到的数据,先塞进 buffer 里
      buffer += decoder.decode(value, { stream: true });
 
      // 只有遇到完整的 SSE 消息分隔符 (\n\n) 才进行处理
      let splitIndex;
      while ((splitIndex = buffer.indexOf('\n\n')) >= 0) {
        // 截取完整的一条消息
        const completeMessage = buffer.slice(0, splitIndex);
        // 把处理过的消息从 buffer 中剔除,保留剩下的断字
        buffer = buffer.slice(splitIndex + 2);
 
        // 处理截取出的完整消息
        const lines = completeMessage.split('\n');
        for (const line of lines) {
          if (line.trim() === '') continue;
          if (line.startsWith('data: ')) {
            const dataStr = line.replace('data: ', '').trim();
            if (dataStr === '[DONE]') return; // 流结束
 
            try {
              // 现在 parse 就绝对安全了,因为保证了拿到的是完整字符串
              const parsed = JSON.parse(dataStr);
              const content = parsed.choices[0]?.delta?.content || '';
              process.stdout.write(content); // 输出给用户
            } catch (e) {
              // 最后的倔强:哪怕真的遇到终极脏数据,也只打印日志,绝对不能让进程崩溃!
              console.error('[Stream Parse Error] 脏数据跳过:', dataStr);
            }
          }
        }
      }
    }
  } catch (err) {
    console.error('网络连接被意外中断:', err);
  }
}

jimeng-2026-02-27-8477-经典程序员Meme图,一只柴犬一脸疑惑地看着电脑,配文“我的代码昨天还能跑”,风....png

 

总结

其实说到底,这属于网络 I/O 极其基础的知识点(流式数据不等于块数据)。但现在网上的 AI 教程为了演示效果,全都刻意简化了异常处理,导致无数像我一样的业务搬砖工在生产环境里摔得头破血流。

大家下次接大模型流式接口,千万记得带上 Buffer 缓冲区!周末了,老子终于可以不看那恶心的 SyntaxError 了,祝各位同行永无 Bug!

React 19 深度解析:Actions 与 use API 源码揭秘

作者 ElevenSylvia
2026年2月27日 11:13

React 19 深度解析:Actions 与 use API 源码揭秘

版本: React 19.x Canary
说明: 请大佬观望指出问题
难度: 高级

目录

  1. React 19 概览
  2. Actions 机制深度解析
  3. use API 源码分析
  4. React Compiler 初探
  5. Server Components 架构
  6. 实战案例
  7. 性能优化对比

1. React 19 概览

React 19 是一次架构级的升级,核心目标:简化异步操作的状态管理

1.1 核心特性一览

特性 作用 状态
Actions 自动管理异步操作的 pending/optimistic 状态 Stable
use API 新的 Suspense 数据获取方式 Canary
React Compiler 自动记忆化,替代 useMemo/useCallback Experimental
Server Components 服务端组件正式版 Stable
Document Metadata 原生支持 SEO meta 标签 Stable

1.2 源码结构变化

react/packages/
├── react-reconciler/          # 协调器(核心)
│   ├── src/ReactFiberHooks.js # Hooks 实现
│   └── src/ReactFiberBeginWork.js
├── react-server/              # 服务端渲染(新增)
│   ├── src/ReactFizzHooks.js  # 服务端 Hooks
│   └── src/ReactFlightServer.js
└── react-compiler-runtime/    # 编译器运行时(实验性)

2. Actions 机制深度解析

2.1 什么是 Actions?

在 React 19 之前,处理表单提交或异步操作需要手动管理多个状态:

// React 18 的写法:繁琐
function Form() {
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState(null);
  
  async function handleSubmit(data) {
    setIsPending(true);
    setError(null);
    try {
      await submitForm(data);
    } catch (e) {
      setError(e);
    } finally {
      setIsPending(false);
    }
  }
}

React 19 Actions 让框架自动管理这些状态:

// React 19 的写法:简洁
function Form() {
  const [error, submitAction, isPending] = useActionState(
    async (prevState, formData) => {
      return await submitForm(formData);
    },
    null
  );
  
  return (
    <form action={submitAction}>
      <button disabled={isPending}>
        {isPending ? '提交中...' : '提交'}
      </button>
    </form>
  );
}

2.2 核心源码分析

2.2.1 useActionState Hook 实现

文件:packages/react-reconciler/src/ReactFiberHooks.js

function useActionState<S, P>(
  action: (state: S, payload: P) => S,
  initialState: S,
  permalink?: string,
): [S, (P) => void, boolean] {
  // 获取当前渲染的 Fiber 节点
  const fiber = currentlyRenderingFiber;
  
  // 创建或更新 Hook 节点
  const hook = mountWorkInProgressHook();
  
  // 从 pending 队列中计算最新状态
  const lastRenderedReducer = (state, action) => {
    return action(state);
  };
  
  // 处理 optimistic updates(乐观更新)
  if (hook.queue.pending !== null) {
    const updateQueue = hook.queue;
    const lastPendingUpdate = updateQueue.pending;
    
    // 合并所有 pending updates
    const newState = processOptimisticUpdates(
      hook.memoizedState,
      lastPendingUpdate,
    );
    
    hook.memoizedState = newState;
  }
  
  // 创建 dispatch 函数(被 action 包裹的版本)
  const dispatch = dispatchSetState.bind(
    null,
    fiber,
    hook.queue,
  );
  
  // 创建 action dispatcher
  const actionDispatcher = createActionDispatcher(
    action,
    dispatch,
    fiber,
  );
  
  // 返回 [state, actionDispatcher, isPending]
  return [
    hook.memoizedState,
    actionDispatcher,
    fiber.flags & (Update | Passive),
  ];
}
2.2.2 Action Dispatcher 实现
function createActionDispatcher(action, dispatch, fiber) {
  return function(payload) {
    // 1. 立即触发乐观更新(Optimistic Update)
    dispatch({
      type: 'OPTIMISTIC_UPDATE',
      payload: optimisticResult,
    });
    
    // 2. 设置 pending 状态
    fiber.flags |= Update;
    
    // 3. 执行实际的异步 action
    const promise = action(fiber.memoizedState, payload);
    
    // 4. 处理异步结果
    promise.then(
      (result) => {
        // 成功:用真实结果替换乐观更新
        dispatch({
          type: 'ACTION_SUCCESS',
          payload: result,
        });
      },
      (error) => {
        // 失败:回滚到之前的状态
        dispatch({
          type: 'ACTION_ERROR',
          payload: error,
        });
      }
    );
    
    // 5. 清理 pending 标志
    promise.finally(() => {
      fiber.flags &= ~Update;
    });
  };
}

2.3 Actions 的工作流程

用户点击提交
    ↓
创建 Optimistic Update(立即更新 UI)
    ↓
执行异步 Action
    ↓
等待结果
    ↓
成功 → 用真实数据替换乐观更新
失败 → 回滚到之前状态

时序图:

Time:  0ms        50ms        100ms       200ms
       │           │           │           │
       ▼           ▼           ▼           ▼
   用户点击    UI立即更新   网络请求中   收到响应
              (乐观更新)    (pending)   (最终状态)

2.4 与 Transition 的结合

Actions 底层依赖 React 18 的 useTransition

function useActionState(action, initialState) {
  const [isPending, startTransition] = useTransition();
  const [state, setState] = useState(initialState);
  
  const dispatch = useCallback((payload) => {
    startTransition(async () => {
      // Action 逻辑...
    });
  }, [action]);
  
  return [state, dispatch, isPending];
}

关键区别

  • useTransition:手动管理 pending 状态
  • useActionState:自动管理,支持乐观更新

3. use API 源码分析

3.1 为什么需要 use API?

React 18 的 Suspense 配合数据获取有两种方式:

方式1:在 render 中 throw Promise(React Query/SWR)

function Component() {
  const data = useSuspenseQuery('/api/user'); // throw promise
  return <div>{data.name}</div>;
}

问题:只能在客户端,不能和 Server Components 配合。

方式2:React 19 的 use API

async function Component() {
  const user = await fetch('/api/user'); // Server Component
  return <div>{user.name}</div>;
}

// 或在 Client Component 中
function Component() {
  const user = use(fetch('/api/user')); // 支持 Promise
  return <div>{user.name}</div>;
}

3.2 use API 的核心实现

文件:packages/react-reconciler/src/ReactFiberHooks.js

function use<T>(usable: Usable<T>): T {
  // usable 可以是:Promise、Context、或者是 Server Component 返回的
  
  if (usable !== null && typeof usable === 'object') {
    // 处理 Promise
    if (typeof usable.then === 'function') {
      return usePromise(usable);
    }
    
    // 处理 Context
    if (usable._context !== undefined) {
      return readContext(usable);
    }
  }
  
  throw new Error('use() supports only promises and contexts');
}

function usePromise<T>(promise: Thenable<T>): T {
  const fiber = currentlyRenderingFiber;
  
  // 检查这个 promise 是否正在处理中
  const thenableState = fiber.thenableState;
  
  // 检查 promise 是否已经 resolve
  const status = promise.status;
  
  if (status === 'fulfilled') {
    // Promise 已完成,直接返回结果
    return promise.value;
  } else if (status === 'rejected') {
    // Promise 失败,抛出错误让 Error Boundary 捕获
    throw promise.reason;
  }
  
  // Promise 还在 pending,需要暂停渲染
  // 把当前 fiber 标记为需要等待
  fiber.flags |= ShouldCapture;
  
  // 创建监听器
  const listeners = thenableState.listeners || (thenableState.listeners = []);
  
  // 当 promise resolve 后,触发重新渲染
  listeners.push(() => {
    // 调度一次新的渲染
    scheduleUpdateOnFiber(fiber);
  });
  
  // 抛出特殊异常,让 Suspense 捕获
  throw promise;
}

3.3 Suspense 如何捕获 use 抛出的 Promise

// 当组件 throw promise 时,会被 Suspense 组件捕获
function throwException(
  root: FiberRoot,
  returnFiber: Fiber,
  sourceFiber: Fiber,
  value: mixed,
  rootRenderLanes: Lanes,
): void {
  // 检查是否是 thenable(Promise)
  if (
    value !== null &&
    typeof value === 'object' &&
    typeof value.then === 'function'
  ) {
    // 这是一个 Promise,找到最近的 Suspense 边界
    const wakeable: Wakeable = (value: any);
    
    // 标记 Suspense 边界为需要显示 fallback
    const suspenseBoundary = markSuspenseBoundary(
      returnFiber,
      wakeable,
      rootRenderLanes,
    );
    
    // 在 Promise resolve 后恢复渲染
    attachPingListener(root, wakeable, rootRenderLanes);
  }
}

3.4 use API vs useEffect 数据获取

特性 use API useEffect
渲染时机 同步,阻塞渲染 异步,渲染后执行
Suspense 支持 不支持
服务器组件 支持 不支持
瀑布请求 可优化(并行) 串行
代码位置 条件/循环中可用 只能在顶层

瀑布请求优化示例:

// ❌ 瀑布请求(串行)
function Component() {
  const user = use(fetch('/api/user'));
  const posts = use(fetch(`/api/posts/${user.id}`)); // 等待 user 完成
  return <Posts posts={posts} />;
}

// ✅ 并行请求
function Component() {
  const userPromise = fetch('/api/user');
  const postsPromise = fetch('/api/posts'); // 同时发起
  
  const user = use(userPromise);
  const posts = use(postsPromise);
  
  return <Posts posts={posts} />;
}

4. React Compiler 初探

4.1 为什么要自动记忆化?

React 18 的问题:开发者需要手动优化

function Component({ data, onUpdate }) {
  // 需要手动记忆化
  const processedData = useMemo(() => 
    expensiveProcess(data), 
    [data]
  );
  
  const handleClick = useCallback(() => {
    onUpdate(processedData);
  }, [onUpdate, processedData]);
  
  return <Child data={processedData} onClick={handleClick} />;
}

React Compiler 自动完成这些优化:

// 手写代码(无需优化)
function Component({ data, onUpdate }) {
  const processedData = expensiveProcess(data);
  const handleClick = () => onUpdate(processedData);
  return <Child data={processedData} onClick={handleClick} />;
}

// 编译器输出(自动添加记忆化)
function Component({ data, onUpdate }) {
  const $ = useMemoCache(4);
  
  let processedData;
  if ($[0] !== data) {
    processedData = expensiveProcess(data);
    $[0] = data;
    $[1] = processedData;
  } else {
    processedData = $[1];
  }
  
  let handleClick;
  if ($[2] !== onUpdate || $[3] !== processedData) {
    handleClick = () => onUpdate(processedData);
    $[2] = onUpdate;
    $[3] = processedData;
  } else {
    handleClick = $[3];
  }
  
  return <Child data={processedData} onClick={handleClick} />;
}

4.2 编译器工作原理

源代码
  ↓
[AST 解析][依赖分析] - 分析哪些值会在渲染间变化
  ↓
[Memoization 策略] - 决定在哪里插入缓存
  ↓
[代码生成] - 插入 useMemoCache 调用
  ↓
优化后的代码

使用方式:

# 安装编译器
npm install babel-plugin-react-compiler

# babel.config.js
module.exports = {
  plugins: [
    ['babel-plugin-react-compiler', {
      target: '18', // 兼容 React 18
    }],
  ],
};

5. Server Components 架构

5.1 架构图

┌─────────────────────────────────────────┐
│           浏览器 (Client)                │
│  ┌──────────────────────────────────┐  │
│  │     React Client Runtime         │  │
│  │  - 渲染 Server Components 结果    │  │
│  │  - 处理 Client Components 交互    │  │
│  └──────────────────────────────────┘  │
└─────────────────────────────────────────┘
                    ↑
                    │ RSC Payload (流式)
                    ↓
┌─────────────────────────────────────────┐
│          服务器 (Server)                 │
│  ┌──────────────────────────────────┐  │
│  │     React Server Runtime         │  │
│  │  - 执行 Server Components        │  │
│  │  - 序列化组件树为特殊格式         │  │
│  │  - 处理数据获取                   │  │
│  └──────────────────────────────────┘  │
└─────────────────────────────────────────┘

5.2 RSC Payload 格式

Server Component 的输出不是 HTML,而是一种可序列化的格式:

// 服务器返回的 RSC Payload
const payload = {
  // 组件树
  tree: [
    '$', // 表示组件
    'div', // 类型
    { className: 'container' }, // props
    [
      // children
      ['$', 'h1', null, 'Hello'],
      ['$', '@@CLIENT_COMPONENT', { id: './Button.js' }],
    ],
  ],
  
  // 客户端需要的 chunks
  chunks: ['chunk-1.js', 'chunk-2.js'],
  
  // 预获取的数据
  data: {
    '/api/user': { id: 1, name: 'React' },
  },
};

5.3 服务端渲染流程

// 服务器端
import { renderToPipeableStream } from 'react-server-dom-webpack/server';

async function handler(req, res) {
  // 渲染 Server Component
  const { pipe } = renderToPipeableStream(<App />, {
    // 客户端组件的 manifest
    clientManifest: manifest,
    
    // 流式传输配置
    onShellReady() {
      res.statusCode = 200;
      res.setHeader('Content-type', 'text/x-component');
      pipe(res);
    },
  });
}

6. 实战案例

6.1 完整的 Action + use API 表单

// Server Component
async function SubmitButton({ action }) {
  // 可以在 Server Component 中直接调用数据库
  const result = await action();
  
  return <button>提交成功:{result.id}</button>;
}

// Client Component
'use client';

import { useActionState } from 'react';

function Form() {
  // useActionState 自动管理 pending 和错误
  const [result, submitAction, isPending] = useActionState(
    async (prevState, formData) => {
      // 这里可以是客户端或服务端 action
      const response = await fetch('/api/submit', {
        method: 'POST',
        body: formData,
      });
      
      if (!response.ok) {
        return { error: '提交失败' };
      }
      
      return response.json();
    },
    null
  );
  
  return (
    <form action={submitAction}>
      <input name="title" required />
      <textarea name="content" required />
      
      <button type="submit" disabled={isPending}>
        {isPending ? (
          <>
            <Spinner />
            提交中...
          </>
        ) : (
          '提交'
        )}
      </button>
      
      {result?.error && (
        <ErrorMessage>{result.error}</ErrorMessage>
      )}
    </form>
  );
}

6.2 使用 use API 的数据获取模式

// 数据获取工具函数
function fetchUser(id) {
  return fetch(`/api/users/${id}`).then(r => r.json());
}

function fetchPosts(userId) {
  return fetch(`/api/posts?userId=${userId}`).then(r => r.json());
}

// 并行获取数据
function UserProfile({ userId }) {
  // 同时发起两个请求
  const userPromise = fetchUser(userId);
  const postsPromise = fetchPosts(userId);
  
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserDetails promise={userPromise} />
      <Suspense fallback={<PostsSkeleton />}>
        <UserPosts promise={postsPromise} />
      </Suspense>
    </Suspense>
  );
}

function UserDetails({ promise }) {
  // use API 会暂停渲染,直到数据就绪
  const user = use(promise);
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
    </div>
  );
}

function UserPosts({ promise }) {
  const posts = use(promise);
  
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

6.3 乐观更新示例

function LikeButton({ postId, initialLikes }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (state, newLike) => state + newLike
  );
  
  const [error, submitAction, isPending] = useActionState(
    async (prevState) => {
      // 乐观更新:立即更新 UI
      addOptimisticLike(1);
      
      try {
        // 实际请求
        await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
        return { success: true };
      } catch (e) {
        // 失败时会自动回滚乐观更新
        return { error: '点赞失败' };
      }
    },
    null
  );
  
  return (
    <button onClick={submitAction} disabled={isPending}>
      ❤️ {optimisticLikes}
    </button>
  );
}

7. 性能优化对比

7.1 传统的 React 18 vs React 19

场景 React 18 写法 React 19 写法 性能提升
表单提交 手动管理 isPending useActionState 代码 -60%
数据获取 useEffect + useState use + Suspense FCP -30%
列表渲染 useMemo + useCallback React Compiler 自动优化
SEO react-helmet 原生 Metadata 体积 -5KB

7.2 关键指标对比

Time to Interactive (TTI)

React 18: 2.4s
React 19: 1.8s  (提升 25%)

First Contentful Paint (FCP)

React 18: 1.2s
React 19: 0.9s  (提升 25%)

Bundle Size

React 18: 42KB (gzipped)
React 19: 38KB (gzipped) (减少 9.5%)

总结

React 19 的三大核心改进:

  1. Actions 机制:自动管理异步状态,告别手动 isPending
  2. use API:统一的 Suspense 数据获取,支持 Server Components
  3. React Compiler:自动记忆化,无需手动 useMemo/useCallback

升级建议

  • ✅ 新项目可以直接使用 React 19
  • ⚠️ 老项目逐步迁移,先用 Actions 简化表单
  • 🔬 React Compiler 等稳定后再用

学习路径

  1. 理解 Fiber 架构(React 18 基础)
  2. 掌握 Actions 和 use API(React 19 核心)
  3. 了解 Server Components 架构(未来方向)
  4. 关注 React Compiler(性能终极方案)

参考资源

Flutter—— 本地存储(shared_preferences)

作者 Haha_bj
2026年2月27日 11:10

一、简介

shared_preferences 是 Flutter 官方提供的键值对(Key-Value) 本地存储插件,本质是对原生平台存储的封装:

  • iOS:封装 NSUserDefaults
  • Android:封装 SharedPreferences
  • 桌面端(Windows/macOS):封装本地 JSON 文件
  • 核心特点:轻量、API 简单、持久化(APP 重启 / 卸载前数据不丢失)、仅支持基础数据类型

二、支持的数据类型

类型 对应 API 方法 说明
字符串 setString()/getString() 存储 token、用户名等
布尔值 setBool()/getBool() 存储开关状态、是否登录等
整数 setInt()/getInt() 存储计数、ID 等
浮点数 setDouble()/getDouble() 存储版本号、数值配置等
字符串列表 setStringList()/getStringList() 存储历史记录、标签等

三、基础使用步骤

1. 安装依赖

pub.dev/packages/sh…

pubspec.yaml 中添加:

dependencies:
  flutter:
    sdk: flutter
  shared_preferences: ^2.5.4 # 推荐使用最新稳定版

2. API 使用

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SharedPreferences 详解',
      home: const SPExamplePage(),
    );
  }
}

class SPExamplePage extends StatefulWidget {
  const SPExamplePage({super.key});

  @override
  State<SPExamplePage> createState() => _SPExamplePageState();
}

class _SPExamplePageState extends State<SPExamplePage> {
  // 存储的测试数据
  String _userToken = "";
  bool _isDarkMode = false;
  int _loginCount = 0;
  double _appVersion = 1.0;
  List<String> _historyList = [];

  // 初始化:页面加载时读取存储的数据
  @override
  void initState() {
    super.initState();
    _loadAllData();
  }

  // ========== 核心方法1:读取数据 ==========
  Future<void> _loadAllData() async {
    // 1. 获取 SharedPreferences 实例(必须异步)
    SharedPreferences prefs = await SharedPreferences.getInstance();

    // 2. 读取数据:第二个参数是「默认值」(key不存在时返回)
    setState(() {
      _userToken = prefs.getString("user_token") ?? ""; // 字符串默认空
      _isDarkMode = prefs.getBool("dark_mode") ?? false; // 布尔默认false
      _loginCount = prefs.getInt("login_count") ?? 0; // 整数默认0
      _appVersion = prefs.getDouble("app_version") ?? 1.0; // 浮点数默认1.0
      _historyList = prefs.getStringList("browse_history") ?? []; // 列表默认空
    });
  }

  // ========== 核心方法2:保存数据 ==========
  Future<void> _saveAllData() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();

    // 1. 保存单个数据
    await prefs.setString("user_token", "abc123456789");
    await prefs.setBool("dark_mode", true);
    await prefs.setInt("login_count", _loginCount + 1); // 计数+1
    await prefs.setDouble("app_version", 2.1);
    await prefs.setStringList("browse_history", ["首页", "我的", "设置"]);

    // 2. 保存后刷新页面数据
    _loadAllData();

    // 提示用户
    if (mounted) { // 防止页面销毁后调用context
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text("数据保存成功 ✅")),
      );
    }
  }

  // ========== 核心方法3:删除数据 ==========
  Future<void> _deleteData() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();

    // 方式1:删除单个key
    await prefs.remove("user_token");

    // 方式2:清空所有数据(谨慎使用!)
    // await prefs.clear();

    // 刷新数据
    _loadAllData();
    if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text("数据删除成功 ❌")),
      );
    }
  }

  // ========== 核心方法4:检查key是否存在 ==========
  Future<void> _checkKeyExists() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    bool hasToken = prefs.containsKey("user_token");
    if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text("user_token 是否存在:$hasToken")),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("SharedPreferences 详解")),
      body: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 显示存储的数据
            Text("用户Token:$_userToken"),
            Text("深色模式:$_isDarkMode"),
            Text("登录次数:$_loginCount"),
            Text("APP版本:$_appVersion"),
            Text("浏览历史:${_historyList.join(", ")}"),
            const SizedBox(height: 30),

            // 操作按钮
            Row(
              children: [
                ElevatedButton(
                  onPressed: _saveAllData,
                  child: const Text("保存数据"),
                ),
                const SizedBox(width: 10),
                ElevatedButton(
                  onPressed: _deleteData,
                  style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
                  child: const Text("删除Token"),
                ),
              ],
            ),
            const SizedBox(height: 10),
            ElevatedButton(
              onPressed: _checkKeyExists,
              child: const Text("检查Token是否存在"),
            ),
          ],
        ),
      ),
    );
  }
}

四、进阶技巧

1. 封装工具类(避免重复代码)

实际项目中建议封装成单例工具类,统一管理存储的 key 和方法:

import 'package:shared_preferences/shared_preferences.dart';

class SPUtil {
  // 单例模式
  static SPUtil? _instance;
  static SharedPreferences? _prefs;

  // 私有化构造函数
  SPUtil._();

  // 获取单例
  static Future<SPUtil> getInstance() async {
    if (_instance == null) {
      _instance = SPUtil._();
    }
    if (_prefs == null) {
      _prefs = await SharedPreferences.getInstance();
    }
    return _instance!;
  }

  // ========== 定义存储的key(统一管理,避免拼写错误) ==========
  static const String KEY_USER_TOKEN = "user_token";
  static const String KEY_DARK_MODE = "dark_mode";
  static const String KEY_LOGIN_COUNT = "login_count";

  // ========== 封装常用方法 ==========
  // 保存字符串
  Future<void> setString(String key, String value) async {
    await _prefs?.setString(key, value);
  }

  // 读取字符串
  String getString(String key, {String defaultValue = ""}) {
    return _prefs?.getString(key) ?? defaultValue;
  }

  // 保存布尔值
  Future<void> setBool(String key, bool value) async {
    await _prefs?.setBool(key, value);
  }

  // 读取布尔值
  bool getBool(String key, {bool defaultValue = false}) {
    return _prefs?.getBool(key) ?? defaultValue;
  }

  // 删除单个key
  Future<void> remove(String key) async {
    await _prefs?.remove(key);
  }

  // 清空所有数据
  Future<void> clear() async {
    await _prefs?.clear();
  }
}

// 使用示例
void useSPUtil() async {
  SPUtil spUtil = await SPUtil.getInstance();
  // 保存
  await spUtil.setString(SPUtil.KEY_USER_TOKEN, "123456");
  // 读取
  String token = spUtil.getString(SPUtil.KEY_USER_TOKEN);
  print("Token:$token");
}

2. 存储复杂对象(序列化 / 反序列化)

shared_preferences 不支持直接存储对象,需先转 JSON 字符串:

import 'dart:convert';

// 定义用户模型
class User {
  String name;
  int age;
  String email;

  User({required this.name, required this.age, required this.email});

  // 转JSON字符串
  String toJson() {
    Map<String, dynamic> map = {
      "name": name,
      "age": age,
      "email": email,
    };
    return json.encode(map);
  }

  // 从JSON字符串转对象
  static User fromJson(String jsonStr) {
    Map<String, dynamic> map = json.decode(jsonStr);
    return User(
      name: map["name"],
      age: map["age"],
      email: map["email"],
    );
  }
}

// 存储/读取对象
Future<void> saveUser() async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  // 1. 创建对象
  User user = User(name: "张三", age: 25, email: "zhangsan@example.com");
  // 2. 转JSON字符串保存
  await prefs.setString("user_info", user.toJson());
  // 3. 读取并转对象
  String userJson = prefs.getString("user_info") ?? "";
  if (userJson.isNotEmpty) {
    User savedUser = User.fromJson(userJson);
    print("用户名:${savedUser.name},年龄:${savedUser.age}");
  }
}

五、避坑指南(常见问题)

1. 同步 / 异步问题(最容易踩坑)

  • ❌ 错误:在 initState 中同步调用 getStringgetInstance 是异步的)

    @override
    void initState() {
      super.initState();
      // 错误!SharedPreferences.getInstance() 是异步,不能直接同步调用
      String token = SharedPreferences.getInstance().then((prefs) => prefs.getString("token"));
    }
    
  • ✅ 正确:用 async/awaitthen 处理异步

    @override
    void initState() {
      super.initState();
      _loadData(); // 异步方法
    }
    
    Future<void> _loadData() async {
      SharedPreferences prefs = await SharedPreferences.getInstance();
      String token = prefs.getString("token") ?? "";
    }
    

2. 页面销毁后调用 setState

  • 问题:异步操作完成后页面已销毁,调用 setState 会报错

  • 解决:用 mounted 判断页面是否挂载

    Future<void> _loadData() async {
      SharedPreferences prefs = await SharedPreferences.getInstance();
      if (mounted) { // 关键:判断页面是否还在
        setState(() {
          _token = prefs.getString("token") ?? "";
        });
      }
    }
    

3. 数据未及时刷新

  • 问题:保存数据后,UI 没有实时更新

  • 解决:保存后重新调用读取方法,触发 setState

    await prefs.setString("token", "new_token");
    _loadData(); // 重新读取,刷新UI
    

5 个让 CSS 起飞的新特性,设计师看了直呼内行

2026年2月27日 11:01

有大佬说: "CSS 而已,能玩出什么花?"

今天我就用 5 个原生 CSS 新特性告诉你——现在的 CSS,已经不是当年的 CSS 了。它不再是那个只会改背景颜色的"样式表",而是进化成了能处理逻辑、响应状态、甚至做动画的系统级设计工具

设计师想在 Figma 里做的效果,CSS 现在不仅能做,而且做得更好。往下看,每一个都能让你删掉一坨 JavaScript 代码。


1. Scroll State Queries:终于知道"粘性元素"什么时候粘住了

以前我们想给 sticky 导航栏加个阴影,怎么做?监听 scroll 事件,计算滚动距离,判断元素是否"粘住"……一堆性能杀手代码

现在?一行 CSS 搞定

css

.sticky-nav {
  container-type: scroll-state;
  position: sticky;
  top: 0;
}

.sticky-nav > nav {
  transition: box-shadow 0.3s;
  
  /* 只有当元素真正"粘住"时,才加阴影 */
  @container scroll-state(stuck: top) {
    box-shadow: 0 4px 20px rgba(0,0,0,0.1);
  }
}

这意味着什么?

  • 不用写 Intersection Observer
  • 不用监听 scroll 事件
  • 浏览器原生告诉你"我粘住了"

这个 API 还能检测"是否被滚动捕捉"、"是否可滚动"等状态。Snap 轮播图的激活态?一行代码的事

设计师惊呼:  "终于不用跟开发解释'当导航栏粘住时加阴影'是什么意思了。"


2. 完全自定义的 Select 下拉框:UI 库的末日

有个笑话:前端开发一辈子都在跟 select 标签较劲。为了让它长得好看,我们引过 Chosen、Select2、React Select……一个下拉框,几百 KB 的 JS

现在,原生 select 终于可以随便改了

css

/* 开启可自定义模式 */
select, ::picker(select) {
  appearance: base-select;
}

/* 选项里甚至可以放图片 */
option {
  display: flex;
  align-items: center;
  gap: 8px;
}

option img {
  width: 24px;
  height: 24px;
  border-radius: 50%;
}

对应的 HTML 长这样:

html

<select>
  <button>
    <selectedcontent></selectedcontent>
    <span class="arrow">👇</span>
  </button>
  <option>
    <img src="avatar1.jpg"> 张三
  </option>
  <option>
    <img src="avatar2.jpg"> 李四
  </option>
</select>

这是什么概念?

  • 下拉箭头可以随便改
  • 选项里可以放任何 HTML
  • 选中的内容可以自定义渲染
  • 完全不需要 JavaScript

设计师惊呼:  "所以以后 Figma 里的下拉框设计,都能 1:1 还原了?"


3. @starting-style:弹窗进出动画,终于丝滑了

以前做弹窗动画有个痛点:元素从 display: none 到显示,过渡效果不生效。因为没有"之前的状态"可以过渡。

@starting-style 专门解决这个问题

css

[popover] {
  /* 默认状态 */
  opacity: 0;
  transform: scale(0.9);
  transition: opacity 0.3s, transform 0.3s;
}

[popover]:popover-open {
  /* 打开后的状态 */
  opacity: 1;
  transform: scale(1);
}

/* 定义"开始动画前的状态" */
@starting-style {
  [popover]:popover-open {
    opacity: 0;
    transform: scale(0.9);
  }
}

就这么简单,弹窗出现时自动从 0 到 1,关闭时自动从 1 到 0。连 backdrop(背景遮罩)都可以一起动画

这意味着什么?

  • 再也不用 JS 控制入场动画
  • display: none 和 display: block 之间的过渡终于完美
  • Popover 和 Dialog 弹窗,天生就有丝滑动画

设计师惊呼:  "所以之前开发说的'弹窗动画不好做',是骗我的?"


4. contrast-color() 函数:自动适配文本颜色,再也不用写 JS 判断

设计师给了一个按钮,背景色是动态的(可能来自用户设置,可能来自数据)。问题来了:背景色深的时候,文字要用白色;背景色浅的时候,文字要用黑色

以前怎么做?JS 计算亮度,然后动态加 class。现在:

css

.button {
  --bg-color: #0066cc;  /* 可以是任何颜色 */
  background-color: var(--bg-color);
  
  /* 自动选择黑色或白色,保证可读性 */
  color: contrast-color(var(--bg-color));
}

contrast-color() 函数自动计算最佳对比色(黑或白),保证 WCAG 标准

更高级的用法:

css

.button {
  /* 指定两个候选色,让函数选择对比度更高的那个 */
  color: contrast-color(var(--bg-color), vs, #333, #eee);
}

这意味着什么?

  • 主题切换再也不用写两套文字颜色
  • 用户自定义主题时,样式自动适配
  • 再也不用为了文字可读性写 JS

设计师惊呼:  "所以以后设计系统里的文本颜色,可以自动适配背景了?"


5. Scroll-driven Animations:滚动即动画,性能炸裂

以前做滚动进度条、视差效果、滚动触发动画,都得靠 JS + requestAnimationFrame,性能消耗大,而且容易卡顿。

现在,CSS 原生支持动画进度绑定滚动位置

css

/* 一个简单的滚动进度条 */
#progress {
  height: 4px;
  background: #0066cc;
  
  /* 动画进度绑定滚动位置 */
  animation: grow-progress linear forwards;
  animation-timeline: scroll();
}

@keyframes grow-progress {
  from { width: 0%; }
  to { width: 100%; }
}

想要更复杂的视差效果?

css

.parallax-image {
  /* 滚动时,图片从 0.5 倍缩放到 1 倍 */
  animation: scale-image linear forwards;
  animation-timeline: scroll();
  animation-range: entry 0% exit 100%;
}

@keyframes scale-image {
  from { transform: scale(0.5); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}

这意味着什么?

  • 滚动进度条:3 行 CSS
  • 视差滚动:5 行 CSS
  • 元素随滚动淡入淡出:4 行 CSS
  • 完全不需要 JS,60fps 稳稳的

设计师惊呼:  "所以之前做的那个滚动交互动效,现在不用等开发排期了?"


写在最后

这 5 个特性只是冰山一角。现在的 CSS 已经有了:

  • 条件逻辑:if() 函数
  • 自定义函数:@function
  • 锚点定位:真正的绝对定位
  • 容器查询:组件内响应式
  • 嵌套语法:再也不用写重复的选择器

CSS 已经不是当初那个 CSS 了。

以前我们说"能用 CSS 解决的问题,就不要用 JS"。现在可以改成: "能用 CSS 解决的问题,都不叫问题。"

设计师和开发的鸿沟,正在被现代 CSS 一点点填平。你设计的每一个细节,现在都能用几行样式代码完美还原。


如果这篇文章让你对 CSS 刮目相看,点个赞,转个发,让更多朋友看到——CSS 起飞了。

评论区告诉我:你最想用哪个特性?或者你还见过哪些让你惊呼的 CSS 新功能?

JS 异步编程实战 | 从回调地狱到 Promise/Async/Await(附代码 + 面试题)

作者 代码煮茶
2026年2月27日 10:41

一、为什么需要异步编程?

JavaScript 是单线程语言,同一时间只能做一件事。如果有耗时操作(如网络请求、文件读取、定时任务),就会阻塞后续代码执行。

// 同步阻塞示例 
console.log('开始')
for(let i = 0; i < 1000000000; i++) {}
// 耗时操作 console.log('结束') 
// 必须等待循环结束才执行

为了解决这个问题,JavaScript 提供了异步编程解决方案。

二、回调函数(Callback)—— 最基础的异步方案

2.1 基本概念

回调函数是将函数作为参数传递给另一个函数,在异步操作完成后调用。

// 模拟异步请求
function fetchData(callback) {
  setTimeout(() => {
    callback('数据加载完成')
  }, 1000)
}

console.log('开始请求')
fetchData((data) => {
  console.log(data) // 1秒后输出:数据加载完成
})
console.log('继续执行其他操作')
// 输出顺序:开始请求 → 继续执行其他操作 → 数据加载完成

2.2 回调地狱的产生

当有多个依赖的异步操作时,回调嵌套会形成"回调地狱":

// 回调地狱示例
getUserInfo(function(user) {
  getOrderList(user.id, function(orders) {
    getOrderDetail(orders[0].id, function(detail) {
      getProductInfo(detail.productId, function(product) {
        console.log('最终数据:', product)
      }, function(error) {
        console.error('获取商品失败', error)
      })
    }, function(error) {
      console.error('获取订单详情失败', error)
    })
  }, function(error) {
    console.error('获取订单列表失败', error)
  })
}, function(error) {
  console.error('获取用户失败', error)
})

回调地狱的问题:

  • 代码难以阅读和维护
  • 错误处理分散
  • 难以复用和调试

三、Promise —— 优雅的异步解决方案

3.1 Promise 基本用法

Promise 是 ES6 引入的异步编程解决方案,它代表一个异步操作的最终完成或失败。

// 创建 Promise
const promise = new Promise((resolve, reject) => {
  // 执行异步操作
  setTimeout(() => {
    const success = true
    if (success) {
      resolve('操作成功') // 成功时调用
    } else {
      reject('操作失败') // 失败时调用
    }
  }, 1000)
})

// 使用 Promise
promise
  .then(result => {
    console.log(result) // 成功:操作成功
  })
  .catch(error => {
    console.error(error) // 失败:操作失败
  })
  .finally(() => {
    console.log('无论成功失败都会执行')
  })

3.2 解决回调地狱

使用 Promise 重构上面的例子:

// 将每个异步操作封装成 Promise
function getUserInfo() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: 1, name: '张三' })
    }, 1000)
  })
}

function getOrderList(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve([{ id: 101, name: '订单1' }, { id: 102, name: '订单2' }])
    }, 1000)
  })
}

function getOrderDetail(orderId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: orderId, productId: 1001, price: 299 })
    }, 1000)
  })
}

function getProductInfo(productId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: productId, name: '商品名称', price: 299 })
    }, 1000)
  })
}

// 链式调用,告别回调地狱
getUserInfo()
  .then(user => {
    console.log('用户:', user)
    return getOrderList(user.id)
  })
  .then(orders => {
    console.log('订单列表:', orders)
    return getOrderDetail(orders[0].id)
  })
  .then(detail => {
    console.log('订单详情:', detail)
    return getProductInfo(detail.productId)
  })
  .then(product => {
    console.log('商品信息:', product)
  })
  .catch(error => {
    console.error('发生错误:', error)
  })

3.3 Promise 静态方法

// Promise.all - 等待所有 Promise 完成
const p1 = Promise.resolve(3)
const p2 = 42
const p3 = new Promise((resolve) => setTimeout(resolve, 100, 'foo'))

Promise.all([p1, p2, p3]).then(values => {
  console.log(values) // [3, 42, "foo"]
})

// Promise.race - 返回最先完成的 Promise
const promise1 = new Promise(resolve => setTimeout(resolve, 500, 'one'))
const promise2 = new Promise(resolve => setTimeout(resolve, 100, 'two'))

Promise.race([promise1, promise2]).then(value => {
  console.log(value) // "two" (因为 promise2 更快)
})

// Promise.allSettled - 等待所有 Promise 完成(无论成功失败)
const promises = [
  Promise.resolve('成功1'),
  Promise.reject('失败2'),
  Promise.resolve('成功3')
]

Promise.allSettled(promises).then(results => {
  results.forEach(result => {
    if (result.status === 'fulfilled') {
      console.log('成功:', result.value)
    } else {
      console.log('失败:', result.reason)
    }
  })
})

// Promise.any - 返回第一个成功的 Promise
const pErr = new Promise((resolve, reject) => reject('总是失败'))
const pSlow = new Promise(resolve => setTimeout(resolve, 500, '最终完成'))
const pFast = new Promise(resolve => setTimeout(resolve, 100, '很快完成'))

Promise.any([pErr, pSlow, pFast]).then(value => {
  console.log(value) // "很快完成"
})

四、Async/Await —— 同步方式的异步编程

4.1 基本语法

Async/Await 是 ES2017 引入的语法糖,让异步代码看起来像同步代码。

// async 函数返回一个 Promise
async function getData() {
  return '数据'
}

getData().then(result => console.log(result)) // 数据

// 使用 await 等待 Promise 完成
async function fetchUserData() {
  try {
    const user = await getUserInfo()
    console.log('用户:', user)
    
    const orders = await getOrderList(user.id)
    console.log('订单:', orders)
    
    const detail = await getOrderDetail(orders[0].id)
    console.log('详情:', detail)
    
    const product = await getProductInfo(detail.productId)
    console.log('商品:', product)
    
    return product
  } catch (error) {
    console.error('出错了:', error)
  }
}

// 调用 async 函数
fetchUserData().then(result => {
  console.log('最终结果:', result)
})

4.2 实战示例:模拟数据请求

// 模拟 API 请求函数
const mockAPI = (url, delay = 1000) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() > 0.1) { // 90% 成功率
        resolve({
          status: 200,
          data: { url, timestamp: Date.now() }
        })
      } else {
        reject(new Error(`请求 ${url} 失败`))
      }
    }, delay)
  })
}

// 使用 async/await 实现并发请求
async function fetchMultipleData() {
  try {
    // 并发请求
    const [userData, productData, orderData] = await Promise.all([
      mockAPI('/api/user', 800),
      mockAPI('/api/product', 1200),
      mockAPI('/api/order', 600)
    ])
    
    console.log('所有数据加载完成:')
    console.log('用户数据:', userData.data)
    console.log('商品数据:', productData.data)
    console.log('订单数据:', orderData.data)
    
    return { userData, productData, orderData }
  } catch (error) {
    console.error('数据加载失败:', error.message)
  }
}

// 串行请求(依赖关系)
async function fetchDependentData() {
  console.time('串行请求耗时')
  
  const user = await mockAPI('/api/user', 1000)
  console.log('第一步完成:', user.data)
  
  const orders = await mockAPI(`/api/user/${user.data.url}/orders`, 1000)
  console.log('第二步完成:', orders.data)
  
  const details = await mockAPI(`/api/orders/${orders.data.url}/details`, 1000)
  console.log('第三步完成:', details.data)
  
  console.timeEnd('串行请求耗时')
  // 总耗时约 3000ms
}

// 优化:并行处理不依赖的数据
async function fetchOptimizedData() {
  console.time('优化后耗时')
  
  // 同时发起两个独立请求
  const [user, products] = await Promise.all([
    mockAPI('/api/user', 1000),
    mockAPI('/api/products', 1000)
  ])
  
  console.log('用户和商品数据已获取')
  
  // 依赖用户数据的请求
  const orders = await mockAPI(`/api/user/${user.data.url}/orders`, 1000)
  
  // 可以并行处理的请求
  const [detail1, detail2] = await Promise.all([
    mockAPI(`/api/orders/${orders.data.url}/detail1`, 500),
    mockAPI(`/api/orders/${orders.data.url}/detail2`, 500)
  ])
  
  console.timeEnd('优化后耗时')
  // 总耗时约 2500ms
}

4.3 错误处理最佳实践

// 统一的错误处理函数
const handleAsyncError = (asyncFn) => {
  return async (...args) => {
    try {
      return [await asyncFn(...args), null]
    } catch (error) {
      return [null, error]
    }
  }
}

// 使用错误处理包装器
const safeFetchUser = handleAsyncError(fetchUserData)

async function main() {
  const [user, error] = await safeFetchUser()
  
  if (error) {
    console.error('操作失败:', error.message)
    return
  }
  
  console.log('操作成功:', user)
}

// 带超时的 Promise
function withTimeout(promise, timeout = 5000) {
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('请求超时')), timeout)
  })
  
  return Promise.race([promise, timeoutPromise])
}

async function fetchWithTimeout() {
  try {
    const result = await withTimeout(mockAPI('/api/data', 3000), 2000)
    console.log('数据:', result)
  } catch (error) {
    console.error('超时或失败:', error.message)
  }
}

五、手写实现(面试高频)

5.1 手写 Promise

class MyPromise {
  constructor(executor) {
    this.state = 'pending'
    this.value = undefined
    this.reason = undefined
    this.onFulfilledCallbacks = []
    this.onRejectedCallbacks = []

    const resolve = (value) => {
      if (this.state === 'pending') {
        this.state = 'fulfilled'
        this.value = value
        this.onFulfilledCallbacks.forEach(fn => fn())
      }
    }

    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected'
        this.reason = reason
        this.onRejectedCallbacks.forEach(fn => fn())
      }
    }

    try {
      executor(resolve, reject)
    } catch (error) {
      reject(error)
    }
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value
    onRejected = typeof onRejected === 'function' ? onRejected : error => { throw error }

    const promise2 = new MyPromise((resolve, reject) => {
      if (this.state === 'fulfilled') {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value)
            this.resolvePromise(promise2, x, resolve, reject)
          } catch (error) {
            reject(error)
          }
        }, 0)
      }

      if (this.state === 'rejected') {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason)
            this.resolvePromise(promise2, x, resolve, reject)
          } catch (error) {
            reject(error)
          }
        }, 0)
      }

      if (this.state === 'pending') {
        this.onFulfilledCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onFulfilled(this.value)
              this.resolvePromise(promise2, x, resolve, reject)
            } catch (error) {
              reject(error)
            }
          }, 0)
        })

        this.onRejectedCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onRejected(this.reason)
              this.resolvePromise(promise2, x, resolve, reject)
            } catch (error) {
              reject(error)
            }
          }, 0)
        })
      }
    })

    return promise2
  }

  resolvePromise(promise2, x, resolve, reject) {
    if (promise2 === x) {
      reject(new TypeError('Chaining cycle detected'))
    }

    if (x && (typeof x === 'object' || typeof x === 'function')) {
      let called = false
      try {
        const then = x.then
        if (typeof then === 'function') {
          then.call(
            x,
            y => {
              if (called) return
              called = true
              this.resolvePromise(promise2, y, resolve, reject)
            },
            error => {
              if (called) return
              called = true
              reject(error)
            }
          )
        } else {
          resolve(x)
        }
      } catch (error) {
        if (called) return
        called = true
        reject(error)
      }
    } else {
      resolve(x)
    }
  }

  catch(onRejected) {
    return this.then(null, onRejected)
  }

  static resolve(value) {
    if (value instanceof MyPromise) return value
    return new MyPromise(resolve => resolve(value))
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason))
  }

  static all(promises) {
    return new MyPromise((resolve, reject) => {
      const result = []
      let count = 0
      
      for (let i = 0; i < promises.length; i++) {
        MyPromise.resolve(promises[i]).then(
          value => {
            result[i] = value
            count++
            if (count === promises.length) resolve(result)
          },
          reject
        )
      }
    })
  }

  static race(promises) {
    return new MyPromise((resolve, reject) => {
      for (const promise of promises) {
        MyPromise.resolve(promise).then(resolve, reject)
      }
    })
  }
}

5.2 手写 async/await 的简单实现

// 使用 Generator 模拟 async/await
function asyncToGenerator(generatorFn) {
  return function() {
    const gen = generatorFn.apply(this, arguments)
    
    return new Promise((resolve, reject) => {
      function step(key, arg) {
        let result
        try {
          result = gen[key](arg)
        } catch (error) {
          reject(error)
          return
        }
        
        const { value, done } = result
        
        if (done) {
          resolve(value)
        } else {
          Promise.resolve(value).then(
            val => step('next', val),
            err => step('throw', err)
          )
        }
      }
      
      step('next')
    })
  }
}

// 使用示例
const fetchData = function() {
  return new Promise(resolve => {
    setTimeout(() => resolve('数据'), 1000)
  })
}

const getData = asyncToGenerator(function* () {
  const data1 = yield fetchData()
  console.log('data1:', data1)
  
  const data2 = yield fetchData()
  console.log('data2:', data2)
  
  return '完成'
})

getData().then(result => console.log(result))

六、面试高频题

6.1 输出顺序题

// 题目1
console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
console.log('4')

// 输出:1, 4, 3, 2
// 解释:同步代码先执行,微任务(Promise)先于宏任务(setTimeout)

// 题目2
async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

async function async2() {
  console.log('async2')
}

console.log('script start')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

async1()

new Promise((resolve) => {
  console.log('promise1')
  resolve()
}).then(() => {
  console.log('promise2')
})

console.log('script end')

// 输出顺序:
// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout

6.2 错误处理题

// 题目:如何捕获 async/await 的错误?
async function getData() {
  try {
    const data = await Promise.reject('出错了')
    console.log(data)
  } catch (error) {
    console.log('捕获到:', error)
  }
}

// 或使用 .catch
async function getData2() {
  const data = await Promise.reject('出错了').catch(err => {
    console.log('处理错误:', err)
    return '默认值'
  })
  console.log(data) // 默认值
}

// 题目:Promise.all 的错误处理
const promises = [
  Promise.resolve(1),
  Promise.reject('错误'),
  Promise.resolve(3)
]

Promise.all(promises)
  .then(console.log)
  .catch(console.error) // 输出:错误

// 如何让 Promise.all 即使有错误也返回所有结果?
Promise.allSettled(promises).then(results => {
  results.forEach(result => {
    if (result.status === 'fulfilled') {
      console.log('成功:', result.value)
    } else {
      console.log('失败:', result.reason)
    }
  })
})

6.3 并发控制题

// 题目:实现一个并发控制器,限制同时执行的 Promise 数量
class PromiseQueue {
  constructor(concurrency = 2) {
    this.concurrency = concurrency
    this.running = 0
    this.queue = []
  }
  
  add(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject })
      this.run()
    })
  }
  
  run() {
    while (this.running < this.concurrency && this.queue.length) {
      const { task, resolve, reject } = this.queue.shift()
      this.running++
      
      Promise.resolve(task())
        .then(resolve, reject)
        .finally(() => {
          this.running--
          this.run()
        })
    }
  }
}

// 使用示例
const queue = new PromiseQueue(2)

for (let i = 0; i < 5; i++) {
  queue.add(() => 
    new Promise(resolve => {
      setTimeout(() => {
        console.log(`任务${i}完成`)
        resolve(i)
      }, 1000)
    })
  )
}
// 每2个任务并行执行

6.4 重试机制题

// 题目:实现一个函数,请求失败时自动重试
async function retryRequest(fn, maxRetries = 3, delay = 1000) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      console.log(`第${i + 1}次尝试`)
      const result = await fn()
      console.log('请求成功')
      return result
    } catch (error) {
      console.log(`第${i + 1}次失败`)
      if (i === maxRetries - 1) {
        throw error
      }
      // 等待延迟时间后重试
      await new Promise(resolve => setTimeout(resolve, delay))
    }
  }
}

// 使用示例
let attempt = 0
const request = () => {
  return new Promise((resolve, reject) => {
    attempt++
    if (attempt < 3) {
      reject('模拟失败')
    } else {
      resolve('成功')
    }
  })
}

retryRequest(request, 3, 1000)
  .then(console.log)
  .catch(console.error)

七、总结与建议

7.1 异步编程演进

  • 回调函数:基础但容易形成"回调地狱"
  • Promise:链式调用,错误统一处理
  • Async/Await:语法糖,代码更直观

7.2 使用建议

  1. 优先使用 async/await,代码更清晰
  2. 并发请求使用 Promise.all,提高性能
  3. 注意错误处理,不要吞掉错误
  4. 避免回调地狱,及时重构代码
  5. 理解事件循环,掌握执行顺序

7.3 面试准备

  • 掌握三种异步方案的原理和用法
  • 能够手写简单的 Promise
  • 理解宏任务和微任务的执行顺序
  • 熟悉常见的异步编程场景和解决方案
  • 能够处理并发控制和错误重试

异步编程是 JavaScript 的核心特性,掌握好这块内容不仅对面试有帮助,更能提升实际开发中的代码质量。

HTML&CSS:纯CSS实现随机转盘抽奖机——无JS,全靠现代CSS黑科技!

作者 前端Hardy
2026年2月27日 10:30

这个 HTML 页面实现了一个交互式转盘抽奖效果,使用了现代 CSS 的一些实验性特性 (如 random() 函数、@layer、sibling-index() 等),并结合 SVG 图标和渐变背景,营造出一个视觉吸引、功能完整的“幸运大转盘”界面。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

演示效果

演示效果

HTML&CSS

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS随机函数实现转盘效果</title>
    <style>
        @import url(https://fonts.bunny.net/css?family=jura:300,700);
        @layer base, notes, demo;

        @layer demo {
            :root {
                --items: 12;
                --spin-easing: cubic-bezier(0, 0.061, 0, 1.032);
                --slice-angle: calc(360deg / var(--items));
                --start-angle: calc(var(--slice-angle) / 2);

                --wheel-radius: min(40vw, 300px);
                --wheel-size: calc(var(--wheel-radius) * 2);
                --wheel-padding: 10%;
                --item-radius: calc(var(--wheel-radius) - var(--wheel-padding));

                --wheel-bg-1: oklch(0.80 0.16 30);
                --wheel-bg-2: oklch(0.74 0.16 140);
                --wheel-bg-3: oklch(0.80 0.16 240);
                --wheel-bg-4: oklch(0.74 0.16 320);

                --marker-bg-color: black;
                --button-text-color: white;
                --spin-duration: random(1s, 3s);

                --random-angle: random(1200deg, 4800deg, by var(--slice-angle));

                @supports not (rotate: random(1deg, 10deg)) {
                    --spin-duration: 2s;
                    --random-angle: 4800deg;
                }
            }


            .wrapper {
                position: relative;
                inset: 0;
                margin: auto;
                width: var(--wheel-size);
                aspect-ratio: 1;

                input[type=checkbox] {
                    position: absolute;
                    opacity: 0;
                    width: 1px;
                    height: 1px;
                    pointer-events: none;
                }

                &:has(input[type=checkbox]:checked) {
                    --spin-it: 1;
                    --btn-spin-scale: 0;
                    --btn-spin-event: none;
                    --btn-spin-trans-duration: var(--spin-duration);
                    --btn-reset-scale: 1;
                    --btn-reset-event: auto;
                    --btn-reset-trans-delay: var(--spin-duration);
                }

                .controls {
                    position: absolute;
                    z-index: 2;
                    inset: 0;
                    margin: auto;
                    width: min(100px, 10vw);
                    aspect-ratio: 1;
                    background: var(--marker-bg-color);
                    border-radius: 9in;
                    transition: scale 150ms ease-in-out;

                    &:has(:hover, :focus-visible) label {
                        scale: 1.2;
                        rotate: 20deg;
                    }

                    &::before {
                        content: '';
                        position: absolute;
                        top: 0;
                        left: 50%;
                        translate: -50% -50%;
                        width: 20%;
                        aspect-radio: 2/10;
                        background-color: transparent;
                        border: 2vw solid var(--marker-bg-color);
                        border-bottom-width: 4vw;
                        border-top: 0;
                        border-left-color: transparent;
                        border-right-color: transparent;
                        z-index: -1;
                    }

                    label {
                        cursor: pointer;
                        display: grid;
                        place-items: center;
                        width: 100%;
                        aspect-ratio: 1;
                        color: var(--button-text-color);
                        transition:
                            rotate 150ms ease-in-out,
                            scale 150ms ease-in-out;

                        svg {
                            grid-area: 1/1;
                            width: 50%;
                            height: 50%;
                            transition-property: scale;
                            transition-timing-function: ease-in-out;

                            &:first-child {
                                transition-duration: var(--btn-spin-trans-duration, 150ms);
                                scale: var(--btn-spin-scale, 1);
                                pointer-events: var(--btn-spin-event, auto);
                            }

                            &:last-child {
                                transition-duration: 150ms;
                                transition-delay: var(--btn-reset-trans-delay, 0ms);
                                scale: var(--btn-reset-scale, 0);
                                pointer-events: var(--btn-reset-event, none);
                            }
                        }
                    }


                }

                &:has(input[type=checkbox]:checked)>.wheel {
                    animation: --spin-wheel var(--spin-duration, 3s) var(--spin-easing, ease-in-out) forwards;
                }

                .wheel {
                    position: absolute;
                    inset: 0;
                    border-radius: 99vw;
                    border: 1px solid white;
                    user-select: none;
                    font-size: 24px;
                    font-weight: 600;
                    background: repeating-conic-gradient(from var(--start-angle),
                            var(--wheel-bg-1) 0deg var(--slice-angle),
                            var(--wheel-bg-2) var(--slice-angle) calc(var(--slice-angle) * 2),
                            var(--wheel-bg-3) calc(var(--slice-angle) * 2) calc(var(--slice-angle) * 3),
                            var(--wheel-bg-4) calc(var(--slice-angle) * 3) calc(var(--slice-angle) * 4));

                    >span {
                        --i: sibling-index();

                        @supports not (sibling-index(0)) {
                            &:nth-child(1) {
                                --i: 1;
                            }

                            &:nth-child(2) {
                                --i: 2;
                            }

                            &:nth-child(3) {
                                --i: 3;
                            }

                            &:nth-child(4) {
                                --i: 4;
                            }

                            &:nth-child(5) {
                                --i: 5;
                            }

                            &:nth-child(6) {
                                --i: 6;
                            }

                            &:nth-child(7) {
                                --i: 7;
                            }

                            &:nth-child(8) {
                                --i: 8;
                            }

                            &:nth-child(9) {
                                --i: 9;
                            }

                            &:nth-child(10) {
                                --i: 10;
                            }

                            &:nth-child(11) {
                                --i: 11;
                            }

                            &:nth-child(12) {
                                --i: 12;
                            }
                        }
                        position: absolute;
                        offset-path: circle(var(--item-radius) at 50% 50%);
                        offset-distance: calc(var(--i) / var(--items) * 100%);
                        offset-rotate: auto;
                    }
                }
            }

            @keyframes --spin-wheel {
                to {
                    rotate: var(--random-angle);
                }
            }
        }

        @layer notes {
            section.notes {
                margin: auto;
                width: min(80vw, 56ch);

                p {
                    text-wrap: pretty;
                }

                > :first-child {
                    color: red;
                    background: rgb(255, 100, 103);
                    padding: .5em;
                    color: white;

                    @supports (rotate: random(1deg, 10deg)) {
                        display: none;
                    }
                }
            }
        }

        @layer base {

            *,
            ::before,
            ::after {
                box-sizing: border-box;
            }

            :root {
                color-scheme: light dark;
                --bg-dark: rgb(21 21 21);
                --bg-light: rgb(248, 244, 238);
                --txt-light: rgb(10, 10, 10);
                --txt-dark: rgb(245, 245, 245);
                --line-light: rgba(0 0 0 / .75);
                --line-dark: rgba(255 255 255 / .25);
                --clr-bg: light-dark(var(--bg-light), var(--bg-dark));
                --clr-txt: light-dark(var(--txt-light), var(--txt-dark));
                --clr-lines: light-dark(var(--line-light), var(--line-dark));
            }

            body {
                background-color: var(--clr-bg);
                color: var(--clr-txt);
                min-height: 100svh;
                margin: 0;
                padding: 2rem;
                font-family: "Jura", sans-serif;
                font-size: 1rem;
                line-height: 1.5;
                display: grid;
                place-content: center;
                gap: 2rem;
            }

            strong {
                font-weight: 700;
            }
        }
    </style>
</head>

<body>

    <section class="wrapper">
        <input type="checkbox" id="radio-spin">
        <div class="controls">
            <label for="radio-spin">
                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
                    stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                    aria-label="Spin the Wheel" title="Spin the Wheel">
                    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                    <path d="M14 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
                    <path d="M12 21c-3.314 0 -6 -2.462 -6 -5.5s2.686 -5.5 6 -5.5" />
                    <path d="M21 12c0 3.314 -2.462 6 -5.5 6s-5.5 -2.686 -5.5 -6" />
                    <path d="M12 14c3.314 0 6 -2.462 6 -5.5s-2.686 -5.5 -6 -5.5" />
                    <path d="M14 12c0 -3.314 -2.462 -6 -5.5 -6s-5.5 2.686 -5.5 6" />
                </svg>

                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
                    stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                    aria-label="Reset the Wheel" title="Reset the Wheel">
                    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                    <path d="M3.06 13a9 9 0 1 0 .49 -4.087" />
                    <path d="M3 4.001v5h5" />
                    <path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
                </svg>
            </label>
        </div>

        <div id="wheel" class="wheel">
            <span>乔丹</span>
            <span>詹姆斯</span>
            <span>布莱恩特</span>
            <span>约翰逊</span>
            <span>库里</span>
            <span>奥尼尔</span>
            <span>邓肯</span>
            <span>贾巴尔</span>
            <span>杜兰特</span>
            <span>哈登</span>
            <span>字母哥</span>
            <span>伦纳德</span>
        </div>
    </section>
</body>

</html>

HTML

  • section:转盘核心容器(语义化区块)相对定位,包含转盘所有子元素
  • input:转盘触发开关(核心交互控件) 视觉隐藏(opacity:0),通过「选中 / 未选中」触发动画
  • div controls:转盘中心按钮容器。绝对定位,层级高于转盘,包含点击触发的 label
  • label :绑定隐藏复选框,作为可点击按钮。点击该标签等价于点击复选框,触发状态切换
  • svg:显示「旋转」「重置」图标。两个 SVG 重叠,通过 CSS 控制显隐
  • div wheel:转盘本体。圆形布局,包含 12 个奖项文本
  • span:转盘奖项文本(12 个 NBA 球星名称) 每个 span 对应转盘一个分区,通过 CSS 定位到圆形轨道

CSS

1. 样式分层管理(@layer)

@layer base, notes, demo;
  • base:全局基础样式(盒模型、明暗色模式、页面布局),优先级最低;
  • notes:兼容提示文本样式,优先级中等;
  • demo:转盘核心样式(尺寸、动画、交互),优先级最高;

作用:按层级管理样式,避免样式冲突,便于维护。

2. 核心变量定义(:root)

:root {
  --items: 12; /* 转盘分区数量 */
  --slice-angle: calc(360deg / var(--items)); /* 每个分区角度(30°) */
  --wheel-radius: min(40vw, 300px); /* 转盘半径(自适应,最大300px) */
  --spin-duration: random(1s, 3s); /* 随机旋转时长(1-3秒) */
  --random-angle: random(1200deg, 4800deg, by var(--slice-angle)); /* 随机旋转角度(步长30°) */
  /* 浏览器兼容降级:不支持random()则固定值 */
  @supports not (rotate: random(1deg, 10deg)) {
    --spin-duration: 2s;
    --random-angle: 4800deg;
  }
}

核心:用变量统一管理转盘尺寸、角度、动画参数,random() 实现「随机旋转」核心效果,同时做浏览器兼容降级。

3. 转盘交互触发逻辑

/* 监听复选框选中状态,更新变量控制图标/动画 */
.wrapper:has(input[type=checkbox]:checked) {
  --btn-spin-scale: 0; /* 隐藏旋转图标 */
  --btn-reset-scale: 1; /* 显示重置图标 */
}
/* 选中时触发转盘旋转动画 */
.wrapper:has(input[type=checkbox]:checked)>.wheel {
  animation: --spin-wheel var(--spin-duration) var(--spin-easing) forwards;
}
/* 旋转动画:转到随机角度后保持状态 */
@keyframes --spin-wheel {
  to { rotate: var(--random-angle); }
}

核心:通过 :has() 伪类监听复选框状态,触发转盘动画,forwards 确保动画结束后不回弹。

4. 转盘视觉与布局

.wheel {
  border-radius: 99vw; /* 圆形转盘 */
  /* 四色循环锥形渐变,实现转盘分区背景 */
  background: repeating-conic-gradient(from var(--start-angle),
    var(--wheel-bg-1) 0deg var(--slice-angle),
    var(--wheel-bg-2) var(--slice-angle) calc(var(--slice-angle)*2),
    var(--wheel-bg-3) calc(var(--slice-angle)*2) calc(var(--slice-angle)*3),
    var(--wheel-bg-4) calc(var(--slice-angle)*3) calc(var(--slice-angle)*4));
  >span {
    offset-path: circle(var(--item-radius) at 50% 50%); /* 圆形轨道 */
    offset-distance: calc(var(--i) / var(--items) * 100%); /* 按索引定位到对应分区 */
  }
}

核心:repeating-conic-gradient 实现转盘彩色分区,offset-path 让奖项文本沿圆形轨道均匀分布。

5. 中心按钮交互

.controls {
  position: absolute;
  z-index: 2; /* 层级高于转盘,确保可点击 */
  border-radius: 9in; /* 圆形按钮 */
  &::before { /* 转盘顶部指针 */
    content: '';
    border: 2vw solid var(--marker-bg-color);
    border-bottom-width: 4vw;
    border-top/left/right-color: transparent; /* 三角指针形状 */
  }
  label:hover { scale: 1.2; rotate: 20deg; } /* 鼠标悬浮时图标放大旋转 */
}

核心:伪元素实现转盘「指针」,hover 动效提升交互反馈,两个 SVG 图标通过 scale 控制显隐。

6. 全局基础样式

@layer base {
  :root {
    color-scheme: light dark; /* 适配系统明暗色模式 */
    --clr-bg: light-dark(var(--bg-light), var(--bg-dark)); /* 自动切换背景色 */
  }
  body {
    min-height: 100svh; /* 适配移动端安全区 */
    display: grid;
    place-content: center; /* 垂直水平居中 */
  }
}

核心:light-dark() 自动适配系统明暗模式,100svh 避免移动端地址栏遮挡,网格布局实现内容居中。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

手写一个无限画布 #3:如何在Canvas 层上建立事件体系

作者 光头老石
2026年2月27日 10:29

你以为你点中了一个圆,其实你只是点中了一堆毫无意义的像素点。在画布里,所谓的“选中”,不过是一场精密的数学与色彩幻术。

上一篇我们终于搞定了渲染层,并明确选择了 Konva (Canvas 2D) 作为我们的底层渲染基石。现在,我们的屏幕上终于可以丝滑地渲染出极具表现力的图形了。

但是,当你试图把鼠标悬停在其中一个图形上,或者想拖拽一条连线时,你会遭遇一个巨大的反直觉打击:浏览器完全不知道你点的是什么。

在传统的前端开发中,原生 DOM 是一棵界限分明的树。鼠标移入一个 <div>,浏览器引擎会在底层自动做碰撞检测,并把 mouseenterclick 事件准确无误地派发给这个节点。如果你给 <div> 加了圆角(border-radius)甚至复杂的 clip-path,浏览器依然能完美识别出精确的边缘。这种体验太理所当然,以至于我们从未思考过背后的代价。

但在 Canvas 的世界里,这套秩序完全失效了。

对于浏览器来说,不管你在 Canvas 里画了多少个圆圈、多复杂的文字,它看到的永远只有一个扁平的 <canvas> 标签。 当用户点击屏幕时,浏览器的原生 Event 对象只能递给你一个冷冰冰的坐标:{ clientX: 500, clientY: 400 }。至于这个坐标下是空气、是红色正方形,还是三个交叠在一起的半透明多边形,对不起,只能你自己算。

要在毫无知觉的像素油盆上,重新赋予图形被“感知”的能力,这就是 命中测试(Hit Testing) 的核心命题。

直觉陷阱:纯算几何碰撞

面对这个问题,多数人脑海里冒出的第一个念头一定是算数学题。

“既然我知道画布上每个方块的长宽、每个圆的半径,那鼠标点下去的时候,去遍历所有图形做个碰撞测试不就好了?”

比如点矩形,就看鼠标坐标是不是在它的上下左右边界内;点圆,就算勾股定理看距离是不是小于半径;如果是多边形,大不了掏出大学计算机图形学里教的“射线法(Ray-Casting)”,看看射线和多边形交点是奇数还是偶数。

在很多游戏开发新手教程里,这确实是讲解命中测试的第一课。

但只要你真的在业务里动手写过,就会立刻体会到这种朴素算法带来的“工程绝望”:

如果是最基础的方块和圆还好,可你在白板工具(如 Excalidraw / Figma)里,最常面对的是用户鼠标画出的一条粗细不均、极度扭曲的自由手绘墨迹(Freehand Draw)。成百上千个点连出来的畸形曲线,你拿什么算交点?

即使你咬着牙把每根线段都算了,还有图形的中空与穿透问题。当用户点在一个空心圆环的正中间,或者字母 "O" 的空白处时,根据最粗糙的外围包围盒(Bounding Box),它是被命中的;但这根本违反了用户“我明明点在透明的地方,我想点它背后元素”的心理预期。哪怕你真算出了鼠标确实落在图形线条上,你又怎么确保,这层图形的正上方,没有被另一个半透明的阴影盖住呢?

别忘了最绝杀的性能噩梦。不仅是点击,鼠标每在屏幕上划过一个像素,就会高频触发 mousemove。如果同屏有几千个杂乱的图形交叠,每移动一毫米就要把所有多边形的射线方程重新算一遍,你的 CPU 风扇会直接起飞,页面帧率瞬间崩盘。

想靠纯写 if-else 的几何穷举来搞定一个不仅带各种圆角、线宽、自交错,还带层级遮挡的生产级别白板交互,可以说是直接在给 CPU 判死刑。


优雅的黑魔法:离屏 Canvas 与 Color Picking

针对纯正向几何数学算不通的情况,业界的顶级绘图引擎往往会使用一招极度聪明且优雅的逆向黑魔法:利用颜色查表法(Color Picking)。这也是 Konva 最为核心的看家本领机制。

hit-test-color-picking.png

它的核心逻辑堪称“暗度陈仓”,分为以下几个精妙的步骤:

1. 建立影分身(Hidden Canvas)

在内存中,创建一个跟主屏幕尺寸完全一致的隐藏 Canvas(用户看不见它)。主屏幕负责渲染展现给用户看的漂亮图形,而这个“影分身”只专门用来做苦力——命中测试。

2. 分配身份色(Color Hash)

当我们要往主屏幕画一个崭新的图形(比如一个带有高斯模糊阴影的蓝色虚线圈)时,引擎会在内存里给这个图形分配一个全局唯一、随机生成的 RGB 颜色值(比如 #000001)。 然后在内存的隐藏 Canvas 的同样坐标处,用这个唯一颜色 #000001 画一个同样轮廓的圆。无论主画布上的圆有多花哨,隐藏画布上的圆统统画成没有阴影、没有抗锯齿的纯色实心/实线

与此同时,维护一个字典(Hash Map),记录:#000001 映射到 蓝色虚线图对象引用

3. O(1) 的降维打击:只读一个像素

见证奇迹的时刻到了。 当前的场景是:主画布上画了成千上万个复杂的图形。隐藏画布上也用同样的布局画了成千上万个纯粹色块。

当用户在主屏幕上点击 (x: 500, y: 400) 时,引擎不去做任何数学几何碰撞计算,除了获取坐标外只做极其底层的一步:

  1. 走到隐藏 Canvas 面前。
  2. 精确地读取它 (500, 400) 这个坐标点上的 1 个像素的 RGB 颜色值getImageData)。
  3. 如果读出来的颜色是黑色(完全透明),说明没点中任何东西。
  4. 如果读出来的颜色是 #000001,引擎立刻去 Hash Map 里查表——破案了!对应的是那个蓝色的虚线圈对象。

为什么这个方案是统治级的?

  1. 彻底无视几何形状的难度。不管你画的是自由手绘还是残缺的文字轮廓,只要它被渲染引擎画在屏幕上,那对应的颜色像素就实打实地落在了隐藏画布上。它巧妙地利用底层的 GPU 渲染规则来替你完成极度复杂的轮廓光栅化判定。
  2. 天然解决重叠遮挡。主画布怎么叠加层级的,隐藏画布也是按同样顺序绘制的。你在隐藏画布上读出来的那个带颜色像素,必然是最顶层、没被别人遮挡的那个对象的颜色。完全不需要自己遍历判断层级。
  3. 极端的性能空间换时间。把原本复杂的 O(N×几何顶点数)O(N \times 几何顶点数) 的每帧遍历计算,直接降维成了读取内存图像一个单像素点的 O(1)O(1) 常数级查表时间。即使屏幕上有十万个对象,鼠标在上面疯狂移动也是绝对丝滑的。

站在巨人的肩膀:这就是 Konva

要在原生 Canvas 上实现一个可用于生产环境的稳健命中测试系统基建,工作量是极其庞大的。你要自己去维护那个巨大的离屏画布上下文同步、自己分配十六进制颜色、自己实现局部重绘优化、还要自己派发所有的模拟 DOM 冒泡事件。

这正是我们放弃从零手写引擎底层,转而选型采用 Konva 的终极原因。

Konva 在底层极其克制且优雅地封装了这套“离屏颜色拾取算法”。在开发者眼里,你完全感受不到那个诡异的“彩色隐藏画布”的存在。

它直接把这套脏活累活,包装成了我们最熟悉的、一如在写原生 DOM 一样的前端语法范式。这就让我们能够完全剥离繁复的数学几何泥潭,将精力投入在画布“事件分发与交互流控制”上:

// 这种久违的、确定的秩序感,对于开发无穷交互的白板来说是极其珍贵的。
import Konva from "konva";

const rect = new Konva.Rect({
  x: 50,
  y: 50,
  width: 100,
  height: 50,
  fill: "blue",
  draggable: true, // 开启拖拽!底层所有复杂的变换全自动运算并重绘画布。
});

// 你仿佛重新拥有了原生的 DOM 事件绑定系统
rect.on("mouseenter", () => {
  document.body.style.cursor = "pointer";
  rect.fill("red"); // 悬浮触发变色响应
});

rect.on("mouseleave", () => {
  document.body.style.cursor = "default";
  rect.fill("blue");
});

// 即使有成百上千个图形交叠,它也能极速计算,精准捕捉顶层响应
rect.on("click", (e) => {
  console.log("极速且精准地点中了我:", e.target);
});

有了 Konva 兜底解决“感知盲区”,我们终于补齐了跨越无限画布最重要、也是最难缠的一块技术栈拼图。

我们不再是在冷冰冰的像素点数组上作画,而是真正在操控和编排一个个有边界、能响应手势、知晓自身存在的“实体对象”

经历三篇的文章,我们已经打通了从“坐标系”、“底层渲染引擎选型博弈”到“重建事件分发秩序”的全部技术基建。

接下来,我们将长驱直入应用数据的深水区:在这块充满感知能力的画布上,我们该如何用正确的数据结构来对这些可被协同、可被导出、可被反序列化的对象进行定义?

BroadcastChannel:浏览器原生跨标签页通信

作者 大知闲闲i
2026年2月27日 10:29

在现代Web应用开发中,跨标签页通信是一个常见需求。无论是实现多标签页间的数据同步、构建协作工具,还是简单的消息广播,开发者都需要一个可靠的通信方案。虽然过去我们有 localStorage、postMessage 等方案,但 BroadcastChannel API 提供了一个更优雅、更专业的解决方案。

什么是 BroadcastChannel?

BroadcastChannel 是 HTML5 中引入的一个专门用于同源页面间通信的 API。它允许同一源下的不同浏览上下文(如标签页、iframe、Web Worker)之间进行消息广播。

核心特点

  • 同源限制:只能在相同协议、域名、端口的页面间通信

  • 一对多通信:一条消息可以同时被所有监听者接收

  • 双向通信:所有参与者既可以发送消息,也可以接收消息

  • 自动清理:页面关闭后自动断开连接

基础用法

1. 创建或加入频道

// 创建/加入名为 "chat_room" 的频道
const channel = new BroadcastChannel('chat_room');

// 查看频道名称
console.log(channel.name); // 输出: "chat_room"

2. 发送消息

// 发送字符串
channel.postMessage('Hello from Page 1');

// 发送对象
channel.postMessage({
  type: 'user_action',
  user: '张三',
  action: 'click',
  timestamp: Date.now()
});

// 支持大多数数据类型
channel.postMessage(['数组', '数据']);
channel.postMessage(new Blob(['文件内容']));
channel.postMessage(new Uint8Array([1, 2, 3]));

3. 接收消息

// 方式1:使用 onmessage
channel.onmessage = (event) => {
  console.log('收到消息:', event.data);
  console.log('消息来源:', event.origin);
  console.log('时间戳:', event.timeStamp);
};

// 方式2:使用 addEventListener
channel.addEventListener('message', (event) => {
  console.log('收到消息:', event.data);
});

// 错误处理
channel.onmessageerror = (error) => {
  console.error('消息处理错误:', error);
};

4. 关闭频道

// 关闭频道,不再接收消息
channel.close();

实际应用场景

场景1:主题同步

当用户在一个标签页切换主题时,所有其他标签页自动同步:

// theme-sync.js
class ThemeSync {
  constructor() {
    this.channel = new BroadcastChannel('theme_sync');
    this.setupListener();
  }
  
  setupListener() {
    this.channel.onmessage = (event) => {
      if (event.data.type === 'theme_change') {
        this.applyTheme(event.data.theme);
      }
    };
  }
  
  changeTheme(theme) {
    this.applyTheme(theme);
    this.channel.postMessage({
      type: 'theme_change',
      theme: theme,
      from: this.getTabId()
    });
  }
  
  applyTheme(theme) {
    document.body.className = `theme-${theme}`;
    localStorage.setItem('preferred_theme', theme);
  }
  
  getTabId() {
    return sessionStorage.getItem('tab_id') || 
           Math.random().toString(36).substring(7);
  }
}

// 使用
const themeSync = new ThemeSync();
themeSync.changeTheme('dark');

场景2:实时聊天室

创建一个简单的多标签页聊天室:

<!-- chat.html -->
<!DOCTYPE html>
<html>
<head>
    <title>BroadcastChannel 聊天室</title>
    <style>
        .chat-container { max-width: 600px; margin: 0 auto; padding: 20px; }
        .message-list { 
            height: 400px; 
            overflow-y: auto; 
            border: 1px solid #ccc; 
            padding: 10px;
            margin-bottom: 10px;
        }
        .message { margin: 5px 0; padding: 8px; background: #f0f0f0; border-radius: 5px; }
        .system { background: #e3f2fd; text-align: center; }
        .self { background: #e8f5e8; border-left: 3px solid #4caf50; }
        .input-area { display: flex; gap: 10px; }
        #messageInput { flex: 1; padding: 8px; }
        button { padding: 8px 15px; background: #4caf50; color: white; border: none; border-radius: 3px; cursor: pointer; }
    </style>
</head>
<body>
    <div class="chat-container">
        <h1>📱 跨标签页聊天室</h1>
        <div class="message-list" id="messageList"></div>
        <div class="input-area">
            <input type="text" id="messageInput" placeholder="输入消息..." onkeypress="if(event.key==='Enter') sendMessage()">
            <button onclick="sendMessage()">发送</button>
            <button onclick="changeNickname()">修改昵称</button>
        </div>
    </div>

    <script>
        // 聊天室逻辑
        const chatChannel = new BroadcastChannel('global_chat');
        const userId = Math.random().toString(36).substring(2, 10);
        let nickname = '用户_' + userId.substring(0, 4);
        
        // 监听消息
        chatChannel.onmessage = (event) => {
            const { type, data, from, userId: msgUserId } = event.data;
            
            switch(type) {
                case 'message':
                    displayMessage(from, data, msgUserId === userId);
                    break;
                case 'join':
                    displaySystemMessage(`${from} 加入了聊天室`);
                    break;
                case 'leave':
                    displaySystemMessage(`${from} 离开了聊天室`);
                    break;
                case 'nickname_change':
                    displaySystemMessage(`${from} 改名为 ${data}`);
                    break;
            }
        };
        
        // 广播加入消息
        chatChannel.postMessage({
            type: 'join',
            from: nickname,
            userId: userId,
            time: Date.now()
        });
        
        function sendMessage() {
            const input = document.getElementById('messageInput');
            const text = input.value.trim();
            
            if (text) {
                chatChannel.postMessage({
                    type: 'message',
                    from: nickname,
                    data: text,
                    userId: userId,
                    time: Date.now()
                });
                
                displayMessage(nickname, text, true);
                input.value = '';
            }
        }
        
        function changeNickname() {
            const newNickname = prompt('请输入新昵称:', nickname);
            if (newNickname && newNickname.trim() && newNickname !== nickname) {
                const oldNickname = nickname;
                nickname = newNickname.trim();
                
                chatChannel.postMessage({
                    type: 'nickname_change',
                    from: oldNickname,
                    data: nickname,
                    userId: userId,
                    time: Date.now()
                });
            }
        }
        
        function displayMessage(sender, text, isSelf = false) {
            const list = document.getElementById('messageList');
            const msgDiv = document.createElement('div');
            msgDiv.className = `message ${isSelf ? 'self' : ''}`;
            
            const time = new Date().toLocaleTimeString('zh-CN', { 
                hour: '2-digit', 
                minute: '2-digit' 
            });
            
            msgDiv.innerHTML = `<strong>${sender}${isSelf ? ' (我)' : ''}:</strong> ${escapeHtml(text)} <small>${time}</small>`;
            
            list.appendChild(msgDiv);
            list.scrollTop = list.scrollHeight;
        }
        
        function displaySystemMessage(text) {
            const list = document.getElementById('messageList');
            const msgDiv = document.createElement('div');
            msgDiv.className = 'message system';
            msgDiv.innerHTML = escapeHtml(text);
            list.appendChild(msgDiv);
            list.scrollTop = list.scrollHeight;
        }
        
        function escapeHtml(text) {
            const div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }
        
        // 页面关闭时通知
        window.addEventListener('beforeunload', () => {
            chatChannel.postMessage({
                type: 'leave',
                from: nickname,
                userId: userId
            });
            chatChannel.close();
        });
    </script>
</body>
</html>

场景3:数据同步

实现购物车在多标签页间的实时同步:

// cart-sync.js
class CartSync {
  constructor() {
    this.channel = new BroadcastChannel('cart_sync');
    this.items = this.loadFromStorage() || [];
    this.listeners = [];
    
    this.setupListener();
    this.syncWithOthers();
  }
  
  setupListener() {
    this.channel.onmessage = (event) => {
      const { type, data, from } = event.data;
      
      switch(type) {
        case 'cart_update':
          this.items = data.items;
          this.saveToStorage();
          this.notifyListeners('update', data);
          break;
          
        case 'cart_request':
          // 新标签页请求同步
          this.channel.postMessage({
            type: 'cart_response',
            data: { items: this.items },
            from: this.getTabId()
          });
          break;
          
        case 'cart_response':
          if (from !== this.getTabId() && this.items.length === 0) {
            this.items = data.items;
            this.saveToStorage();
            this.notifyListeners('sync', data);
          }
          break;
      }
    };
  }
  
  syncWithOthers() {
    // 请求其他标签页的数据
    this.channel.postMessage({
      type: 'cart_request',
      from: this.getTabId()
    });
  }
  
  addItem(item) {
    this.items.push({
      ...item,
      id: Date.now() + Math.random(),
      addedAt: new Date().toISOString()
    });
    
    this.broadcastUpdate();
  }
  
  removeItem(itemId) {
    this.items = this.items.filter(item => item.id !== itemId);
    this.broadcastUpdate();
  }
  
  updateQuantity(itemId, quantity) {
    const item = this.items.find(item => item.id === itemId);
    if (item) {
      item.quantity = Math.max(1, quantity);
      this.broadcastUpdate();
    }
  }
  
  broadcastUpdate() {
    this.saveToStorage();
    
    this.channel.postMessage({
      type: 'cart_update',
      data: { items: this.items },
      from: this.getTabId(),
      timestamp: Date.now()
    });
    
    this.notifyListeners('update', { items: this.items });
  }
  
  loadFromStorage() {
    const saved = localStorage.getItem('cart_items');
    return saved ? JSON.parse(saved) : null;
  }
  
  saveToStorage() {
    localStorage.setItem('cart_items', JSON.stringify(this.items));
  }
  
  getTabId() {
    let tabId = sessionStorage.getItem('tab_id');
    if (!tabId) {
      tabId = Math.random().toString(36).substring(2, 10);
      sessionStorage.setItem('tab_id', tabId);
    }
    return tabId;
  }
  
  subscribe(callback) {
    this.listeners.push(callback);
    return () => {
      this.listeners = this.listeners.filter(cb => cb !== callback);
    };
  }
  
  notifyListeners(event, data) {
    this.listeners.forEach(callback => callback(event, data));
  }
}

// 使用示例
const cart = new CartSync();

// 订阅更新
cart.subscribe((event, data) => {
  console.log(`购物车${event}:`, data);
  updateCartUI(data.items);
});

// 添加商品
cart.addItem({
  name: '商品名称',
  price: 99.9,
  quantity: 1
});

场景4:Web Worker 协作

// main.js
// 主线程
const workerChannel = new BroadcastChannel('worker_tasks');
const worker = new Worker('worker.js');

// 发送任务到所有worker
workerChannel.postMessage({
  type: 'new_task',
  taskId: 'task_001',
  data: [1, 2, 3, 4, 5]
});

// 接收worker结果
workerChannel.onmessage = (event) => {
  if (event.data.type === 'task_result') {
    console.log('任务完成:', event.data.result);
  }
};

// worker.js
// Web Worker
const channel = new BroadcastChannel('worker_tasks');
const workerId = Math.random().toString(36).substring(2, 6);

channel.onmessage = (event) => {
  const { type, taskId, data } = event.data;
  
  if (type === 'new_task') {
    console.log(`Worker ${workerId} 接收任务:`, taskId);
    
    // 模拟耗时计算
    const result = data.map(x => x * 2);
    
    // 广播结果
    channel.postMessage({
      type: 'task_result',
      taskId: taskId,
      result: result,
      workerId: workerId
    });
  }
};

与其他通信方案的比较

1. vs localStorage

// localStorage 方案
window.addEventListener('storage', (e) => {
  if (e.key === 'message') {
    console.log('收到消息:', e.newValue);
  }
});
localStorage.setItem('message', 'hello');

// BroadcastChannel 方案
const channel = new BroadcastChannel('messages');
channel.onmessage = (e) => console.log('收到消息:', e.data);
channel.postMessage('hello');

优势对比

  • BroadcastChannel:专门为通信设计,语义清晰,性能更好,支持复杂数据类型

  • localStorage:主要用于存储,通信只是附带功能,有大小限制(通常5MB)

2. vs postMessage

// postMessage 需要知道目标窗口
const otherWindow = window.open('other.html');
otherWindow.postMessage('hello', '*');

// BroadcastChannel 无需知道目标
const channel = new BroadcastChannel('messages');
channel.postMessage('hello');

优势对比

  • BroadcastChannel:一对多广播,无需维护窗口引用

  • postMessage:一对一通信,更灵活但需要管理目标

3. vs WebSocket

高级技巧

1. 频道管理器

class BroadcastChannelManager {
  constructor() {
    this.channels = new Map();
    this.globalListeners = new Set();
  }
  
  // 获取或创建频道
  getChannel(name) {
    if (!this.channels.has(name)) {
      const channel = new BroadcastChannel(name);
      
      channel.onmessage = (event) => {
        // 触发全局监听器
        this.globalListeners.forEach(listener => {
          listener(name, event.data, event);
        });
        
        // 触发频道特定监听器
        const channelListeners = this.channels.get(name)?.listeners || [];
        channelListeners.forEach(listener => {
          listener(event.data, event);
        });
      };
      
      this.channels.set(name, {
        channel,
        listeners: []
      });
    }
    
    return this.channels.get(name).channel;
  }
  
  // 订阅频道消息
  subscribe(channelName, listener) {
    this.getChannel(channelName); // 确保频道存在
    
    const channel = this.channels.get(channelName);
    channel.listeners.push(listener);
    
    return () => {
      channel.listeners = channel.listeners.filter(l => l !== listener);
    };
  }
  
  // 订阅所有频道消息
  subscribeAll(listener) {
    this.globalListeners.add(listener);
    return () => this.globalListeners.delete(listener);
  }
  
  // 发送消息到频道
  send(channelName, data) {
    const channel = this.getChannel(channelName);
    channel.postMessage(data);
  }
  
  // 关闭频道
  closeChannel(channelName) {
    if (this.channels.has(channelName)) {
      const { channel } = this.channels.get(channelName);
      channel.close();
      this.channels.delete(channelName);
    }
  }
  
  // 关闭所有频道
  closeAll() {
    this.channels.forEach(({ channel }) => channel.close());
    this.channels.clear();
    this.globalListeners.clear();
  }
}

// 使用示例
const manager = new BroadcastChannelManager();

// 订阅特定频道
const unsubscribe = manager.subscribe('chat', (data) => {
  console.log('聊天消息:', data);
});

// 订阅所有频道
const unsubscribeAll = manager.subscribeAll((channel, data) => {
  console.log(`[${channel}] 收到:`, data);
});

// 发送消息
manager.send('chat', { text: 'Hello' });

2. 消息确认机制

class ReliableBroadcastChannel {
  constructor(name) {
    this.channel = new BroadcastChannel(name);
    this.pendingMessages = new Map();
    this.messageId = 0;
    
    this.setupListener();
  }
  
  setupListener() {
    this.channel.onmessage = (event) => {
      const { type, id, data, from } = event.data;
      
      if (type === 'ack') {
        // 收到确认,移除待确认消息
        this.pendingMessages.delete(id);
      } else {
        // 处理消息
        this.handleMessage(data, from);
        
        // 发送确认
        this.channel.postMessage({
          type: 'ack',
          id: id,
          from: this.getSenderId()
        });
      }
    };
  }
  
  send(data, requireAck = true) {
    const id = ++this.messageId;
    
    this.channel.postMessage({
      type: 'message',
      id: id,
      data: data,
      from: this.getSenderId(),
      timestamp: Date.now()
    });
    
    if (requireAck) {
      // 存储待确认消息
      this.pendingMessages.set(id, {
        data,
        timestamp: Date.now(),
        retries: 0
      });
      
      // 启动重试机制
      this.startRetry(id);
    }
  }
  
  startRetry(id) {
    const maxRetries = 3;
    const timeout = 1000;
    
    const check = () => {
      const message = this.pendingMessages.get(id);
      
      if (message && message.retries < maxRetries) {
        message.retries++;
        console.log(`重发消息 ${id},第 ${message.retries} 次`);
        
        this.channel.postMessage({
          type: 'message',
          id: id,
          data: message.data,
          from: this.getSenderId(),
          retry: true
        });
        
        setTimeout(check, timeout * message.retries);
      } else if (message) {
        console.error(`消息 ${id} 发送失败`);
        this.pendingMessages.delete(id);
      }
    };
    
    setTimeout(check, timeout);
  }
  
  handleMessage(data, from) {
    console.log('可靠收到:', data, '来自:', from);
  }
  
  getSenderId() {
    return sessionStorage.getItem('sender_id') || 
           Math.random().toString(36).substring(2);
  }
}

3. 心跳检测和状态同步

class TabHeartbeat {
  constructor() {
    this.channel = new BroadcastChannel('heartbeat');
    this.tabId = Math.random().toString(36).substring(2, 10);
    this.tabs = new Map();
    
    this.setupListener();
    this.startHeartbeat();
    this.requestStatus();
  }
  
  setupListener() {
    this.channel.onmessage = (event) => {
      const { type, tabId, data } = event.data;
      
      switch(type) {
        case 'heartbeat':
          this.updateTab(tabId, data);
          break;
          
        case 'status_request':
          this.sendStatus();
          break;
          
        case 'status_response':
          this.updateTab(tabId, data);
          break;
      }
    };
  }
  
  startHeartbeat() {
    // 每秒发送心跳
    setInterval(() => {
      this.channel.postMessage({
        type: 'heartbeat',
        tabId: this.tabId,
        data: {
          url: window.location.href,
          title: document.title,
          lastActive: Date.now(),
          scrollY: window.scrollY
        }
      });
    }, 1000);
    
    // 每30秒清理离线标签
    setInterval(() => {
      this.cleanOfflineTabs();
    }, 30000);
  }
  
  requestStatus() {
    this.channel.postMessage({
      type: 'status_request',
      tabId: this.tabId
    });
  }
  
  sendStatus() {
    this.channel.postMessage({
      type: 'status_response',
      tabId: this.tabId,
      data: {
        url: window.location.href,
        title: document.title,
        lastActive: Date.now(),
        scrollY: window.scrollY
      }
    });
  }
  
  updateTab(tabId, data) {
    this.tabs.set(tabId, {
      ...data,
      lastSeen: Date.now()
    });
  }
  
  cleanOfflineTabs() {
    const now = Date.now();
    for (const [tabId, data] of this.tabs) {
      if (now - data.lastSeen > 5000) {
        this.tabs.delete(tabId);
      }
    }
  }
  
  getOnlineTabs() {
    return Array.from(this.tabs.values());
  }
}

降级方案

class CrossTabChannel {
  constructor(name) {
    this.name = name;
    this.listeners = [];
    
    if ('BroadcastChannel' in window) {
      // 使用 BroadcastChannel
      this.channel = new BroadcastChannel(name);
      this.channel.onmessage = (event) => {
        this.notifyListeners(event.data);
      };
    } else {
      // 降级到 localStorage
      this.setupLocalStorageFallback();
    }
  }
  
  setupLocalStorageFallback() {
    window.addEventListener('storage', (event) => {
      if (event.key === `channel_${this.name}` && event.newValue) {
        try {
          const data = JSON.parse(event.newValue);
          // 避免循环
          if (data.from !== this.getTabId()) {
            this.notifyListeners(data.payload);
          }
        } catch (e) {
          console.error('解析消息失败:', e);
        }
      }
    });
  }
  
  postMessage(data) {
    if (this.channel) {
      // 使用 BroadcastChannel
      this.channel.postMessage(data);
    } else {
      // 使用 localStorage
      localStorage.setItem(`channel_${this.name}`, JSON.stringify({
        from: this.getTabId(),
        payload: data,
        timestamp: Date.now()
      }));
      // 立即清除,避免积累
      setTimeout(() => {
        localStorage.removeItem(`channel_${this.name}`);
      }, 100);
    }
  }
  
  onMessage(callback) {
    this.listeners.push(callback);
  }
  
  notifyListeners(data) {
    this.listeners.forEach(callback => callback(data));
  }
  
  getTabId() {
    let tabId = sessionStorage.getItem('tab_id');
    if (!tabId) {
      tabId = Math.random().toString(36).substring(2, 10);
      sessionStorage.setItem('tab_id', tabId);
    }
    return tabId;
  }
  
  close() {
    if (this.channel) {
      this.channel.close();
    }
    this.listeners = [];
  }
}

最佳实践总结

1. 命名规范

// 使用清晰的命名空间
const channel = new BroadcastChannel('app_name:feature:room');
// 例如:'myapp:chat:room1', 'myapp:cart:sync'

2. 错误处理

channel.onmessageerror = (error) => {
  console.error('消息处理失败:', error);
  // 可以尝试重新发送或降级处理
};

3. 资源清理

// 组件卸载时关闭频道
useEffect(() => {
  const channel = new BroadcastChannel('my_channel');
  
  return () => {
    channel.close();
  };
}, []);

4. 消息格式标准化

// 统一的消息格式
const message = {
  type: 'MESSAGE_TYPE',     // 消息类型
  id: 'unique_id',          // 唯一标识
  from: 'sender_id',        // 发送者
  payload: {},              // 实际数据
  timestamp: Date.now(),    // 时间戳
  version: '1.0'            // 版本号
};

5. 避免消息风暴

// 使用防抖或节流
function debounceBroadcast(fn, delay = 100) {
  let timer;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

const debouncedSend = debounceBroadcast((data) => {
  channel.postMessage(data);
});

结语

BroadcastChannel API 为浏览器原生环境提供了一个简单而强大的跨页面通信解决方案。它不仅语法简洁、性能优秀,而且与现代Web开发范式完美契合。无论是构建实时协作应用、实现多标签页状态同步,还是简单的消息广播,BroadcastChannel 都能优雅地解决问题。

随着浏览器支持的不断完善,BroadcastChannel 必将成为Web开发中不可或缺的工具之一。希望本文能帮助您更好地理解和使用这个强大的API,在实际项目中发挥其最大价值。

Vue3+Element Plus 通用表格组件封装与使用实践

作者 _AaronWong
2026年2月27日 10:27

在中后台项目开发中,表格是高频使用的核心组件,基于 Element Plus 的el-table封装通用表格组件,能够统一表格样式、简化重复代码、提升开发效率。本文将详细讲解一款通用表格组件的封装思路、完整实现及使用方式,该组件兼顾了通用性与灵活性,适配日常开发中的各类表格场景。

一、封装思路

本次封装的核心目标是打造一款「基础能力通用化、个性化配置灵活化」的表格组件:

  1. 抽离表格通用配置(如高度、高亮行、合并单元格方法)作为基础 Props;
  2. el-tableel-pagination的原生属性 / 事件通过透传方式交给父组件控制,保留原生组件的灵活性;
  3. 统一列渲染逻辑,支持自定义render函数实现复杂单元格内容展示;
  4. 整合表格标题、分页等常用元素,形成完整的表格模块。

二、通用表格组件完整实现(MineTable.vue)

<template>
    <el-card class="mine-table">
        <!-- 表格标题 -->
        <el-text class="table-name">{{ tableName }}</el-text>
        <!-- 核心表格容器 -->
        <el-table 
            ref="elTable" 
            class="base-table" 
            :highlight-current-row="currentRow" 
            :preserve-expanded-content="true" 
            :span-method="spanMethod"
            :data="data" 
            :height="height"
            v-bind="tableProps"   <!-- 透传el-table原生属性 -->
            v-on="tableEvents"    <!-- 透传el-table原生事件 -->
        >
            <el-table-column 
                v-for="(item, index) in columnsData" 
                :key="index" 
                v-bind="item"      <!-- 透传列配置属性 -->
            >
                <!-- 展开列自定义渲染 -->
                <template v-if="item.type === 'expand'" #default="scope">
                    <component :is="item.render" v-bind="scope"></component>
                </template>
            </el-table-column>
        </el-table>

        <!-- 分页组件 -->
        <el-pagination 
            class="base-pagination" 
            layout="total, sizes, prev, pager, next, jumper"
            :page-sizes="[5, 10, 20, 30, 40, 50]" 
            background
            v-bind="paginationProps"  <!-- 透传el-pagination原生属性 -->
            v-on="paginationEvents"   <!-- 透传el-pagination原生事件 -->
        />
    </el-card>
</template>

<script setup>
import { computed, ref } from "vue"

// 关闭默认属性透传,避免属性泄露到外层DOM节点
defineOptions({
    inheritAttrs: false
})

// 定义组件Props
const props = defineProps({
    // 表格基础配置
    tableName: { type: String, default: "", description: "表格标题" },
    currentRow: { type: Boolean, default: false, description: "是否高亮当前行" },
    height: { type: String, default: "60vh", description: "表格高度" },
    data: { type: Array, default: () => [], description: "表格数据源" },
    columns: { type: Array, default: () => [], description: "列配置项" },
    spanMethod: { type: Function, default: () => {}, description: "单元格合并方法" },
    
    // el-table原生属性透传(支持所有el-table属性)
    tableProps: { type: Object, default: () => ({}) },
    // el-table原生事件透传(支持所有el-table事件)
    tableEvents: { type: Object, default: () => ({}) },
    
    // el-pagination原生属性透传(支持所有el-pagination属性)
    paginationProps: { type: Object, default: () => ({}) },
    // el-pagination原生事件透传(支持所有el-pagination事件)
    paginationEvents: { type: Object, default: () => ({}) },
})

// 暴露表格Ref,方便父组件调用el-table的原生方法
const elTable = ref(null)
defineExpose({ elTable })

// 列数据格式化处理,统一支持render函数渲染
const columnsData = computed(() => {
    return props.columns.map(item => ({
        formatter: (row, column, cellValue, index) => formatter(item, row, column, cellValue, index),
        ...item
    }))
})

// 单元格内容格式化逻辑
const formatter = (item, row, column, cellValue, index) => {
    // 优先级:行数据中的render函数 > 列配置中的render函数 > 默认值
    if (row?.[column.property]?.render) {
        return row[column.property].render(row, column, cellValue, index)
    } else if (item?.render) {
        return item.render(row, column, cellValue, index)
    }
    return row[column.property]
}
</script>

<style lang="scss" scoped>
.mine-table {
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    
    .table-name {
        font-size: 18px;
        font-weight: bold;
        margin-bottom: 12px;
        display: flex;
        align-items: center;
        &::after {
            content: "";
            width: 5px;
            height: 100%;
            background-color: var(--el-color-primary);
            margin-right: 12px;
        }
    }

    .base-table {
        width: 100%;
        margin: 0 auto;
        min-width: 0;
        border: var(--el-table-border);
        border-radius: 4px;
    }

    .base-pagination {
        margin-top: 12px;
    }
}
</style>

核心封装点说明

  1. 属性 / 事件透传:通过tableProps/tableEventspaginationProps/paginationEvents分别透传el-tableel-pagination的原生属性与事件,既保留了原生组件的全部能力,又无需在组件内重复定义中转逻辑。
  2. 统一列渲染:封装了formatter函数,支持两种自定义渲染方式 —— 列配置中的render函数、行数据中的render函数,满足复杂单元格的展示需求。
  3. 基础样式整合:内置了表格标题、表格容器、分页的统一样式,无需在业务页面重复编写样式代码。
  4. Ref 暴露:将el-table的 Ref 暴露给父组件,方便调用clearSelectiontoggleRowSelection等原生方法。

三、组件使用示例

1. 基础使用(仅核心配置)

这是最常用的场景,只需配置表格数据、列配置、基础样式即可:

<template>
  <div class="demo-container">
    <!-- 通用表格组件使用 -->
    <MineTable
      height="200px"
      tableName="用户列表"
      :data="tableData"
      :columns="tableColumns"
    />
  </div>
</template>

<script setup>
import { ref } from "vue"
import MineTable from "@/components/MineTable.vue"
import { ElMessage, ElPopconfirm, ElButton, ElText } from "element-plus"

// 表格数据源
const tableData = ref([
  { id: 1, name: "张三", email: "zhangsan@example.com" },
  { id: 2, name: "李四", email: "lisi@example.com" },
  { id: 3, name: "王五", email: "wangwu@example.com" }
])

// 列配置项
const tableColumns = ref([
  { type: "index", label: "序号", width: 80 }, // 序号列
  {
    label: "用户名称",
    prop: "name",
    // 自定义单元格渲染
    render: (row) => <ElText type="primary">{row.name}</ElText>
  },
  {
    label: "操作",
    width: 100,
    // 操作列:带确认弹窗的删除按钮
    render: (row) => {
      const deleteUser = () => {
        // 模拟删除逻辑
        tableData.value = tableData.value.filter(item => item.id !== row.id)
        ElMessage.success(`已删除用户:${row.name}`)
      }

      return (
        <ElPopconfirm 
          title="确定删除吗?" 
          onConfirm={deleteUser}
          confirmButtonText="确定" 
          cancelButtonText="取消"
          v-slots={{
            reference: () => <ElButton type="danger" size="small" link>删除</ElButton>
          }}
        />
      )
    }
  }
])
</script>

<style scoped>
.demo-container {
  width: 800px;
  margin: 20px auto;
}
</style>

2. 进阶使用(透传原生属性 / 事件)

如果需要使用el-tableel-pagination的原生能力(如斑马纹、行点击事件、分页回调等),可通过透传 Props 实现:

<template>
  <div class="demo-container">
    <MineTable
      height="300px"
      tableName="用户列表"
      :data="tableData"
      :columns="tableColumns"
      <!-- 透传el-table原生属性 -->
      :table-props="{
        border: true,        // 显示表格边框
        stripe: true,        // 斑马纹效果
        showHeader: true     // 显示表头
      }"
      <!-- 透传el-table原生事件 -->
      :table-events="{
        'row-click': (row) => ElMessage.info(`点击了${row.name}的行`), // 行点击事件
        'sort-change': (val) => console.log('排序变更:', val)       // 排序变更事件
      }"
      <!-- 透传el-pagination原生属性 -->
      :pagination-props="{
        currentPage: 1,      // 当前页码
        pageSize: 10,        // 每页条数
        total: 100           // 总条数
      }"
      <!-- 透传el-pagination原生事件 -->
      :pagination-events="{
        'size-change': (size) => console.log('每页条数变更:', size), // 页大小变更
        'current-change': (page) => console.log('页码变更:', page)   // 页码变更
      }"
    />
  </div>
</template>

四、总结

本次封装的通用表格组件具备以下特点:

  1. 通用性强:整合了表格标题、分页等常用元素,统一了基础样式和渲染逻辑;
  2. 灵活性高:通过属性 / 事件透传,保留了 Element Plus 原生组件的全部能力,适配各类个性化需求;
  3. 易用性好:使用方式简洁,基础场景只需配置数据和列,进阶场景可透传原生属性 / 事件;
  4. 可扩展:在此基础上可进一步扩展空状态、加载状态、列宽自适应等通用能力,适配更多业务场景。

该组件能够有效减少中后台项目中表格相关的重复代码,提升开发效率,同时保持了足够的灵活性,满足不同业务场景的个性化需求。

图形编辑器开发:文字排版如何实现自动换行?

2026年2月27日 10:26

大家好,我是前端西瓜哥。

之前我们通过字体解析库,拿到了文字的字形路径数据,实现了 手动换行

但是对于换行,只有手动换行还是不够的。

在文字排版中,我们希望可以给定一个区域宽度,让输入的单行文本超过这个宽度,需要对这行文字进行 “软换行”,将文本拆分成多行。

图片

这个能力就是 “自动换行”。

我的开源图形编辑器项目目前已支持文字自动换行了,欢迎体验。

github.com/F-star/suik…

下面来探究自动换行要如何实现。

排版计算改造

我们之前实现过支持手动换行的文字排版。

图形编辑器:类 Figma 所见即所得文本编辑(2)

思路就是基于文本的换行符 \n 将文本拆分成多个单行文本,然后生成它们的 glyph 字形,基于 glyph 的宽度更新排版的 x、y。

现在要自动换行了,所以要再提供一个 maxWidth 表示最大宽度

在拿到单行文本 glyph 字形的基础上,遍历累加计算总宽度,当宽度超过 maxWidth 的情况下,补上一个 “软换行”,即额外添加  \n 空 glyph 进行占位。

注意这是排版的上 “补充”,并不会修改原文本内容。

let x = 0;  
const y = 0;  
let i = 0;  
  
for (; i < originGlyphs.length; i++) {  
const glyph = originGlyphs[i];  
let width = glyph.advanceWidth ?? 0;  
  
// 超过最大宽度,添加一个  
const isSoftWrapAdd = i > 0 && x + width > maxWidth;  
if (isSoftWrapAdd) {  
    glyphs.push({  
      position: { x, y: y },  
      width0,  
      commands'',  
      logicIndex: i,  
    });  
  
    // 到下一行了,更新 glyphLines,重置当前行 glyphs  
    x = 0;  
    glyphLines.push(glyphs);  
    glyphs = [];  
  }  
  
// ...  
  
  glyphs.push({  
    position: { x: x, y: y },  
    width: width,  
    commands: glyph.path.toPathData(100),  
    logicIndex: i, // 逻辑索引  
  });  
  x += width;  
}

最后得到的 glyphs 不再是一维数组,而是变成二维数组,因为可能会返回多行的信息。

另外, 这里额外新增一个 logicIndex 属性,表达 glyph 对应文本的字符串位置。因为现在不再是原来的一一对应关系了。

选区模型方案更换

这里出现了一个问题:”软换行“ 的额外添加,导致 “逻辑索引”(offset,字符串下标) 和 “可视索引”(position,光标位置) 无法匹配上。

在自动换行(soft wrap)的场景下,换行点的前后两个视觉位置,在文档模型中对应的是同一个 index。例如:

|The quick brown fox jum| <-- 视觉第一行
|ps over the lazy dog  | <-- 视觉第二行

假设 jum 后面的位置是 index 24,那么 “第一行末尾” 和 “第二行开头” 都是 index 24,但视觉上是不同的光标位置。

图片

这对我们更新选区位置信息,或是转换为逻辑索引(字符串索引位置)转换都比较麻烦。

如果我们要继续使用原来的 线性选区模型(如 { start: 0, end: 24 }),在软换行场景,可能需要再 引入一个 affinity 概念来表达光标是在行末还是行首,如{ start: 0, startAffinity: 'downstream' end: 24, endAffinity: 'upstream' } 表达选区选中为第一行行首到行末。

但这种写法个人不是很喜欢,且我调研了下,这种方案在文本编辑中还是比较少。

最后我参考了 Monaco editor 的 selection 表达,使用的 行(line)和列(column)的表达

textEditor.selectionManager.setSelection({  
  // 基准位置  
  anchorLineNum0,  
  anchorColumn0,  
  // 聚焦位置  
  focusLineNum0,  
  focusColumn24,  
});

选区位置更新

重要的排版计算和选区模型改造完成后,后面就是一些细节的调整了。

举个例子。

插入光标在视觉第二行的开头,此时我们 按下左方向键,对光标进行左移

在 逻辑索引 上,其实就是将 “m” 字符删除,逻辑索引向左移动 1 个距离。但对于 可视索引,则是要向前移动 2 个距离的("m" 和软换行符)。

这个就是  “逻辑索引” 和 “可视索引” 无法匹配的问题。

解决方案是,先根据插入光标位置的可视索引,转换为逻辑索引,然后减 1,然后再求对应的可视索引。

const { focusColumn, focusLineNum } = this.selection;  
  
// 可视索引 -> 逻辑索引  
const offset = textGraphics.paragraph.getOffsetAt({  
lineNum: focusLineNum,  
column: focusColumn,  
});  
// 逻辑索引减 1,然后转换为可视索引  
const newPosition = textGraphics.paragraph.getPositionAt(offset - 1'downstream');  
this.setSelection({  
anchorColumn: newPosition.column,  
anchorLineNum: newPosition.lineNum,  
focusColumn: newPosition.column,  
focusLineNum: newPosition.lineNum,  
});

getPositionAt 方法的第二个参数是 affinity,downstream 希望得到靠下的 position。如下图,左移时,光标还在当前行,再左移,就跑到上一行的最后一个字符前方了。光标没有在行末出现。

getOffsetAt 实现:

getOffsetAt(pos: IPosition): number {  
const glyphs = this.getGlyphs();  
const lineNum = Math.min(Math.max(pos.lineNum0), glyphs.length1);  
const line = glyphs[lineNum];  
  
if (line.length === 0) return0;  
  
const column = Math.min(Math.max(pos.column0), line.length1);  
return line[column].logicIndex;  
}

getPositionAt 实现:

getPositionAt(  
  offsetnumber,  
  affinity'upstream' | 'downstream' = 'downstream',  
): IPosition {  
const glyphs = this.getGlyphs();  
  
let isFound = false;  
const position: IPosition = { lineNum0, column0 };  
for (let lineNum = 0; lineNum < glyphs.length; lineNum++) {  
    const line = glyphs[lineNum];  
    for (let column = 0; column < line.length; column++) {  
      if (line[column].logicIndex === offset) {  
        position.lineNum = lineNum;  
        position.column = column;  
        isFound = true;  
        break;  
      }  
    }  
    if (isFound) break;  
  }  
// if affinity is 'downstream' and the position is not the last line,  
// get the position of the next line  
if (affinity === 'downstream' && position.lineNum < glyphs.length1) {  
    const nextLine = glyphs[position.lineNum1];  
    if (nextLine.length0 && nextLine[0].logicIndex === offset) {  
      position.lineNum = position.lineNum1;  
      position.column0;  
    }  
  }  
return position;  
}

Figma 文字对象

Figma 的文字对象,同时自适应宽度、固定宽度两种效果。

自适应宽度,表现为文本内容,宽高会自动调整适应文本宽高。固定宽度,则要超出的文本自动换行到下一行。

Figma 的文字对象有一个属性 textAutoResize,表示是否根据文本内容自适应修改宽或高。

它支持的值有:

  1. WIDTH_AND_HEIGHT,属性面板表达为:自动宽度(Auto width),表示宽高都自适应;

  2. HEIGHT,属性面板表达为:自动高度(Auto height),表示宽固定,高自适应;

  3. NONE,属性面板表达为:固定宽高(Fixed Size),表示宽固定,高也固定(文字渲染的实际高度可超出定高);

可以通过属性面板的 Resizing 项下直接修改这个属性。

图片

也可以拖拽控制点修改宽高。

如果当前文字是“自动宽度”策略,当用户修改其宽高属性,如果高度发生改变,会变成“固定宽高”策略。如果高度没改变,但宽度发生改变,则会变成 “自动高度策略”。

图片

创建文字的时候,如果拖拽会产生一个矩形区域,释放时会基于该宽高创建一个使用 “固定宽高” 策略的文字对象。

如果没有发生拖拽,则创建 “自动宽度” 的文字对象。

图片

Adobe Illustrator 文字对象

Adobe Illustrator 的文字对象,只支持自动宽高,修改宽高只会在垂直方向拉伸文字,或是改变字体大小。

另外有一个 区域文字对象,支持在特定的路径下填充文本,是一种更灵活更复杂的表达。

容器除了可以是常规的矩形,也可以是复杂的路径。

修改容器图形的宽高,文字会自动换行去自适应容器。

结尾

自动换行的核心原理,是累积当行文本的宽,当超过容器的固定宽度时,在视觉上加上一个 “软换行”。这种做法会导致逻辑位置(offset)和 可视位置(position)的不一致,在进行一些文本编辑相关操作时,需要做一些转换处理。

当然这里说的是最基础版本的自动换行,之后可以考虑分词处理,基于多个字符形成的词为一个整体进行换行,或是支持一些特殊的效果,比如超出高度的文字不做显示,或是像 Adobe Illustrator 支持不规则的容器。

我是前端西瓜哥,关注我,学习更多文字排版知识。


相关阅读,

图形编辑器:类 Figma 所见即所得文本编辑(2)

 图形编辑器:基于 canvas的所见即所得文本编辑

图形编辑器开发:使用 opentype.js 解析字体并渲染文本

 opentype.js 使用与文字渲染

Cloudflare 掀桌子了,Next.js 迎来重大变化,尤雨溪都说酷!

2026年2月27日 10:15

Vite 作为 2025 年满意度最大的技术,下载量已经超过的 webpack,加入 Vite 工具链变成一种趋势,而这次是 Nextjs!

Next.js 13.4 之前,webpack 是默认的构建工具,Next.js 13.4 及以上版本引入了 Turbopack 作为开发环境的默认工具,但生产环境目前仍主要依赖 webpack 进行最终打包。

2 月 25 日,Cloudflare 发布了 Vinext

在 Vite 上重新实现了 Next.js 的 API,让开发者可以脱离官方的 webpack 和 Turbopack,转而使用 Vite 来 构建 Next.js 应用。

AI 开发,人类主导

Vinext 是一个极具探索性的项目。

使用 Claude Code 开发:

  • 绝大部分代码
  • 测试
  • 文档
  • 一周内完成

架构、优先级和设计决策则由人类主导。

AI驱动的开发过程

  1. 适配 AI 开发的条件:Next.js 文档/测试完善、Vite 提供坚实基础、新一代 AI 模型可处理大型代码库复杂度
  2. 开发流程:定架构拆任务AI写代码+测试自动验证+迭代
  3. 投入:800+ 次 OpenCode 会话,代码通过严格的测试、类型检查与CI验证

AI 完成代码评审,人工把控方向与纠错。

Vinext 的核心

Vinext 底层基于 Vite 开发,开发者可以直接享受到 Vite 庞大的、干净且标准化的插件生态。

对于 Next.js 来说,目前的痛点:

  • 本地服务器启动慢
  • HMR 速度慢
  • 无服务器生态适配难
  • 与 Vercel 强绑定,部署至第三方平台配置复杂
  • 适配方案 OpenNext 成本高、维护困难
  • 开发与构建流程与 Node.js 强绑定,跨平台体验差

而 Vinext 的优势非常明显👇🏻

极致的性能表现

vinext 通过底层重构,显著提升了 Next.js 在开发和生产阶段的性能指标。

  • 构建速度飞跃:生产环境提速 4 倍
  • 包体积更小:体积比原生 Next.js 缩小了 57%
  • 瞬时 HMR:开发环境下,瞬时热更新,极致的开发体验。

边缘原生,解决部署痛点

  • 零冷启动部署:几乎没有冷启动延迟。
  • 开发生产一致性:支持在开发环境调用 Cloudflare 的平台 API,避免开发环境和生产环境不一致的尴尬。
  • 脱离厂商锁定:不和 Vercel 强绑定,可自由地部署在第三方平台。

创新技术:流量感知预渲染

这是 Vinext 最具差异化的技术优势:

  • 精准优化:通过真实分析数据,识别出那些 10% 贡献了 90% 流量的热门页面。
  • 按需生成:预渲染高频页面,保证用户的极速体验,缩短大型站点构建时间,避免冷门页面的浪费。

迁移与兼容成本极低

  • 无感迁移:支持一键转换,不破坏原有的 Next 配置,做到共存
  • 高覆盖率 API:实现了约 94% 的 Next.js 16 API
  • AI 赋能:专门的 AI Agent Skill,让 AI 助手自动处理迁移冲突

快速上手

安装:

npm install vinext

替换 Next:

{
  "scripts": {
    "dev": "vinext dev",
    "build": "vinext build",
    "start": "vinext start"
  }
}

核心命令:

# 启动开发环境
vinext dev   
# 构建打包
vinext build
# 构建并部署的到 Cloudflare Workers
vinext deploy       

AI 迁移助手

用 AI 助手帮助你将旧项目迁移至 Vinext。

安装 AI Skills

npx skills add cloudflare/vinext

在AI编码工具执行迁移命令,自动完成适配

npx skills add cloudflare/vinext

总结

cloudflare 推出 Vinext 无疑是开始掀桌子了,在发布后就获得了广泛的讨论,虽然该项目还处在实验阶段,但已经有一些项目开始使用了。

对于 Next.js 开发者,如果你想体验:

  • Vite 生态
  • 极致的开发体验
  • 高效的打包构建
  • 零启动的 Cloudflare 访问

不妨关注一下这个项目。

display: contents 详解

作者 ze_juejin
2026年2月27日 10:07

display: contents 是一个相对较新的 CSS 属性值,它会让元素自身不生成任何盒子,但它的子元素伪元素仍然正常生成。简单说:元素本身从渲染树中消失,但它的孩子还在。

基本概念

工作原理

<div class="parent">
  <div class="child">内容</div>
</div>
/* 正常情况下 */
.parent { 
  display: block;  /* parent 生成一个块级盒子 */
}

/* 使用 contents 后 */
.parent { 
  display: contents;  /* parent 不生成盒子,child 直接"上升"到 parent 的位置 */
}

直观对比

应用前:

<main>
  <div class="grid-container">  <!-- 这个元素只是个包装 -->
    <div>Item 1</div>
    <div>Item 2</div>
    <div>Item 3</div>
  </div>
</main>

应用后:

<main>
  <!-- grid-container 元素消失了,但它的子元素还在 -->
  <div>Item 1</div>
  <div>Item 2</div>
  <div>Item 3</div>
</main>

主要用途

1. 语义化与布局分离

<!-- 想要使用 ul,但又需要 flex 布局 -->
<ul style="display: contents;">
  <li>项目1</li>
  <li>项目2</li>
  <li>项目3</li>
</ul>
ul {
  display: contents;  /* ul 本身不生成盒子 */
}

/* li 直接参与父容器的布局 */
.parent-of-ul {
  display: flex;  /* li 会成为 flex 项目,而不是 ul */
}

2. 网格布局中的包装器

.grid-container {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
}

/* 包装器不破坏网格布局 */
.wrapper {
  display: contents;
  /* 这个元素不生成盒子,它的子元素直接成为 grid 项目 */
}

html

<div class="grid-container">
  <div>直接项目1</div>
  <div class="wrapper">  <!-- 这个元素不占位置 -->
    <div>包装的项目2</div>
    <div>包装的项目3</div>
  </div>
  <div>直接项目4</div>
</div>

3. Flexbox 中的包装器

.flex-container {
  display: flex;
}

.group {
  display: contents;  /* 子元素直接成为 flex 项目 */
}
<div class="flex-container">
  <div>项目1</div>
  <div class="group">  <!-- 这个 div 不生成盒子 -->
    <div>组内项目1</div>  <!-- 直接成为 flex 项目 -->
    <div>组内项目2</div>  <!-- 直接成为 flex 项目 -->
  </div>
  <div>项目3</div>
</div>

实际应用场景

场景1:表格布局优化

<table>
  <tr style="display: contents;">  <!-- tr 不生成盒子 -->
    <td>单元格1</td>
    <td>单元格2</td>
    <td>单元格3</td>
    <!-- td 直接成为 table 的子元素 -->
  </tr>
</table>

场景2:避免多余的 DOM 层级

/* 原本需要额外 div 来添加样式 */
.card {
  display: flex;
}

.card-extra {
  display: contents;  /* 这个 div 只用于逻辑分组,不影响布局 */
}

/* 现在可以更灵活地组织代码 */

场景3:响应式设计中的重组

<div class="responsive-grid">
  <!-- 移动端:堆叠显示 -->
  <!-- 桌面端:网格显示 -->
  <div class="group" style="display: contents;">
    <div>项目A</div>
    <div>项目B</div>
  </div>
</div>

注意事项和限制

1. 对可访问性的影响

/* ⚠️ 注意:元素本身消失,但语义还在吗? */
.button-group {
  display: contents;
  role: group;  /* 虽然设置了 ARIA 角色,但元素不生成盒子,可能无效 */
}

2. 对事件处理的影响

// ⚠️ 元素不生成盒子,点击事件可能无法在元素上触发
document.querySelector('.contents-element').addEventListener('click', () => {
  // 这个元素在视觉上不存在,点击区域是子元素的
});

3. 对背景和边框的影响

.contents-element {
  display: contents;
  background: red;    /* ❌ 不会显示,因为元素没有盒子 */
  border: 1px solid;  /* ❌ 不会显示 */
  padding: 10px;      /* ❌ 不会显示 */
  margin: 10px;       /* ❌ 不会显示 */
  width: 100px;       /* ❌ 不会显示 */
  height: 100px;      /* ❌ 不会显示 */
}

4. 对伪元素的影响

.contents-element {
  display: contents;
}

.contents-element::before {
  content: "✨";  /* ✅ 伪元素仍然会显示,成为第一个子元素 */
}

5. 浏览器兼容性

  • Chrome/Edge: 完全支持
  • Firefox: 完全支持
  • Safari: 支持但有部分问题
  • IE: 不支持

调试技巧

如何检查 display: contents 的效果

/* 在开发者工具中检查元素布局 */
.contents-element {
  display: contents;
  outline: 2px solid red;  /* 不会显示,帮助理解元素确实消失了 */
}

临时禁用调试

.contents-element {
  display: contents;
  /* 临时查看元素范围 */
  display: block;  /* 临时改为 block 查看原始位置 */
}

实际案例

案例:卡片布局

<div class="card-grid">
  <!-- 想要分组但不破坏网格 -->
  <div class="card-group" style="display: contents;">
    <div class="card">卡片1</div>
    <div class="card">卡片2</div>
  </div>
  <div class="card-group" style="display: contents;">
    <div class="card">卡片3</div>
    <div class="card">卡片4</div>
  </div>
</div>
.card-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 20px;
}

/* card-group 不破坏网格,所有卡片直接成为 grid 项目 */

总结

优点:

  • ✅ 保持 HTML 语义化
  • ✅ 避免多余的包装元素
  • ✅ 更灵活的布局控制
  • ✅ 减少不必要的 DOM 层级

缺点:

  • ❌ 元素样式(背景、边框等)失效
  • ❌ 事件绑定可能受影响
  • ❌ 可访问性需要考虑
  • ❌ 调试相对困难

最佳实践:

  • 主要用于布局分组
  • 配合 Grid/Flex 使用效果最好
  • 注意不要依赖元素的样式属性
  • 测试可访问性表现

记住:display: contents 是一个强大的工具,但应该用在合适的场景,主要是为了解决语义化标签布局需求之间的矛盾。

分享URL地址到微信朋友圈没有缩略图?

作者 DeathGhost
2026年2月27日 09:39

分享URL网址到朋友圈啥时候支持og:image?还是说一直都支持的?一直也没在意,直到今天试了试我的鸿蒙系统应用分享,才……

以前用土方法在页面display none 一个图片,一直以为需要调用微信内部方法方可展示缩略图。😂

meta property="og:xxx" 以前倒是看到过,一直没在意~

最近手机升级到鸿蒙 (HarmonyOS) 系统,发现以前的方法竟然失效了 —— 更换了多个类型的浏览器分享朋友圈,页面缩略图都是空的。🙄

于是试着用了下 OG 标签,结果…居然可以了 ( 就试了下朋友圈,其他的暂时也懒得理 ) !🧐

分享URL地址到微信朋友圈没有缩略图?

示例代码

<head>
    <meta property="og:title" content="您分享的页面标题" />
    <meta property="og:description" content="您分享的页面描述" />
    <!-- 这是最关键的一行 -->
    <meta property="og:image" content="https://您的域名.com/图片路径/thumbnail.jpg" />
    <meta property="og:url" content="https://您的域名.com/当前页面路径" />
    <meta property="og:type" content="website" />
</head>

og:image content 必须是完整的、以 http://https:// 开头的绝对URL。相对路径(如 /images/thumb.jpg或//开始的地址)是无效的。

图片格式:支持 JPG, PNG, WebP 等常见格式。

效果演示图

分享到朋友圈 - 缩略图

我的 Nunjucks 模板代码

{# 作者 -#}
    {%- if basic and basic.site_info and basic.site_info.og_author -%}
    <meta property="article:author" content="{{ basic.site_info.og_author }}" />
    {% endif %}
    {# 发布时间 -#}
    {%- if basic and basic.site_info and basic.site_info.og_published_time -%}
    <meta property="article:published_time" content="{{ basic.site_info.og_published_time }}" />
    {%- endif -%}
    {# 标签 #}
    {%- if basic and basic.site_info and basic.site_info.keywords -%}
    {%- set keywords = basic.site_info.keywords -%}
    {%- if keywords is string %}
    {%- set tagArray = keywords.split(',') -%}
    {%- elif keywords is array -%}
    {%- set tagArray = keywords -%}
    {% else %}
    {%- set tagArray = [] -%}
    {% endif %}
    {# 生成标签 #}
    {%- for tag in tagArray -%}
    {%- set cleanTag = tag | trim -%}
    {%- if cleanTag -%}
    <meta property="article:tag" content="{{ cleanTag }}" />
    {%- endif -%}
    {%- endfor -%}
    {%- endif -%}

填坑经历

一开始我设置成了下面这样 ( 没有带 https ) ,分享出来是个空图 ⬜️

<meta property="og:image" content="//static.deathghost.cn/assets/avatar.jpg" />

所以,配置时按照常用的规范来,并注意上面提到的要点即可。这玩意以前都有,就是没用,也不知道应用支持不支持,今天才试了试~

来源:www.deathghost.cn/article/htm…

【ThreeJS调试技巧】那些让 Bug 无所遁形的“脏套路”

作者 叶智辽
2026年2月27日 09:37

前言

有些 Bug 不报错、不崩溃,就静静在那儿恶心你

做 Web 3D 开发最头疼的是什么?

不是编译报错,不是性能瓶颈,而是那种画面看起来不对劲,但代码没任何错误的视觉 Bug。

模型缺了一半、纹理糊成一团、颜色阴阳脸、设备半透明像鬼影……这些 Bug 不会让控制台飘红,不会让页面崩溃,就静静在那儿恶心你

更气人的是,很多时候你盯着看半天,死活找不到原因。改几行代码试试?Bug 还在。回退版本?Bug 还在。重装依赖?Bug 还在。

到最后你甚至开始怀疑:是不是显卡坏了?

今天就来聊聊,我这两年攒下来的调试视觉 Bug 的脏套路。不求优雅,只求让 Bug 现原形。


场景一:模型“缺胳膊少腿” —— 面去哪儿了?

症状

模型加载完,转一圈看,有些面没了。机械臂少个夹爪、设备缺个盖子,像被切掉了一样。

排查思路

第一反应:模型导错了?用建模软件打开原文件,好好的。

第二反应:代码里隐藏了?搜 visible = false,没有。

脏套路一:强制双面渲染

// 调试代码:暴力解决
scene.traverse((child) => {
  if (child.isMesh) {
    child.material.side = THREE.DoubleSide; // 两面都给我渲染
  }
});

消失的面出现了!

这说明什么问题?面法线反了。正常应该是正面朝外,但模型里有些面是正面朝里。Three.js 默认只渲染正面(FrontSide),朝里的面直接忽略。

根治方案

// 不能留 DoubleSide,性能扛不住
// 正确做法:加载时修正法线方向
loader.load('model.glb', (gltf) => {
  gltf.scene.traverse((child) => {
    if (child.isMesh) {
      child.geometry.computeVertexNormals(); // 重新计算法线方向
    }
  });
});

注意:有些模型是艺术家手调的法线,computeVertexNormals 可能会破坏原有效果。如果修正后出现硬边或光影异常,还是得回源头改模型。


场景二:设备“鬼影重重” —— 半透明叠加

症状

某个设备变得半透明,后面的物体清晰可见,像开了透视挂。而且一旦变透明,就再也恢复不了正常。

排查思路

检查代码,发现之前加了一个“高亮选中设备”的功能:

function highlightDevice(device) {
  device.material.transparent = true;
  device.material.opacity = 0.8;
  device.material.emissive.setHex(0xffaa00);
}

高亮完恢复了吗?恢复了。但问题出在:transparent 一旦设为 true,即使改回 opacity = 1,混合模式还开着

脏套路二:重置材质

// 暴力恢复:新建材质
function resetMaterial(mesh) {
  const oldMat = mesh.material;
  mesh.material = new THREE.MeshStandardMaterial({
    map: oldMat.map,
    color: oldMat.color,
    // ... 复制其他属性
  });
  oldMat.dispose(); // 记得释放
}

设备恢复正常。

根本原因:transparent状态,不是操作。你打开它,GPU 就切换渲染管线;关掉 opacity 没用,得彻底关掉 transparent


场景三:物体“一闪一闪” —— 深度冲突

症状

两个物体挨在一起,交接处出现闪烁的白线画面抖动,转视角时尤其明显。

排查思路

第一反应:性能问题?帧率稳定 60。

第二反应:光照问题?动画循环里有光源变化?没有。

第三反应:检查两个物体的位置。

脏套路三:微调位置

// 检查两个物体的位置关系
console.log(objectA.position.z, objectB.position.z);
// 输出:0, 0

// 果然,两个面完全重合
// 微调其中一个的高度
objectB.position.z += 0.01;

不闪了。

原理:两个面完全重合,GPU 不知道谁前谁后,每帧随机决定谁在上面,看起来就是闪烁。稍微错开一点,深度测试就老实了。

进阶方案

如果必须完全重合(比如地面上铺的网格和地面本身):

// 调整渲染顺序
ground.renderOrder = 0;
grid.renderOrder = 1;

// 或者关闭一个的深度写入
grid.material.depthWrite = false;

场景四:纹理“糊成一团” —— mipmap 策略不当

症状

纹理明明分辨率很高,但在屏幕上就是模糊的色块,细节全无。

排查思路

纹理分辨率 2048x2048,不小。各向异性过滤开了 16,最大了。mipmap 也正常。问题在哪儿?

脏套路四:强制用最近过滤

// 调试代码:临时替换过滤方式
texture.magFilter = THREE.NearestFilter; // 最近点采样,清晰但锯齿严重

纹理瞬间清晰了!但锯齿出来了。

问题找到了:纹理在屏幕上的实际显示尺寸,比原始纹理小太多。GPU 用 mipmap 选了低精度层,导致模糊。

根治方案

// 方案一:限制最小 mipmap 层级
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.anisotropy = 16; // 已经开了

// 方案二:如果摄像机永远不会靠近,关掉 mipmap
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;

方案二适合固定视角的监控画面,方案一适合需要远近切换的场景。


场景五:颜色“阴阳脸” —— 法线方向混乱

症状

同样的模型、同样的材质、同样的光照,左右两边颜色不一样。一边偏暖,一边偏冷,像阴阳脸。

排查思路

光照一样,材质一样,位置对称。邪门了。

脏套路五:检查法线

// 临时显示法线方向
scene.overrideMaterial = new THREE.MeshNormalMaterial();

画面变成五彩斑斓的“法线可视化”。左右对比,发现问题:左边模型的法线方向乱了,有些面朝左,有些面朝右,光照计算自然就歪了。

根治

重新导入模型,检查建模软件里的法线方向。如果是复制过程中产生的错误,可以:

// 尝试重新计算法线
mesh.geometry.computeVertexNormals();

但同样要注意:如果模型是手调法线,重新计算可能会破坏原有光影效果。


场景六:文字“模糊不清” —— CanvasTexture 更新失败

症状

用 Canvas 生成动态纹理(比如仪表盘数字),第一次显示正常,后面数字变了,但纹理还是旧的。

排查思路

检查代码,Canvas 确实重绘了,纹理也调用了 needsUpdate。为什么没变?

脏套路六:强制清空再更新

// 错误示范
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillText(newValue, 100, 100);
texture.needsUpdate = true; // 有时候不灵

// 脏套路:重新设置整个 canvas
function updateCanvasTexture(texture, newValue) {
  const canvas = texture.image;
  const ctx = canvas.getContext('2d');
  
  // 1. 清空
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  
  // 2. 画新内容
  ctx.fillStyle = '#ffffff';
  ctx.font = 'bold 48px Arial';
  ctx.fillText(newValue, 100, 100);
  
  // 3. 脏套路:重新设置 image 并强制更新
  texture.image = canvas; // 重新赋值触发更新
  texture.needsUpdate = true;
  
  // 4. 如果还不行,重建纹理
  // const newTexture = new THREE.CanvasTexture(canvas);
  // mesh.material.map = newTexture;
  // oldTexture.dispose();
}

原理:CanvasTexture 底层缓存机制有时候会“偷懒”,重新赋值 image 能强制它刷新。


场景七:模型“位置错乱” —— 矩阵没更新

症状

InstancedMesh 或者手动修改了 matrix,但模型位置没变,或者位置乱飞。

排查思路

检查代码,确实调用了 setMatrixAt,也调用了 instanceMatrix.needsUpdate = true。为什么不动?

脏套路七:检查矩阵标志位

// 常见错误
instancedMesh.setMatrixAt(index, matrix);
// 忘了设置 needsUpdate

// 正确写法
instancedMesh.setMatrixAt(index, matrix);
instancedMesh.instanceMatrix.needsUpdate = true;

// 脏套路:如果还不动,试试强制重新上传
instancedMesh.instanceMatrix.array.set(matrix.elements, index * 16);
instancedMesh.instanceMatrix.needsUpdate = true;
instancedMesh.computeBoundingSphere(); // 有时候需要这个

进阶排查

// 打印矩阵看是否真的写进去了
const testMatrix = new THREE.Matrix4();
instancedMesh.getMatrixAt(0, testMatrix);
console.log(testMatrix); // 跟预期的一样吗?

// 检查 usage
console.log(instancedMesh.instanceMatrix.usage); // 应该是 DynamicDrawUsage 或 StaticDrawUsage

调试工具包:三板斧让 Bug 现形

遇到视觉 Bug,别急着改代码,先用这三板斧让问题显形

1. 材质覆盖大法

// 强制所有模型用一种材质,排除材质差异
scene.overrideMaterial = new THREE.MeshBasicMaterial({ color: 0xff00ff });

// 查看法线方向
scene.overrideMaterial = new THREE.MeshNormalMaterial();

// 查看 UV 分布
scene.overrideMaterial = new THREE.MeshBasicMaterial({
  map: new THREE.CanvasTexture(uvGridCanvas) // 自定义 UV 网格
});

// 查看深度
scene.overrideMaterial = new THREE.MeshDepthMaterial();

2. 线框透视

// 显示线框,看结构是否完整
scene.traverse((child) => {
  if (child.isMesh) {
    child.material.wireframe = true;
  }
});

3. 包围盒可视化

// 查看模型的包围盒是否准确
import { BoxHelper } from 'three/examples/jsm/helpers/BoxHelper.js';

const boxHelper = new BoxHelper(scene, 0xff0000);
scene.add(boxHelper);

// 查看摄像机视锥
import { CameraHelper } from 'three';
const cameraHelper = new CameraHelper(camera);
scene.add(cameraHelper);

4. 光源可视化

// 查看光源位置和方向
import { PointLightHelper, DirectionalLightHelper, SpotLightHelper } from 'three';

scene.traverse((child) => {
  if (child.isLight) {
    let helper;
    if (child.isPointLight) helper = new PointLightHelper(child, 1);
    if (child.isDirectionalLight) helper = new DirectionalLightHelper(child, 1);
    if (child.isSpotLight) helper = new SpotLightHelper(child);
    if (helper) scene.add(helper);
  }
});

调试心法:让 Bug 现形的五个问题

遇到 Bug 时,按顺序问自己这五个问题,90% 的情况都能找到线索:

1. 是所有的模型都这样,还是只有特定的?

  • 全都这样 → 可能是全局设置(光照、渲染器、后期)
  • 只有某个 → 查那个模型的材质、几何体、矩阵

2. 是固定出现,还是转视角才出现?

  • 固定出现 → 纹理、材质、模型本身的问题
  • 转视角出现 → 法线、视锥裁剪、深度测试的问题

3. 是加载完就这样,还是运行后才出现?

  • 加载完就这样 → 模型导出、加载解析的问题
  • 运行后才出现 → 动画、交互、内存泄漏的问题

4. 是只有这个设备这样,还是所有设备?

  • 只有某台电脑 → 显卡驱动、浏览器版本、硬件兼容性
  • 所有设备都这样 → 代码逻辑问题

5. 去掉这个模型/材质/纹理,Bug 还在吗?

  • 不在了 → 问题就出在去掉的东西上
  • 还在 → 继续二分法排查

终极脏套路:二分法注释

如果实在找不到原因,上最原始的二分法

  1. 注释掉一半场景
  2. Bug 还在吗?
    • 在 → 问题在前一半
    • 不在 → 问题在后一半
  3. 继续二分,直到锁定到具体某个模型或某行代码

这方法虽然笨,但绝对有效。有时候 Bug 找不到,是因为你太相信自己的直觉,而不是相信数据。


总结

视觉 Bug 调试的核心思路就一条:剥离所有“美化”,看最原始的数据

  • 颜色不对?用法线材质看方向
  • 位置不对?用线框看结构
  • 纹理糊了?用 NearestFilter 看原始分辨率
  • 闪烁?检查深度冲突
  • 半透明?重置材质

把这些“脏套路”走一遍,90% 的 Bug 都能现原形。

剩下的 10% 怎么办?那就得动真正的“脏套路”了 —— 比如把同事叫过来一起盯着看,两个人一起怀疑人生,Bug 往往就自己跑了。。。😆


互动

你在项目里遇到过什么“诡异的视觉 Bug”?最后怎么解决的?评论区分享出来,咱们一起“驱鬼” 😏

下篇预告:【Three.js 性能分析】从 Draw Call 到显存占用,一张表看懂瓶颈在哪

Diff算法基础:同层比较与key的作用

作者 wuhen_n
2026年2月27日 09:37

在上一篇文章中,我们深入探讨了 patch 算法的完整实现。今天,我们将聚焦于 Diff 算法的核心思想——为什么需要它?它如何工作?key 又为什么如此重要?通过这篇文章,我们将彻底理解 Diff 算法的基础原理。

前言:从生活中的例子理解Diff

想象一下,假如我们有一排积木:

A B C D

然后我们想把它变成这样:

A C D B

这时,我们应该怎么做呢?

  • 方式一:全部推倒重来:移除所有,按照我们想要的顺序重新摆放

  • 方式二:只调整变化的部分:移动位置,替换积木,即:我们只需要调整 B C D 三块积木的位置即可。

很显然,方式二的做法更高效。这就是 Diff 算法的本质——找出最小化的更新方案。

为什么需要 Diff 算法?

没有 Diff 算法会怎样?

假设我们有一个简单的列表:

<!-- 旧列表 -->
<ul>
  <li>苹果</li>
  <li>香蕉</li>
  <li>橙子</li>
</ul>

<!-- 新列表(只改了最后一个) -->
<ul>
  <li>苹果</li>
  <li>香蕉</li>
  <li>橘子</li>
</ul>

上述两个列表中,新列表只改了最后一项数据,如果没有 Diff 算法,我们只能按照 前言 中的方式一处理:删除整个 ul,重新创建:

const oldUl = document.querySelector('ul');
oldUl.remove();

const newUl = document.createElement('ul');
newUl.innerHTML = `
  <li>苹果</li>
  <li>香蕉</li>
  <li>橘子</li>
`;
container.appendChild(newUl);

这种方式虽然可以解决问题,但存在很大的风险:

  1. 性能极差:即使只改一个字,也要重建整个 DOM 树
  2. 状态丢失:输入框内容、滚动位置都会丢失
  3. 浪费资源:创建了大量不必要的 DOM 节点

此时 Diff 算法的重要性就凸显出来了!

Diff 算法的目标

Diff 算法的核心目标可以概括为三点:

  1. 尽可能复用已有节点
  2. 只更新变化的部分
  3. 最小化 DOM 操作

还是以上述 ul 结构为例,理想中的 Diff 操作应该是:

  1. 更新第三个 li 的文本内容:将 <li>橙子</li> 替换成 <li>橘子</li>
  2. 其他节点完全复用,不作任何更改

传统 Diff 算法

function diff(oldList, newList){
  for(let i = 0; i < oldList.length; i++){
    for(let j = 0; j < newList.length; j++){
      if(oldList[i] === newList[j]){
        // 找到相同的节点,进行复用
        console.log('找到了相同的节点', oldList[i]);
        break;
      } else {
        // 没找到相同的节点,进行新增
        console.log('需要新增节点', newList[j]);
      }
    }
  }
}

上述代码的时间复杂度为:O(n²);如果再考虑到移动、删除、新增等操作,其时间复杂度可以达到:O(n³)。这显然是不合理的。

同层比较的核心思想

为了解决传统 Diff 算法的时间复杂度问题,Vue 团队通过两个关键思想,将 Diff 算法的时间复杂降低到了:O(n):

  1. 同层比较,即只比较同一层级的节点
  2. 类型相同,即不同类型节点直接替换

什么是同层比较?

同层比较的意思是:只比较同一层级的节点,不跨层级移动。 我们来看一个简单的例子: 同层比较 上图两个新旧 VNode 树中,对比过程是这样的: 同层比较示例图

为什么不跨层级比较?

我们可以再来一个更复杂的示例:

<!-- 旧列表 -->
<ul>
  <li>li-1</li>
  <li>li-2</li>
  <li>
    <span>
      <a>
        li-3
      </a>
    </span>
  </li>
</ul>

<!-- 新列表 -->
<ul>
  <li>li-1</li>
  <li>li-2</li>
  <li>
    <a>
      li-3
    </a>
  </li>
</ul>

假设新旧两个列表是这样的,如果支持跨层级比较和移动,那么上述列表应该进行如下操作:

  1. 发现旧列表中 a 标签位于 span 标签下,新列表中直接位于 li 标签下;
  2. 记录这个操作差异,保存 a 标签,删除 span 标签,再把 a 标签挂载到 li 标签下;
  3. 更新父子节点关系。

这种操作会让算法变得极其复杂,而且实际开发中,跨层级移动节点的情况非常罕见。所以 Vue 选择简化问题:如果节点跨层级了,就视为不同类型,直接替换。

function patch(oldVNode, newVNode) {
  // 如果节点类型不同,直接替换
  if (oldVNode.type !== newVNode.type) {
    unmount(oldVNode);
    mount(newVNode);
    return;
  }
  
  // 同类型节点,进行深度比较
  patchChildren(oldVNode, newVNode);
}

同层比较的优势

优势 说明 示例
算法简单 只需要比较同一层 树形结构简化为线性比较
性能可控 复杂度O(n) 1000个节点只需比较1000次
实现可靠 边界情况少 不需要处理复杂移动

key在节点复用中的作用

为什么需要key?

我们来看一个简单的代办列表:

<!-- 旧列表 -->
<li>学习Vue</li>
<li>写文章</li>
<li>休息一下</li>

<!-- 新列表(删除了中间项 写文章) -->
<li>学习Vue</li>
<li>休息一下</li>

如果没有 key,Vue 会如何进行 diff 比较呢:

  1. 比较位置0:都是"学习Vue",直接复用;
  2. 比较位置1:旧的是"写文章",新的是"休息一下" ,更新文本进行替换
  3. 比较位置2:旧的有"休息一下",新的没有,则删除

这样操作过程中,更新了一个 li 的文本,删除了一个 li 。 这个过程看起来是没有问题的,但是如果上述列表有状态呢?

<!-- 带输入框的列表 -->
<li>
  <input value="学习Vue" />
  学习Vue
</li>
<li>
  <input value="写文章" />
  写文章
</li>
<li>
  <input value="休息一下" />
  休息一下
</li>

<!-- 删除中间项后 -->
<li>
  <input value="学习Vue" />  <!-- 输入框内容被保留了 -->
  学习Vue
</li>
<li>
  <input value="休息一下" />  <!-- 这里会是"休息一下"吗? -->
  休息一下
</li>

这时候问题就出现了:输入框的内容被错误地复用了!由于没有 key 的情况下,Vue 只按位置比较,最后的实际结果是:

<li>
  <input value="学习Vue" />  <!-- 输入框内容被保留了 -->
  学习Vue
</li>
<li>
  <input value="写文章" />  <!-- label变成了"写文章" -->
  休息一下
</li>

这个例子也同样解释了为什么不推荐,或者说不能用 index 作为 key 的原因。正确的做法是使用唯一的、稳定的标识作为 key。

key的作用图解

key的作用可以这样理解: key的作用图解

手写实现:简单Diff算法

class SimpleDiff {
  constructor(options) {
    this.options = options;
  }
  
  /**
   * 执行diff更新
   * @param {Array} oldChildren 旧子节点数组
   * @param {Array} newChildren 新子节点数组
   * @param {HTMLElement} container 父容器
   */
  diff(oldChildren, newChildren, container) {
    // 1. 创建key到索引的映射(如果有key)
    const oldKeyMap = this.createKeyMap(oldChildren);
    const newKeyMap = this.createKeyMap(newChildren);
    
    // 2. 记录已处理的节点
    const processed = new Set();
    
    // 3. 第一轮:尝试复用有key的节点
    this.patchKeyedNodes(oldChildren, newChildren, oldKeyMap, newKeyMap, processed, container);
    
    // 4. 第二轮:处理剩余节点
    this.processRemainingNodes(oldChildren, newChildren, processed, container);
  }
  
  /**
   * 创建key到索引的映射
   */
  createKeyMap(children) {
    const map = new Map();
    for (let i = 0; i < children.length; i++) {
      const child = children[i];
      if (child.key != null) {
        map.set(child.key, i);
      }
    }
    return map;
  }
  
  /**
   * 处理有key的节点
   */
  patchKeyedNodes(oldChildren, newChildren, oldKeyMap, newKeyMap, processed, container) {
    // 遍历新节点
    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i];
      
      // 如果新节点没有key,跳过第一轮处理
      if (newVNode.key == null) continue;
      
      // 尝试在旧节点中找相同key的节点
      const oldIndex = oldKeyMap.get(newVNode.key);
      
      if (oldIndex !== undefined) {
        const oldVNode = oldChildren[oldIndex];
        
        // 标记为已处理
        processed.add(oldIndex);
        
        // 执行patch更新
        this.patchVNode(oldVNode, newVNode, container);
      } else {
        // 没有找到对应key,说明是新增节点
        this.mountVNode(newVNode, container);
      }
    }
  }
  
  /**
   * 处理剩余节点
   */
  processRemainingNodes(oldChildren, newChildren, processed, container) {
    // 1. 卸载未处理的旧节点
    for (let i = 0; i < oldChildren.length; i++) {
      if (!processed.has(i)) {
        this.unmountVNode(oldChildren[i]);
      }
    }
    
    // 2. 挂载新节点中未处理的节点
    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i];
      
      // 如果没有key或者key不在旧节点中,需要挂载
      if (newVNode.key == null) {
        this.mountVNode(newVNode, container);
      } else {
        const oldIndex = oldChildren.findIndex(old => old.key === newVNode.key);
        if (oldIndex === -1) {
          this.mountVNode(newVNode, container);
        }
      }
    }
  }
  
  /**
   * 更新节点
   */
  patchVNode(oldVNode, newVNode, container) {
    console.log(`更新节点: ${oldVNode.key || '无key'}`);
    
    // 复用DOM元素
    newVNode.el = oldVNode.el;
    
    // 更新属性
    this.updateProps(newVNode.el, oldVNode.props, newVNode.props);
    
    // 更新子节点
    if (newVNode.children !== oldVNode.children) {
      newVNode.el.textContent = newVNode.children;
    }
  }
  
  /**
   * 挂载新节点
   */
  mountVNode(vnode, container) {
    console.log(`挂载新节点: ${vnode.key || '无key'}`);
    
    // 创建DOM元素
    const el = document.createElement(vnode.type);
    vnode.el = el;
    
    // 设置属性
    this.updateProps(el, {}, vnode.props);
    
    // 设置内容
    if (vnode.children) {
      el.textContent = vnode.children;
    }
    
    // 插入到容器
    container.appendChild(el);
  }
  
  /**
   * 卸载节点
   */
  unmountVNode(vnode) {
    console.log(`卸载节点: ${vnode.key || '无key'}`);
    if (vnode.el && vnode.el.parentNode) {
      vnode.el.parentNode.removeChild(vnode.el);
    }
  }
  
  /**
   * 更新属性
   */
  updateProps(el, oldProps = {}, newProps = {}) {
    // 移除不存在的属性
    for (const key in oldProps) {
      if (!(key in newProps)) {
        el.removeAttribute(key);
      }
    }
    
    // 设置新属性
    for (const key in newProps) {
      if (oldProps[key] !== newProps[key]) {
        el.setAttribute(key, newProps[key]);
      }
    }
  }
}

// 创建VNode的辅助函数
function h(type, props = {}, children = '') {
  return {
    type,
    props,
    key: props.key,
    children,
    el: null
  };
}

结语

理解 Diff 算法的基础原理,就像掌握了Vue 更新 DOM 的"思维模式"。知道它如何思考、如何决策,才能写出与框架配合最好的代码。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

patch算法:新旧节点的比对与更新

作者 wuhen_n
2026年2月27日 09:33

在前面的文章中,我们深入探讨了虚拟 DOM 的创建和组件的挂载过程。当数据变化时,Vue 需要高效地更新 DOM。这个过程的核心就是 patch 算法——新旧虚拟 DOM 的比对与更新策略。本文将带你深入理解 Vue3 的 patch 算法,看看它如何以最小的代价完成 DOM 更新。

前言:为什么需要patch?

想象一下,你有一个展示用户列表的页面。当某个用户的名字改变时,我们会怎么做?

  • 粗暴方式:重新渲染整个列表(性能差)
  • 聪明方式:只更新那个改变的用户名(性能好)

patch 算法就是 Vue 采用的"聪明方式"。它的核心思想是:找出新旧 VNode 的差异,只更新变化的部分,而不是重新渲染整个 DOM 树:

patch 过程图

patch函数的核心逻辑

patch的整体架构

patch 函数是整个更新过程的总调度器,它根据节点类型分发到不同的处理函数:

function patch(oldVNode, newVNode, container, anchor = null) {
  // 如果是同一个引用,无需更新
  if (oldVNode === newVNode) return;
  
  // 如果类型不同,直接替换
  if (oldVNode && !isSameVNodeType(oldVNode, newVNode)) {
    unmount(oldVNode);
    oldVNode = null;
  }
  
  const { type, shapeFlag } = newVNode;
  
  // 根据类型分发处理
  switch (type) {
    case Text:
      processText(oldVNode, newVNode, container, anchor);
      break;
    case Comment:
      processComment(oldVNode, newVNode, container, anchor);
      break;
    case Fragment:
      processFragment(oldVNode, newVNode, container, anchor);
      break;
    case Static:
      processStatic(oldVNode, newVNode, container, anchor);
      break;
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(oldVNode, newVNode, container, anchor);
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        processComponent(oldVNode, newVNode, container, anchor);
      } else if (shapeFlag & ShapeFlags.TELEPORT) {
        processTeleport(oldVNode, newVNode, container, anchor);
      } else if (shapeFlag & ShapeFlags.SUSPENSE) {
        processSuspense(oldVNode, newVNode, container, anchor);
      }
  }
}

patch 的分发流程图

patch的分发流程图

判断节点类型的关键:isSameVNodeType

function isSameVNodeType(n1, n2) {
  // 比较类型和key
  return n1.type === n2.type && n1.key === n2.key;
}

为什么需要key?

我们看看下面的例子:

<!-- 旧列表 -->
<li key="a">A</li>
<li key="b">B</li>
<li key="c">C</li>

<!-- 新列表 -->
<li key="a">A</li>
<li key="c">C</li>
<li key="b">B</li>

<!-- 有key: 只移动节点,不重新创建 -->
<!-- 无key: 全部重新创建,性能差 -->

不同类型节点的处理策略

文本节点的处理

文本节点是最简单的节点类型,处理逻辑也最直接:

function processText(oldVNode, newVNode, container, anchor) {
  if (oldVNode == null) {
    // 首次挂载
    const textNode = document.createTextNode(newVNode.children);
    newVNode.el = textNode;
    container.insertBefore(textNode, anchor);
  } else {
    // 更新
    const el = (newVNode.el = oldVNode.el);
    if (newVNode.children !== oldVNode.children) {
      // 只有文本变化时才更新
      el.nodeValue = newVNode.children;
    }
  }
}

文本节点更新过程

文本节点更新过程

注释节点的处理

注释节点基本不需要更新,因为用户通常不关心注释的变化:

function processComment(oldVNode, newVNode, container, anchor) {
  if (oldVNode == null) {
    const commentNode = document.createComment(newVNode.children);
    newVNode.el = commentNode;
    container.insertBefore(commentNode, anchor);
  } else {
    // 注释节点很少变化,直接复用
    newVNode.el = oldVNode.el;
  }
}

元素节点的处理

元素节点的更新是最复杂的,需要处理属性和子节点:

function processElement(oldVNode, newVNode, container, anchor) {
  if (oldVNode == null) {
    // 首次挂载
    mountElement(newVNode, container, anchor);
  } else {
    // 更新
    patchElement(oldVNode, newVNode);
  }
}

function patchElement(oldVNode, newVNode) {
  const el = (newVNode.el = oldVNode.el);
  
  // 1. 更新props
  patchProps(el, oldVNode.props, newVNode.props);
  
  // 2. 更新children
  patchChildren(oldVNode, newVNode, el);
}

function patchProps(el, oldProps, newProps) {
  oldProps = oldProps || {};
  newProps = newProps || {};
  
  // 移除旧props中不存在于新props的属性
  for (const key in oldProps) {
    if (!(key in newProps)) {
      patchProp(el, key, oldProps[key], null);
    }
  }
  
  // 添加或更新新props
  for (const key in newProps) {
    const old = oldProps[key];
    const next = newProps[key];
    if (old !== next) {
      patchProp(el, key, old, next);
    }
  }
}

子节点的比对策略

子节点的比对是 patch 算法中最复杂、也最关键的部分。Vue3 根据子节点的类型,采用不同的策略。

子节点类型组合的处理策略

下表总结了所有可能的子节点类型组合及对应的处理方式:

旧子节点 新子节点 处理策略 示例
文本 文本 直接替换文本内容 "old" → "new"
文本 数组 清空文本,挂载数组 "text" → [vnode1, vnode2]
文本 清空文本 "text" → null
数组 文本 卸载数组,设置文本 [vnode1, vnode2] → "text"
数组 数组 执行核心diff [a,b,c] → [a,d,e]
数组 卸载所有子节点 [a,b,c] → null
文本 设置文本 null → "text"
数组 挂载数组 null → [a,b,c]

当新旧节点都为数组时,需要执行 diff 算法,diff 算法的内容在后面的文章中会专门介绍。

Fragment和Text节点的特殊处理

Fragment的处理

Fragment 是 Vue3 新增的节点类型,用于支持多根节点:

function processFragment(oldVNode, newVNode, container, anchor) {
  if (oldVNode == null) {
    // 首次挂载
    mountFragment(newVNode, container, anchor);
  } else {
    // 更新
    patchFragment(oldVNode, newVNode, container, anchor);
  }
}

function mountFragment(vnode, container, anchor) {
  const { children, shapeFlag } = vnode;
  
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 文本子节点:挂载为文本节点
    const textNode = document.createTextNode(children);
    vnode.el = textNode;
    container.insertBefore(textNode, anchor);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 数组子节点:挂载所有子节点
    mountChildren(children, container, anchor);
    
    // 设置el和anchor
    vnode.el = children[0]?.el;
    vnode.anchor = children[children.length - 1]?.el;
  }
}

function patchFragment(oldVNode, newVNode, container, anchor) {
  const oldChildren = oldVNode.children;
  const newChildren = newVNode.children;
  
  // Fragment本身没有DOM,直接patch子节点
  patchChildren(oldVNode, newVNode, container);
  
  // 更新el和anchor
  if (Array.isArray(newChildren)) {
    newVNode.el = newChildren[0]?.el || oldVNode.el;
    newVNode.anchor = newChildren[newChildren.length - 1]?.el || oldVNode.anchor;
  }
}

文本节点的优化

Vue3 对纯文本节点做了特殊优化,避免不必要的 VNode 创建:

// 模板:<div>{{ message }}</div>
// 编译后:
function render(ctx) {
  return h('div', null, ctx.message, PatchFlags.TEXT);
}

// 在patch过程中:
if (newVNode.patchFlag & PatchFlags.TEXT) {
  // 只需要更新文本内容,不需要比较其他属性
  const el = oldVNode.el;
  if (newVNode.children !== oldVNode.children) {
    el.textContent = newVNode.children;
  }
  newVNode.el = el;
  return;
}

手写实现:完整的patch函数基础版本

基础工具函数

// 类型标志
const ShapeFlags = {
  ELEMENT: 1,
  FUNCTIONAL_COMPONENT: 1 << 1,
  STATEFUL_COMPONENT: 1 << 2,
  TEXT_CHILDREN: 1 << 3,
  ARRAY_CHILDREN: 1 << 4,
  SLOTS_CHILDREN: 1 << 5,
  TELEPORT: 1 << 6,
  SUSPENSE: 1 << 7,
  COMPONENT_SHOULD_KEEP_ALIVE: 1 << 8,
  COMPONENT_KEPT_ALIVE: 1 << 9
};

// 特殊节点类型
const Text = Symbol('Text');
const Comment = Symbol('Comment');
const Fragment = Symbol('Fragment');

// 判断是否同类型节点
function isSameVNodeType(n1, n2) {
  return n1.type === n2.type && n1.key === n2.key;
}

完整的patch函数

class Renderer {
  constructor(options) {
    this.options = options;
  }
  
  patch(oldVNode, newVNode, container, anchor = null) {
    if (oldVNode === newVNode) return;
    
    // 处理不同类型的节点
    if (oldVNode && !isSameVNodeType(oldVNode, newVNode)) {
      this.unmount(oldVNode);
      oldVNode = null;
    }
    
    const { type, shapeFlag } = newVNode;
    
    // 根据类型分发
    switch (type) {
      case Text:
        this.processText(oldVNode, newVNode, container, anchor);
        break;
      case Comment:
        this.processComment(oldVNode, newVNode, container, anchor);
        break;
      case Fragment:
        this.processFragment(oldVNode, newVNode, container, anchor);
        break;
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          this.processElement(oldVNode, newVNode, container, anchor);
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          this.processComponent(oldVNode, newVNode, container, anchor);
        } else if (shapeFlag & ShapeFlags.TELEPORT) {
          this.processTeleport(oldVNode, newVNode, container, anchor);
        }
    }
  }
  
  processElement(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      // 挂载
      this.mountElement(newVNode, container, anchor);
    } else {
      // 更新
      this.patchElement(oldVNode, newVNode);
    }
  }
  
  mountElement(vnode, container, anchor) {
    const { type, props, children, shapeFlag } = vnode;
    
    // 创建元素
    const el = this.options.createElement(type);
    vnode.el = el;
    
    // 设置属性
    if (props) {
      for (const key in props) {
        this.options.patchProp(el, key, null, props[key]);
      }
    }
    
    // 处理子节点
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      this.options.setElementText(el, children);
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      this.mountChildren(children, el);
    }
    
    // 插入
    this.options.insert(el, container, anchor);
  }
  
  patchElement(oldVNode, newVNode) {
    const el = (newVNode.el = oldVNode.el);
    const oldProps = oldVNode.props || {};
    const newProps = newVNode.props || {};
    
    // 更新属性
    this.patchProps(el, oldProps, newProps);
    
    // 更新子节点
    this.patchChildren(oldVNode, newVNode, el);
  }
  
  patchChildren(oldVNode, newVNode, container) {
    const oldChildren = oldVNode.children;
    const newChildren = newVNode.children;
    
    const oldShapeFlag = oldVNode.shapeFlag;
    const newShapeFlag = newVNode.shapeFlag;
    
    // 新子节点是文本
    if (newShapeFlag & ShapeFlags.TEXT_CHILDREN) {
      if (oldShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        this.unmountChildren(oldChildren);
      }
      if (oldChildren !== newChildren) {
        this.options.setElementText(container, newChildren);
      }
    }
    // 新子节点是数组
    else if (newShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      if (oldShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        this.options.setElementText(container, '');
        this.mountChildren(newChildren, container);
      } else if (oldShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        this.patchKeyedChildren(oldChildren, newChildren, container);
      }
    }
    // 新子节点为空
    else {
      if (oldShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        this.unmountChildren(oldChildren);
      } else if (oldShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        this.options.setElementText(container, '');
      }
    }
  }
  
  processText(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const textNode = this.options.createText(newVNode.children);
      newVNode.el = textNode;
      this.options.insert(textNode, container, anchor);
    } else {
      const el = (newVNode.el = oldVNode.el);
      if (newVNode.children !== oldVNode.children) {
        this.options.setText(el, newVNode.children);
      }
    }
  }
  
  processFragment(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      this.mountFragment(newVNode, container, anchor);
    } else {
      this.patchFragment(oldVNode, newVNode, container, anchor);
    }
  }
  
  mountFragment(vnode, container, anchor) {
    const { children, shapeFlag } = vnode;
    
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      const textNode = this.options.createText(children);
      vnode.el = textNode;
      this.options.insert(textNode, container, anchor);
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      this.mountChildren(children, container, anchor);
      vnode.el = children[0]?.el;
      vnode.anchor = children[children.length - 1]?.el;
    }
  }
  
  mountChildren(children, container, anchor) {
    for (let i = 0; i < children.length; i++) {
      this.patch(null, children[i], container, anchor);
    }
  }
  
  unmount(vnode) {
    const { shapeFlag, el } = vnode;
    
    if (shapeFlag & ShapeFlags.COMPONENT) {
      this.unmountComponent(vnode);
    } else if (shapeFlag & ShapeFlags.FRAGMENT) {
      this.unmountFragment(vnode);
    } else if (el) {
      this.options.remove(el);
    }
  }
}

Vue2 与 Vue3 的 patch 差异

核心差异对比表

特性 Vue2 Vue3 优势
数据劫持 Object.defineProperty Proxy Vue3可以监听新增/删除属性
编译优化 全量比较 静态提升 + PatchFlags Vue3跳过静态节点比较
diff算法 双端比较 最长递增子序列 Vue3移动操作更少
Fragment 不支持 支持 多根节点组件
Teleport 不支持 支持 灵活的DOM位置控制
Suspense 不支持 支持 异步依赖管理
性能 中等 优秀 Vue3更新速度提升1.3-2倍

PatchFlags 带来的优化

Vue3 通过 PatchFlags 标记动态内容,减少比较范围:

const PatchFlags = {
  TEXT: 1,           // 动态文本
  CLASS: 2,          // 动态class
  STYLE: 4,          // 动态style
  PROPS: 8,          // 动态属性
  FULL_PROPS: 16,    // 全量props
  HYDRATE_EVENTS: 32, // 事件
  STABLE_FRAGMENT: 64, // 稳定Fragment
  KEYED_FRAGMENT: 128, // 带key的Fragment
  UNKEYED_FRAGMENT: 256, // 无key的Fragment
  NEED_PATCH: 512,   // 需要非props比较
  DYNAMIC_SLOTS: 1024, // 动态插槽
  
  HOISTED: -1,       // 静态节点
  BAIL: -2           // 退出优化
};

结语

理解 patch 算法,就像是掌握了 Vue 更新 DOM 的"手术刀"。知道它如何精准地找到需要更新的部分,以最小的代价完成更新,这不仅能帮助我们写出更高效的代码,还能在遇到性能问题时快速定位和优化。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

微任务链式派生阻塞渲染

作者 MrBread
2026年2月27日 09:26

测试前两天提给我一个bug,点击一个按钮后页面会卡顿很久,然后才能出现弹窗。

我自己试了一下,点击按钮后,果然整个页面陷入了完全的卡顿,过了一会后弹窗出现,但此时操作页面同样会卡顿,一看就是js执行阻塞了渲染。

我又点击了其他数据同样位置的按钮,此时却一点都不卡顿。

可以推测应该是数据量太大导致处理函数的执行时间过长阻塞了渲染。

数据处理优化

于是找到处理函数,发现其中对列表数据进行了循环,又在循环中在一个大的字典表做字典匹配

// 示例代码
async function buildOptions(list) {
    for (let i = 0; i < list.length; i++) {
        const dictList = await getDict(config.dicType);
    }
}

async getDict(dicType) {
  let list: DicItem[] = [];
  if (!this.dictionaryList.length) {
    list = await this.getDictAll();
  } else {
    list = this.dictionaryList;
  }
  return list.filter(o => o.id == dicType);
}

由于是大数据的循环套循环,所以只要将字典表做一次循环变为map,就可以让效率提升。

async getDict(dicType) {
  if (!this.dictionaryList.length) {
    await this.getDictAll();
  }
  return this.dictionaryMap.get(dicType);
}

改好,再次点击按钮,熟悉卡顿、熟悉的掉帧,完全没有效果。

我老老实实的打开performance,查看一下这地方的点击前后的性能分析,发现点击按钮后执行的时间来到了3秒,其中大部分时间都在执行微任务。

image-20251231155124203.png

我再看处理函数的代码,突然明白了。

同一事件循环中大量微任务阻塞

因为循环中使用了await,当dictionaryList中有值的时候,await创建的Promise会在本轮宏任务中立刻resolve,这时候后续的代码会作为微任务进入到微任务队列中。等执行该微任务的时候,下一次的循环依然会产生新的微任务,也就是 执行微任务=>微任务生成微任务=>执行微任务 这样一个过程直到循环结束。而所有微任务都在同一个宏任务内连续执行,于是浏览器渲染被阻塞。

知道了原因,改正就简单了,只要先执行一次带有await的方法,然后通过同步函数获取就可以了

async function buildOptions(list) {
    await getDict('')
    for (let i = 0; i < list.length; i++) {
        const dictList = getDictSync(config.dicType);
    }
}
// 同步获取字典
function getDictSync(dicType) {
    return this.dictionaryMap.get(dicType) || [];
}

再次查看这个地方的性能分析,总耗时已经降下来了,来到了100ms

image-20251231163802589.png

关键点:  循环内对同步函数用 await,每个 await 都把下一次循环推迟到微任务队列,前一个微任务执行完又产生下一个微任务,所有微任务连续执行不中断,直到循环结束,浏览器才有机会渲染。

❌
❌