阅读视图

发现新文章,点击刷新页面。

解决 Vite 代理中的 E RR_CONTENT_DECODING_FAILED 错误:禁用自动压缩的实践

最近在使用 Vite 开发一个 Vue3 项目时,遇到了一个颇为棘手的网络错误。项目配置了代理 ( serv er.proxy ) 将特定前缀(比如 /api )的请求转发到后端服务。大部分接口工作正常,但部分接口在浏览器控制台会抛出 ERR_ CONTENT_DECODING_FAILED 错误。这个错误通常意味着浏览器接收到了经过压缩(如 gzip, br)的响应内容,但无法正确解码。

排查过程

  1. 检查后端服务: 首先确认后端服务本身是正常的,直接访问后端接口 URL(不通过 Vite 代理)可以成功返回预期的 JSON 数据或其它内容,且响应头 Content-Encoding 显示后端确实返回了压缩内容(如 gzip )。

  2. 检查 Vite 代理配置: 基础的代理配置看起来没有问题:

    // vite.config.jsexport default defineConfig({ server: { proxy: { '/api': { target: ' your-backend-server.com', // 后端地址 changeOrigin: true, // 通常建议开启 rewrite: (path) => path.replace(/^/api/, ''), // 可选,重写路径 // ... 其他配置 ... } } }, // ... 其他配置 ...});

JavaScript

配置了 changeOrigin: true 确保请求头中的 Host 和 Origin 被正确修改以应对跨域问题。

  1. 对比请求差异: 使用浏览器开发者工具对比了通过 Vite 代理的请求和直接请求后端的请求/响应头信息。发现关键差异在于 Accept-Encoding 请求头:
  • 直接请求后端: 浏览器发送的 Accept-Encoding 通常包含 gzip, deflate, br 等,表明浏览器可以接受这些压缩格式。后端据此返回压缩内容并设置 Content-Encoding: gzip 。

  • 通过 Vite 代理请求: Vite 开发服务器在转发请求给后端时,默认也会带上 Accept-Encoding: gzip, deflate, br (或类似)的请求头。后端同样识别到这个头,并返回了压缩内容 ( Content-Encoding: gzip )。

  1. 问题定位: 问题出在 Vite 开发服务器对代理响应的处理上。当后端返回压缩内容时:
  • Vite 开发服务器(基于 http-proxy-middleware )接收到了这个压缩的响应体。

  • 试图将这个压缩的响应体原样转发给浏览器

  • 然而,浏览器在接收到这个响应时,发现响应头 Content-Encoding: gzip 存在,表明内容需要解压。

  • 浏览器尝试解压这个响应体,但失败了,导致 ERR_CONTENT_DECODING_FAILED 错误。

核心原因

Vite 代理默认行为是“透明”转发请求和响应。它不会主动解压后端返回的压缩内容,而是直接将其传递给前端浏览器。浏览器看到 Content-Encoding 头,就会尝试解压,但如果这个压缩流在传输或处理过程中出现任何不兼容或损坏(即使后端压缩本身是正确的,代理的传递过程也可能引入微妙的不兼容),或者浏览器对特定压缩算法的实现有细微差异,解压就可能失败。

解决方案:强制后端返回未压缩内容

既然问题源于浏览器无法正确处理代理转发的压缩响应,最直接的思路就是阻止后端返回压缩内容。我们可以在代理请求中明确告诉后端:“我不接受任何压缩格式,请给我原始(identity)内容”。

这就是通过设置 headers 选项中的 Accept-Encoding 来实现的:

// vite.config.jsexport default defineConfig({  server: {    proxy: {      '/api': {        target: ' https://your-backend-server.com',        changeOrigin: true,        rewrite: (path) => path.replace(/^\/api/, ''), // 可选        // 关键解决方案:添加 headers 配置        headers: {          'Accept-Encoding': 'identity', // 明确要求后端不要压缩响应体        },      }    }  },  // ... 其他配置 ...});

TypeScript

解释

  • headers 选项允许我们在 Vite 代理将请求转发给目标服务器(后端)之前,修改或添加请求头。

  • 设置 'Accept-Encoding': 'identity' :

  • Accept-Encoding 是 HTTP 请求头,用于告知服务器客户端能够理解的内容编码(压缩)方式。

  • identity 是一个特殊值,表示“不压缩”、“无编码”、“原样”。它明确告诉服务器:“请直接返回原始数据,不要进行任何压缩”。

  • 效果: 后端服务器收到这个请求头后,知道客户端(此时是 Vite 代理服务器,它代表浏览器)不接受压缩,因此会返回未经压缩的原始响应体,并且响应头中通常不会包含 Content-Encoding ,或者其值为 identity 。

  • 结果: Vite 代理将这个未压缩的响应体转发给浏览器。浏览器没有看到 Content-Encoding 头,或者看到 identity ,就知道内容不需要解压,直接使用即可。 ERR_CONTENT_DECODING_FAILED 错误消失。

总结与启示

  1. 问题本质: ERR_CONTENT_DECODING_FAILED 在 Vite 代理场景下,通常是由于代理直接转发了后端的压缩响应,而浏览器解压该响应时失败。

  2. 解决方案: 在 Vite 的代理配置 ( server.proxy[xxx].headers ) 中设置 'Accept-Encoding': 'identity' ,强制要求后端返回未压缩的原始内容。这消除了浏览器解压环节,从而避免了解压失败的错误。

  3. 权衡: 此方案的代价是牺牲了网络传输的压缩效率。未压缩的内容体积更大,可能会略微增加加载时间。但在开发环境或部分特定接口遇到此问题时,稳定性优先于那一点传输效率通常是更合理的选择。对于生产环境,静态资源应使用构建时预压缩(如 vite-plugin-compression ),并由服务器(如 Nginx)根据请求头 Accept-Encoding 动态提供正确的压缩版本或原始版本给浏览器。

  4. 排查技巧: 遇到代理相关问题时,仔细对比代理前后请求/响应头的差异是至关重要的第一步。开发者工具的网络面板是解决此类问题的利器。

微信小程序同声传译插件深度应用:语音合成与长文本播放优化

之前的文章 微信小程序同声传译插件接入实战:语音识别功能完整实现指南介绍如何使用同声传译插件进行语音识别,这篇将会讲述同声传译的另一个功能语音合成。

功能概述

微信小程序同声传译插件的语音合成(TTS)功能能将文字内容转换为语音播放,适用于内容朗读、语音提醒、无障碍阅读等场景。

核心实现架构

状态管理

const textToSpeechContent = ref("")
const textToSpeechStatus = ref(0)  // 0 未播放 1 合成中 2 正在播放

核心功能实现

语音合成主函数

function onTextToSpeech(text = "") {
  // 如果正在播放,先停止
  if(textToSpeechStatus.value > 0) {
    uni.$emit("STOP_INNER_AUDIO_CONTEXT")
  }
  
  textToSpeechStatus.value = 1
  uni.showLoading({
    title: "语音合成中...",
    mask: true,
  })
  
  // 处理文本内容
  if(text.length) {
    textToSpeechContent.value = text
  }
  
  // 分段处理长文本(微信限制每次最多200字)
  let content = textToSpeechContent.value.slice(0, 200)
  textToSpeechContent.value = textToSpeechContent.value.slice(200)
  
  if(!content) {
    uni.hideLoading()
    return
  }
  
  // 调用合成接口
  plugin.textToSpeech({
    lang: "zh_CN",
    tts: true,
    content: content,
    success: (res) => {
      handleSpeechSuccess(res)
    },
    fail: (res) => {
      handleSpeechFail(res)
    }
  })
}

合成成功处理

function handleSpeechSuccess(res) {
  uni.hideLoading()
  
  // 创建音频上下文
  innerAudioContext = uni.createInnerAudioContext()
  innerAudioContext.src = res.filename
  innerAudioContext.play()
  textToSpeechStatus.value = 2
  
  // 播放结束自动播下一段
  innerAudioContext.onEnded(() => {
    innerAudioContext = null
    textToSpeechStatus.value = 0
    onTextToSpeech() // 递归播放剩余内容
  })
  
  setupAudioControl()
}

音频控制管理

function setupAudioControl() {
  uni.$off("STOP_INNER_AUDIO_CONTEXT")
  uni.$on("STOP_INNER_AUDIO_CONTEXT", (pause) => {
    textToSpeechStatus.value = 0
    if(pause) {
      innerAudioContext?.pause()
    } else {
      innerAudioContext?.stop()
      innerAudioContext = null
      textToSpeechContent.value = ""
    }
  })
}

错误处理

function handleSpeechFail(res) {
  textToSpeechStatus.value = 0
  uni.hideLoading()
  toast("不支持合成的文字")
  console.log("fail tts", res)
}

关键技术点

1. 长文本分段处理

由于微信接口限制,单次合成最多200字,需要实现自动分段:

let content = textToSpeechContent.value.slice(0, 200)
textToSpeechContent.value = textToSpeechContent.value.slice(200)

2. 播放状态管理

通过状态值精确控制播放流程:

  • 0:未播放,可以开始新的合成
  • 1:合成中,显示loading状态
  • 2:播放中,可以暂停或停止

3. 自动连续播放

利用递归实现长文本的自动连续播放:

innerAudioContext.onEnded(() => {
  onTextToSpeech() // 播放结束继续合成下一段
})

完整代码

export function useTextToSpeech() {
  const plugin = requirePlugin('WechatSI')
  let innerAudioContext = null
  const textToSpeechContent = ref("")
  const textToSpeechStatus = ref(0)
  
  function onTextToSpeech(text = "") {
    if(textToSpeechStatus.value > 0) {
      uni.$emit("STOP_INNER_AUDIO_CONTEXT")
    }
    textToSpeechStatus.value = 1
    uni.showLoading({
      title: "语音合成中...",
      mask: true,
    })
    
    if(text.length) {
      textToSpeechContent.value = text
    }
    
    let content = textToSpeechContent.value.slice(0, 200)
    textToSpeechContent.value = textToSpeechContent.value.slice(200)
    
    if(!content) {
      uni.hideLoading()
      return
    }
    
    plugin.textToSpeech({
      lang: "zh_CN",
      tts: true,
      content: content,
      success: (res) => {
        uni.hideLoading()
        innerAudioContext = uni.createInnerAudioContext()
        innerAudioContext.src = res.filename
        innerAudioContext.play()
        textToSpeechStatus.value = 2
        
        innerAudioContext.onEnded(() => {
          innerAudioContext = null
          textToSpeechStatus.value = 0
          onTextToSpeech()
        })
        
        uni.$off("STOP_INNER_AUDIO_CONTEXT")
        uni.$on("STOP_INNER_AUDIO_CONTEXT", (pause) => {
          textToSpeechStatus.value = 0
          if(pause) {
            innerAudioContext?.pause()
          } else {
            innerAudioContext?.stop()
            innerAudioContext = null
            textToSpeechContent.value = ""
          }
        })
      },
      fail: (res) => {
        textToSpeechStatus.value = 0
        uni.hideLoading()
        toast("不支持合成的文字")
        console.log("fail tts", res)
      }
    })
  }
  
  return {
    onTextToSpeech,
    textToSpeechContent,
    textToSpeechStatus
  }
}

Vue 与 React 应用初始化机制对比 - 前端框架思考笔记

Vue 与 React 应用初始化机制对比 - 前端框架思考笔记

引子:从挂载点开始的思考

最近在准备前端面试时,我一直在思考一个问题:为什么 Vue 和 React 都需要一个挂载点?这个看似简单的 <div id="app"></div> 到底在框架中扮演什么角色?

我当时想:这不就是一个普通的 div 吗?为什么非要指定它?直接往 body 里塞内容不行吗?

通过深入理解,我发现这背后涉及到现代前端框架的核心设计理念。

什么是挂载点?为什么需要它?

挂载点就是一个特定的 DOM 元素,作为我们应用的渲染容器。在 Vue 或 React 中,我们通过指定挂载点来告诉框架:"请把整个应用的内容都渲染到这个元素内部"。

<body>
  <!-- 这就是挂载点 -->
  <div id="app"></div>
  
  <script src="main.js"></script>
</body>

我当时疑惑:如果不指定挂载点会怎样?框架会把内容直接插入到 body 中吗?

确实如此!如果没有明确的挂载点,Vue 或 React 可能会直接把内容插入到 body 或其他 DOM 元素中,造成页面结构混乱。想象一下,你的应用内容散落在 body 的各个角落,没有统一的容器,管理和定位 DOM 元素会变得极其困难。

Vue 的应用初始化过程

createApp 和 mount 的分离

在 Vue 3 中,应用初始化分为两个清晰的步骤:

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

我当时不理解:为什么要分 createApp 和 mount 两步?直接像 React 那样渲染不行吗?

深入思考后我明白了:

  • createApp(App):创建 Vue 应用实例
  • .mount('#app'):将实例挂载到 DOM

这种分离设计让 Vue 在挂载前可以进行各种配置,比如注册全局组件、插件等。

Vue 的组件解析过程

我当时问:Vue 是怎么把模板变成实际页面的?

Vue 的模板编译过程是这样的:

  1. 模板解析:Vue 将 .vue 文件中的模板代码转换成 JavaScript 对象
  2. 生成虚拟 DOM:这些对象构成了虚拟 DOM(VNode),描述页面结构
  3. 渲染到实际 DOM:虚拟 DOM 通过比对算法更新实际页面
// 模板
<template>
  <div>{{ message }}</div>
</template>

// 被编译成渲染函数
render() {
  return createVNode('div', null, this.message)
}

我当时想:为什么要经过虚拟 DOM 这个中间步骤?

虚拟 DOM 的优势在于性能优化。Vue 通过比较新旧虚拟 DOM 的差异,只更新发生变化的部分,而不是重新渲染整个页面。

React 的应用初始化

直接的渲染方式

React 的初始化相对直接:

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

ReactDOM.render(<App />, document.getElementById('app'))

我当时对比:React 为什么不需要像 Vue 那样先创建应用实例?

这与两个框架的设计哲学有关。React 更专注于组件本身的渲染,而 Vue 强调应用级别的管理和配置。

JSX 与 Vue 模板的差异

我当时注意到:React 的组件导出看起来比 Vue 简单很多:

// React 组件
function App() {
  return (
    <div>
      <h1>Welcome to My React App</h1>
    </div>
  )
}

export default App
<!-- Vue 组件 -->
<template>
  <div>
    <h1>Welcome to My Vue App</h1>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: 'Hello, world!'
    }
  }
}
</script>

这种差异源于 Vue 的响应式系统需要更明确的数据声明。

设计哲学的深层差异

React:专注于组件渲染的简洁性

我当时困惑:React 是从根组件开始构建虚拟 DOM 树,而 Vue 是组件级框架自底向上构建,这和我前文的这两个框架的设计哲学:React 更专注于组件本身的渲染,而 Vue 强调应用级别的管理和配置。总觉得有哪里矛盾?

通过深入研究,我发现 React 的设计哲学是:

组件树作为应用核心

  • React 将整个应用视为组件树,根组件是起点
  • 通过 ReactDOM.render() 从根组件开始渲染整个树结构
  • 每个组件在渲染和状态管理上保持独立性
  • 不需要显式的应用实例,简化了配置

我当时理解:React 的简洁性体现在它把应用管理隐藏在组件树中,让开发者更专注于组件本身的实现。

Vue:应用实例与组件化的平衡

Vue 采取了不同的路径:

明确的应用实例概念

  • 通过 createApp() 创建明确的应用实例
  • 应用实例负责全局配置、插件、状态管理
  • 在保持组件化的同时,提供应用级别的管理能力

我当时对比:Vue 的设计既照顾了大型应用的需求(通过应用实例),又保持了组件级别的灵活性。

引用GPT的精彩理解image.png

单页应用(SPA)与多页应用(MPA)

我当时困惑:什么叫做"页面本身只有一个 HTML 文件"?我们不是有 index.html 还有各种 .vue 文件吗?

这里的关键区别在于:

单页应用(SPA)

  • 只有一个 HTML 文件(通常是 index.html
  • 页面切换通过 JavaScript 动态渲染内容
  • 不会重新加载整个页面
  • 用户体验更流畅

多页应用(MPA)

  • 每个页面都有独立的 HTML 文件
  • 页面切换需要重新加载
  • 传统的网页开发方式

我当时恍然大悟:原来 .vue 文件在构建时会被打包工具处理,最终都合并到同一个 HTML 中!

构建工具的作用

我当时问:Vue 的模板编译是通过什么工具完成的?

现代前端开发离不开构建工具:

  • Webpack/Vite:模块打包和构建
  • Babel:JavaScript 代码转换
  • Vue Loader:处理 .vue 文件

Vue 的模板编译器会将模板转换成抽象语法树(AST),然后生成渲染函数。这个过程在构建阶段完成,而不是在浏览器中运行时。

多个 Vue 实例的情况

我当时好奇:什么情况下需要多个 Vue 实例?

虽然在单页应用中通常只有一个 Vue 实例,但在某些场景下可能需要多个:

// 不同功能模块使用不同实例
createApp(App1).mount('#app1')
createApp(App2).mount('#app2')

这种情况常见于:

  • 老项目渐进式迁移
  • 页面中有多个独立的功能模块
  • 微前端架构

虚拟 DOM 的重要性

我当时不理解:为什么要用虚拟 DOM?直接操作真实 DOM 不行吗?

虚拟 DOM(VNode)的本质是 JavaScript 对象,它描述了页面的结构。优势在于:

  1. 性能优化:通过 Diff 算法最小化 DOM 操作
  2. 跨平台能力:同一套虚拟 DOM 可以渲染到不同平台
  3. 开发体验:让开发者更关注业务逻辑而不是 DOM 操作

总结与面试要点

通过这番探索,我对 Vue 和 React 的初始化机制有了更深入的理解:

Vue 的特点

  • 明确的应用实例概念
  • 模板编译在构建时完成
  • 响应式数据系统
  • 配置灵活,适合大型应用

React 的特点

  • 专注于组件渲染
  • JSX 语法更接近 JavaScript
  • 函数式编程思想
  • 生态丰富,社区活跃

面试中如何描述

当被问到 Vue 和 React 的区别时,我可以这样回答:

"两者都是优秀的现代前端框架,但在设计理念上有所不同。Vue 通过 createApp 创建明确的应用实例,提供了更多的配置和管理能力;而 React 更专注于组件本身的渲染,通过 ReactDOM.render 直接渲染组件。这种差异体现在开发体验、性能优化和适用场景上。"

我的最终感悟:前端框架的每一个设计选择都有其深层考量。从简单的挂载点开始,深入理解框架的设计哲学,才能真正掌握前端开发的精髓。

可怕!我的Nodejs系统因为日志打印了Error 对象就崩溃了😱 Node.js System Crashed Because of Logging

本文为中英文双语,需要英文博客可以滑动到下面查看哦 | This is a bilingual article. Scroll down for the English version.

小伙伴们!今天我在本地调试项目的过程中,想记录一下错误信息,结果程序就"啪"地一下报出 "Maximum call stack size exceeded" 错误,然后项目直接就crash了。但是我看我用的这个开源项目,官方的代码里好多地方就是这么用的呀?我很纳闷,这是为什么呢?

Snipaste_2025-10-10_00-28-45.png

报错信息


[LOGGER PARSING ERROR] Maximum call stack size exceeded
2025-10-13T17:06:59.643Z debug: Error code: 400 - {'error': {'message': 'Budget has been exceeded! Current cost: 28.097367900000002, Max budget: 0.0', 'type': 'budget_exceeded', 'par... [truncated]
{
  unknown: [object Object],
}
2025-10-13T17:06:59.643Z debug: [api/server/middleware/abortMiddleware.js] respondWithError called
2025-10-13T17:06:59.644Z error: There was an uncaught error: Cannot read properties of undefined (reading 'emit')
2025-10-13T17:06:59.645Z debug: [indexSync] Clearing sync timeouts before exiting...
[nodemon] app crashed - waiting for file changes before starting...

报错截图

image

错误分析

晚上下班以后,晚上躺在床上,我翻来覆去睡不着,干脆打开电脑一番探究,想要知道 ,这个错误到底为何触发,实质原因是什么,以及如何解决它。让我们一起把这个小调皮鬼揪出来看看它到底在搞什么鬼吧!👻

场景复现

想象一下这个场景,你正在开心地写着代码:

app.get('/api/data', async (req, res) => {
  try {
    // 一些可能会出小差错的业务逻辑
    const data = await fetchDataFromAPI();
    res.json(data);
  } catch (error) {
    // 记录错误信息
    logger.debug('获取数据时出错啦~', error); // 哎呀!这一行可能会让我们的程序崩溃哦!
    res.status(500).json({ error: '内部服务器出错啦~' });
  }
});

看起来是不是很正常呢?但是当你运行这段代码的时候,突然就出现了这样的错误:

[LOGGER PARSING ERROR] Maximum call stack size exceeded

更神奇的是,如果你把代码改成这样:

console.log(error); // 这一行却不会让程序崩溃哦,但是上prod的系统,不要这么用哦

它就能正常工作啦!这是为什么呢?🤔

小秘密大揭秘!🔍

console.log虽好,但请勿用它来记录PROD错误!

console.log 是 Node.js 原生提供的函数,它就像一个经验超级丰富的大叔,知道怎么处理各种"调皮"的对象。当 console.log 遇到包含循环引用的对象时,它会聪明地检测这些循环引用,并用 [Circular] 标记来代替实际的循环部分,这样就不会无限递归啦!

简单来说,Node.js 的 console.log 就像一个超级厉害的武林高手,知道如何闪转腾挪,避开各种陷阱!🥋

日志库的"小烦恼"

但是我们自己封装的日志系统(比如项目中使用的 Winston)就不一样啦!为了实现各种炫酷的功能(比如格式化、过滤敏感信息等),日志库通常会使用一些第三方库来处理传入的对象。

在我们的案例中,日志系统使用了 [traverse] 库来遍历对象。这个库在大多数情况下工作得都很好,但当它遇到某些复杂的 Error 对象时,就可能会迷路啦!

Error 对象可不是普通对象那么简单哦!它们可能包含各种隐藏的属性、getter 方法,甚至在某些情况下会动态生成属性。当 [traverse] 库尝试遍历这些复杂结构时,就可能陷入无限递归的迷宫,最终导致调用栈溢出。

什么是循环引用?🌀

在深入了解这个问题之前,我们先来了解一下什么是循环引用。循环引用指的是对象之间相互引用,形成一个闭环。比如说:

const objA = { name: '小A' };
const objB = { name: '小B' };

objA.ref = objB;
objB.ref = objA; // 哎呀!形成循环引用啦!

当尝试序列化这样的对象时(比如用 JSON.stringify),就会出现问题,因为序列化过程会无限递归下去,就像两只小仓鼠在滚轮里永远跑不完一样!🐹

Error 对象虽然看起来简单,但内部结构可能非常复杂,特别是在一些框架或库中创建的 Error 对象,它们可能包含对 request、response 等对象的引用,而这些对象又可能包含对 Error 对象的引用,从而形成复杂的循环引用网络,就像一张大蜘蛛网一样!🕷️

怎样才能让我们的日志系统乖乖听话呢?✨

1. 只记录我们需要的信息

最简单直接的方法就是不要把整个 Error 对象传递给日志函数,而是只传递我们需要的具体属性:

// ❌ 不推荐的做法 - 会让日志系统"生气"
logger.debug('获取数据时出错啦~', error);

// ✅ 推荐的做法 - 让日志系统开心地工作
logger.debug('获取数据时出错啦~', {
  message: error.message,
  stack: error.stack,
  code: error.code
});

2. 使用专门的错误序列化函数

你可以创建一个专门用于序列化 Error 对象的函数,就像给 Error 对象穿上一件"安全外套":

function serializeError(error) {
  return {
    name: error.name,
    message: error.message,
    stack: error.stack,
    code: error.code,
    // 添加其他你需要的属性
  };
}

// 使用方式
logger.debug('获取数据时出错啦~', serializeError(error));

3. 使用成熟的错误处理库

有些库专门为处理这类问题而设计,比如 serialize-error,它们就像专业的保姆一样,会把 Error 对象照顾得好好的:

const { serializeError } = require('serialize-error');

logger.debug('获取数据时出错啦~', serializeError(error));

4. 配置日志库的防护机制

如果你使用的是 Winston,可以配置一些防护机制,给它穿上"防弹衣":

const winston = require('winston');

const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  // ... 其他配置
});

最佳实践小贴士 🌟

  1. 永远不要直接记录原始的 Error 对象:它们可能包含复杂的循环引用结构,就像一个调皮的小恶魔。

  2. 提取关键信息:只记录我们需要的错误信息,比如 message、stack 等,就像挑选糖果一样只拿最喜欢的。

  3. 使用安全的序列化方法:确保我们的日志系统能够处理各种边界情况,做一个贴心的小棉袄。

  4. 添加防护措施:在日志处理逻辑中添加 try-catch 块,防止日志系统本身成为故障点,就像给程序戴上安全帽。

  5. 测试边界情况:在测试中模拟各种错误场景,确保日志系统在极端情况下也能正常工作,做一个负责任的好孩子。

image

Terrifying! My Node.js System Crashed Because of Logging an Error Object 😱

Fellow developers! Today, while debugging a project locally, I wanted to log some error information, but suddenly the program threw a "Maximum call stack size exceeded" error and crashed the entire project. But when I look at the open-source project I'm using, I see that the official code does this in many places. I was puzzled, why is this happening?

Error Message


[LOGGER PARSING ERROR] Maximum call stack size exceeded
2025-10-13T17:06:59.643Z debug: Error code: 400 - {'error': {'message': 'Budget has been exceeded! Current cost: 28.097367900000002, Max budget: 0.0', 'type': 'budget_exceeded', 'par... [truncated]
{
  unknown: [object Object],
}
2025-10-13T17:06:59.643Z debug: [api/server/middleware/abortMiddleware.js] respondWithError called
2025-10-13T17:06:59.644Z error: There was an uncaught error: Cannot read properties of undefined (reading 'emit')
2025-10-13T17:06:59.645Z debug: [indexSync] Clearing sync timeouts before exiting...
[nodemon] app crashed - waiting for file changes before starting...

Error Screenshot

image

Error Analysis

After work, I couldn't resist investigating why this error was triggered, what the root cause was, and how to solve it. Let's together catch this little troublemaker and see what it's up to! 👻

Reproducing the Scenario

Imagine this scenario, you're happily coding:

app.get('/api/data', async (req, res) => {
  try {
    // Some business logic that might go wrong
    const data = await fetchDataFromAPI();
    res.json(data);
  } catch (error) {
    // Log the error
    logger.debug('Error fetching data~', error); // Oops! This line might crash our program!
    res.status(500).json({ error: 'Internal server error~' });
  }
});

Doesn't this look normal? But when you run this code, suddenly this error appears:

[LOGGER PARSING ERROR] Maximum call stack size exceeded

What's even more神奇 is, if you change the code to this:

console.log(error); // This line won't crash the program, but don't use this in production systems

It works fine! Why is that? 🤔

The Big Reveal of Little Secrets! 🔍

console.log is Good, But Don't Use It to Log PROD Errors!

console.log is a native Node.js function. It's like an extremely experienced uncle who knows how to handle all kinds of "naughty" objects. When console.log encounters objects with circular references, it cleverly detects these circular references and replaces the actual circular parts with [Circular] markers, so it won't recurse infinitely!

Simply put, Node.js's console.log is like a super skilled martial arts master who knows how to dodge and avoid all kinds of traps! 🥋

The "Little Troubles" of Logging Libraries

But our custom logging systems (like Winston used in the project) are different! To implement various cool features (like formatting, filtering sensitive information, etc.), logging libraries often use third-party libraries to process incoming objects.

In our case, the logging system uses the [traverse] library to traverse objects. This library works well in most cases, but when it encounters certain complex Error objects, it might get lost!

Error objects are not as simple as ordinary objects! They may contain various hidden properties, getter methods, and in some cases, dynamically generated properties. When the [traverse] library tries to traverse these complex structures, it may fall into an infinite recursion maze, ultimately causing a stack overflow.

What Are Circular References? 🌀

Before diving deeper into this issue, let's first understand what circular references are. Circular references refer to objects that reference each other, forming a closed loop. For example:

const objA = { name: 'A' };
const objB = { name: 'B' };

objA.ref = objB;
objB.ref = objA; // Oops! Circular reference formed!

When trying to serialize such objects (like with JSON.stringify), problems arise because the serialization process will recurse infinitely, like two hamsters running forever in a wheel! 🐹

Although Error objects look simple, their internal structure can be very complex, especially Error objects created in some frameworks or libraries. They may contain references to request, response, and other objects, and these objects may in turn contain references to the Error object, forming a complex circular reference network, like a giant spider web! 🕷️

How to Make Our Logging System Behave? ✨

1. Only Log the Information We Need

The simplest and most direct method is not to pass the entire Error object to the logging function, but to pass only the specific properties we need:

// ❌ Not recommended - will make the logging system "angry"
logger.debug('Error fetching data~', error);

// ✅ Recommended - makes the logging system work happily
logger.debug('Error fetching data~', {
  message: error.message,
  stack: error.stack,
  code: error.code
});

2. Use a Dedicated Error Serialization Function

You can create a dedicated function for serializing Error objects, like putting a "safety coat" on the Error object:

function serializeError(error) {
  return {
    name: error.name,
    message: error.message,
    stack: error.stack,
    code: error.code,
    // Add other properties you need
  };
}

// Usage
logger.debug('Error fetching data~', serializeError(error));

3. Use Mature Error Handling Libraries

Some libraries are specifically designed to handle these kinds of issues, such as serialize-error. They're like professional nannies who will take good care of Error objects:

const { serializeError } = require('serialize-error');

logger.debug('Error fetching data~', serializeError(error));

4. Configure Protective Mechanisms for Logging Libraries

If you're using Winston, you can configure some protective mechanisms to give it "bulletproof armor":

const winston = require('winston');

const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  // ... other configurations
});

Best Practice Tips 🌟

  1. Never log raw Error objects directly: They may contain complex circular reference structures, like a mischievous little devil.

  2. Extract key information: Only log the error information we need, such as message, stack, etc., like picking candy - only take your favorites.

  3. Use safe serialization methods: Ensure our logging system can handle various edge cases, be a thoughtful companion.

  4. Add protective measures: Add try-catch blocks in the logging logic to prevent the logging system itself from becoming a failure point, like giving the program a safety helmet.

  5. Test edge cases: Simulate various error scenarios in testing to ensure the logging system works properly under extreme conditions, be a responsible good child.

Conclusion | 结语

  • That's all for today~ - | 今天就写到这里啦~

  • Guys, ( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ See you tomorrow~ | 小伙伴们,( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ我们明天再见啦~~

  • Everyone, be happy every day! 大家要天天开心哦

  • Welcome everyone to point out any mistakes in the article~ | 欢迎大家指出文章需要改正之处~

  • Learning has no end; win-win cooperation | 学无止境,合作共赢

  • Welcome all the passers-by, boys and girls, to offer better suggestions! ~ | 欢迎路过的小哥哥小姐姐们提出更好的意见哇~~

理解 JavaScript 中的 this 上下文保存

保存 this 上下文是 JavaScript 中一个非常重要的概念,尤其是在处理闭包、定时器等场景时。让我们深入理解这个概念。

this 是什么?

this 是 JavaScript 中的一个特殊关键字,它指向的是当前代码执行的上下文对象。简单来说,this 的值取决于函数被调用的方式,而不是函数被定义的位置。

为什么需要保存 this 上下文?

在防抖函数中,我们遇到了一个典型问题:在 setTimeout 回调函数中,this 的指向会发生变化

让我们看一个例子来说明这个问题:

function debounce(func, wait) {
    let timeout;
    
    return function executedFunction(...args) {
        // 这里的 this 指向的是调用 debounced 函数的对象
        console.log('外层 this:', this); // 假设是按钮元素
        
        timeout = setTimeout(function() {
            // 这里的 this 默认指向 window 或 undefined(严格模式)
            console.log('setTimeout 中的 this:', this);
            func.apply(this, args); // 这会导致错误,因为 this 已经变了
        }, wait);
    };
}

问题所在:当我们在 setTimeout 的回调函数中使用 this 时,它不再指向原始调用上下文(比如按钮元素),而是指向全局对象 window(非严格模式)或 undefined(严格模式)。

如何正确保存 this 上下文

为了解决这个问题,我们需要在进入 setTimeout 之前保存原始的 this 引用:

function debounce(func, wait) {
    let timeout;
    
    return function executedFunction(...args) {
        // 保存原始的 this 上下文
        const context = this; // 关键步骤!
        
        timeout = setTimeout(function() {
            // 现在我们使用保存的 context 而不是这里的 this
            func.apply(context, args);
        }, wait);
    };
}

通过 const context = this; 这行代码,我们将原始的 this 引用保存到了 context 变量中,这样即使在 setTimeout 回调函数中 this 发生了变化,我们仍然可以通过 context 访问到原始的上下文。

实际应用场景示例

让我们看一个更贴近实际开发的例子:

// 假设我们有一个计数器对象
const counter = {
    count: 0,
    increment: function() {
        this.count++;
        console.log(`当前计数: ${this.count}`);
    }
};

// 创建防抖版本的 increment 方法
const debouncedIncrement = debounce(counter.increment, 1000);

// 添加事件监听
button.addEventListener('click', debouncedIncrement);

如果防抖函数中没有正确保存 this 上下文,点击按钮时会出现错误,因为 this.count 会变成 undefined.count

但如果我们使用正确实现的防抖函数(保存了 this 上下文),就不会有问题:

button.addEventListener('click', function() {
    // 手动绑定 this 到 counter
    debouncedIncrement.call(counter);
});

总结

保存 this 上下文是 JavaScript 中处理函数调用的重要技巧,特别是在使用闭包和定时器时:

  1. this 的值取决于函数被调用的方式
  2. setTimeout 等异步回调中,this 的指向会改变
  3. 通过在异步操作前保存 this 引用,我们可以确保函数在正确的上下文中执行
  4. applycall 方法允许我们显式地设置函数执行的上下文

理解并掌握 this 的工作原理,对于前端开发者至关重要,前端学习ing,欢迎各位佬指正

代码质量工程完全指南 🚀

代码质量工程完全指南 🚀

构建可维护、高质量代码库的完整实践方案

TypeScript 高级用法 ⚙️

1. 泛型约束(Generics & Constraints)🎯

为什么需要泛型约束?

在开发可复用组件时,我们经常需要处理多种数据类型,但又不想失去 TypeScript 的类型安全优势。泛型约束允许我们在保持灵活性的同时,对类型参数施加限制。

解决的问题:

  • 避免使用 any 类型导致类型信息丢失
  • 在通用函数中保持输入输出类型关系
  • 提供更好的 IDE 智能提示和自文档化

缺点与限制:

  • 过度复杂的约束会让错误信息难以理解
  • 嵌套泛型可能导致编译性能下降
  • 初学者可能需要时间适应这种抽象思维
详细代码示例
// 🎯 基础泛型函数
function identity<T>(value: T): T {
  return value;
}

// 使用显式类型参数
const result1 = identity<string>("Hello"); // 类型: string
// 使用类型推断
const result2 = identity(42); // 类型: number

// 🎯 泛型约束 - 确保类型具有特定属性
interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(item: T): void {
  console.log(`Length: ${item.length}`);
}

// 这些调用都是合法的
logLength("hello");     // 字符串有 length 属性
logLength([1, 2, 3]);   // 数组有 length 属性
logLength({ length: 5, name: "test" }); // 对象有 length 属性

// 🎯 泛型约束与 keyof 结合
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { 
  id: 1, 
  name: "Alice", 
  email: "alice@example.com" 
};

const userName = getProperty(user, "name");    // 类型: string
const userId = getProperty(user, "id");        // 类型: number

// 🎯 多重约束
interface Serializable {
  serialize(): string;
}

interface Identifiable {
  id: number;
}

function processEntity<T extends Serializable & Identifiable>(entity: T): void {
  console.log(`ID: ${entity.id}`);
  console.log(`Serialized: ${entity.serialize()}`);
}

// 🎯 泛型类示例
class Repository<T extends { id: number }> {
  private items: T[] = [];

  add(item: T): void {
    this.items.push(item);
  }

  findById(id: number): T | undefined {
    return this.items.find(item => item.id === id);
  }

  getAll(): T[] {
    return [...this.items];
  }
}

// 使用泛型类
interface Product {
  id: number;
  name: string;
  price: number;
}

const productRepo = new Repository<Product>();
productRepo.add({ id: 1, name: "Laptop", price: 999 });
const laptop = productRepo.findById(1); // 类型: Product | undefined

2. 条件类型与推断(Conditional Types & infer)🧠

为什么需要条件类型?

条件类型允许我们在类型级别进行条件判断,实现基于输入类型的动态类型转换。这在创建灵活的类型工具和库时特别有用。

解决的问题:

  • 根据条件动态推导类型
  • 从复杂类型中提取子类型
  • 减少重复的类型定义

缺点与限制:

  • 可读性较差,特别是嵌套条件类型
  • 错误信息可能非常复杂难懂
  • 需要深入理解 TypeScript 的类型系统
详细代码示例
// 🧠 基础条件类型
type IsString<T> = T extends string ? true : false;

type Test1 = IsString<"hello">;    // true
type Test2 = IsString<number>;     // false
type Test3 = IsString<string | number>; // boolean

// 🧠 使用 infer 进行类型提取
type ExtractPromiseType<T> = T extends Promise<infer U> ? U : T;

type AsyncString = ExtractPromiseType<Promise<string>>; // string
type JustNumber = ExtractPromiseType<number>;           // number

// 🧠 从函数类型中提取参数和返回类型
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type GetParameters<T> = T extends (...args: infer P) => any ? P : never;

type Func = (a: number, b: string) => boolean;
type Return = GetReturnType<Func>;    // boolean
type Params = GetParameters<Func>;    // [number, string]

// 🧠 分发条件类型(分布式条件类型)
type ToArray<T> = T extends any ? T[] : never;

type StringOrNumberArray = ToArray<string | number>; 
// 等价于: string[] | number[]

type NeverArray = ToArray<never>; // never

// 🧠 排除 null 和 undefined
type NonNullable<T> = T extends null | undefined ? never : T;

type ValidString = NonNullable<string | null>;        // string
type ValidNumber = NonNullable<number | undefined>;   // number

// 🧠 递归条件类型 - DeepPartial
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

interface User {
  id: number;
  profile: {
    name: string;
    settings: {
      theme: string;
      notifications: boolean;
    };
  };
}

type PartialUser = DeepPartial<User>;
// 等价于:
// {
//   id?: number;
//   profile?: {
//     name?: string;
//     settings?: {
//       theme?: string;
//       notifications?: boolean;
//     };
//   };
// }

// 🧠 条件类型与模板字面量结合
type GetterName<T extends string> = T extends `_${infer Rest}` 
  ? `get${Capitalize<Rest>}` 
  : `get${Capitalize<T>}`;

type NameGetter = GetterName<"name">;     // "getName"
type PrivateGetter = GetterName<"_email">; // "getEmail"

3. 映射类型(Mapped Types)🔁

为什么需要映射类型?

映射类型允许我们基于现有类型创建新类型,通过转换每个属性来实现类型的批量操作。

解决的问题:

  • 批量修改类型属性(只读、可选等)
  • 基于现有类型创建变体
  • 减少重复的类型定义代码

缺点与限制:

  • 映射类型不会自动递归处理嵌套对象
  • 复杂的映射类型可能难以理解和调试
  • 某些高级用法需要深入的类型系统知识
详细代码示例
// 🔁 基础映射类型
type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

type Partial<T> = {
  [P in keyof T]?: T[P];
};

type Required<T> = {
  [P in keyof T]-?: T[P];
};

// 🔁 键重映射
type Getters<T> = {
  [P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
};

interface User {
  name: string;
  age: number;
}

type UserGetters = Getters<User>;
// 等价于:
// {
//   getName: () => string;
//   getAge: () => number;
// }

// 🔁 过滤属性
type OnlyFunctions<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K];
};

interface MixedInterface {
  name: string;
  age: number;
  getName(): string;
  setAge(age: number): void;
}

type FunctionsOnly = OnlyFunctions<MixedInterface>;
// 等价于:
// {
//   getName: () => string;
//   setAge: (age: number) => void;
// }

// 🔁 基于值的类型映射
type EventConfig<T extends { kind: string }> = {
  [E in T as E["kind"]]: (event: E) => void;
};

type Event = 
  | { kind: "click"; x: number; y: number }
  | { kind: "keypress"; key: string }
  | { kind: "focus"; element: HTMLElement };

type Config = EventConfig<Event>;
// 等价于:
// {
//   click: (event: { kind: "click"; x: number; y: number }) => void;
//   keypress: (event: { kind: "keypress"; key: string }) => void;
//   focus: (event: { kind: "focus"; element: HTMLElement }) => void;
// }

// 🔁 实用映射类型示例
// 1. 将所有属性变为可空
type Nullable<T> = {
  [P in keyof T]: T[P] | null;
};

// 2. 将函数返回值包装为 Promise
type Promisify<T> = {
  [P in keyof T]: T[P] extends (...args: infer A) => infer R 
    ? (...args: A) => Promise<R> 
    : T[P];
};

// 3. 创建严格的不可变类型
type Immutable<T> = {
  readonly [P in keyof T]: T[P] extends object ? Immutable<T[P]> : T[P];
};

4. 实用工具类型(Utility Types)🧰

为什么需要工具类型?

工具类型提供了常见的类型转换操作,让类型定义更加简洁和可维护。

解决的问题:

  • 减少重复的类型定义
  • 提供标准的类型转换模式
  • 提高代码的可读性和一致性

缺点与限制:

  • 初学者可能需要时间学习各种工具类型
  • 过度使用可能让代码看起来更复杂
  • 某些工具类型的行为可能不符合直觉
详细代码示例
interface User {
  id: number;
  name: string;
  email: string;
  age?: number;
  createdAt: Date;
  updatedAt?: Date;
}

// 🧰 Partial - 所有属性变为可选
type UserUpdate = Partial<User>;
// 等价于:
// {
//   id?: number;
//   name?: string;
//   email?: string;
//   age?: number;
//   createdAt?: Date;
//   updatedAt?: Date;
// }

// 🧰 Required - 所有属性变为必需
type CompleteUser = Required<User>;
// 等价于:
// {
//   id: number;
//   name: string;
//   email: string;
//   age: number;
//   createdAt: Date;
//   updatedAt: Date;
// }

// 🧰 Pick - 选择特定属性
type UserBasicInfo = Pick<User, 'id' | 'name'>;
// 等价于:
// {
//   id: number;
//   name: string;
// }

// 🧰 Omit - 排除特定属性
type UserWithoutDates = Omit<User, 'createdAt' | 'updatedAt'>;
// 等价于:
// {
//   id: number;
//   name: string;
//   email: string;
//   age?: number;
// }

// 🧰 Record - 创建键值映射
type UserMap = Record<number, User>;
// 等价于:
// {
//   [key: number]: User;
// }

type StatusMap = Record<'success' | 'error' | 'loading', boolean>;
// 等价于:
// {
//   success: boolean;
//   error: boolean;
//   loading: boolean;
// }

// 🧰 Extract - 提取匹配的类型
type StringKeys = Extract<keyof User, string>;
// 从 'id' | 'name' | 'email' | 'age' | 'createdAt' | 'updatedAt'
// 提取出所有字符串键(这里全部都是)

// 🧰 Exclude - 排除匹配的类型
type NonFunctionKeys = Exclude<keyof User, Function>;
// 排除函数类型的键(这里没有函数,所以返回所有键)

// 🧰 工具类型组合使用
// 创建用户表单数据类型
type UserFormData = Partial<Pick<User, 'name' | 'email' | 'age'>>;
// 等价于:
// {
//   name?: string;
//   email?: string;
//   age?: number;
// }

// 创建 API 响应类型
type ApiResponse<T> = {
  data: T;
  success: boolean;
  message?: string;
};

type UserResponse = ApiResponse<Omit<User, 'password'>>;

// 🧰 自定义工具类型
// 1. 值类型为特定类型的属性键
type KeysOfType<T, U> = {
  [K in keyof T]: T[K] extends U ? K : never;
}[keyof T];

type StringKeysOfUser = KeysOfType<User, string>; 
// "name" | "email"

// 2. 深度只读
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object 
    ? DeepReadonly<T[P]> 
    : T[P];
};

// 3. 异步函数包装
type AsyncFunction<T extends (...args: any[]) => any> = 
  (...args: Parameters<T>) => Promise<ReturnType<T>>;

5. 模板字面量类型 ✂️

为什么需要模板字面量类型?

模板字面量类型允许在类型级别进行字符串操作,创建精确的字符串字面量类型。

解决的问题:

  • 创建精确的字符串联合类型
  • 基于模式生成类型安全的字符串
  • 减少运行时字符串验证的需要

缺点与限制:

  • 复杂的模板类型可能影响编译性能
  • 错误信息可能难以理解
  • 某些字符串操作在类型级别有限制
详细代码示例
// ✂️ 基础模板字面量类型
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type ApiVersion = 'v1' | 'v2' | 'v3';

type ApiEndpoint = `/${ApiVersion}/${string}`;
type FullEndpoint = `${HttpMethod} ${ApiEndpoint}`;

// 使用示例
type UserEndpoint = `GET /v1/users` | `POST /v1/users` | `GET /v1/users/${string}`;

// ✂️ 字符串操作类型
// Uppercase, Lowercase, Capitalize, Uncapitalize
type UpperCaseMethod = Uppercase<HttpMethod>; 
// "GET" | "POST" | "PUT" | "DELETE" | "PATCH"

type EventName = 'click' | 'change' | 'submit';
type EventHandlerName = `on${Capitalize<EventName>}`;
// "onClick" | "onChange" | "onSubmit"

// ✂️ 路径参数提取
type ExtractPathParams<T extends string> = 
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractPathParams<`/${Rest}`>
    : T extends `${string}:${infer Param}`
    ? Param
    : never;

type RouteParams = ExtractPathParams<'/users/:userId/posts/:postId'>;
// "userId" | "postId"

// ✂️ 配置键生成
type FeatureFlags = 'darkMode' | 'notifications' | 'analytics';
type ConfigKeys = `feature_${Uppercase<FeatureFlags>}`;
// "feature_DARKMODE" | "feature_NOTIFICATIONS" | "feature_ANALYTICS"

// ✂️ CSS 类名生成
type Color = 'primary' | 'secondary' | 'success' | 'danger';
type Size = 'sm' | 'md' | 'lg';

type ButtonClass = `btn-${Color}-${Size}`;
// "btn-primary-sm" | "btn-primary-md" | "btn-primary-lg" | ...

// ✂️ 高级模式匹配
type ParseQueryString<T extends string> = 
  T extends `${infer Key}=${infer Value}&${infer Rest}`
    ? { [K in Key]: Value } & ParseQueryString<Rest>
    : T extends `${infer Key}=${infer Value}`
    ? { [K in Key]: Value }
    : {};

type QueryParams = ParseQueryString<'name=John&age=30&city=NY'>;
// 等价于:
// {
//   name: "John";
//   age: "30";
//   city: "NY";
// }

// ✂️ 自动生成 API 客户端类型
type Resource = 'users' | 'posts' | 'comments';
type Action = 'create' | 'read' | 'update' | 'delete';

type ApiAction<T extends Resource> = {
  [K in Action as `${K}${Capitalize<T>}`]: () => Promise<void>;
};

type UserApi = ApiAction<'users'>;
// 等价于:
// {
//   createUsers: () => Promise<void>;
//   readUsers: () => Promise<void>;
//   updateUsers: () => Promise<void>;
//   deleteUsers: () => Promise<void>;
// }

6. 类型推断与保护 🔎

为什么需要类型保护?

类型保护允许我们在运行时检查值的类型,并让 TypeScript 编译器理解这些检查,从而在特定代码块中缩小类型范围。

解决的问题:

  • 安全地处理联合类型
  • 减少类型断言的使用
  • 提供更好的开发体验和代码安全性

缺点与限制:

  • 需要编写额外的运行时检查代码
  • 复杂的类型保护可能难以维护
  • 某些模式可能无法被 TypeScript 正确推断
详细代码示例
// 🔎 基础类型保护
const isString = (value: unknown): value is string => {
  return typeof value === 'string';
};

const isNumber = (value: unknown): value is number => {
  return typeof value === 'number' && !isNaN(value);
};

const isArray = <T>(value: unknown): value is T[] => {
  return Array.isArray(value);
};

// 🔎 自定义类型保护
interface Cat {
  type: 'cat';
  meow(): void;
  climbTrees(): void;
}

interface Dog {
  type: 'dog';
  bark(): void;
  fetch(): void;
}

type Animal = Cat | Dog;

const isCat = (animal: Animal): animal is Cat => {
  return animal.type === 'cat';
};

const isDog = (animal: Animal): animal is Dog => {
  return animal.type === 'dog';
};

function handleAnimal(animal: Animal) {
  if (isCat(animal)) {
    animal.meow();        // TypeScript 知道这是 Cat
    animal.climbTrees();  // 可以安全调用
  } else {
    animal.bark();        // TypeScript 知道这是 Dog
    animal.fetch();       // 可以安全调用
  }
}

// 🔎  discriminated unions(可区分联合)
type NetworkState = 
  | { state: 'loading' }
  | { state: 'success'; data: string }
  | { state: 'error'; error: Error };

function handleNetworkState(state: NetworkState) {
  switch (state.state) {
    case 'loading':
      console.log('Loading...');
      break;
    case 'success':
      console.log('Data:', state.data);  // TypeScript 知道有 data 属性
      break;
    case 'error':
      console.log('Error:', state.error.message);  // TypeScript 知道有 error 属性
      break;
  }
}

// 🔎 使用 in 操作符进行类型保护
interface AdminUser {
  role: 'admin';
  permissions: string[];
  manageUsers(): void;
}

interface RegularUser {
  role: 'user';
  preferences: object;
}

type User = AdminUser | RegularUser;

function handleUser(user: User) {
  if ('permissions' in user) {
    user.manageUsers();  // TypeScript 知道这是 AdminUser
  } else {
    console.log(user.preferences);  // TypeScript 知道这是 RegularUser
  }
}

// 🔎 类型断言的最佳实践
// 方式1: as 语法
const element1 = document.getElementById('my-input') as HTMLInputElement;

// 方式2: 尖括号语法(不推荐在 JSX 中使用)
const element2 = <HTMLInputElement>document.getElementById('my-input');

// 方式3: 非空断言(谨慎使用)
const element3 = document.getElementById('my-input')!;

// 方式4: 安全的类型断言函数
function assertIsHTMLElement(element: unknown): asserts element is HTMLElement {
  if (!(element instanceof HTMLElement)) {
    throw new Error('Not an HTMLElement');
  }
}

const element4 = document.getElementById('my-input');
assertIsHTMLElement(element4);
element4.style.color = 'red';  // 现在可以安全访问

// 🔎 复杂的类型保护示例
interface ApiSuccess<T> {
  status: 'success';
  data: T;
  timestamp: Date;
}

interface ApiError {
  status: 'error';
  error: string;
  code: number;
}

type ApiResponse<T> = ApiSuccess<T> | ApiError;

function isApiSuccess<T>(
  response: ApiResponse<T>
): response is ApiSuccess<T> {
  return response.status === 'success';
}

async function fetchData<T>(url: string): Promise<T> {
  const response: ApiResponse<T> = await fetch(url).then(res => res.json());
  
  if (isApiSuccess(response)) {
    return response.data;  // TypeScript 知道这是 ApiSuccess<T>
  } else {
    throw new Error(`API Error ${response.code}: ${response.error}`);
  }
}

// 🔎 类型保护与错误处理
class ValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'ValidationError';
  }
}

class NetworkError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'NetworkError';
  }
}

type AppError = ValidationError | NetworkError;

const isValidationError = (error: Error): error is ValidationError => {
  return error.name === 'ValidationError';
};

function handleError(error: AppError) {
  if (isValidationError(error)) {
    console.log('Validation error:', error.message);
    // 可以访问 ValidationError 特有的属性或方法
  } else {
    console.log('Network error:', error.message);
    // 可以访问 NetworkError 特有的属性或方法
  }
}

测试策略 🧪

测试金字塔架构

graph TB
    A[单元测试 70%] --> B[集成测试 20%]
    B --> C[E2E 测试 10%]
    
    subgraph A [单元测试 - 快速反馈]
        A1[工具函数]
        A2[React 组件]
        A3[自定义 Hooks]
        A4[工具类]
    end
    
    subgraph B [集成测试 - 模块协作]
        B1[组件集成]
        B2[API 集成]
        B3[状态管理]
        B4[路由测试]
    end
    
    subgraph C [E2E 测试 - 用户流程]
        C1[关键业务流程]
        C2[跨页面交互]
        C3[性能测试]
        C4[兼容性测试]
    end
    
    style A fill:#e3f2fd
    style B fill:#f3e5f5
    style C fill:#e8f5e8

1. 单元测试:Jest + React Testing Library

为什么需要单元测试?

单元测试确保代码的最小单元(函数、组件)按预期工作,提供快速反馈和代码质量保障。

解决的问题:

  • 快速发现回归问题
  • 提供代码文档和示例
  • 支持重构和代码演进

缺点与限制:

  • 不能完全模拟真实用户行为
  • 过度 mock 可能导致测试与实现耦合
  • 维护测试需要额外工作量
详细配置与示例
// 🧪 Jest 配置文件示例
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/index.tsx',
    '!src/reportWebVitals.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  transform: {
    '^.+\.(ts|tsx)$': 'ts-jest',
  },
};

// 🧪 测试工具函数示例
// src/utils/format.test.ts
import { formatDate, capitalize, debounce } from './format';

describe('format utilities', () => {
  describe('formatDate', () => {
    it('格式化日期为 YYYY-MM-DD', () => {
      const date = new Date('2023-12-25');
      expect(formatDate(date)).toBe('2023-12-25');
    });

    it('处理无效日期', () => {
      expect(formatDate(new Date('invalid'))).toBe('Invalid Date');
    });
  });

  describe('capitalize', () => {
    it('将字符串首字母大写', () => {
      expect(capitalize('hello world')).toBe('Hello world');
    });

    it('处理空字符串', () => {
      expect(capitalize('')).toBe('');
    });
  });

  describe('debounce', () => {
    jest.useFakeTimers();

    it('防抖函数延迟执行', () => {
      const mockFn = jest.fn();
      const debouncedFn = debounce(mockFn, 100);

      debouncedFn();
      debouncedFn();
      debouncedFn();

      expect(mockFn).not.toHaveBeenCalled();

      jest.advanceTimersByTime(100);
      expect(mockFn).toHaveBeenCalledTimes(1);
    });
  });
});

// 🧪 React 组件测试示例
// src/components/Button/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

describe('Button Component', () => {
  const defaultProps = {
    onClick: jest.fn(),
    children: 'Click me',
  };

  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('渲染按钮文本', () => {
    render(<Button {...defaultProps} />);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  it('点击时触发回调', async () => {
    const user = userEvent.setup();
    render(<Button {...defaultProps} />);

    await user.click(screen.getByRole('button'));
    expect(defaultProps.onClick).toHaveBeenCalledTimes(1);
  });

  it('禁用状态下不触发点击', async () => {
    const user = userEvent.setup();
    render(<Button {...defaultProps} disabled />);

    await user.click(screen.getByRole('button'));
    expect(defaultProps.onClick).not.toHaveBeenCalled();
  });

  it('显示加载状态', () => {
    render(<Button {...defaultProps} loading />);
    
    const button = screen.getByRole('button');
    expect(button).toBeDisabled();
    expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
  });

  it('应用正确的 CSS 类', () => {
    render(<Button {...defaultProps} variant="primary" size="large" />);
    
    const button = screen.getByRole('button');
    expect(button).toHaveClass('btn-primary', 'btn-large');
  });
});

// 🧪 自定义 Hook 测试
// src/hooks/useCounter/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('使用初始值初始化', () => {
    const { result } = renderHook(() => useCounter(5));
    expect(result.current.count).toBe(5);
  });

  it('默认初始值为 0', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it('递增计数器', () => {
    const { result } = renderHook(() => useCounter(0));
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });

  it('递减计数器', () => {
    const { result } = renderHook(() => useCounter(2));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(1);
  });

  it('重置计数器', () => {
    const { result } = renderHook(() => useCounter(10));
    
    act(() => {
      result.current.increment();
      result.current.reset();
    });
    
    expect(result.current.count).toBe(10);
  });

  it('设置特定值', () => {
    const { result } = renderHook(() => useCounter(0));
    
    act(() => {
      result.current.setCount(42);
    });
    
    expect(result.current.count).toBe(42);
  });
});

2. 集成测试 🔗

为什么需要集成测试?

集成测试验证多个模块如何协同工作,确保系统各部分正确集成。

解决的问题:

  • 发现模块间的集成问题
  • 验证数据流和状态管理
  • 确保 API 集成正常工作

缺点与限制:

  • 执行速度比单元测试慢
  • 设置和维护更复杂
  • 可能需要真实的外部依赖
详细代码示例
// 🔗 组件集成测试示例
// src/components/UserProfile/UserProfile.integration.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from './UserProfile';
import { UserProvider } from '@/contexts/UserContext';
import { NotificationProvider } from '@/contexts/NotificationContext';
import { server } from '@/mocks/server';

// 设置 API Mock
beforeAll(() => server.listen());
afterEach(() => {
  server.resetHandlers();
  jest.clearAllMocks();
});
afterAll(() => server.close());

describe('UserProfile Integration', () => {
  const renderWithProviders = (component: React.ReactElement) => {
    return render(
      <UserProvider>
        <NotificationProvider>
          {component}
        </NotificationProvider>
      </UserProvider>
    );
  };

  it('加载并显示用户信息', async () => {
    renderWithProviders(<UserProfile userId="123" />);

    // 验证加载状态
    expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();

    // 等待数据加载完成
    await waitFor(() => {
      expect(screen.getByText('张三')).toBeInTheDocument();
    });

    // 验证用户信息显示
    expect(screen.getByText('zhangsan@example.com')).toBeInTheDocument();
    expect(screen.getByText('高级用户')).toBeInTheDocument();
  });

  it('编辑用户信息', async () => {
    const user = userEvent.setup();
    renderWithProviders(<UserProfile userId="123" />);

    // 等待数据加载
    await screen.findByText('张三');

    // 点击编辑按钮
    await user.click(screen.getByRole('button', { name: /编辑/i }));

    // 验证表单显示
    expect(screen.getByLabelText(/姓名/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/邮箱/i)).toBeInTheDocument();

    // 修改信息
    await user.clear(screen.getByLabelText(/姓名/i));
    await user.type(screen.getByLabelText(/姓名/i), '李四');

    // 提交表单
    await user.click(screen.getByRole('button', { name: /保存/i }));

    // 验证成功消息
    await waitFor(() => {
      expect(screen.getByText('用户信息更新成功')).toBeInTheDocument();
    });

    // 验证数据更新
    expect(screen.getByText('李四')).toBeInTheDocument();
  });

  it('处理网络错误', async () => {
    // 模拟 API 错误
    server.use(
      rest.get('/api/users/123', (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ error: '服务器错误' }));
      })
    );

    renderWithProviders(<UserProfile userId="123" />);

    // 验证错误处理
    await waitFor(() => {
      expect(screen.getByText('加载失败,请重试')).toBeInTheDocument();
    });

    // 验证重试功能
    const retryButton = screen.getByRole('button', { name: /重试/i });
    await userEvent.click(retryButton);

    // 注意:这里需要重新 mock 成功的响应
  });
});

// 🔗 API 集成测试
// src/services/api.integration.test.ts
import { fetchUser, updateUser, deleteUser } from './userApi';
import { server } from '@/mocks/server';

describe('User API Integration', () => {
  beforeAll(() => server.listen());
  afterEach(() => server.resetHandlers());
  afterAll(() => server.close());

  it('成功获取用户信息', async () => {
    const user = await fetchUser('123');
    
    expect(user).toEqual({
      id: '123',
      name: '测试用户',
      email: 'test@example.com',
      role: 'user',
    });
  });

  it('处理 404 错误', async () => {
    server.use(
      rest.get('/api/users/999', (req, res, ctx) => {
        return res(ctx.status(404));
      })
    );

    await expect(fetchUser('999')).rejects.toThrow('用户不存在');
  });

  it('更新用户信息', async () => {
    const updates = { name: '新名字', email: 'new@example.com' };
    const updatedUser = await updateUser('123', updates);
    
    expect(updatedUser.name).toBe('新名字');
    expect(updatedUser.email).toBe('new@example.com');
  });
});

// 🔗 状态管理集成测试
// src/store/userStore.integration.test.ts
import { renderHook, act } from '@testing-library/react';
import { useUserStore } from './userStore';
import { server } from '@/mocks/server';

describe('User Store Integration', () => {
  beforeAll(() => server.listen());
  afterEach(() => {
    server.resetHandlers();
    // 重置 store 状态
    const { result } = renderHook(() => useUserStore());
    act(() => result.current.reset());
  });
  afterAll(() => server.close());

  it('登录流程', async () => {
    const { result } = renderHook(() => useUserStore());

    expect(result.current.user).toBeNull();
    expect(result.current.isLoading).toBe(false);

    // 执行登录
    await act(async () => {
      await result.current.login('test@example.com', 'password');
    });

    // 验证登录结果
    expect(result.current.user).toEqual({
      id: '123',
      name: '测试用户',
      email: 'test@example.com',
    });
    expect(result.current.isLoading).toBe(false);
  });

  it('登录失败处理', async () => {
    // 模拟登录失败
    server.use(
      rest.post('/api/login', (req, res, ctx) => {
        return res(ctx.status(401), ctx.json({ error: '认证失败' }));
      })
    );

    const { result } = renderHook(() => useUserStore());

    await act(async () => {
      await expect(
        result.current.login('wrong@example.com', 'wrong')
      ).rejects.toThrow('认证失败');
    });

    expect(result.current.user).toBeNull();
    expect(result.current.error).toBe('认证失败');
  });
});

3. E2E 测试:Playwright 🌍

为什么需要 E2E 测试?

E2E 测试模拟真实用户行为,验证整个应用程序从开始到结束的工作流程。

解决的问题:

  • 验证完整的用户流程
  • 发现集成和环境相关问题
  • 确保关键业务功能正常工作

缺点与限制:

  • 执行速度最慢
  • 测试脆弱,容易受 UI 变化影响
  • 调试和维护成本较高
详细配置与示例
// 🌍 Playwright 配置文件
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html'],
    ['json', { outputFile: 'test-results.json' }],
  ],
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

// 🌍 关键用户流程测试
// e2e/critical-flows.spec.ts
import { test, expect } from '@playwright/test';

test.describe('关键用户流程', () => {
  test('用户完整注册流程', async ({ page }) => {
    // 1. 访问首页
    await page.goto('/');
    await expect(page).toHaveTitle('我的应用');
    
    // 2. 导航到注册页
    await page.click('text=注册');
    await expect(page).toHaveURL(/.*\/register/);
    
    // 3. 填写注册表单
    await page.fill('[data-testid="email"]', 'test@example.com');
    await page.fill('[data-testid="password"]', 'Password123!');
    await page.fill('[data-testid="confirmPassword"]', 'Password123!');
    await page.fill('[data-testid="fullName"]', '测试用户');
    
    // 4. 提交表单
    await page.click('button[type="submit"]');
    
    // 5. 验证重定向和成功消息
    await expect(page).toHaveURL(/.*\/dashboard/);
    await expect(page.locator('[data-testid="welcome-message"]'))
      .toContainText('欢迎,测试用户');
  });

  test('购物车完整流程', async ({ page }) => {
    await page.goto('/products');
    
    // 1. 浏览商品
    await expect(page.locator('[data-testid="product-list"]')).toBeVisible();
    
    // 2. 搜索商品
    await page.fill('[data-testid="search-input"]', '笔记本电脑');
    await page.click('[data-testid="search-button"]');
    
    // 3. 添加商品到购物车
    const firstProduct = page.locator('[data-testid="product-item"]').first();
    await firstProduct.locator('[data-testid="add-to-cart"]').click();
    
    // 验证购物车数量更新
    await expect(page.locator('[data-testid="cart-count"]')).toContainText('1');
    
    // 4. 前往购物车
    await page.click('[data-testid="cart-icon"]');
    await expect(page).toHaveURL(/.*\/cart/);
    
    // 5. 验证购物车内容
    await expect(page.locator('[data-testid="cart-item"]')).toHaveCount(1);
    await expect(page.locator('[data-testid="total-price"]')).toBeVisible();
    
    // 6. 结账流程
    await page.click('text=去结账');
    await expect(page).toHaveURL(/.*\/checkout/);
    
    // 7. 填写配送信息
    await page.fill('[data-testid="shipping-name"]', '收货人');
    await page.fill('[data-testid="shipping-address"]', '收货地址');
    await page.fill('[data-testid="shipping-phone"]', '13800138000');
    
    // 8. 选择支付方式并提交订单
    await page.click('[data-testid="payment-method-alipay"]');
    await page.click('[data-testid="place-order"]');
    
    // 9. 验证订单完成
    await expect(page).toHaveURL(/.*\/order-success/);
    await expect(page.locator('[data-testid="success-message"]'))
      .toContainText('订单提交成功');
  });

  test('用户登录和权限控制', async ({ page }) => {
    // 1. 访问受保护页面
    await page.goto('/dashboard');
    
    // 2. 验证重定向到登录页
    await expect(page).toHaveURL(/.*\/login/);
    
    // 3. 登录
    await page.fill('[data-testid="email"]', 'user@example.com');
    await page.fill('[data-testid="password"]', 'password');
    await page.click('button[type="submit"]');
    
    // 4. 验证成功登录并重定向
    await expect(page).toHaveURL(/.*\/dashboard/);
    
    // 5. 验证用户菜单显示
    await page.click('[data-testid="user-menu"]');
    await expect(page.locator('[data-testid="user-name"]'))
      .toContainText('当前用户');
  });
});

// 🌍 页面对象模型 (Page Object Model)
// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.locator('[data-testid="email"]');
    this.passwordInput = page.locator('[data-testid="password"]');
    this.submitButton = page.locator('button[type="submit"]');
    this.errorMessage = page.locator('[data-testid="error-message"]');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async getErrorMessage() {
    return this.errorMessage.textContent();
  }
}

// 🌍 使用页面对象的测试
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test.describe('登录功能', () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.goto();
  });

  test('成功登录', async ({ page }) => {
    await loginPage.login('user@example.com', 'password');
    
    await expect(page).toHaveURL(/.*\/dashboard/);
    await expect(page.locator('[data-testid="welcome-message"]')).toBeVisible();
  });

  test('登录失败显示错误信息', async () => {
    await loginPage.login('wrong@example.com', 'wrong');
    
    const errorMessage = await loginPage.getErrorMessage();
    expect(errorMessage).toContain('邮箱或密码错误');
  });

  test('表单验证', async () => {
    await loginPage.login('', '');
    
    await expect(loginPage.emailInput).toHaveAttribute('aria-invalid', 'true');
    await expect(loginPage.passwordInput).toHaveAttribute('aria-invalid', 'true');
  });
});

// 🌍 CI 集成配置
// .github/workflows/e2e.yml
name: E2E Tests
on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'
          
      - name: Install dependencies
        run: npm ci
        
      - name: Install Playwright
        run: npx playwright install --with-deps
        
      - name: Build application
        run: npm run build
        
      - name: Run E2E tests
        run: npx playwright test
        
      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

4. 视觉回归测试 🖼️

为什么需要视觉测试?

视觉测试确保 UI 组件在不同版本间保持一致的视觉外观,捕捉意外的样式变化。

解决的问题:

  • 检测意外的视觉回归
  • 确保跨浏览器一致性
  • 验证响应式设计

缺点与限制:

  • 对微小变化敏感,可能产生误报
  • 需要维护基线图片
  • 执行速度较慢
详细配置与示例
// 🖼️ Storybook 配置
// .storybook/main.js
module.exports = {
  stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-a11y',
    '@storybook/addon-interactions',
    '@storybook/addon-viewport',
  ],
  framework: '@storybook/react-vite',
  typescript: {
    check: false,
    reactDocgen: 'react-docgen-typescript',
  },
  staticDirs: ['../public'],
};

// 🖼️ 组件 Stories
// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'UI/Button',
  component: Button,
  parameters: {
    layout: 'centered',
    chromatic: { 
      disable: false,
      viewports: [375, 768, 1200],
    },
  },
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: ['primary', 'secondary', 'danger'],
    },
    size: {
      control: { type: 'select' },
      options: ['small', 'medium', 'large'],
    },
    disabled: {
      control: { type: 'boolean' },
    },
    loading: {
      control: { type: 'boolean' },
    },
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: {
    variant: 'primary',
    children: '主要按钮',
  },
};

export const Secondary: Story = {
  args: {
    variant: 'secondary',
    children: '次要按钮',
  },
};

export const Danger: Story = {
  args: {
    variant: 'danger',
    children: '危险操作',
  },
};

export const Small: Story = {
  args: {
    size: 'small',
    children: '小按钮',
  },
};

export const Large: Story = {
  args: {
    size: 'large',
    children: '大按钮',
  },
};

export const Disabled: Story = {
  args: {
    disabled: true,
    children: '禁用按钮',
  },
};

export const Loading: Story = {
  args: {
    loading: true,
    children: '加载中',
  },
};

// 🖼️ 交互测试 Stories
// src/components/Modal/Modal.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within, expect } from '@storybook/test';
import { Modal } from './Modal';

const meta: Meta<typeof Modal> = {
  title: 'UI/Modal',
  component: Modal,
  parameters: {
    layout: 'centered',
  },
};

export default meta;
type Story = StoryObj<typeof Modal>;

export const Default: Story = {
  args: {
    title: '示例弹窗',
    children: '这是弹窗的内容',
    isOpen: true,
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // 验证弹窗标题
    await expect(canvas.getByText('示例弹窗')).toBeInTheDocument();
    
    // 验证弹窗内容
    await expect(canvas.getByText('这是弹窗的内容')).toBeInTheDocument();
  },
};

export const WithInteractions: Story = {
  args: {
    title: '交互测试',
    children: '点击关闭按钮应该关闭弹窗',
    isOpen: true,
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    
    // 点击关闭按钮
    const closeButton = canvas.getByLabelText('关闭');
    await userEvent.click(closeButton);
    
    // 验证 onClose 被调用
    await expect(args.onClose).toHaveBeenCalled();
  },
};

// 🖼️ Chromatic 配置
// .storybook/chromatic.config.js
import { defineConfig } from 'chromatic';

export default defineConfig({
  projectId: 'your-project-id',
  storybook: {
    build: {
      outputDir: 'storybook-static',
    },
  },
  // 只在 main 分支上自动接受更改
  autoAcceptChanges: process.env.BRANCH === 'main',
  // 设置视觉测试的阈值
  diffThreshold: 0.2,
  // 需要手动审核的 stories
  storiesToReview: [
    'UI/Button--Primary',
    'UI/Modal--Default',
  ],
});

// 🖼️ package.json 脚本
{
  "scripts": {
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build",
    "chromatic": "chromatic --exit-zero-on-changes",
    "test:visual": "npm run build-storybook && chromatic"
  }
}

// 🖼️ CI 集成配置
// .github/workflows/visual.yml
name: Visual Tests
on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  visual:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          
      - uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'
          
      - name: Install dependencies
        run: npm ci
        
      - name: Build Storybook
        run: npm run build-storybook
        
      - name: Publish to Chromatic
        uses: chromaui/action@v1
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          exitOnceUploaded: true
          autoAcceptChanges: ${{ github.ref == 'refs/heads/main' }}

工程规范 📋

1. ESLint 配置 🛡️

为什么需要 ESLint?

ESLint 通过静态分析识别代码中的问题和模式违规,确保代码质量和一致性。

解决的问题:

  • 强制执行编码标准
  • 提前发现潜在错误
  • 保持代码风格一致性

缺点与限制:

  • 配置复杂,学习曲线较陡
  • 可能产生误报或漏报
  • 严格的规则可能影响开发速度
详细配置示例
// .eslintrc.js
module.exports = {
  root: true,
  parser: '@typescript-eslint/parser',
  parserOptions: {
    project: './tsconfig.json',
    tsconfigRootDir: __dirname,
    ecmaVersion: 2022,
    sourceType: 'module',
    ecmaFeatures: {
      jsx: true,
    },
  },
  env: {
    browser: true,
    es2022: true,
    node: true,
    jest: true,
  },
  extends: [
    // ESLint 推荐规则
    'eslint:recommended',
    
    // TypeScript 规则
    '@typescript-eslint/recommended',
    '@typescript-eslint/recommended-requiring-type-checking',
    
    // React 规则
    'plugin:react/recommended',
    'plugin:react-hooks/recommended',
    
    // 可访问性规则
    'plugin:jsx-a11y/recommended',
    
    // 导入排序规则
    'plugin:import/errors',
    'plugin:import/warnings',
    'plugin:import/typescript',
    
    // Prettier 兼容(必须放在最后)
    'prettier',
  ],
  plugins: [
    '@typescript-eslint',
    'react',
    'react-hooks',
    'jsx-a11y',
    'import',
    'prettier',
  ],
  rules: {
    // TypeScript 规则
    '@typescript-eslint/no-unused-vars': 'error',
    '@typescript-eslint/explicit-function-return-type': 'off',
    '@typescript-eslint/explicit-module-boundary-types': 'off',
    '@typescript-eslint/no-explicit-any': 'warn',
    '@typescript-eslint/prefer-const': 'error',
    '@typescript-eslint/no-floating-promises': 'error',
    '@typescript-eslint/await-thenable': 'error',
    
    // React 规则
    'react/react-in-jsx-scope': 'off',
    'react/prop-types': 'off',
    'react-hooks/rules-of-hooks': 'error',
    'react-hooks/exhaustive-deps': 'warn',
    
    // 导入规则
    'import/order': [
      'error',
      {
        groups: [
          'builtin',
          'external',
          'internal',
          'parent',
          'sibling',
          'index',
        ],
        'newlines-between': 'always',
        alphabetize: { 
          order: 'asc',
          caseInsensitive: true,
        },
      },
    ],
    'import/no-unresolved': 'error',
    'import/no-cycle': 'error',
    
    // 代码质量规则
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
    'prefer-const': 'error',
    'no-var': 'error',
    'object-shorthand': 'error',
    'prefer-template': 'error',
    
    // Prettier 集成
    'prettier/prettier': 'error',
  },
  settings: {
    react: {
      version: 'detect',
    },
    'import/resolver': {
      typescript: {
        alwaysTryTypes: true,
      },
      node: {
        paths: ['src'],
        extensions: ['.js', '.jsx', '.ts', '.tsx'],
      },
    },
  },
  overrides: [
    {
      files: ['**/*.test.{js,jsx,ts,tsx}'],
      env: {
        jest: true,
      },
      rules: {
        '@typescript-eslint/no-explicit-any': 'off',
      },
    },
    {
      files: ['**/*.stories.{js,jsx,ts,tsx}'],
      rules: {
        'import/no-anonymous-default-export': 'off',
      },
    },
  ],
};

// 自定义 ESLint 规则示例
// eslint-plugin-custom-rules/index.js
module.exports = {
  rules: {
    'no-relative-imports': {
      meta: {
        type: 'problem',
        docs: {
          description: '禁止使用相对路径导入',
          category: 'Best Practices',
          recommended: true,
        },
        messages: {
          noRelativeImports: '请使用绝对路径导入,避免使用相对路径',
        },
      },
      create(context) {
        return {
          ImportDeclaration(node) {
            const importPath = node.source.value;
            
            // 检查是否是相对路径
            if (importPath.startsWith('.')) {
              context.report({
                node,
                messageId: 'noRelativeImports',
              });
            }
          },
        };
      },
    },
    
    'no-hardcoded-colors': {
      meta: {
        type: 'problem',
        docs: {
          description: '禁止硬编码颜色值',
          category: 'Best Practices',
          recommended: true,
        },
        messages: {
          noHardcodedColors: '请使用设计系统中的颜色变量,避免硬编码颜色值',
        },
      },
      create(context) {
        return {
          Literal(node) {
            const value = node.value;
            const colorRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})|rgb|hsl|rgba|hsla/i;
            
            if (typeof value === 'string' && colorRegex.test(value)) {
              context.report({
                node,
                messageId: 'noHardcodedColors',
              });
            }
          },
        };
      },
    },
  },
};

2. Prettier 配置 🧹

为什么需要 Prettier?

Prettier 自动格式化代码,确保团队代码风格一致,减少格式争议。

解决的问题:

  • 自动统一代码风格
  • 减少代码审查中的格式讨论
  • 提高代码可读性

缺点与限制:

  • 某些自定义格式可能无法配置
  • 可能与现有代码风格冲突
  • 需要团队适应自动化格式
详细配置示例
// .prettierrc.js
module.exports = {
  // 每行最大字符数
  printWidth: 100,
  
  // 缩进使用空格数
  tabWidth: 2,
  
  // 使用空格而不是制表符
  useTabs: false,
  
  // 语句末尾添加分号
  semi: true,
  
  // 使用单引号
  singleQuote: true,
  
  // 对象属性引号使用方式
  quoteProps: 'as-needed',
  
  // JSX 中使用单引号
  jsxSingleQuote: true,
  
  // 尾随逗号(ES5 标准)
  trailingComma: 'es5',
  
  // 对象花括号内的空格
  bracketSpacing: true,
  
  // JSX 标签的闭合括号位置
  bracketSameLine: false,
  
  // 箭头函数参数括号
  arrowParens: 'avoid',
  
  // 格式化范围
  rangeStart: 0,
  rangeEnd: Infinity,
  
  // 不需要在文件顶部添加 @format 标记
  requirePragma: false,
  
  // 不插入 @format 标记
  insertPragma: false,
  
  // 折行标准
  proseWrap: 'preserve',
  
  // HTML 空白敏感性
  htmlWhitespaceSensitivity: 'css',
  
  // Vue 文件脚本和样式标签缩进
  vueIndentScriptAndStyle: false,
  
  // 换行符
  endOfLine: 'lf',
  
  // 嵌入式语言格式化
  embeddedLanguageFormatting: 'auto',
  
  // 单个属性时的括号
  singleAttributePerLine: false,
};

// Prettier 忽略文件
// .prettierignore
# 依赖目录
node_modules/
dist/
build/

# 生成的文件
coverage/
*.log

# 配置文件
*.config.js

# 锁文件
package-lock.json
yarn.lock

# 文档
*.md
*.mdx

# 图片和字体
*.png
*.jpg
*.jpeg
*.gif
*.svg
*.woff
*.woff2

// package.json 中的格式化脚本
{
  "scripts": {
    "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md,mdx,css,scss}\"",
    "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,md,mdx,css,scss}\"",
    "format:staged": "lint-staged"
  }
}

3. Git Hooks 配置 🪝

为什么需要 Git Hooks?

Git Hooks 在代码提交和推送前自动运行检查,防止低质量代码进入仓库。

解决的问题:

  • 自动化代码质量检查
  • 强制执行代码标准
  • 减少 CI 失败次数

缺点与限制:

  • 可能减慢开发流程
  • 需要团队统一配置
  • 复杂的钩子可能难以调试
详细配置示例
// package.json 中的 Husky 配置
{
  "scripts": {
    "prepare": "husky install",
    "lint": "eslint src --ext .ts,.tsx,.js,.jsx --max-warnings 0",
    "lint:fix": "npm run lint -- --fix",
    "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md,mdx,css,scss}\"",
    "typecheck": "tsc --noEmit",
    "test": "vitest",
    "test:ci": "vitest run --coverage",
    "validate": "npm run lint && npm run typecheck && npm run test:ci"
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,mdx,css,scss,yml,yaml}": [
      "prettier --write"
    ],
    "*.{ts,tsx}": [
      "bash -c 'npm run typecheck'"
    ]
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "pre-push": "npm run test:ci",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  }
}

// commitlint 配置
// .commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      [
        'feat',     // 新功能
        'fix',      // 修复 bug
        'docs',     // 文档更新
        'style',    // 代码格式调整
        'refactor', // 代码重构
        'test',     // 测试相关
        'chore',    // 构建过程或辅助工具变动
        'perf',     // 性能优化
        'ci',       // CI 配置变更
        'revert',   // 回滚提交
      ],
    ],
    'type-case': [2, 'always', 'lower-case'],
    'type-empty': [2, 'never'],
    'scope-case': [2, 'always', 'lower-case'],
    'subject-empty': [2, 'never'],
    'subject-full-stop': [2, 'never', '.'],
    'header-max-length': [2, 'always', 100],
  },
};

// 手动设置 Git Hooks(Husky v8+)
// .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npm run lint-staged

// .husky/pre-push
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npm run test:ci

// .husky/commit-msg
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx --no -- commitlint --edit "$1"

// 自定义 Git Hook 脚本示例
// scripts/pre-commit-check.sh
#!/bin/bash

# 检查是否有未解决的合并冲突
if git grep -l '<<<<<<<' -- ':(exclude)package-lock.json' | grep -q .; then
  echo "错误: 发现未解决的合并冲突"
  git grep -l '<<<<<<<' -- ':(exclude)package-lock.json'
  exit 1
fi

# 检查调试语句
if git diff --cached --name-only | xargs grep -l 'console.log\|debugger' | grep -q .; then
  echo "警告: 发现调试语句"
  git diff --cached --name-only | xargs grep -l 'console.log\|debugger'
  read -p "是否继续提交? (y/n) " -n 1 -r
  echo
  if [[ ! $REPLY =~ ^[Yy]$ ]]; then
    exit 1
  fi
fi

# 检查文件大小
MAX_FILE_SIZE=5242880 # 5MB
for file in $(git diff --cached --name-only); do
  if [ -f "$file" ]; then
    size=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file" 2>/dev/null)
    if [ "$size" -gt "$MAX_FILE_SIZE" ]; then
      echo "错误: 文件 $file 过大 ($size 字节),最大允许 $MAX_FILE_SIZE 字节"
      exit 1
    fi
  fi
done

echo "预提交检查通过"
exit 0

4. Commitizen 标准化提交 ✍️

为什么需要标准化提交?

标准化提交信息便于自动化生成变更日志、版本管理和代码审查。

解决的问题:

  • 统一的提交信息格式
  • 自动化版本管理
  • 清晰的变更历史

缺点与限制:

  • 需要团队成员适应新流程
  • 可能增加提交的复杂性
  • 某些简单修改可能显得过度正式
详细配置示例
// .cz-config.js
module.exports = {
  types: [
    { value: 'feat', name: 'feat:     新功能' },
    { value: 'fix', name: 'fix:      修复 bug' },
    { value: 'docs', name: 'docs:     文档更新' },
    { value: 'style', name: 'style:    代码格式调整(不影响功能)' },
    { value: 'refactor', name: 'refactor: 代码重构(既不是新功能也不是修复 bug)' },
    { value: 'perf', name: 'perf:     性能优化' },
    { value: 'test', name: 'test:     测试相关' },
    { value: 'chore', name: 'chore:    构建过程或辅助工具变动' },
    { value: 'ci', name: 'ci:        CI 配置变更' },
    { value: 'revert', name: 'revert:   回滚提交' },
  ],
  
  scopes: [
    { name: 'ui', description: '用户界面相关' },
    { name: 'api', description: 'API 相关' },
    { name: 'auth', description: '认证授权相关' },
    { name: 'database', description: '数据库相关' },
    { name: 'config', description: '配置相关' },
    { name: 'deps', description: '依赖更新' },
    { name: 'other', description: '其他' },
  ],
  
  messages: {
    type: '选择提交类型:',
    scope: '选择影响范围 (可选):',
    customScope: '输入自定义范围:',
    subject: '简短描述(必填):\n',
    body: '详细描述(可选). 使用 "|" 换行:\n',
    breaking: '破坏性变化说明(可选):\n',
    footer: '关联关闭的 issue(可选). 例如: #31, #34:\n',
    confirmCommit: '确认提交?',
  },
  
  allowCustomScopes: true,
  allowBreakingChanges: ['feat', 'fix'],
  skipQuestions: ['body', 'footer'],
  subjectLimit: 100,
  
  // 范围验证
  scopeOverrides: {
    fix: [
      { name: 'merge' },
      { name: 'style' },
      { name: 'e2eTest' },
      { name: 'unitTest' },
    ],
  },
};

// 提交信息验证脚本
// scripts/verify-commit-msg.js
const fs = require('fs');
const path = require('path');

// 获取提交信息
const commitMsgFile = process.argv[2];
const commitMsg = fs.readFileSync(commitMsgFile, 'utf8').trim();

// 提交信息格式正则
const commitRegex = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|revert)(\([^)]+\))?: .{1,100}/;

if (!commitRegex.test(commitMsg)) {
  console.error(`
    提交信息格式错误!
    
    正确格式: <type>(<scope>): <subject>
    
    示例:
    - feat(auth): 添加用户登录功能
    - fix(ui): 修复按钮点击无效的问题
    - docs: 更新 README 文档
    
    允许的类型:
    - feat:     新功能
    - fix:      修复 bug
    - docs:     文档更新
    - style:    代码格式调整
    - refactor: 代码重构
    - perf:     性能优化
    - test:     测试相关
    - chore:    构建过程或辅助工具变动
    - ci:       CI 配置变更
    - revert:   回滚提交
  `);
  process.exit(1);
}

console.log('✅ 提交信息格式正确');
process.exit(0);

// 自动化版本管理和变更日志生成
// .versionrc.js
module.exports = {
  types: [
    { type: 'feat', section: '新功能' },
    { type: 'fix', section: 'Bug 修复' },
    { type: 'docs', section: '文档' },
    { type: 'style', section: '代码风格' },
    { type: 'refactor', section: '代码重构' },
    { type: 'perf', section: '性能优化' },
    { type: 'test', section: '测试' },
    { type: 'chore', section: '构建工具' },
    { type: 'ci', section: 'CI 配置' },
    { type: 'revert', section: '回滚' },
  ],
  commitUrlFormat: '{{host}}/{{owner}}/{{repository}}/commit/{{hash}}',
  compareUrlFormat: '{{host}}/{{owner}}/{{repository}}/compare/{{previousTag}}...{{currentTag}}',
  issueUrlFormat: '{{host}}/{{owner}}/{{repository}}/issues/{{id}}',
  userUrlFormat: '{{host}}/{{user}}',
};

// package.json 中的相关脚本
{
  "scripts": {
    "commit": "cz",
    "commit:retry": "git add . && cz --retry",
    "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
    "release": "standard-version",
    "release:minor": "standard-version --release-as minor",
    "release:major": "standard-version --release-as major"
  },
  "config": {
    "commitizen": {
      "path": "./node_modules/cz-conventional-changelog"
    }
  }
}

总结 🎯

通过实施完整的代码质量工程体系,团队可以获得以下收益:

核心优势

  1. 类型安全 - 减少运行时错误,提高代码可靠性
  2. 测试覆盖 - 确保功能正确性,支持持续重构
  3. 规范统一 - 提高代码可读性和可维护性
  4. 自动化流程 - 减少人为错误,提高开发效率

实施建议

  1. 渐进式采用 - 从最急需的环节开始,逐步推广
  2. 团队培训 - 确保所有成员理解并认可质量工程的价值
  3. 持续优化 - 定期回顾和改进质量工程实践
  4. 工具整合 - 将质量检查集成到开发工作流中

成功指标

  • 类型检查通过率 100%
  • 测试覆盖率 > 80%
  • CI/CD 流水线通过率 > 95%
  • 代码审查反馈周期缩短
  • 生产环境 bug 数量显著减少

通过系统化地实施这些代码质量工程实践,团队可以构建出更加健壮、可维护且高质量的软件产品。

JavaScript字符串填充:padStart()方法

原文:xuanhu.info/projects/it…

JavaScript字符串填充:padStart()方法

在编程实践中,字符串填充是高频操作需求。无论是格式化输出、数据对齐还是生成固定格式标识符,都需要高效可靠的填充方案。本文将深入探讨JavaScript中最优雅的字符串填充方案——padStart()方法,通过理论解析+实战案例带你掌握这一核心技能。

🧩 字符串填充的本质需求

字符串填充指在原始字符串的指定侧添加特定字符直至达到目标长度。常见应用场景包括:

  • 数字补零(如日期格式化 "2023-1-1" → "2023-01-01")
  • 表格数据对齐
  • 生成固定长度交易号
  • 控制台输出美化

🚫 传统填充方案的痛点

在ES2017规范前,开发者通常采用以下方式实现填充:

// 手动实现左填充函数
function leftPad(str, length, padChar = ' ') {
  const padCount = length - str.length;
  return padCount > 0 
    ? padChar.repeat(padCount) + str 
    : str;
}

console.log(leftPad('42', 5, '0')); // "00042"

这种方案存在三大缺陷:

  1. 代码冗余:每个项目需重复实现工具函数
  2. 边界处理复杂:需手动处理超长字符串、空字符等边界情况
  3. 性能瓶颈:大数量级操作时循环效率低下

✨ padStart()方法

ES2017引入的padStart()是String原型链上的原生方法,完美解决上述痛点。

📚 方法参数

/**
 * 字符串起始位置填充
 * @param {number} targetLength - 填充后目标长度
 * @param {string} [padString=' '] - 填充字符(默认空格)
 * @returns {string} 填充后的新字符串
 */
String.prototype.padStart(targetLength, padString);

🔬 核心特性详解

  1. 智能截断:当填充字符串超出需要长度时自动截断

    '7'.padStart(3, 'abcdef'); // "ab7" 
    
  2. 类型安全:自动转换非字符串参数

    const price = 9.9;
    price.toString().padStart(5, '0'); // "09.9"
    
  3. 空值处理:对null/undefined返回原始值

    String(null).padStart(2, '0'); // "null"
    

🚀 应用场景

场景1:数据格式化

// 金额分转元并补零
function formatCurrency(cents) {
  const yuan = (cents / 100).toFixed(2);
  return yuan.padStart(8, ' '); // 对齐到8位
}

console.log(formatCurrency(12345)); // "  123.45"

场景2:二进制数据转换

// 10进制转8位二进制
function toBinary(num) {
  return num.toString(2).padStart(8, '0');
}

console.log(toBinary(42)); // "00101010"

场景3:日志系统对齐

const logLevels = ['DEBUG', 'INFO', 'WARN'];
const messages = ['Starting app', 'User logged in', 'Memory low'];

// 生成对齐的日志输出
logLevels.forEach((level, i) => {
  console.log(
    `[${level.padStart(5)}] ${messages[i].padEnd(20)}`
  );
});
/*
[DEBUG] Starting app        
[ INFO] User logged in      
[ WARN] Memory low          
*/

⚖️ 性能对比测试

通过Benchmark.js对10万次操作进行性能测试:

方法 操作耗时(ms) 内存占用(MB)
手动循环填充 142.5 82.3
Array.join填充 98.7 76.1
padStart 32.8 54.2
pie
    title 各方法CPU耗时占比
    "手动循环填充" : 42
    "Array.join填充" : 29
    "padStart" : 29

🛠️ 进阶技巧与陷阱规避

技巧1:链式填充组合

// 生成银行账号格式:****-****-1234
const lastFour = '1234';
const masked = lastFour
  .padStart(12, '*')      // "********1234"
  .replace(/(.{4})/g, '$1-') // 每4位加分隔符
  .slice(0, -1);          // 移除末尾多余分隔符

console.log(masked); // "****-****-1234"

技巧2:多字符模式填充

// 创建文本装饰线
const title = " CHAPTER 1 ";
console.log(
  title.padStart(30, '═').padEnd(40, '═')
);
// "══════════ CHAPTER 1 ══════════"

⚠️ 常见陷阱及解决方案

  1. 负数长度处理:目标长度小于原字符串时返回原字符串

    'overflow'.padStart(3); // "overflow" 
    
  2. 非字符串填充符:自动调用toString()转换

    '1'.padStart(3, true); // "tr1" 
    
  3. 多字符截断规则:从左向右截取填充字符

    'A'.padStart(5, 'XYZ'); // "XYXYA" 
    

🌐 浏览器兼容性与Polyfill

虽然现代浏览器普遍支持padStart(),但需考虑兼容旧版环境:

// 安全垫片实现
if (!String.prototype.padStart) {
  String.prototype.padStart = function(targetLen, padStr) {
    targetLen = Math.floor(targetLen) || 0;
    if (targetLen <= this.length) return String(this);
    
    padStr = padStr ? String(padStr) : ' ';
    let repeatCnt = Math.ceil((targetLen - this.length) / padStr.length);
    
    return padStr.repeat(repeatCnt).slice(0, targetLen - this.length) 
           + String(this);
  };
}

💡 总结

  1. 优先选择padStart:性能优于手动实现方案
  2. 明确长度预期:提前计算目标长度避免意外截断
  3. 处理特殊字符:对换行符等特殊字符需额外处理
  4. 组合使用padEnd:实现双向填充需求

原文:xuanhu.info/projects/it…

深入Next.js应用性能优化:懒加载技术全解析

原文:xuanhu.info/projects/it…

深入Next.js应用性能优化:懒加载技术全解析

在现代Web应用开发中,性能优化是至关重要的一环。用户对加载速度的敏感度极高,研究表明,超过3秒的加载时间会导致大量用户流失。Next.js作为基于React的框架,提供了强大的工具和特性来构建高性能应用。本文将深入探讨如何通过懒加载技术优化Next.js应用的性能,涵盖理论、实践案例以及最佳实践。

1. 什么是懒加载?

懒加载(Lazy Loading)是一种延迟加载资源的技术,直到它们真正需要时才进行加载。在现代Web开发中,我们通常将代码拆分为多个模块,而不是将所有逻辑放在一个文件中。这样做有助于代码组织,但可能导致初始加载时下载大量不必要的资源。

1.1 代码拆分与捆绑

在构建阶段,打包工具(如Webpack、Rollup)将源代码转换为捆绑包(bundles)。如果所有捆绑包在初始加载时一并下载,会导致加载缓慢。懒加载允许我们将代码拆分为更小的块,并按需加载。

graph TD
    A[源代码] --> B(打包工具)
    B --> C[捆绑包]
    C --> D{初始加载}
    D --> E[立即需要的块]
    D --> F[延迟加载的块]
    E --> G[用户界面功能]
    F --> H[按需加载]

1.2 懒加载的优势

  • 减少初始加载时间:只加载关键资源,延迟非关键资源。
  • 提升用户体验:快速呈现初始内容,减少等待时间。
  • 优化带宽使用:避免下载未使用的代码。

2. Next.js中的懒加载技术

Next.js提供了两种主要的懒加载技术:

  1. 使用next/dynamic进行动态导入。
  2. 使用React.lazy()Suspense

2.1 使用next/dynamic进行动态导入

next/dynamic是Next.js提供的封装,结合了React的lazy()Suspense。它是Next.js中实现懒加载的首选方法。

2.1.1 创建示例组件

首先,我们创建一个简单的演示组件。假设我们有一个关于Tom & Jerry卡通中Tom猫的组件。

// app/components/tom/tom.jsx
const LazyTom = () => {
  return (
    <div className="flex flex-col">
      <h1 className="text-3xl my-2">The Lazy Tom</h1>
      <p className="text-xl my-1">
        xxxx
      </p>
      <p className="text-xl my-1">
        yyyy
      </p>
    </div>
  );
};

export default LazyTom;
2.1.2 实现懒加载

接下来,我们使用next/dynamic来懒加载这个组件。

// app/components/tom/tom-story.jsx
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";

// 使用dynamic导入组件,并配置加载状态
const LazyTom = dynamic(() => import("./tom"), {
  loading: () => <h1>Loading Tom's Story...</h1>,
});

function TomStory() {
  const [shown, setShown] = useState(false);

  return (
    <div className="flex flex-col m-8 w-[300px]">
      <h2 className="text-xl my-1">
        Demonstrating <strong>dynamic</strong>
      </h2>
      <button
        className="bg-blue-600 text-white rounded p-1"
        onClick={() => setShown(!shown)}
      >
        Load 🐈🐈🐈 Tom's Story
      </button>
      {shown && <LazyTom />}
    </div>
  );
}

export default TomStory;

代码解释

  • dynamic函数接受一个返回import语句的函数作为参数。
  • 可选的配置对象允许自定义加载状态。
  • 组件在第一次按钮点击时加载,之后不会重新加载除非浏览器刷新。
2.1.3 在主页面中使用

在主页中引入该组件。

// app/page.js
import TomStory from "./components/tom/tom-story";

export default function Home() {
  return (
    <div className="flex flex-wrap justify-center ">
      <TomStory />
    </div>
  );
}

2.2 使用React.lazy()Suspense

React.lazy()是React提供的懒加载函数,必须与Suspense组件一起使用。

2.2.1 创建Jerry组件

类似于Tom组件,我们创建一个关于Jerry老鼠的组件。

// app/components/jerry/jerry.jsx
const LazyJerry = () => {
  return (
    <div className="flex flex-col justify-center">
      <h1 className="text-3xl my-2">The Lazy Jerry</h1>
      <p className="text-xl my-1">
        xxxx
      </p>
      <p className="text-xl my-1">
        yyyy
      </p>
    </div>
  );
};

export default LazyJerry;
2.2.2 实现懒加载

使用React.lazy()Suspense来懒加载Jerry组件。

// app/components/jerry/jerry-story.jsx
"use client";
import React, { useState, Suspense } from "react";

// 使用React.lazy导入组件
const LazyJerry = React.lazy(() => import('./jerry'));

function JerryStory() {
  const [shown, setShown] = useState(false);

  return (
    <div className="flex flex-col m-8 w-[300px]">
      <h2 className="text-xl my-1">
        Demonstrating <strong>React.lazy()</strong>
      </h2>
      <button
        className="bg-pink-600 text-white rounded p-1"
        onClick={() => setShown(!shown)}
      >
        Load 🐀🐀🐀 Jerry's Story
      </button>
      {shown && (
        <Suspense fallback={<h1>Loading Jerry's Story</h1>}>
          <LazyJerry />
        </Suspense>
      )}
    </div>
  );
}

export default JerryStory;

代码解释

  • React.lazy()接受一个返回import语句的函数。
  • Suspense组件包裹懒加载组件,并提供fallback属性定义加载状态。
  • 加载行为与dynamic类似,只在第一次点击时加载。
2.2.3 在主页面中使用

将Jerry组件添加到主页。

// app/page.js
import TomStory from "./components/tom/tom-story";
import JerryStory from "./components/jerry/jerry-story";

export default function Home() {
  return (
    <div className="flex flex-wrap justify-center ">
      <TomStory />
      <JerryStory />
    </div>
  );
}

3. 懒加载命名导出组件

JavaScript模块支持两种导出方式:默认导出(default export)和命名导出(named export)。前面我们处理了默认导出,现在来看如何处理命名导出。

3.1 创建Spike组件

我们创建一个关于Spike狗的组件,使用命名导出。

// app/components/spike/spike.jsx
export const LazySpike = () => {
  return (
    <div className="flex flex-col">
      <h1 className="text-3xl my-2">The Lazy Spike</h1>
      <p className="text-xl my-1">
        xxxx
      </p>
      <p className="text-xl my-1">
        yyyy
      </p>
    </div>
  );
};

3.2 实现懒加载

对于命名导出,我们需要显式解析模块。

// app/components/spike/spike-story.jsx
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";

// 动态导入命名导出组件,通过then处理解析模块
const LazySpike = dynamic(() => import("./spike").then((mod) => mod.LazySpike), {
  loading: () => <h1>Loading Spike's Story...</h1>,
});

function SpikeStory() {
  const [shown, setShown] = useState(false);

  return (
    <div className="flex flex-col m-8 w-[300px]">
      <h2 className="text-xl my-1">
        Demonstrating <strong>Named Export</strong>
      </h2>
      <button
        className="bg-slate-600 text-white rounded p-1"
        onClick={() => setShown(!shown)}
      >
        Load 🦮🦮🦮 Spike's Story
      </button>
      {shown && <LazySpike />}
    </div>
  );
}

export default SpikeStory;

代码解释

  • import("./spike")返回一个Promise,我们使用.then()解析模块。
  • mod.LazySpike指定了要导入的命名导出组件。
  • 其余部分与默认导出类似。

3.3 在主页面中使用

将Spike组件添加到主页。

// app/page.js
import TomStory from "./components/tom/tom-story";
import JerryStory from "./components/jerry/jerry-story";
import SpikeStory from "./components/spike/spike-story";

export default function Home() {
  return (
    <div className="flex flex-wrap justify-center ">
      <TomStory />
      <JerryStory />
      <SpikeStory />
    </div>
  );
}

4. 懒加载服务器组件

服务器组件(Server Components)在Next.js中默认已进行代码拆分,因此通常不需要手动懒加载。但如果你动态导入一个包含客户端组件的服务器组件,这些客户端组件会被懒加载。

4.1 示例:服务器组件包含客户端组件

假设有一个服务器组件,它包含两个客户端组件。

// app/components/server-comps/server-comp.jsx
import ComponentA from "./a-client-comp";
import ComponentB from "./b-client-comp";
import React from 'react'

const AServerComp = () => {
  return (
    <div className="flex flex-col m-8 w-[300px]">
      <ComponentA />
      <ComponentB />
    </div>
  )
}

export default AServerComp

4.2 动态导入服务器组件

即使动态导入服务器组件,其子客户端组件也会被懒加载。

// app/page.js
import dynamic from "next/dynamic";
import TomStory from "./components/tom/tom-story";
import JerryStory from "./components/jerry/jerry-story";
import SpikeStory from "./components/spike/spike-story";

const AServerComp = dynamic(() => import('./components/server-comps/server-comp'), {
  loading: () => <h1>Loading Through Server Component...</h1>,
})

export default function Home() {
  return (
    <div className="flex flex-wrap justify-center ">
      <TomStory />
      <JerryStory />
      <SpikeStory />
      <AServerComp />
    </div>
  );
}

注意:服务器组件本身不会被懒加载,但其子客户端组件会。

5. 性能优化考量

懒加载是一种强大的优化技术,但并不是所有组件都需要懒加载。过度优化可能导致复杂性和维护成本增加。

5.1 何时使用懒加载?

  • 大型组件:当组件包含大量代码或依赖时。
  • 低优先级内容:如弹窗、选项卡内容等非初始显示内容。
  • 路由级别拆分:使用Next.js的路由级代码拆分。

5.2 避免过度优化

  • 关键组件:初始渲染所需的组件不应懒加载。
  • 轻量级组件:小组件懒加载可能得不偿失。
  • 频繁使用组件:经常使用的组件最好预先加载。

5.3 最佳实践

  1. 分析包大小:使用工具如Webpack Bundle Analyzer识别大型依赖。
  2. 组合使用:结合树摇(tree-shaking)和代码拆分。
  3. 测试性能:通过Lighthouse和WebPageTest等工具测量优化效果。

6. 总结

懒加载是提升Next.js应用性能的有效手段。通过next/dynamicReact.lazy(),我们可以按需加载客户端组件,减少初始加载时间。本文通过Tom、Jerry和Spike的示例,演示了默认导出、命名导出以及服务器组件的懒加载实现。

6.1 关键 takeaways

  • 懒加载减少初始负载:推迟非关键资源加载。
  • 两种主要技术next/dynamicReact.lazy() with Suspense
  • 命名导出需显式解析:通过.then()处理模块。
  • 服务器组件默认优化:无需手动懒加载,但子客户端组件会被优化。

6.2 进一步学习

通过合理应用懒加载,你可以显著提升Next.js应用的性能,提供更流畅的用户体验。优化是一个持续的过程,需要根据具体场景权衡利弊。

原文:xuanhu.info/projects/it…

这份超全JavaScript函数指南让你从小白变大神

你是不是曾经看着JavaScript里各种函数写法一头雾水?是不是经常被作用域搞得晕头转向?别担心,今天这篇文章就是要帮你彻底搞懂JavaScript函数!

读完本文,你将收获:

  • 函数的各种写法和使用场景
  • 参数传递的底层逻辑
  • 作用域和闭包的彻底理解
  • 箭头函数的正确使用姿势

准备好了吗?让我们开始这场函数探险之旅!

函数基础:从“Hello World”开始

先来看最基础的函数声明方式:

// 最传统的函数声明
function sayHello(name) {
  return "Hello, " + name + "!";
}

// 调用函数
console.log(sayHello("小明")); // 输出:Hello, 小明!

这里有几个关键点要记住:function是关键字,sayHello是函数名,name是参数,花括号里面是函数体。

但JavaScript的函数写法可不止这一种,还有函数表达式:

// 函数表达式
const sayHello = function(name) {
  return "Hello, " + name + "!";
};

console.log(sayHello("小红")); // 输出:Hello, 小红!

这两种写法看起来差不多,但在底层处理上有些细微差别。函数声明会被提升到作用域顶部,而函数表达式不会。

函数参数:比你想的更灵活

JavaScript的函数参数处理真的很贴心,不像其他语言那么死板:

function introduce(name, age, city) {
  console.log("我叫" + name + ",今年" + age + "岁,来自" + city);
}

// 正常调用
introduce("张三", 25, "北京"); // 输出:我叫张三,今年25岁,来自北京

// 参数不够 - 缺失的参数会是undefined
introduce("李四", 30); // 输出:我叫李四,今年30岁,来自undefined

// 参数太多 - 多余的参数会被忽略
introduce("王五", 28, "上海", "多余参数1", "多余参数2"); // 输出:我叫王五,今年28岁,来自上海

看到没?JavaScript不会因为参数个数不匹配就报错,这既是优点也是坑点。

为了解决参数不确定的情况,我们可以用arguments对象或者更现代的rest参数:

// 使用arguments对象(较老的方式)
function sum() {
  let total = 0;
  for (let i = 0; i < arguments.length; i++) {
    total += arguments[i];
  }
  return total;
}

console.log(sum(1, 2, 3, 4)); // 输出:10

// 使用rest参数(ES6新特性,推荐!)
function sum2(...numbers) {
  return numbers.reduce((total, num) => total + num, 0);
}

console.log(sum2(1, 2, 3, 4)); // 输出:10

rest参数的写法更清晰,而且它是个真正的数组,能用所有数组方法。

作用域深度探秘:变量在哪生效?

作用域可能是JavaScript里最让人困惑的概念之一,但理解它至关重要。

先看个简单例子:

let globalVar = "我是全局变量"; // 全局变量,在任何地方都能访问

function testScope() {
  let localVar = "我是局部变量"; // 局部变量,只在函数内部能访问
  console.log(globalVar); // 可以访问全局变量
  console.log(localVar); // 可以访问局部变量
}

testScope();
console.log(globalVar); // 可以访问
// console.log(localVar); // 报错!localVar在函数外部不存在

但事情没那么简单,看看这个经典的var和let区别:

// var的怪癖
function varTest() {
  if (true) {
    var x = 10; // var没有块级作用域
    let y = 20; // let有块级作用域
  }
  console.log(x); // 输出:10 - var声明的变量在整个函数都可用
  // console.log(y); // 报错!y只在if块内可用
}

varTest();

这就是为什么现在大家都推荐用let和const,避免var的奇怪行为。

闭包:JavaScript的超级力量

闭包听起来高大上,其实理解起来并不难:

function createCounter() {
  let count = 0; // 这个变量被"封闭"在返回的函数里
  
  return function() {
    count++; // 内部函数可以访问外部函数的变量
    return count;
  };
}

const counter = createCounter();
console.log(counter()); // 输出:1
console.log(counter()); // 输出:2
console.log(counter()); // 输出:3

看到神奇之处了吗?count变量本来应该在createCounter执行完就消失的,但因为返回的函数还在引用它,所以它一直存在。

闭包在实际开发中超级有用,比如创建私有变量:

function createBankAccount(initialBalance) {
  let balance = initialBalance; // 私有变量,外部无法直接访问
  
  return {
    deposit: function(amount) {
      balance += amount;
      return balance;
    },
    withdraw: function(amount) {
      if (amount <= balance) {
        balance -= amount;
        return balance;
      } else {
        return "余额不足";
      }
    },
    getBalance: function() {
      return balance;
    }
  };
}

const myAccount = createBankAccount(1000);
console.log(myAccount.getBalance()); // 输出:1000
console.log(myAccount.deposit(500)); // 输出:1500
console.log(myAccount.withdraw(200)); // 输出:1300
// console.log(balance); // 报错!balance是私有变量,无法直接访问

这样我们就实现了数据的封装和保护。

箭头函数:现代JavaScript的利器

ES6引入的箭头函数让代码更简洁:

// 传统函数
const add = function(a, b) {
  return a + b;
};

// 箭头函数
const addArrow = (a, b) => {
  return a + b;
};

// 更简洁的箭头函数(只有一条return语句时)
const addShort = (a, b) => a + b;

console.log(add(1, 2)); // 输出:3
console.log(addArrow(1, 2)); // 输出:3
console.log(addShort(1, 2)); // 输出:3

但箭头函数不只是语法糖,它没有自己的this绑定:

const obj = {
  name: "JavaScript",
  regularFunction: function() {
    console.log("普通函数this:", this.name);
  },
  arrowFunction: () => {
    console.log("箭头函数this:", this.name); // 这里的this不是obj
  }
};

obj.regularFunction(); // 输出:普通函数this: JavaScript
obj.arrowFunction(); // 输出:箭头函数this: undefined(在严格模式下)

这就是为什么在对象方法里通常不用箭头函数。

立即执行函数:一次性的工具

有时候我们需要一个函数只执行一次:

// 立即执行函数表达式 (IIFE)
(function() {
  const secret = "这个变量不会污染全局作用域";
  console.log("这个函数立即执行了!");
})();

// 带参数的IIFE
(function(name) {
  console.log("Hello, " + name);
})("世界");

// 用箭头函数写的IIFE
(() => {
  console.log("箭头函数版本的IIFE");
})();

在模块化规范出现之前,IIFE是防止变量污染全局的主要手段。

高阶函数:把函数当参数传递

在JavaScript中,函数是一等公民,可以像变量一样传递:

// 高阶函数 - 接收函数作为参数
function processArray(arr, processor) {
  const result = [];
  for (let i = 0; i < arr.length; i++) {
    result.push(processor(arr[i]));
  }
  return result;
}

const numbers = [1, 2, 3, 4, 5];

// 传递不同的处理函数
const doubled = processArray(numbers, function(num) {
  return num * 2;
});

const squared = processArray(numbers, function(num) {
  return num * num;
});

console.log(doubled); // 输出:[2, 4, 6, 8, 10]
console.log(squared); // 输出:[1, 4, 9, 16, 25]

这就是函数式编程的基础,也是数组方法map、filter、reduce的工作原理。

实战演练:构建一个简单的事件系统

让我们用今天学的知识构建一个实用的小工具:

function createEventEmitter() {
  const events = {}; // 存储所有事件和对应的监听器
  
  return {
    // 监听事件
    on: function(eventName, listener) {
      if (!events[eventName]) {
        events[eventName] = [];
      }
      events[eventName].push(listener);
    },
    
    // 触发事件
    emit: function(eventName, data) {
      if (events[eventName]) {
        events[eventName].forEach(listener => {
          listener(data);
        });
      }
    },
    
    // 移除监听器
    off: function(eventName, listenerToRemove) {
      if (events[eventName]) {
        events[eventName] = events[eventName].filter(
          listener => listener !== listenerToRemove
        );
      }
    }
  };
}

// 使用示例
const emitter = createEventEmitter();

// 定义监听器函数
function logData(data) {
  console.log("收到数据:", data);
}

// 监听事件
emitter.on("message", logData);

// 触发事件
emitter.emit("message", "你好世界!"); // 输出:收到数据: 你好世界!
emitter.emit("message", "这是第二条消息"); // 输出:收到数据: 这是第二条消息

// 移除监听器
emitter.off("message", logData);
emitter.emit("message", "这条消息不会被接收"); // 不会有输出

这个例子用到了我们今天学的几乎所有概念:函数返回函数、闭包、高阶函数等。

常见坑点与最佳实践

学到这里,你已经是函数小能手了!但还要注意这些常见坑点:

// 坑点1:循环中的闭包
console.log("=== 循环闭包问题 ===");
for (var i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i); // 输出:3, 3, 3 而不是 0, 1, 2
  }, 100);
}

// 解决方案1:使用let
for (let i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i); // 输出:0, 1, 2
  }, 100);
}

// 解决方案2:使用IIFE
for (var i = 0; i < 3; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j); // 输出:0, 1, 2
    }, 100);
  })(i);
}

最佳实践总结:

  1. 优先使用const,其次是let,避免var
  2. 简单的函数用箭头函数,方法定义用普通函数
  3. 注意this的指向问题
  4. 合理使用闭包,但要注意内存泄漏

总结

恭喜你!现在已经对JavaScript函数有了全面的理解。从基础声明到高级概念,从作用域到闭包,这些都是JavaScript编程的核心基础。

记住,理解函数的关键在于多写代码、多思考。每个概念都要亲手试一试,看看不同的写法会产生什么效果。

opentype.js 使用与文字渲染

笔者在某个需求实现中使用了 opentype.js 这个库,现将一些使用过程记录在本篇文章中。

opentype.js 是一个 JavaScript 库,支持浏览器和 Node.js,可以解析字体文件,拿到字体信息,并提供一些渲染方法。

虽然名字叫做 opentype.js,但除了可以解析 OpenType,也可以解析 TrueType。

支持常见的字体类型,比如 WOFF, OTF, TTF,像是 AutoCAD 的 shx 就不支持了。

需要注意的是,woff2 字体是用 Brotli 压缩过的文件,需要额外用解压库做解压。 opentype.js 没有提供对应解压 Brotli 的能力,倒是提供了 Inflate 解压能力,所以可以解析 woff 字体。

opentype.js 解析字体

    // 从 URL 下载字体
    const response: HttpClientResponse = await makeHttpRequest(url);

    if (!response.status || response.status !== 200) {
      handleError(`HTTP error! status: ${response.status}`);
    }
    // 加载文件字体为二进制数据,然后使用 opentype.js 解析
    const buffer = response.data as Buffer;
    const arrayBuffer = buffer.buffer.slice(
      buffer.byteOffset,
      buffer.byteOffset + buffer.byteLength
    );
    const font = await opentype.parse(arrayBuffer);

这个 font 这个对象保存了很多属性,比如所有的 glyph(字形)、一些 table(表)、字体的信息(字体名、设计师等)等等。

image.png

获取字形(glyph)信息

字形(glyph)是一个用于在字体排印中表示一个或多个字符的视觉表征的术语。

const glyph = font.charToGlyph('A')

有了字形,我们就能拿到某个或者某段文本字符串渲染所需要的一些关键信息(width、height、ascender、descender):

/**
   * 测量文本宽度
   * @param text 文本
   * @param fontUrl 字体URL
   * @param fontSize 字体大小
   * @returns 宽度、高度以及字体的上下边界信息
   */
  async measureText(
    text: string,
    fontUrl: string,
    fontSize: number
  ): Promise<FontMetrics> {
    // 1. 加载字体文件(opentrue
    const font = await this.loadFontFromUrl(fontUrl);

    // 2. 计算缩放比例:将字体的原始单位(unitsPerEm)转换为实际像素大小
    //    font.unitsPerEm 通常是 1000 或 2048,表示字体设计时的基准网格
    //    fontSize 是你想要渲染的大小(比如 32px)
    //    所以 scale = fontSize / unitsPerEm,用于把字体的“逻辑单位”转为“像素”
    const scale = fontSize / font.unitsPerEm;

    // 3. 将字符串转换为字形(glyph)数组
    //    每个字符可能对应一个或多个 glyph(比如连字 "fi")
    //    glyphs 是字体中实际的图形对象,包含路径、宽度等信息
    const glyphs = font.stringToGlyphs(text);

    // 4. 计算文本总宽度
    let width = 0;
    glyphs.forEach((glyph, i) => {
      // 如果不是第一个字符,加上前一个字符和当前字符之间的“字距调整”(kerning)
      // kerning 是为了让某些字符组合(如 "A" 和 "V")看起来更美观,自动缩小间距
      if (i > 0) {
        width += font.getKerningValue(glyphs[i - 1], glyph);
      }

      // 加上当前字形的“前进宽度”(advanceWidth)
      // 注意:这不是字形的绘制宽度,而是光标移动的距离(包含右侧空白)
      width += glyph.advanceWidth;
    });

    // 5. 返回测量结果(全部乘以 scale 转为像素单位)
    return {
      // 文本总宽度(含 kerning)
      width: width * scale,

      // 文本总高度 = ascender(上部) - descender(下部)
      // ascender 是基线以上部分(如 "b", "h" 的顶部)
      // descender 是基线以下部分(如 "g", "y" 的底部)
      height: (font.ascender - font.descender) * scale,

      // 基线以上的高度(正数),可用于垂直对齐计算
      ascender: font.ascender * scale,

      // 基线以下的高度(通常是负数,但这里保留原值)
      descender: font.descender * scale,
    };
  }

image.png

获取文字轮廓(path)

getPaths 计算得到一段字符串中每个 glyph 的轮廓数据。 注意:传入的y坐标确实表示的是基线坐标(baseline),而不是字符的顶部或底部。

const textPaths = font.getPaths(text, x, y, fontSize);

textPaths 是一个 path 数组。 字符串长度为 6,产生了 6 个 glyph(字形),所以一共有 6 个 path 对象。 形状的表达使用了经典的 SVG 的 Path 命令,对应着 command 属性。 TrueType 字体的曲线使用二阶贝塞尔曲线(对应 Q 命令);而 OpenType 支持三阶贝塞尔曲线(对应 C 命令)。

image.png

转成真正能用的path路径,需要调用OpenType.js 暴露的另一个方法:

textPaths.toPathData(2);

image.png

基于生成的 Path 路径与字形信息,我们便能实现文本在某种字体下的 SVG 绘制了,剩下的步骤待读者自行探索。

Vue 3 组合式函数(Composables)全面解析:从原理到实战

一、前言

当 Vue 3 发布时,组合式 API(Composition API) 带来了一个革命性的变化:

我们不再需要依赖 data、methods、computed 这些分散的选项,而是能用函数的方式,灵活组织逻辑。

这套函数化逻辑复用方案,就叫做 组合式函数(Composables)

简单来说:

  • Options API 更像是“配置式”;

  • Composition API 则让我们“像写逻辑一样组织组件”。

组合式函数(Composables) ,就是在这个新体系下,用于封装和复用有状态逻辑的函数。


二、什么是组合式函数?

先来看一句官方定义:

“组合式函数是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑的函数。”

也就是说,它不仅可以处理计算逻辑、请求接口、事件监听,还能和组件生命周期绑定,并且是响应式的。

按照惯例,我们命名时一般以 use 开头

// useXxx 组合式函数命名惯例
export function useMouse() { ... }
export function useFetch() { ... }
export function useEventListener() { ... }

三、基础示例:从组件逻辑到组合式函数

假设我们要做一个“鼠标追踪器”,实时显示鼠标位置。

如果直接写在组件里,可能是这样 👇

<!-- MouseComponent.vue -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

// 定义响应式状态
const x = ref(0)
const y = ref(0)

// 事件处理函数:更新坐标
function update(e) {
  x.value = e.pageX
  y.value = e.pageY
}

// 生命周期绑定
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>

<template>
  鼠标坐标:{{ x }}, {{ y }}
</template>

很好,但如果我们多个页面都要复用这个逻辑呢?

那就应该把它抽出来!


四、封装成组合式函数

// mouse.js
import { ref, onMounted, onUnmounted } from 'vue'

// 约定:组合式函数以 use 开头
export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  // 内部逻辑:跟踪鼠标移动
  function update(e) {
    x.value = e.pageX
    y.value = e.pageY
  }

  // 生命周期钩子
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  // 返回需要暴露的状态
  return { x, y }
}

使用起来非常简单:

<!-- MouseComponent.vue -->
<script setup>
import { useMouse } from './mouse.js'
const { x, y } = useMouse()
</script>

<template>
  鼠标坐标:{{ x }}, {{ y }}
</template>

✅ 这样写的好处是:

  • 组件逻辑更清晰;
  • 多处可复用;
  • 生命周期自动关联;
  • 每个组件都拥有独立的状态(互不干扰)。

五、进阶封装:useEventListener

假如我们还想监听滚动、键盘等事件,可以进一步抽象出一个事件监听函数 👇

// event.js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}

接着 useMouse 就能进一步简化:

// mouse.js
import { ref } from 'vue'
import { useEventListener } from './event'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  useEventListener(window, 'mousemove', (e) => {
    x.value = e.pageX
    y.value = e.pageY
  })

  return { x, y }
}

💡 这样我们不仅复用了逻辑,还建立了逻辑的“组合关系” ——

组合式函数可以嵌套调用另一个组合式函数


六、异步场景:useFetch 示例

除了事件逻辑,我们常常需要封装“异步请求逻辑”,比如:

// fetch.js
import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  fetch(url)
    .then((res) => res.json())
    .then((json) => (data.value = json))
    .catch((err) => (error.value = err))

  return { data, error }
}

使用方式:

<script setup>
import { useFetch } from './fetch.js'

const { data, error } = useFetch('https://api.example.com/posts')
</script>

<template>
  <div v-if="error">❌ 出错:{{ error.message }}</div>
  <div v-else-if="data">✅ 数据:{{ data }}</div>
  <div v-else>⏳ 加载中...</div>
</template>

七、响应式参数:动态请求的 useFetch

上面 useFetch 只会执行一次,

但如果我们希望在 URL 改变时自动重新请求呢?

就可以用 watchEffect() + toValue():

// fetch.js
import { ref, watchEffect, toValue } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  const fetchData = () => {
    data.value = null
    error.value = null

    fetch(toValue(url)) // 兼容 ref / getter / 普通字符串
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) => (error.value = err))
  }

  watchEffect(() => {
    fetchData() // url 改变时会自动重新执行
  })

  return { data, error }
}

使用示例:

<script setup>
import { ref } from 'vue'
import { useFetch } from './fetch.js'

const postId = ref(1)
const { data, error } = useFetch(() => `/api/posts/${postId.value}`)

// 模拟切换文章
function nextPost() {
  postId.value++
}
</script>

<template>
  <button @click="nextPost">下一篇</button>
  <div v-if="error">❌ 出错:{{ error.message }}</div>
  <div v-else-if="data">📰 文章:{{ data.title }}</div>
  <div v-else>⏳ 加载中...</div>
</template>

✅ 这就让你的 useFetch 成为了真正“响应式的请求函数”。


八、组合式函数的使用规范

项目 推荐做法 原因
🧩 命名 useXxx() 一目了然,符合惯例
📦 返回值 返回多个 ref,不要直接返回 reactive 对象 防止解构时丢失响应性
🔁 生命周期 必须在 Vue 需要绑定当前组件实例
⚙️ 参数 建议使用 toValue() 规范化输入 兼容 ref、getter、普通值
🧹 清理 要在 onUnmounted() 清理副作用 避免内存泄漏

九、与其他模式的比较

模式 优点 缺点
Mixins 逻辑复用简单 来源不清晰、命名冲突、隐式依赖
无渲染组件 (Renderless) 可复用逻辑 + UI 会额外创建组件实例,性能差
组合式函数 (Composables) 无实例开销、逻辑清晰、依赖显式 不直接提供模板复用

✅ 结论:

纯逻辑复用 → 用组合式函数

逻辑 + UI 复用 → 用无渲染组件


十、总结

概念 说明
组合式函数 利用 Vue 组合式 API 封装可复用逻辑的函数
核心特性 可使用 ref / reactive / 生命周期钩子 / watch
优势 灵活组合、逻辑清晰、性能优秀、类型友好
常见应用 请求封装、事件监听、滚动追踪、权限控制、表单管理等
开发建议 命名统一、输入规范化、注意生命周期上下文

✨ 最后

Composables 就像是 Vue 世界里的「逻辑积木」——

你可以自由拼接、拆解、组合它们,构建出任何复杂的交互逻辑。

如果你曾觉得逻辑在组件里越堆越乱,

那是时候开始用 组合式函数 让代码“呼吸”了。

面试是一门学问

前言

作为一位资深架构师,我面试过形形色色的候选人,见过各式各样的简历,听过各式各样的自我介绍,坦白说,大多数人根本不会面试。

简历筛选环节,首先hr会根据业务的情况,筛选掉百分之80的简历,然后简历到我这里,我一般会在剩下的百分之20中,再筛选掉一半人。

在面试环节,根据岗位情况,面试到合适的候选人,大概来面试的同学中,有三分之一,是达标的。

面试通过后,大概就是谈薪环节,一般而言,只要薪资预期相差不大,这个节点应该能够把岗位确认下来。

在每一个环节,都有一些需要注意的要点,如果能够提前把一些雷点避开,我们面试入职的成功率,会很高很多,那么,我们求职者在面试环节,到底会有哪些需要避雷的点呢?

1、简历一定要写好,它是敲门砖

那么,什么样的简历,是一个好的简历呢?

1、求职目标明确,并且符合市场的需求。

当下的市场,是一个存量博弈的市场,这个阶段的企业,首要的目标是赚钱、存活下来。所以企业对人的要求,更多的体现到能真正解决问题,收拾一些烂摊子的候选人。或者是在技术上,能够有独当一面的能力,给出的需求,能给人家切切实实的做出来。

与之相对应的,就是整个招聘市场,我们会发现,初级岗位几乎断崖式的减少,甚至很多公司,已经关闭的初级岗位的入职渠道。并且随着AI时代的到来,这个现象,变动的格外剧烈。

所以,当下的场景下,我们的简历,不管是谁,在降低薪资预期的同时,应该实实在在的锻炼自己的能力,让自己真的能独当一面。并且把这种能力体现到简历上。

2、简历要体现成长性

对于刚工作三年以内的同学,他的简历的目标,应该体现比较高的发展潜力上。比如,具备扎实的前端基础知识,从前端新手,到一个成熟的前端开发者的转变,甚至在一个公司中,慢慢有了主导业务开发的能力,在态度上,要具有极客精神,积极好学的一面。

对于工作3到8年的同学,他的简历的目标,应该体现到技术/业务的领导力上,如果做技术,在简历的履历中,尽可能的看到从前端开发工程师到高级前端开发,甚至到技术专家的转变上。如果做业务,简历上应该能够看到,从前端开发,到高级前端开发,再到前端leader的角色转变。我们简历中的技术内容,也应该符合这个角色的变化。

对于年纪更大的同学,他的简历目标,应该体现到成熟的管理能力/技术上的架构能力,当然在现实中,两方面其实都挺难的,但是我们每一个技术人,都有一些自己的特质,比如你技术还在及格线以上,那做一做高级开发,也是可以的,如果你的沟通协调能力不错,你还可以做一做前端基层管理。

3、简历中的内容,要具有深刻的价值

我们的工作履历中,只要有工作,一定能提炼出有价值的东西。

比如,单独完成一个项目/一个模块,你在其中提升了哪些能力?你的技能体系,到底能完成哪些事情。团队因为有了你,到底有哪些改变?

比如,你在简历中,如果写了,组件库建设经验,那你就真的,得在这方面,有比较深入的做了一些东西,解决了一些问题。

比如,你在简历中,写了精通xxx,那就是真的,你在这个方向上,做了比较深入的研究,至少在面试官问你的时候,你不至于出错。

2、以积极的心态,自信的状态去面对每一场面试

在这么多年的面试中,那些真正淡定从容的面试者,属于极少数人,但是每一个,都会给我留下不错的印象。

积极的心态,自信的状态,代表着你是一个成熟的个体,企业用人做事,一定会选择一个成熟的个体,帮它完成它的业务。而成熟的个体,也代表着,更高效的沟通,勇于承担责任等等一些优良品质。

社会是一个竞争关系,消极/软弱/不自信,并不能让一个人在竞争中,多一些优势,反而在绝大多数场合,增加了自己的劣势。

而我之所以一直强调这个点,就是因为大多数人,真的存在这样的问题。

当然,如果作为自身,真的很难克服这个点,那么就好好历练,争取每一次面试,都比上一次更好一点。

3、沟通要有节奏感

大多数时候,程序员的一大缺点,就是自说自话。

什么是一个好的沟通?好的沟通,第一步一定是倾听,听明白别人在说什么,然后再回答问题。

如果你的回答,和人家面试官问的东西,牛头不对马嘴,那么首先可以判断的是,这个人一定是一个沟通比较困难的人,企业用人做事,沟通是效率的前提,沟通都那么费劲,合作起来怎么可能顺畅。

自说自话的回答的另一个巨大弊端,就是让面试官觉得自己不被尊重,这会让他觉得,他说了半天话,别人压根就没关注他说的。

良性的沟通应该是,哪怕在没听懂的时候,也愿意向人家面试官,再次尝试沟通理解。比如你可以这么说:“抱歉面试官,您可以再详细的描述一下吗?”

人往往对自己处于同频的人,产生好感,有节奏感的沟通,就能快速让双方进入那种同频的交流状态。

我的初衷

作为一位架构师,我深知我们程序员这个群体,在面试中有各种各样的问题。

很多时候,我们讲前端面试,讲技术的有很多,但是讲针对面试的反思的人,很少。

后续,我依然会针对面试的这个场景,谈一谈我们可能会遇到的各种问题,期待与大家一起向上成长。

Vue 3 中 Provide / Inject 在异步时不起作用原因分析(二)?

本文是继续上一篇文章《Vue 3 中 Provide / Inject 在异步时不起作用原因分析(一)》

在线查看示例(需要科学上网)

示例源码下载地址:分析demo

🧩 一、核心原理(简单讲人话)

在 Vue3 中:

  • provide 是父组件提供一个依赖值

  • inject 是子组件接收这个依赖值

  • 默认情况下,provide 提供的是一个「普通的引用值」,而不是响应式的。

👉 这意味着:

如果你在父组件中 later(异步)修改了 provide 的值,而这个值不是响应式对象,那么子组件不会自动更新。


🧠 二、最简单示例:静态 provide(不响应)

<!-- App.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <button @click="changeName">修改名字</button>
    <Child />
  </div>
</template>

<script setup>
import { provide } from 'vue'
import Child from './Child.vue'

let username = '小明'

// 向子组件提供 username
provide('username', username)

function changeName() {
  username = '小红'
  console.log('父组件修改了 username =', username)
}
</script>

<!-- Child.vue -->
<template>
  <div>
    <h3>子组件</h3>
    <p>用户名:{{ username }}</p>
  </div>
</template>

<script setup>
import { inject } from 'vue'
const username = inject('username')
</script>

🧩 运行结果:

  • 初始显示:用户名:小明

  • 点击“修改名字”按钮后,子组件界面不会更新

📖 原因:

因为 provide('username', username) 提供的是普通字符串,不具备响应式特性。


✅ 三、扩展版:让 provide 变成响应式的(推荐写法)

要让子组件能「自动响应父组件异步变化」,只需要用 ref 或 reactive 包装即可。

<!-- App.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <button @click="changeName">异步修改名字(2秒后)</button>
    <Child />
  </div>
</template>

<script setup>
import { ref, provide } from 'vue'
import Child from './Child.vue'

const username = ref('小明')

// ✅ 提供响应式的值
provide('username', username)

function changeName() {
  setTimeout(() => {
    username.value = '小红'
    console.log('父组件异步修改 username = 小红')
  }, 2000)
}
</script>

<!-- Child.vue -->
<template>
  <div>
    <h3>子组件</h3>
    <p>用户名:{{ username }}</p>
  </div>
</template>

<script setup>
import { inject } from 'vue'
const username = inject('username') // 自动响应
</script>

🧩 运行结果:

  • 初始显示:用户名:小明

  • 点击按钮后 2 秒 → 自动更新为:用户名:小红

✅ 因为我们注入的是 ref,Vue3 会自动处理 .value 的响应式绑定。


❌ 四、错误示例:异步 provide 失效的情况(常见坑)

有时新手会这么写:

<!-- App.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <button @click="loadData">异步加载 provide 值</button>
    <Child />
  </div>
</template>

<script setup>
import { provide, ref } from 'vue'
import Child from './Child.vue'

let user = null

function loadData() {
  setTimeout(() => {
    user = { name: '异步用户' }
    provide('user', user) // ❌ 错误!在 setup 外部、异步中调用 provide 无效
    console.log('异步 provide 完成')
  }, 2000)
}

provide('user', user)
</script>

<!-- Child.vue -->
<template>
  <div>
    <p>子组件:{{ user }}</p>
  </div>
</template>

<script setup>
import { inject } from 'vue'
const user = inject('user')
</script>

🧩 现象:

  • 初始显示:子组件:null

  • 点击“异步加载”后,依然不变!

📖 原因:

provide 只能在组件 setup() 执行时建立依赖关系,

异步调用 provide() 没有效果,Vue 根本不会重新建立依赖注入。


🔍 五、正确的异步写法总结

场景 错误示例 正确写法
父组件 setup 后再异步修改 普通变量 ✅ 使用 ref 或 reactive
异步中重新调用 provide() ❌ 无效 ✅ 一次 provide 响应式引用即可
想实时共享对象状态 ❌ 普通对象 ✅ 用 reactive() 或 Pinia

🧱 六、总结

类型 响应式 子组件会更新? 推荐
provide('a', 普通变量) ❌ 否 ❌ 否
provide('a', ref()) ✅ 是 ✅ 是
provide('a', reactive()) ✅ 是 ✅ 是
异步重新调用 provide() ❌ 无效 ❌ 否

Vue 3 中 Provide / Inject 在异步时不起作用原因分析(一)?


一、先搞清楚:Provide / Inject 是什么机制

provide 和 inject 是 Vue 组件之间 祖孙通信的一种机制

它允许上层组件提供数据,而下层组件直接获取,不需要层层 props 传递。

简单关系图:

App.vue (provide)
   └── ChildA.vue
         └── ChildB.vue (inject)

App 通过 provide 提供,ChildB 直接拿到。

在 Vue 3 中:

// 父组件
import { provide } from 'vue'

setup() {
  provide('theme', 'dark')
}
// 孙组件
import { inject } from 'vue'

setup() {
  const theme = inject('theme')
  console.log(theme) // 'dark'
}

这本质上是 Vue 在「组件初始化时」建立的一种依赖注入映射关系(依赖树)


二、误区:为什么“异步”时会失效?

很多人说“在异步组件里 inject 不到值”,其实问题出在「加载时机」上。

❌ 错误理解:

以为 inject 是“运行时全局取值”,随时都能拿到。

✅ 实际原理:

inject() 的查找是在 组件创建阶段(setup 执行时) 完成的。

也就是说:

只有当父组件已经被挂载并执行了 provide() 后,子组件在 setup 时才能拿到。

如果异步加载的子组件在 provide 之前被初始化,或者在懒加载时「上下文丢失」,那它当然拿不到值。


三、可复现测试案例(你可以直接复制运行)

我们写一个最常见的「异步子组件注入」示例。

你可以用 Vite 新建项目,然后建这三个文件:


🟢App.vue(父组件)

<template>
  <div>
    <h2>父组件</h2>
    <p>当前主题:{{ theme }}</p>
    <button @click="loadAsync">加载异步子组件</button>

    <!-- 当点击后才加载 -->
    <component :is="childComp" />
  </div>
</template>

<script setup>
import { ref, provide, defineAsyncComponent } from 'vue'

// 1️⃣ 提供一个响应式值
const theme = ref('🌙 暗黑模式')
provide('theme', theme)

// 2️⃣ 模拟异步组件加载
const childComp = ref(null)
function loadAsync() {
  // 模拟异步加载组件(1 秒后返回)
  const AsyncChild = defineAsyncComponent(() =>
    new Promise(resolve => {
      setTimeout(() => resolve(import('./Child.vue')), 1000)
    })
  )
  childComp.value = AsyncChild
}
</script>

🟡Child.vue(中间组件)

<template>
  <div class="child">
    <h3>中间组件</h3>
    <GrandChild />
  </div>
</template>

<script setup>
import GrandChild from './GrandChild.vue'
</script>

<style scoped>
.child {
  border: 1px solid #aaa;
  margin: 8px;
  padding: 8px;
}
</style>

🔵GrandChild.vue(孙组件)

<template>
  <div class="grand">
    <h4>孙组件</h4>
    <p>从 provide 注入的主题:{{ theme }}</p>
  </div>
</template>

<script setup>
import { inject } from 'vue'

// 1️⃣ 注入父级 provide 的数据
const theme = inject('theme', '默认主题')

// 2️⃣ 打印验证
console.log('孙组件注入的 theme 值是:', theme)
</script>

<style scoped>
.grand {
  border: 1px dashed #666;
  margin-top: 8px;
  padding: 6px;
}
</style>

✅ 运行结果验证:

1️⃣ 页面初始只显示父组件。

2️⃣ 点击「加载异步子组件」。

3️⃣ 一秒后加载完成,控制台输出:

孙组件注入的 theme 值是:RefImpl {value: '🌙 暗黑模式'}

页面上显示:

从 provide 注入的主题:🌙 暗黑模式

👉 说明:即使是 异步组件,也能正确拿到 provide 的值。


四、那为什么有时真的“不起作用”?

有三种常见原因:

原因 说明 解决方案
1️⃣ 在 setup 外使用 inject() Vue 只能在组件初始化(setup 阶段)内建立依赖 一定要在 setup() 中调用
2️⃣ 异步组件创建时父组件上下文丢失 如果异步加载组件时没有挂在已有的上下文中(比如 createApp 动态 mount) 保证异步组件是作为「现有组件树」的子节点被渲染
3️⃣ SSR 场景中 hydration 时机问题 如果在服务器端渲染中,provide 未在客户端同步恢复 SSR 需保证 provide/inject 在同一上下文实例中执行

五、底层原理小科普(可选理解)

Vue 内部维护了一棵「依赖注入树」,

每个组件实例在初始化时会记录自己的 provides 对象:

instance.provides = Object.create(parent.provides)

所以当 inject('theme') 时,它会:

  1. 向上查找父组件的 provides;

  2. 找到对应 key;

  3. 返回对应的值(引用)。

这就是为什么:

  • 父子必须在「同一组件树上下文」中;
  • 异步不会破坏注入关系(除非脱离这棵树)。

✅ 总结重点

概念 说明
Provide / Inject 用于祖孙通信的依赖注入机制
异步组件能否注入? ✅ 能,只要仍在同一组件树中
什么时候会失效? 父未先 provide、或异步 mount 独立实例
验证方法 使用 defineAsyncComponent 懒加载组件
推荐做法 始终在 setup 内使用 provide/inject

Vue 异步组件(defineAsyncComponent)全指南:写给新手的小白实战笔记

在现代前端应用中,性能优化几乎是每个开发者都要面对的课题。

尤其是使用 Vue 构建大型单页应用(SPA)时,首屏加载慢、包体积大 成了常见的痛点。

这时,“异步组件”就登场了。

它能让你把页面拆成小块按需加载,只在用户真正需要时才下载对应的模块,显著减少首屏压力。

这篇文章是写给 刚入门 Vue 3 的开发者 的异步组件实战指南,

我会用简单的语言、可运行的代码和图景化的思维带你彻底搞懂——

defineAsyncComponent 到底做了什么、怎么用、有哪些坑。


一、为什么需要异步组件

🚀 核心动机:提升首屏速度,减少无用资源加载。

想象一个后台系统,首屏只展示“仪表盘”,但你的 bundle 里却打包了“用户管理”、“统计分析”、“设置中心”……

即使用户一天都没点进去,这些模块也会白白加载。

异步组件正是用来解决这种浪费的:

  • 不会被打进主包
  • 只有在组件首次渲染时,才会异步加载真实实现;
  • 这就是所谓的 按需加载 (lazy load)代码分割 (code-splitting)

二、最简单的异步加载:

defineAsyncComponent+import()

import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() =>
  import('./components/MyComponent.vue')
)

使用方式完全与普通组件一致:

<template>
  <AsyncComp some-prop="Hello Vue!" />
</template>

解释一下背后的机制:

  • import() 会返回一个 Promise;

  • 打包工具(Vite / Webpack)会自动把它拆成独立的 chunk 文件

  • defineAsyncComponent() 会创建一个“外壳组件”,在内部完成加载逻辑;

  • 一旦加载完成,它会自动渲染内部真正的 MyComponent.vue;

  • 所有 props、插槽、事件 都会被自动透传。

简单来说,它是 Vue 帮你封装好的“懒加载包装器”。


三、加载中 & 加载失败状态:更友好的配置写法

网络总是有延迟或失败的时候,Vue 官方提供了更完善的配置:

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Foo.vue'),
  loadingComponent: LoadingComponent, // 加载中占位
  delay: 200,                          // 多少 ms 后显示 loading
  errorComponent: ErrorComponent,      // 失败时的提示
  timeout: 3000                        // 超时视为失败
})

🧠 要点:

  • delay:默认 200ms,如果加载太快就不显示 loading,防止闪烁;
  • timeout:超过指定时间自动触发错误;
  • loadingComponent / errorComponent 都是普通组件,可以是骨架屏或重试按钮;
  • Vue 会自动处理 Promise 的状态变化。

四、SSR 场景下的新玩法:Hydration 策略(Vue 3.5+)

在服务器端渲染(SSR)场景下,HTML 首屏已经输出,但 JS 模块还没激活。

Vue 3.5 开始支持为异步组件设置「延迟激活策略」:

import { defineAsyncComponent, hydrateOnVisible } from 'vue'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: hydrateOnVisible({ rootMargin: '100px' })
})

这意味着:

  • 组件只在滚动到可视区时才激活;

  • SSR 首屏照常渲染,但 hydration(激活)被延后;

  • 从而减少初始脚本执行量,提高 TTI(可交互时间)。

其他常见策略:

策略函数 行为
hydrateOnIdle() 浏览器空闲时激活
hydrateOnVisible() 元素进入视口时激活
hydrateOnMediaQuery() 媒体查询匹配时激活
hydrateOnInteraction('click') 用户交互后激活

你甚至可以自定义策略,在合适时机调用 hydrate() 完成手动激活。


五、搭配

使用,构建优雅的异步界面

是 Vue 专门为异步组件设计的辅助标签,它可以集中控制加载状态与回退界面。

<Suspense>
  <template #default>
    <AsyncComp />
  </template>
  <template #fallback>
    <div>正在努力加载中...</div>
  </template>
</Suspense>

的工作原理:

  • 会等待内部所有异步依赖(包括 defineAsyncComponent)加载完成;
  • 如果有 delay 或网络延迟,会自动显示 fallback 内容;
  • 当所有异步都 resolve 后,才一次性切换到真实内容;
  • 适合并行加载多个异步子组件时使用。

六、实战建议与最佳实践

1. 优先按路由懒加载:

const routes = [
  { path: '/admin', component: () => import('./views/Admin.vue') }
]

这能最大化地减少首包体积。

2. 小组件不建议懒加载:

懒加载有 HTTP 开销,过度拆包反而拖慢渲染。

3. 善用 loadingComponent 做骨架屏:

用灰色框或占位元素代替 spinner,更自然。

4. 设置合理 delay / timeout:

避免闪烁,也要能及时处理网络异常。

5. 支持重试:

function retryImport(path, retries = 3, interval = 500) {
  return new Promise((resolve, reject) => {
    const attempt = () => {
      import(path).then(resolve).catch(err => {
        if (retries-- <= 0) reject(err)
        else setTimeout(attempt, interval)
      })
    }
    attempt()
  })
}

const AsyncComp = defineAsyncComponent(() => retryImport('./Foo.vue', 2))

6. SSR 优化:

配合 hydrateOnVisible / hydrateOnIdle 让页面更快可交互。


七、常见陷阱 Q&A

Q1:defineAsyncComponent 会影响 props 或 slot 吗?

👉 不会,Vue 内部会自动透传所有 props / slot。

Q2:可以全局注册异步组件吗?

👉 可以:

app.component('MyComp', defineAsyncComponent(() => import('./MyComp.vue')))

Q3:delay=0 会怎样?

👉 loading 组件会立刻显示,建议保留短延迟防闪烁。

Q4:如何在 errorComponent 里实现重试?

👉 通过 emit 通知父组件重新渲染异步组件实例即可。


八、完整实战示例

<script setup>
import { defineAsyncComponent } from 'vue'
import LoadingSkeleton from './LoadingSkeleton.vue'
import ErrorBox from './ErrorBox.vue'

const AsyncWidget = defineAsyncComponent({
  loader: () => import('./HeavyWidget.vue'),
  loadingComponent: LoadingSkeleton,
  errorComponent: ErrorBox,
  delay: 200,
  timeout: 5000
})
</script>

<template>
  <section class="dashboard">
    <h2>📊 仪表盘</h2>
    <AsyncWidget />
  </section>
</template>

📌 ErrorBox 可加上「重试」按钮,点击后 emit 事件让父组件重新创建 AsyncWidget 实例即可。


九、总结回顾

要点 说明
defineAsyncComponent() 创建懒加载包装组件
import() 触发动态分包
loadingComponent / errorComponent 优化加载与失败体验
SSR Hydration 策略 控制何时激活异步组件
统一处理异步加载状态
实战建议 只懒加载页面级或大型组件,合理延迟与重试

VChart 官网上线 智能助手与分享功能

1 🚀 VChart 官网全新分享功能上线,让你的图表“活”起来!

还在为如何分享和展示你的数据可视化作品而烦恼吗?现在,VChart 为您带来一系列强大的分享功能,让您的图表以前所未有的方式“活”起来,轻松嵌入任何应用场景!

该功能有助于您持久化图表案例,快速创建demo与他人进行分享讨论,也可以用来嵌入在其他web 应用中。


1.1 ✨ 分享功能入口

  1. 进入visactor.io/vchartvisactor.com/vchart)官网
  2. Playground页面任一图表Demo 页面
  3. 点击右上角的分享按钮

1.2 ✨ 核心功能说明

全新的分享功能全面支持 原生 JavaScript、 React 和 OpenInula 三大主流环境,确保您的图表Spec在任何环境都能完美呈现!

1.2.1 分享为 Playground:实时互动,即时调试

一键将您的图表配置分享为一个独立的 Playground 链接。您的同事或合作伙伴无需任何本地配置,即可在浏览器中直接查看、修改和调试图表,更可以用来和官方反馈问题,github 提issue时使用。

  • 跨环境支持:在原生、React 和 OpenInula 环境中无缝切换。
  • 配置继承:自动继承您当前使用的 VChart 版本和主题,保证环境一致性。
  • 效果演示:

2. 分享为 iframe:轻松嵌入,无缝集成

需要将图表嵌入到您的网站、博客或内部系统中?现在,只需复制一行 iframe 代码,即可将动态图表无缝集成到任何网页中。

  • 全环境覆盖:同样支持原生、React 和 OpenInula。
  • 版本与主题:自动同步 VChart 版本和主题,与您的应用风格保持一致。
  • 效果演示:
3. 分享为图片:一键截图,快速分享

需要将图表用于报告、演示文稿或社交媒体?全新的“分享为图片”功能,让您一键生成高清图表图片,随时随地分享您的数据洞察。

  • 简单快捷:在原生环境下,一键生成并下载图表图片。
  • 效果演示:

2 🚀 AI 智能助手,让图表编辑更“智能”

VChart 中的 AI 智能助手为图表编辑带来了更多便利与智能体验。一方面,其搜索框集成了 AI 助手,能帮助用户快速查找所需信息,大大提高信息检索效率,减少搜索时间成本 。另一方面,针对 AI 编辑功能,进行了 UI 优化,采用抽屉式交互,这种改进为用户营造了更流畅、更沉浸的编辑环境,减少外界干扰,让用户能够更专注于图表编辑。


2.1 ✨ 搜索框新增 AI 助手

VChart 的搜索框现已集成强大的 AI 助手,助您快速查找所需信息。

通过搜索框打开ai助手

输入问题获取答案:

2.2 ✨ AI 编辑功能 UI 优化

我们对 AI 编辑功能进行了 UI 优化,现已改为抽屉式交互,为您提供更流畅、更沉浸的编辑体验。

3 🚀 立即体验

访问 VChart 官网,立即体验吧!

这只是我们通过 AI 来提升用户体验的一小步,后面还有更多的大的动作,欢迎关注我们,进行交流和建议!

欢迎交流

最后,我们诚挚的欢迎所有对数据可视化感兴趣的朋友参与进来,参与 VisActor 的开源建设:

VChartVChart 官网VChart Github(欢迎 Star)

VTableVTable 官网VTable Github(欢迎 Star)

VMindVMind 官网VMind Github(欢迎 Star)

官方网站:www.visactor.io/www.viactor.com

Discord:discord.gg/3wPyxVyH6m

飞书群(外网):打开链接扫码

微信公众号:打开链接扫码

github:github.com/VisActor

【uniapp】体验优化:开源工具集 uni-toolkit 发布

背景

最近在做一些 uniapp 小程序 相关的 体积优化功能补充 工作,写了几个插件觉得太分散,不好梳理和归类,于是就创建一个 github 组织 来整理我的一些工具和插件,一方面方便我的日常工作,另一方面可以搜集来自社区的想法或者建议,可以首先考虑加到 uniapp 官方仓库 中,不方便加的再通过插件等形式实现。

插件列表

目前该仓库下已经有了三个插件,如下所示

功能

名称 描述 地址
@uni_toolkit/vite-plugin-component-config 一个用于处理 Vue 文件中的 <component-config> 标签的 vite插件,将配置提取并合并到对应的 JSON 文件 中,弥补组件无法自定义 JSON 配置 的缺陷 vite-plugin-component-config
@uni_toolkit/webpack-plugin-component-config 一个用于处理 Vue 文件中的 <component-config> 标签的 webpack插件,将配置提取并合并到对应的 小程序 JSON 文件 中,弥补组件无法自定义 JSON 配置 的缺陷 webpack-plugin-component-config

性能

名称 描述 地址
@uni_toolkit/unplugin-compress-json 一个用于压缩 JSON 文件的 unplugin 插件,支持 Vite 和 Webpack。自动压缩 JSON 文件 ,减小文件体积。 unplugin-compress-json

结语

如果这个库的插件帮助到了你,可以点个 star✨ 鼓励一下。

如果你有什么好的想法或者建议,欢迎在 github.com/uni-toolkit… 提 issue 或者 pr

尤雨溪强烈推荐的这个库你一定要知道 ⚡️⚡️⚡️

前言

今天带大家看看尤雨溪在推特墙裂推荐的 Nitro v3 这个库!

尤雨溪推特

往期精彩推荐

正文

Nitro 是一个全栈框架,兼容任何运行时。

Nitro v3 可以通过 Vite 插件形式集成,扩展 dev 服务器为生产服务器,支持 vite build 统一输出前后端代码。

快速启动与渐进集成

Nitro 支持 npx create-nitro-app 一键创建项目,或在现有 Vite 项目中安装 nitro 包并添加插件:

import { defineConfig } from "vite";
import { nitro } from "nitro/vite";

export default defineConfig({
  plugins: [nitro()]
});

这样配置后 Vite 的 HMR 无缝延伸至后端路由和 API,可以在单一项目中处理前后端逻辑,无需切换工具链。

路由与生命周期优化

Nitro 的文件系统路由(routes/目录)与 Vite 的模块解析结合,自动映射API端点,支持动态参数、方法特定(如.get.ts)和中间件。

routes/
  api/
    test.ts      <-- /api/test
  hello.get.ts   <-- /hello (GET only)
  hello.post.ts  <-- /hello (POST only)

文件中只需要通过函数暴露对象即可:

import { defineHandler } from "nitro/h3";

export default defineHandler(() => {
  return { hello: "API" };
});

生命周期从路由规则到全局中间件,再到自定义服务器入口和渲染器,确保请求高效处理。

渲染器与SSR支持

Nitro 的渲染器(renderer)捕捉未匹配路由,支持自动 index.html 或自定义模板,使用 Rendu 预处理器注入动态内容。自定义渲染器通过defineRenderHandler生成响应!

插件与扩展性

Nitro 插件(plugins/目录)在启动时运行,支持钩子(如closeerrorrequest)扩展行为。

// nitro.config.ts
export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook("close", async () => {
    // Will run when nitro is being closed
  });
})

支持 hook 时机如下:

"close", () => {}
"error", (error, { event? }) => {}
"render:response", (response, { event }) => {}
"request", (event) => {}
"beforeResponse", (event, { body }) => {}
"afterResponse", (event, { body }) => {}

最后

Nitro v3 与 Vite 搭配,提供高效的全栈方案,从快速启动到路由渲染,再到插件扩展,可以全方位的优化工作流!

今天的分享就这些了,感谢大家的阅读,如果文章中存在错误的地方欢迎指正!

往期精彩推荐

uniapp+vue3+vite+ts+xr-frame实现ar+vr渲染踩坑记

大家好,我的开源项目PakePlus可以将网页/Vue/React项目打包为桌面/手机应用并且小于5M只需几分钟,官网地址:pakeplus.com

这个坑真的好深,如果有产品经理给你提这个需求,能把他打一顿就打一顿吧,反正这玩意真的挺费事的,而且这玩意在实际应用中没有太大的用处,只会拉垮小程序的性能,我个人认为是画蛇添足,但是又能怎么办呢,现在还没有实现财富自由

如果像参考的,可以看我的开源地址:github.com/Sjj1024/uni…

注意:如果只是安装依赖后,还不一定能正常使用,因为需要修改一下uniapp底层的编译代码

就是这个路径下的customElements这个数组里面,要添加上你的xr组件!!!!!很重要

创建XR组件和配置

在pages同级目录创建一个wxcomponents目录,里面创建微信原生的xr组件:

然后在pages.json中将这些组件添加到你要展示的页面中:

这里的路径要注意一定是:../../开头的,因为这个组件最终编译后,要在微信小程序中引入的,而不是根据pages.json文件的路径来说的

还要在manifest.json中配置上usingComponents和lazyCodeLoading为true:

还有特别注意的是,这些xr组件中的index.json里面要配置:

添加展示页面和渲染

因为渲染的时候,需要用到容器的宽高和渲染的宽高,所以需要先在全局App.vue中获取到手机屏幕尺寸信息:

获取到这些信息后,存储到本地缓存中,然后在别的组建中就可以使用了,这里比较重要的就是:

创建一个vue页面来展示并渲染这些场景,并引入刚才获取到的屏幕宽高:

这里拿到宽高之后,要给到xr-ar-tracker这个组件,但是需要注意:一定要先修改uniapp底层的编译代码,修改的文件/node_modules/@dcloudio/uni-mp-weixin/dist/uni.compiler.js里面的customElements变量:

然后你通过属性设置的宽高才会生效!这是uniapp的回答,所以一定要注意!

整体的vue组件内容如下:

<template>
    <view class="content">
        <xr-ar-tracker
            :width="renderWidth"
            :height="renderHeight"
            :style="
                'width:' + screenWidth + 'px;height:' + screenHeight + 'px;'
            "
        >
        </xr-ar-tracker>
    </view>
</template>

<script>
import {
    screenWidth,
    screenHeight,
    renderWidth,
    renderHeight,
    windowWidth,
    windowHeight,
} from '@/utils/comm'

export default {
    data() {
        return {
            width: windowWidth,
            height: windowHeight,
            renderWidth: renderWidth,
            renderHeight: renderHeight,
            screenWidth: screenWidth,
            screenHeight: screenHeight,
        }
    },
    onLoad() {
        console.log('windowWidth', windowWidth)
        console.log('windowHeight', windowHeight)
        console.log('renderWidth', renderWidth)
        console.log('renderHeight', renderHeight)
    },
    methods: {},
}
</script>

<style>
.content {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
}
</style>

使用Vue3的setup也可以

换成使用vue3的setup函数也可以正常使用,代码简洁了不少:

<template>
    <xr-ar-tracker
        :width="renderWidth"
        :height="renderHeight"
        :style="'width:' + screenWidth + 'px;height:' + screenHeight + 'px;'"
    >
    </xr-ar-tracker>
</template>

<script setup lang="ts">
import {
    screenWidth,
    screenHeight,
    renderWidth,
    renderHeight,
} from '@/utils/comm'
</script>

<style>
.content {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
}
</style>

其他的组件也可以正常使用,这应该算是第一个讲解怎么在uniapp的vue3种正常使用xr-frame的教程了,希望能帮到大家少走弯路

大家好,我是1024小神,技术群 / 私活群 / 股票群 或 交朋友 都可以私信我。 如果你觉得本文有用,一键三连 (点赞、评论、关注),就是对我最大的支持~
❌