普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月22日掘金 前端

Three.js 入门:30行代码画出你的第一条3D线条

作者 烛阴
2026年1月22日 22:33

核心概念:3个必备元素

在 Three.js 中,想要渲染任何东西,你需要理解3个核心概念:

  1. 场景 (Scene) - 就像一个舞台,所有物体都放在这里
  2. 相机 (Camera) - 就像你的眼睛,决定从哪个角度看舞台
  3. 渲染器 (Renderer) - 把场景和相机的内容画到屏幕上

完整代码

import * as THREE from 'three';

// 1️⃣ 创建场景 - 所有物体的容器
const scene = new THREE.Scene();

// 2️⃣ 创建相机 - 决定我们从哪里看
const camera = new THREE.PerspectiveCamera(
  75,                           // 视野角度
  innerWidth / innerHeight,     // 宽高比
  0.1,                          // 近裁剪面
  1000                          // 远裁剪面
);
camera.position.z = 5;          // 把相机往后移,才能看到原点的物体

// 3️⃣ 创建渲染器 - 把3D内容画到网页上
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

// 4️⃣ 画线!
// 定义线条经过的点
const points = [
  new THREE.Vector3(-2, 0, 0),   // 左边的点
  new THREE.Vector3(0, 2, 0),    // 顶部的点
  new THREE.Vector3(2, 0, 0)     // 右边的点
];

// 用这些点创建几何体
const geometry = new THREE.BufferGeometry().setFromPoints(points);

// 创建线条材质(绿色)
const material = new THREE.LineBasicMaterial({ color: 0x00ff00 });

// 组合几何体和材质,创建线条对象
const line = new THREE.Line(geometry, material);

// 把线条添加到场景中
scene.add(line);

// 5️⃣ 渲染!
renderer.render(scene, camera);

代码解析

画线三步曲

步骤 代码 说明
1 BufferGeometry().setFromPoints(points) 定义线条的形状(经过哪些点)
2 LineBasicMaterial({ color }) 定义线条的外观(颜色)
3 new THREE.Line(geometry, material) 把形状和外观组合成线条对象

📂 核心代码与完整示例:      my-three-app

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货

前端性能优化之首屏时间采集篇

2026年1月22日 22:01

所谓首屏,就是用户看到当前页面的第一屏,首屏时间就是第一屏被完全加载出来的时间点。

比如一个电商网站,首屏就包括导航栏、搜索框、商品头图等内容。那么,如何采集用户的首屏时间呢?

你可能会说,我直接用 Chrome DevTools 看一下就行了。

1、容易误导开发者的 Chrome DevTools

每次拿 Chrome DevTools 一看,好像自家网站的性能杠杠的,页面加载嘎嘎快,但结果却是用户反馈进入网站很卡,究其原因,这是由 Chrome DevTools 的局限性导致的:

  • 网络环境差异:使用 Chrome DevTools 是内网访问,往往网络环境很好,而用户的网络环境就很复杂,在偏远地区或者电梯、地铁等弱网环境体验会更差。
  • 访问方式不同:调试工具和真机有一定的差距。
  • 访问设备有限:测试机观察到的首屏时间机型有限,而真实用户的手机机型五花八门。

所以通过 Chrome DevTools 采集到的数据是不够准确的。所以我们需要通过添加相关代码进行采集,然后把采集到的数据上报到服务器中,这样就能获取大量用户的首屏时间数据。

采集方式一般有两种,手动采集自动化采集

2、手动采集

手动采集一般是通过埋点的方式来实现:

  • 比如是电商网站首页,需要在导航栏、搜索框、商品头图等内容加载完毕的位置打上点。
  • 如果是一个列表页,需要根据各个机型下的首屏位置,计算出一个平均的首屏位置,打上点。
  • 如果首屏仅仅是一张图片,则需要在图片加载完成之后,打上点。

优点

  • 灵活性强,可以根据网页的特点随时改变打点策略,以保证首屏时间采集的准确性。
  • 去中心化,各个业务部分自己打自己点即可,自行采集和维护。

缺点

  • 通用性差,各个业务要自己去设计打点方案。
  • 和业务代码耦合在一起,维护性差,而且随着业务的变化,打点代码也需要调整,较为麻烦。
  • 依赖人,不同人对首屏的理解不一样,导致不同人采集的结果有差异,还需要花时间和成本去校正,或者忘记打点。

3、自动化采集

自动化采集就是指插入一段通用代码进行自动化采集。

优点

  • 通用性强,多个业务线都可用,使用和接入简单。

缺点

  • 无法满足业务的个性化需求。

自动化采集对于不同的场景,采集方案也不一样:

  • 对于服务端渲染 SSR 来说,客户端拿到的就是拼接好的 html 字符串,所以直接采集 DOMContentLoaded 的时间即可。
  • 对于客户端渲染的 SPA 应用来说,DOMContentLoaded 的时间并不一定准确,因为里面的内容开始只有一个容器 <div id="app"></div>,后续内容是通过 js 动态渲染出来的,而用户需要看到完整的首屏实际内容,才能算首屏加载完成了。

那么,如何准确采集单页面(SPA)应用的首屏时间呢?

4、单页面(SPA)应用的首屏采集方案

首先先了解下单页应用的渲染大概流程:

  1. 输入网址,从服务器拿到 index.html 文件;
  2. 浏览器使用 html 解析器解析 html 文件,并加载 cssjs 等资源。
  3. 执行 js 代码,初始化框架 Vue/React/Angular,执行里面相关生命周期钩子,使用 xhr/axios 请求数据,并渲染 DOM 到页面上。

那么,我们的核心就是需要知道,渲染 DOM 到页面上的时间。以 Vue 框架为例,它有一个 mounted(Vue2 Options API)、onMounted(Vue3 Composition API ) 钩子,可以拿到 DOM 加载的时间,那么我们是不是能利用这个钩子来进行首屏时间的采集呢?

显然是不行的,这样做有如下缺点:

  1. 如果页面数据是通过请求异步拿到并渲染到页面上,mounted 采集的首屏时间就不准确了,如果要知道准确的时间,需要等请求完成的时间点进行采集,这样会侵入业务代码,违背了通用性,再说如果有多个请求抽离在各个地方,还需要用类似 Promise.all 进行整合,还是需要修改业务代码。
  2. 如果首页是一张图片,而 mounted 的时间,图片内容可能并没有加载完,用户也看不到内容。

5、使用 MutationObserver 采集首屏时间

所以,我们应该采用 MutationObserver 进行采集。它能监听 DOM 树的更改并执行相关的回调。核心的统计思路就是:在页面初始化时,使用 MutationObserver 监听 DOM 元素,当其发生变化时,程序会标记变化的元素,并记录时间点和分数,存储到数组中,当达到如下条件时,说明首屏渲染已经结束:

  • 计算时间超过 30s 还没结束。
  • 计算了 4 次且 1s 内分数不再变化。
  • 计算了 9 次且分数不再变化。

统计分数过程如下:

  • 递归遍历 DOM 元素及其子元素,根据元素层级设定元素权重。层级越深的元素最接近用户看到的内容,权重也就越高。比如第一层权重为 1 ,渲染完成得 1 分,没增加一层权重增加 0.5,第三层的权重为 3.5,也就是渲染完成得 3.5 分。

最终,我们拿到一个记录了时间点和分数的数组,然后通过数组的后一项 - 数组前一项求出元素分数变化率,找到变化率最大点的分数对应的时间,即为首屏时间。

那这样算出来的首屏时间是否准确呢?其实不然,像我们之前说的首屏为一张图片的情况,就采集的不准。

所以对于图片来说,我们需要拿到页面中所有的 img,其来源主要有两方面:

  • img 标签:通过拿到 dom 节点,判断其 nodeName.toUpperCase === 'IMG'
  • CSS 背景中的图片background: url("https://static.xxx.png")。可以通过如下方式来拿到:
if (dom.nodeName.toUpperCase !== 'IMG') {
  const domStyle = window.getComputedStyle(dom);
  const imgUrl = domStyle.getPropertyValue('background-image') || domStyle.getPropertyValue('background');
}

拿到图片的 url 之后,通过 performance.getEntriesByName(imgUrl)[0].responseEnd 获取图片的加载时间,然后拿到图片最长的加载时间和之前变化率最大点的分数对应的时间进行对比,哪个更长哪个就是最终的首屏时间。

小结

  • 首屏时间会受用户设备、网络环境的影响,使用 Chrome DevTools 拿到的首屏时间存在偏差。
  • 手动采集方案较为灵活,能满足个性化需求,去中心化,但没有自动采集通用性好,会跟业务代码耦合,接入成本也更高,会受人为影响,所以一般都会选择自动化采集方案。
  • 采集时,服务端 SSR 应用和单页 SPA 应用的采集有很大不同,SSR 应用只需要采集 DOMContentLoaded 时间即可,而单页应用则需要使用 MutationObserver 监听 DOM,并设置元素权重,统计每个元素的分数和时间,最终拿到变化率最大的分数及时间点。
  • 计算出所有图片的加载时间,与变化率最大的分数的时间进行比较,更大的作为最终的首屏时间。

往期回顾

🚀 @empjs/skill:让 AI SKill 管理变得前所未有的简单

作者 KenXu
2026年1月22日 18:57

一个命令,管理所有 AI Skill。告别重复安装,拥抱统一管理。

💡 你是否遇到过这样的困扰?

想象一下这个场景:你同时使用 CursorClaude CodeWindsurf 等多个 AI 编程助手。每次发现一个好用的技能(Skill),你都需要:

  • 🔄 在 Cursor 的目录下手动安装一次
  • 🔄 在 Claude Code 的目录下再安装一次
  • 🔄 在 Windsurf 的目录下还要安装一次
  • 📁 每个 AI 代理都有自己的技能目录,文件散落各处
  • 🔍 想查看安装了哪些技能?得一个个目录去翻
  • 🗑️ 想删除某个技能?得记住它在哪些地方,一个个删除

更糟糕的是,如果你是一个技能开发者,想要测试你的技能在不同 AI 代理上的表现,你需要:

  • 📦 打包发布到 NPM
  • 🔄 在每个代理上分别安装
  • 🔄 修改代码后,重新打包、重新安装...
  • 😫 开发效率低到令人抓狂

✨ eskill:一次安装,全平台可用

eskill 是一个革命性的 CLI 工具,它彻底改变了 AI 代理技能的管理方式。

🎯 核心价值

一个统一的技能库,自动分发到所有 AI 代理

# 安装一个技能
eskill install my-awesome-skill

# ✨ 自动检测并链接到:
#   ✅ Cursor (~/.cursor/skills)
#   ✅ Claude Code (~/.claude/skills)  
#   ✅ Windsurf (~/.windsurf/skills)
#   ✅ Cline (~/.cline/skills)
#   ✅ Gemini Code (~/.gemini/skills)
#   ✅ GitHub Copilot (~/.copilot/skills)
#   ✅ ... 还有更多!

就这么简单! 一次安装,所有已安装的 AI 代理都能立即使用。

🌟 五大核心亮点

1️⃣ 统一存储架构

所有技能统一存储在 ~/.emp-agent/skills/,通过符号链接(symlink)技术智能分发到各个 AI 代理。

优势:

  • 📦 单一数据源:技能只存储一份,节省磁盘空间
  • 🔄 自动同步:更新一次,所有代理自动生效
  • 🎯 集中管理:所有技能一目了然

2️⃣ 智能代理检测

自动检测你系统中已安装的所有 AI 代理,无需手动配置。

支持的 AI 代理(13+):

  • Claude Code
  • Cursor
  • Windsurf
  • Cline
  • Gemini Code
  • GitHub Copilot
  • OpenCode
  • Antigravity
  • Kiro
  • Codex CLI
  • Qoder
  • Roo Code
  • Trae
  • Continue

还在不断增加中!

3️⃣ 多源安装支持

支持从多种来源安装技能:

# 从 NPM 安装
eskill install @myorg/react-skill

# 从 Git 仓库安装
eskill install https://github.com/user/repo/tree/main/skills/my-skill

# 从本地目录安装(开发模式)
eskill install ./my-local-skill --link

4️⃣ 开发模式:即时更新

这是技能开发者的福音

# 进入你的技能目录
cd ~/projects/my-skill

# 链接到开发环境
eskill install . --link

# ✨ 现在修改代码,所有 AI 代理立即生效!
# 无需重新打包,无需重新安装

开发体验提升 10 倍!

5️⃣ 灵活的安装策略

# 安装到所有代理(默认)
eskill install my-skill

# 只安装到特定代理
eskill install my-skill --agent cursor

# 强制重新安装
eskill install my-skill --force

🎨 技术架构亮点

符号链接技术

使用操作系统的符号链接功能,实现零拷贝的技能分发:

~/.emp-agent/skills/my-skill/     # 实际存储位置
    ├── SKILL.md
    └── references/

~/.cursor/skills/my-skill -> ~/.emp-agent/skills/my-skill  # 符号链接
~/.claude/skills/my-skill -> ~/.emp-agent/skills/my-skill  # 符号链接

优势:

  • 零延迟:链接创建瞬间完成
  • 💾 省空间:不占用额外存储
  • 🔄 自动同步:源文件更新,所有链接自动反映

智能路径解析

支持复杂的 Git URL 解析:

# 支持分支
eskill install https://github.com/user/repo/tree/dev/skills/my-skill

# 支持子目录
eskill install https://github.com/user/repo/tree/main/packages/skill

# 自动提取技能名称

完善的错误处理

  • ⏱️ 超时控制:网络请求自动超时,避免无限等待
  • 🔍 详细错误提示:遇到问题,提供清晰的解决方案
  • 🛡️ 权限处理:智能处理文件权限问题,提供修复建议

📊 使用场景

场景 1:多 AI 代理用户

痛点: 需要在多个 AI 代理上使用相同的技能

解决方案:

eskill install react-best-practices
# 自动在所有已安装的代理上可用

场景 2:技能开发者

痛点: 开发技能时需要频繁测试

解决方案:

eskill install . --link
# 修改代码,立即在所有代理上测试

场景 3:团队协作

痛点: 团队成员需要统一管理技能

解决方案:

# 统一从 NPM 或 Git 安装
eskill install @team/shared-skills
# 确保团队使用相同版本的技能

场景 4:技能探索

痛点: 想尝试新技能,但不确定是否适合

解决方案:

eskill install experimental-skill
eskill list  # 查看所有已安装的技能
eskill remove experimental-skill  # 轻松卸载

🚀 快速开始

安装 eskill

# 使用 pnpm(推荐)
pnpm add -g @empjs/skill

# 或使用 npm
npm install -g @empjs/skill

# 或使用 yarn
yarn global add @empjs/skill

# 或使用 bun
bun install -g @empjs/skill

安装你的第一个技能

# 查看可用的技能
eskill list

# 安装一个技能
eskill install <skill-name>

# 查看已安装的技能
eskill list

# 查看支持的 AI 代理
eskill agents

💎 为什么选择 eskill?

✅ 对比传统方式

特性 传统方式 eskill
安装步骤 每个代理单独安装 一次安装,全平台可用
存储空间 每个代理一份副本 统一存储,节省空间
更新效率 需要逐个更新 一次更新,全部生效
开发体验 打包→安装→测试循环 链接模式,即时生效
管理复杂度 高(多个目录) 低(统一管理)

🎯 核心优势总结

  1. 🚀 效率提升:一次操作,全平台生效
  2. 💾 空间节省:统一存储,避免重复
  3. 🛠️ 开发友好:链接模式,即时测试
  4. 🔧 灵活配置:支持多源、多代理、多模式
  5. 📦 生态兼容:支持 NPM、Git、本地目录

🔮 未来展望

eskill 正在快速发展,未来将支持:

  • 📊 技能市场:内置技能发现和评分系统
  • 🔄 版本管理:技能版本控制和回滚
  • 👥 团队协作:技能共享和权限管理
  • 📈 使用统计:技能使用情况分析
  • 🔌 插件系统:扩展更多 AI 代理支持

🤔 还在犹豫?

试试看,只需要 30 秒:

# 1. 安装 eskill
pnpm add -g @empjs/skill

# 2. 查看你的 AI 代理
eskill agents

# 3. 安装一个技能试试
eskill install <any-skill-name>

如果它不能提升你的效率,卸载它只需要:

npm uninstall -g @empjs/skill

但相信我,一旦你体验过统一管理的便利,就再也回不去了! 🎉

📚 了解更多

  • 📖 完整文档:查看项目 README
  • 🐛 问题反馈:GitHub Issues
  • 💬 社区讨论:加入我们的社区
  • 🔧 贡献代码:欢迎 Pull Request

现在就试试 eskill,让 AI 代理技能管理变得前所未有的简单!

pnpm add -g @empjs/skill

一个命令,改变你的工作流。

nuxt 配 modules模块 以及 数据交互

作者 江湖文人
2026年1月22日 18:26
  • 类型 Array

modulesNuxt.js扩展,可以扩展它的核心功能并添加无限。

例如(nuxt.config.js):

export default {
  modules: [
    // Using package name
    '@nuxtjs/axios',
    
    // Relative to your project srcDir
    '~/modules/awesome.js',
    
    // Providing options
    ['@nuxtjs/google-analytics', { ua: 'X1234567' }],
    
    // Inline definition
    function() {}
  ]
}

安装过程中,它会让我们选择模块。

image.png

Axios - Promise based HTTP client

// nuxt.config.js
{
  modules: [
    '@nuxtjs/axios' // 前面安装nuxtjs的时候没选,也可以后续一条命令去装上去 ==> npm install @nuxtjs/axios -initial-scale
  ]
}

// 笔记.html

一、安装nuxt的axios
    1.1 npm install @nuxtjs/axios -S
    1.2 nuxt.config.js进行配置
    
    modules: [
      '@nuxtjs/axios',
    ]

二、安装axios
    2.1 npm install axios -S
    

在每一个页面中或者每个component中用axios。

<template>
  <div>页面</div>
</template>

<script>
import axios from 'axios'
export default {
  name: 'IndexPage'
}
</script>

在nuxt中如何请求接口。两种方式:

异步数据

请求接口一定是首先先把服务器端的接口拿到了。然后再打开页面,这个时候源代码中就有接口数据了。那这个时候蜘蛛就可以爬取到这个数据了。如果还是像vue一样,这个页面打开了,再把数据返回来,那蜘蛛就抓取不到了。

在页面中有一个生命周期。叫asyncData

Nuxt.js扩展了Vue.js,增加了一个叫asyncData的方法,使得我们可以在设置组件的数据之前能异步获取或处理数据。

asyncData

这个是个生命周期。

asyncData方法会在组件(限于页面组件)每次加载之前被调用。可在服务端或路由更新之前被调用。

在这个方法被调用的时候,第一个参数被设定为当前页面的上下文对象,可以用asyncData方法来获取数据,

三、asyncData生命周期 || 方法
  
  pages 目录中的页面组件才可以去用
  
    ***注意components内的.vue文件是不可以使用的。
  
// index.vue
<template>
  <div>首页</div>
</template>

<script type="text/script">
export default {
  data() {},
  asyncData(app) {
    console.log(app)
  }
}
</script>

可以看到,app对象下面有一个$axios

在控制台,和在服务端都可以打印出来。

所以也可以这样子写:

// index.vue
<template>
  <div>首页</div>
</template>

<script type="text/script">
export default {
  data() {},
  asyncData({ $axios }) {
    console.log($axios)
  }
}
</script>

这样子就可以去请求接口,放进去。

接口给过来,都是后端代码上面去解决跨域问题。至于nuxt如何解决跨域,待会说。

这里写一个async 和 await

// pages/index.vue
<template>
  <div>首页</div>
</template>

<script>
export default {
  name: 'IndexPage',
  async asyncData({ $axios }) {
    const res = await $axios('后端给的接口地址')
    console.log(res)
  }
}
</scirpt>

拿到数据之后呢,要把数据渲染到也米娜上,如果是vue的话,

// pages/index.vue
<template>
  <div>
    <ul>
      <li v-for='item in list' :key='item.id'>
        {{ item.imageName }}
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'IndexPage',
  data () {
    return {
      list: []
    }
  },
  async asyncData({ $axios }) {
    const res = await $axios('后端给的接口地址')
    const list = res.data
    console.log(res)
    // 要这样
    return { list }
    // 不要这样
    this.list = list
  }
}
</scirpt>

按照vue来讲,这上面完全没问题,但是nuxt中不行。其实不是nuxt不行,而是asyncData不行,在asyncData中是不能写this的,因为在asyncData中this是undefined。

注意:在asyncData中没有this

其实在这个地方,有写到,说要return。说白了,nuxt.js会将asyncData返回的数据融合组件data方法返回的数据并返回给当前组件。

其实就是data () { return { list: [] } },和asyncData里面的return 合并数据,然后。

然后重新去看页面,就可以看到页面生效了。

fetch

还有方式请求接口。

四、fetch生命周期 || 方法

首先fetch是在aysncData之后的生命周期,然后fetch也有参数({ $axios }),它也是当前组件的上下文,所以这里的$axios也有接口请求,

// pages/index.vue
<template>
  <div>
    <News />
    <ul>
      <li v-for='item in list' :key='item.id'>
        {{ item.imageName }}
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'IndexPage',
  data () {
    return {
      list: [],
      items: []
    }
  },
  async asyncData({ $axios }) {
    const res = await $axios('后端给的接口地址')
    const list = res.data
    console.log(res)
    // 要这样
    return { list }
    // 不要这样
    this.list = list
  },
  // 注意fetch里面是可以有this的
  async fetch({ $axios }) {
    const res = await $axios('后端给的接口地址')
    const list = res.data
   
    this.items = list
  }
}
</scirpt>

不过在页面上能拿到数据,不过在template上打印出来是空数组。所以说在页面级的请求用asyncData。

四、fetch生命周期 || 方法
  fetch是有this的

components下创建个News.vue:

<template>
  <div>111</div>
</template>
<script>
export default {
  // 注意fetch里面是可以有this的
  async fetch({ $axios }) {
    const res = await $axios('后端给的接口地址')
    const list = res.data
   
    this.items = list
  }
}
</script>

刚刚提到的一点,asyncData在页面级别的组件是可以拿到的,可以执行的,在某个组件中,component中。asyncData是不能用在component上的,那它这种只能引入fetch,必须用fetch。

fetch方法用于在渲染页面前填充应用的状态树(store)数据,与asyncData方法类似,不同的是它不会设置组件的数据。

四、fetch生命周期 || 方法
  fetch是有this的

components下创建个News.vue:

<template>
  <div>111</div>
</template>
<script>
export default {
  // 注意fetch里面是可以有this的
  async fetch({ $axios }) {
    // 在组件中,这里是没有$axios的,
    const res = await $axios('后端给的接口地址')
    const list = res.data
   
    this.items = list
  }
}
</script>
<template>
  <div>
      111
      {{ list }}
  </div>
</template>
<script>
export default {
  data () {
    return {
      list
    }
  },
  // 注意fetch里面是可以有this的
  async fetch() {
    // 正确
    const res = await this.$axios('后端给的接口地址')
    const list = res.data
    this.list = list
  }
}
</script>

ThreeJS 着色器图形特效

2026年1月22日 18:18

本文档涵盖Three.js中高级着色器图形特效的实现方法,基于实际代码示例进行讲解。

最终效果如图: Title

1. 着色器图形特效基础

1.1 复杂着色器材质创建

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import gsap from "gsap";
import * as dat from "dat.gui";
import deepVertexShader from "../shaders/deep/vertex.glsl";
import deepFragmentShader from "../shaders/deep/fragment.glsl";

// 创建带有多个uniforms的着色器材质
const shaderMaterial = new THREE.ShaderMaterial({
  vertexShader: deepVertexShader,
  fragmentShader: deepFragmentShader,
  uniforms: {
    uColor: {
      value: new THREE.Color("purple"),
    },
    // 波浪的频率
    uFrequency: {
      value: params.uFrequency,
    },
    // 波浪的幅度
    uScale: {
      value: params.uScale,
    },
    // 动画时间
    uTime: {
      value: 0,
    },
    uTexture: {
      value: texture,
    },
  },
  side: THREE.DoubleSide,
  transparent: true,
});

1.2 GUI参数控制

通过dat.GUI实时控制着色器参数:

// 控制频率参数
gui
  .add(params, "uFrequency")
  .min(0)
  .max(50)
  .step(0.1)
  .onChange((value) => {
    shaderMaterial.uniforms.uFrequency.value = value;
  });

// 控制幅度参数
gui
  .add(params, "uScale")
  .min(0)
  .max(1)
  .step(0.01)
  .onChange((value) => {
    shaderMaterial.uniforms.uScale.value = value;
  });

2. 高级片元着色器技术

2.1 UV坐标操作

UV坐标是纹理映射的基础,也是创建各种图形效果的关键:

void main(){
    // 1. 通过顶点对应的uv,决定每一个像素在uv图像的位置,通过这个位置x,y决定颜色
    // gl_FragColor =vec4(vUv,0,1) ;

    // 2. 对第一种变形
    // gl_FragColor = vec4(vUv,1,1);

    // 3. 利用uv实现渐变效果,从左到右
    float strength = vUv.x;
    gl_FragColor =vec4(strength,strength,strength,1);
}

2.2 数学函数应用

利用GLSL内置数学函数创建复杂效果:

// 随机函数
float random (vec2 st) {
    return fract(sin(dot(st.xy,vec2(12.9898,78.233)))*43758.5453123);
}

// 噪声函数
float noise (in vec2 _st) {
    vec2 i = floor(_st);
    vec2 f = fract(_st);

    // 四个角落的随机值
    float a = random(i);
    float b = random(i + vec2(1.0, 0.0));
    float c = random(i + vec2(0.0, 1.0));
    float d = random(i + vec2(1.0, 1.0));

    vec2 u = f * f * (3.0 - 2.0 * f);

    return mix(a, b, u.x) +
            (c - a)* u.y * (1.0 - u.x) +
            (d - b) * u.x * u.y;
}

2.3 几何图形绘制

使用数学函数绘制各种几何图形:

// 绘制圆形
float strength = 1.0 - step(0.5,distance(vUv,vec2(0.5))+0.25) ;
gl_FragColor =vec4(strength,strength,strength,1);

// 绘制圆环
float strength = step(0.5,distance(vUv,vec2(0.5))+0.35) ;
strength *= (1.0 - step(0.5,distance(vUv,vec2(0.5))+0.25)) ;
gl_FragColor =vec4(strength,strength,strength,1);

// 波浪效果
vec2 waveUv = vec2(
    vUv.x+sin(vUv.y*100.0)*0.1,
    vUv.y+sin(vUv.x*100.0)*0.1
);
float strength = 1.0 - step(0.01,abs(distance(waveUv,vec2(0.5))-0.25)) ;
gl_FragColor =vec4(strength,strength,strength,1);

3. 动画与时间控制

3.1 时间uniform应用

在动画循环中更新时间uniform:

const clock = new THREE.Clock();
function animate(t) {
  const elapsedTime = clock.getElapsedTime();
  shaderMaterial.uniforms.uTime.value = elapsedTime;  // 更新时间
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

3.2 着色器中的动画效果

// 使用时间创建波浪动画
float strength = step(0.9,sin(cnoise(vUv * 10.0)*20.0+uTime)) ;

// 波纹效果
float strength = sin(cnoise(vUv * 10.0)*5.0+uTime) ;

4. 颜色混合与插值

4.1 颜色混合函数

// 使用混合函数混颜色
vec3 purpleColor = vec3(1.0, 0.0, 1.0);
vec3 greenColor = vec3(1.0, 1.0, 1.0);
vec3 uvColor = vec3(vUv,1.0);
float strength = step(0.9,sin(cnoise(vUv * 10.0)*20.0)) ;

vec3 mixColor =  mix(greenColor,uvColor,strength);
gl_FragColor =vec4(mixColor,1.0);

5. 纹理与采样

5.1 纹理采样

uniform sampler2D uTexture;

void main(){
    vec4 textureColor = texture2D(uTexture,vUv);
    textureColor.rgb*=height;
    gl_FragColor = textureColor;
}

6. 几何变换

6.1 旋转函数

// 旋转函数
vec2 rotate(vec2 uv, float rotation, vec2 mid)
{
    return vec2(
      cos(rotation) * (uv.x - mid.x) + sin(rotation) * (uv.y - mid.y) + mid.x,
      cos(rotation) * (uv.y - mid.y) - sin(rotation) * (uv.x - mid.x) + mid.y
    );
}

// 使用旋转函数
vec2 rotateUv = rotate(vUv,-uTime*5.0,vec2(0.5));

7. 复杂效果实现

7.1 万花筒效果

// 万花筒效果
float angle = atan(vUv.x-0.5,vUv.y-0.5)/PI;
float strength = mod(angle*10.0,1.0);
gl_FragColor =vec4(strength,strength,strength,1);

7.2 雷达扫描效果

// 雷达扫描效果
vec2 rotateUv = rotate(vUv,-uTime*5.0,vec2(0.5));
float alpha =  1.0 - step(0.5,distance(vUv,vec2(0.5)));
float angle = atan(rotateUv.x-0.5,rotateUv.y-0.5);
float strength = (angle+3.14)/6.28;
gl_FragColor =vec4(strength,strength,strength,alpha);

8. 性能优化与调试

8.1 性能优化技巧

  1. 减少复杂计算:避免在着色器中进行过于复杂的数学运算
  2. 合理使用纹理:预先计算复杂效果并存储在纹理中
  3. 简化几何体:在不影响视觉效果的前提下减少顶点数

8.2 调试技巧

  1. 逐步构建:从简单效果开始,逐步增加复杂性
  2. 输出中间值:将中间计算结果输出为颜色进行调试
  3. 使用常量验证:先用常量验证逻辑,再引入变量

总结

本章深入探讨了Three.js中高级着色器图形特效的实现方法,包括:

  1. 复杂着色器材质的创建和参数控制
  2. 数学函数在图形生成中的应用
  3. UV坐标操作和几何图形绘制
  4. 时间动画和颜色混合技术
  5. 纹理采样和几何变换
  6. 复杂视觉效果的实现方法
  7. 性能优化和调试技巧

通过掌握这些技术,可以创建出丰富的视觉效果和动态图形。

ThreeJS 着色器编程基础入门

2026年1月22日 18:11

本文档涵盖Three.js中着色器编程的基础概念和实现方法,基于实际代码示例进行讲解。

最终效果如图: 懂王在风中凌乱

1. 着色器基础概念

着色器(Shader)是运行在GPU上的小程序,用于计算3D场景中每个像素的颜色。在Three.js中,有两种主要的着色器:

  • 顶点着色器(Vertex Shader):处理每个顶点的位置变换
  • 片元着色器(Fragment Shader):确定每个像素的最终颜色

1.1 着色器导入和初始化

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import gsap from "gsap";
import * as dat from "dat.gui";

// 顶点着色器
import basicVertexShader from "../shader/raw/vertex.glsl";
// 片元着色器
import basicFragmentShader from "../shader/raw/fragment.glsl";

2. 着色器材质创建

2.1 RawShaderMaterial vs ShaderMaterial

RawShaderMaterial直接使用GLSL代码,不会自动添加默认的uniforms和attributes:

// 创建原始着色器材质
const rawShaderMaterial = new THREE.RawShaderMaterial({
  vertexShader: basicVertexShader,
  fragmentShader: basicFragmentShader,
  side: THREE.DoubleSide,
  uniforms: {
    uTime: {
      value: 0,
    },
    uTexture: {
      value: texture,
    },
  },
});

2.2 基础着色器材质

// 创建着色器材质
const shaderMaterial = new THREE.ShaderMaterial({
  vertexShader: `
    void main(){
        gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4( position, 1.0 ) ;
    }
  `,
  fragmentShader: `
    void main(){
        gl_FragColor = vec4(1.0, 1.0, 0.0, 1.0);
    }
  `,
});

3. 顶点着色器详解

顶点着色器负责处理3D空间中的顶点位置,以下是一个包含动画效果的顶点着色器:

precision lowp float;
attribute vec3 position;
attribute vec2 uv;

uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;

// 获取时间
uniform float uTime;

varying vec2 vUv;
varying float vElevation;

void main(){
    vUv = uv;
    vec4 modelPosition = modelMatrix * vec4( position, 1.0 );
    
    // 添加基于时间的波浪动画
    modelPosition.z = sin((modelPosition.x+uTime) * 10.0)*0.05 ;
    modelPosition.z += sin((modelPosition.y+uTime)  * 10.0)*0.05 ;
    vElevation = modelPosition.z;

    gl_Position = projectionMatrix * viewMatrix * modelPosition ;
}

4. 片元着色器详解

片元着色器负责确定每个像素的颜色,以下是一个处理纹理和高度的片元着色器:

precision lowp float;
varying vec2 vUv;
varying float vElevation;

uniform sampler2D uTexture; 

void main(){
    // 根据UV,取出对应的颜色
    float height = vElevation + 0.05 * 20.0;
    vec4 textureColor = texture2D(uTexture,vUv);
    textureColor.rgb*=height;
    gl_FragColor = textureColor;
}

5. Uniforms统一变量

Uniforms是在JavaScript代码和着色器之间传递数据的变量:

const rawShaderMaterial = new THREE.RawShaderMaterial({
  vertexShader: basicVertexShader,
  fragmentShader: basicFragmentShader,
  side: THREE.DoubleSide,
  uniforms: {
    uTime: {
      value: 0,  // 时间变量,用于动画
    },
    uTexture: {
      value: texture,  // 纹理变量
    },
  },
});

在动画循环中更新uniform值:

const clock = new THREE.Clock();
function animate(t) {
  const elapsedTime = clock.getElapsedTime();
  // 更新着色器中的时间uniform
  rawShaderMaterial.uniforms.uTime.value = elapsedTime;
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

6. 几何体与着色器结合

使用平面几何体展示着色器效果:

// 创建平面
const floor = new THREE.Mesh(
  new THREE.PlaneBufferGeometry(1, 1, 64, 64),  // 细分更多,波浪效果更明显
  rawShaderMaterial
);

scene.add(floor);

7. 基础着色器示例

创建一个简单的黄色平面着色器:

// 创建基础着色器材质
const shaderMaterial = new THREE.ShaderMaterial({
  vertexShader: `
    void main(){
        gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4( position, 1.0 ) ;
    }
  `,
  fragmentShader: `
    void main(){
        gl_FragColor = vec4(1.0, 1.0, 0.0, 1.0);  // 黄色
    }
  `,
});

8. 着色器开发最佳实践

  1. 精度声明:在片元着色器中声明精度

    precision lowp float;  // 低精度
    precision mediump float;  // 中等精度
    precision highp float;  // 高精度
    
  2. 变量类型

    • attribute:每个顶点独有的数据(如位置、UV坐标)
    • uniform:所有顶点共享的数据(如时间、纹理)
    • varying:在顶点着色器和片元着色器之间传递的数据
  3. 性能优化:避免在着色器中使用复杂运算,尽可能在CPU端预计算

  4. 调试技巧:通过将中间计算结果输出到颜色来调试着色器

总结

本章介绍了Three.js中着色器编程的基础知识,包括:

  1. 着色器的基本概念和类型
  2. 如何创建和使用着色器材质
  3. 顶点着色器和片元着色器的编写
  4. 如何通过uniforms在JavaScript和着色器间传递数据
  5. 基础的着色器动画实现

通过掌握这些基础知识,可以进一步探索更复杂的着色器效果。

三个方法优化JS的setTimeout实现的倒计误差,看完包会!

2026年1月22日 17:42

你肯定遇到过这种情况。页面上有一个倒计时,显示“距离活动结束还有 10 秒”。你屏住呼吸,准备在最后一刻点击抢购按钮。但奇怪的是,倒计时从 10 跳到 9 时,好像停顿了一下,或者跳得特别快。最终,你点击按钮时,系统提示“活动已结束”。

这不是你的错觉。前端实现的倒计时,确实存在误差。今天,我们就来聊聊这个误差是怎么产生的,以及我们能做些什么来减小它。

误差从何而来?

要理解误差,我们得先看看最常见的前端倒计时是怎么工作的。

1. 核心机制:setInterval 与 setTimeout

大多数倒计时使用 JavaScript 的 setInterval 或递归的 setTimeout 来实现。代码逻辑很简单:

  1. 设定一个目标时间(比如活动结束时间)。
  2. 每秒执行一次函数,计算“当前时间”与“目标时间”的差值。
  3. 将这个差值转换成天、时、分、秒,显示在页面上。 看起来天衣无缝,对吗?问题就藏在“每秒执行一次”这个动作里。

2. 误差的三大“元凶”

元凶一:JavaScript 的单线程与事件循环

JavaScript 是单线程语言。这意味着它一次只能做一件事。setInterval 和 setTimeout 指定的延迟时间,并不是精确的“等待 X 毫秒后执行”,而是“等待至少 X 毫秒后,将回调函数放入任务队列”。

什么时候执行呢?要等主线程上当前的任务都执行完了,才会从队列里取出这个回调来执行。

想象一下:

  • 你设定 setInterval(fn, 1000),希望每秒跑一次。
  • 第0秒,fn 执行了。
  • 第1秒,fn 被放入队列。但此时主线程正在处理一个复杂的动画计算,花了 200 毫秒。
  • 结果,fn 直到第1.2秒才真正开始执行。

这就产生了至少 200 毫秒的延迟。

元凶二:浏览器标签页休眠

为了节省电量,当用户切换到其他标签页或最小化浏览器时,当前页面的 setInterval 和 setTimeout 会被“限流”。它们的执行频率会大大降低,可能变成每秒一次,甚至更慢。

如果你的倒计时在后台运行了5分钟,再切回来,它可能直接显示“已结束”,或者时间跳了一大截。

元凶三:系统时间依赖

很多倒计时是这样计算剩余时间的:

剩余秒数 = Math.floor((目标时间戳 - Date.now()) / 1000);

这里有两个潜在问题:

  1.  Date.now() 的精度:它返回的是系统时间。如果用户手动修改了电脑时间,或者系统时间同步有微小偏差,倒计时就会出错。
  2.  计算时机:这个计算发生在回调函数执行的那一刻。如果回调函数本身被延迟了,那么用来计算的“当前时刻”也已经晚了。

如何减小误差?试试这些方案

知道了原因,我们就可以对症下药。解决方案的目标是:让显示的时间尽可能接近真实的世界时间

方案一:优化计时器逻辑

这是最基础的改进,核心思想是:不依赖计时器的周期,而是依赖绝对时间

具体做法:

  1. 在倒计时启动时,记录一个精确的开始时间戳startTime = Date.now())和目标结束时间戳endTime)。

  2. 在每次更新函数中,不再简单地“减1秒”,而是重新计算:

    const now = Date.now();
    const elapsed = now - startTime; // 已经过去的时间
    const remainingTime = endTime - now; // 剩余时间
    const displaySeconds = Math.floor(remainingTime / 1000);
    
  3. 动态调整下一次执行的时间。例如,我们希望每 1000 毫秒更新一次显示,但上次执行晚了 50 毫秒,那么下次就只延迟 950 毫秒。

    function updateTimer() {
      // ... 计算并显示时间
      const deviation = Date.now() - (startTime + expectedElapsed); // 计算偏差
      const nextTick = 1000 - deviation; // 调整下次间隔
      setTimeout(updateTimer, Math.max(0, nextTick)); // 确保间隔不为负数
    }
    

优点:

• 实现相对简单。 • 能有效抵消单次延迟的累积。一次慢了,下次会找补回来一些。

缺点:

• 无法解决浏览器标签页休眠导致的长时间停滞。 • 仍然依赖 Date.now(),受系统时间影响。

方案二:使用 Web Worker(隔离线程)

既然主线程繁忙会导致延迟,那我们就把计时任务放到一个独立的线程里去。

Web Worker 可以让脚本在后台线程运行。在这个线程里运行的 setInterval 不容易被主线程的繁重任务阻塞。

实现思路:

  1. 创建一个 Web Worker 文件(timer.worker.js),在里面用 setInterval 向主线程发送消息。
  2. 主线程接收消息,更新界面。

优点:

• 计时更稳定,受主线程影响小。 • 代码分离,逻辑清晰。

缺点:

• 仍然无法解决浏览器标签页休眠限流的问题。 • 增加了一定的架构复杂度。

方案三:终极方案:服务器时间同步 + 前端补偿

这是目前最精确、最可靠的方案。核心原则是:前端不再信任本地时间,而是以服务器时间为准,并持续校准。

步骤拆解:

第一步:获取权威的服务器时间
在页面加载或倒计时开始时,向服务器发送一个请求。服务器在响应中返回当前的服务器时间戳

注意:这个时间戳应该放在 HTTP 响应的 Date 头或 body 里,避免受到网络传输时间的影响。更专业的做法是,计算一个往返延迟(RTT),然后估算出当前的准确服务器时间。

第二步:在前端建立一个“虚拟的服务器时钟”
我们不在前端直接使用 Date.now(),而是自己维护一个时钟:

// 假设通过 API 得到:serverTime 是服务器当前时间,rtt 是网络往返延迟
const initialServerTime = serverTime + rtt / 2; // 估算的准确服务器时间
const localTimeAtThatMoment = Date.now();

// 此后,要获取“当前服务器时间”,就用这个公式:
function getCurrentServerTime() {
  const nowLocal = Date.now();
  const elapsedLocal = nowLocal - localTimeAtThatMoment;
  return initialServerTime + elapsedLocal;
}

这个时钟的原理是:服务器告诉我们一个“起点时间”,我们记录下那个时刻的本地时间。之后,我们相信本地时间的流逝速度是基本准确的(电脑的晶体振荡器很稳定),用本地流逝的时间加上服务器的起点时间,就得到了连续的“服务器时间”。

第三步:用这个虚拟时钟驱动倒计时
倒计时的更新函数,使用 getCurrentServerTime() 来计算剩余时间,而不是 Date.now()

第四步:定期校准
本地时钟的流逝速度可能有微小偏差(时钟漂移)。我们可以设置一个间隔(比如每1分钟或5分钟),悄悄地再向服务器请求一次时间,来修正我们的 initialServerTime 和 localTimeAtThatMoment,让虚拟时钟始终与服务器保持同步。

这个方案的优点非常突出:

• 抗干扰:用户修改本地时间,完全不影响倒计时。 • 高精度:误差主要来自时钟漂移和网络延迟,通过定期校准可以控制在极低水平(百毫秒内)。 • 一致性:所有用户看到的倒计时基于同一时间源,公平公正。

当然,它的实现也最复杂,需要前后端配合。

实战建议:如何选择?

面对不同的场景,你可以这样选择:

• 对精度要求不高的展示型倒计时(如文章发布后的阅读时间):使用方案一(优化计时器逻辑)  就足够了。简单有效。 • 营销活动、秒杀抢购倒计时:必须使用方案三(服务器时间同步) 。这是保证公平性和准确性的底线。方案一和方案二可以作为辅助,让更新更平滑。

React记录之context:useContext、use-context-selector

作者 web_bee
2026年1月22日 17:34

原生context、useContext详解

React 的 Context API 是一种组件间共享数据的机制,它允许你在组件树中传递数据而不必手动逐层传递 props,特别适合"全局"数据的共享(如主题、用户认证信息等)。

基本使用:

创建context:

import { createContext, useContext } from 'react';

export type ThemeType = 'light' | 'dark';

export interface ThemeContextType {
  theme: ThemeType;
  toggleTheme: () => void;
}

// 1. 创建 Context
export const ThemeContext = createContext<ThemeContextType>({
  theme: 'light',
  toggleTheme: () => {},
});

type ThemeProviderProps = {
  children: React.ReactNode;
} & ThemeContextType;

// 2. 创建 Provider 组件
export const ThemeProvider = ({
  children,
  theme,
  toggleTheme,
}: ThemeProviderProps) => {
  return (
    <ThemeContext.Provider value={{
      theme,
      toggleTheme,
    }}>
      {children}
    </ThemeContext.Provider>
  );
};

// 3. 自定义 Hook(可选,提升可读性)
export const useTheme = () => useContext(ThemeContext);

顶层组件 top.tsx

"use client";

import React, { useState } from 'react';
import { ThemeContext, ThemeContextType, ThemeType } from './context';
import Button from '../../components/button';

function App() {
  const [theme, setTheme] = useState<ThemeType>('light');

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };

  const value: ThemeContextType = { theme, toggleTheme };

  return (
    <ThemeContext.Provider value={value}>
      <div
        style={{
          padding: '20px',
          background: theme === 'dark' ? '#000' : '#fff',
          color: theme === 'dark' ? '#fff' : '#000'
        }}
      >
        <h1>Current theme: {theme}</h1>
        <Button />
      </div>
    </ThemeContext.Provider>
  );
}

export default App;

Button组件

import React from 'react';
import { useTheme } from '../hook-api/use-context/context';

export default function Button() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button onClick={toggleTheme}>Toggle Theme {theme}</button>
  );
}

使用场景

  • 全局主题(亮色/暗色模式)
  • 用户认证状态(登录用户信息)
  • 多语言国际化(i18n)
  • 全局配置或状态(如购物车、通知设置)

注意事项:

性能问题:当 Provider 的 value 发生变化时,所有使用该 Context 的子组件都会重新渲染(即使只用到部分字段)。为避免不必要的重渲染:

  • value 拆分为多个 Context;
  • 使用 useMemo 稳定 value 引用;
  • 将不依赖 Context 的子组件提取到 Provider 外部。

不要滥用:Context 不是万能的状态管理工具。对于复杂状态逻辑,建议结合 useReducer 或使用 Redux、Zustand 等状态库。

use-context-selector

use-context-selector 是一个 React 上下文(Context)优化库,它解决了 React 原生 useContext 在性能上的一个关键问题:当上下文值变化时,所有使用该上下文的组件都会重新渲染,即使它们只依赖上下文中的一小部分数据。

核心特性

  1. 选择性订阅:允许组件只订阅上下文中的特定部分数据
  2. 精确更新:只有当下文中的选定部分变化时才会触发组件更新
  3. 与原生Context API兼容:使用方式与React原生Context相似
  4. 轻量级:体积小,对应用包大小影响小

基本使用:

App.tsx

'use client'

import React, { StrictMode } from 'react';
import { MyProvider } from './context';
import CounterA from './components/CounterA';
import CounterB from './components/CounterB';

function App() {
  return (
    <StrictMode>
      <MyProvider>
        <CounterA />
        <CounterB />
      </MyProvider>
    </StrictMode>
  );
}

export default App;

context.tsx

'use client'

import { useState } from 'react';
import{ createContext } from 'use-context-selector';

const MyContext = createContext({} as any);

export function MyProvider({ children }: any) {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);

  const state: any = {
    countA,
    setCountA,
    countB,
    setCountB,
  };

  return (
    <MyContext.Provider value={state}>
      {children}
    </MyContext.Provider>
  );
}

export default MyContext;

CounterA.tsx

'use client'


import React from 'react';
import { useContextSelector, useContext } from 'use-context-selector';
import MyContext from '../context';

function CounterA() {
  const countA = useContextSelector(MyContext, (v) => v.countA);
  const setCountA = useContextSelector(MyContext, (v) => v.setCountA);

  const increment = () =>
    setCountA((s) => s -1);

  console.log('CounterA rendered');

  return (
    <div>
      <p>{new Date().getTime()}</p>
      <p>Counter A: {countA}</p>
      <button onClick={increment}>
        Increment A
      </button>
    </div>
  );
}

export default CounterA;

CounterB.tsx

'use client'

import React from 'react';
import { useContextSelector, useContext } from 'use-context-selector';
import MyContext from '../context';

function CounterB() {
  const countB = useContextSelector(MyContext, (v) => v.countB);
  const setCountB = useContextSelector(MyContext, (v) => v.setCountB);

  const increment = () =>
    setCountB((s) => s -1);

  console.log('CounterB rendered');

  return (
    <div>
      <button onClick={increment}>
        Increment B
      </button>
      <p>{new Date().getTime()}</p>
      <p>Counter B: {countB}</p>
    </div>
  );
}

export default CounterB;

从 0 到 1 实战 Flutter 蓝牙通信:无硬件,用手机完成 BLE 双向通信

作者 StarkCoder
2026年1月22日 16:53

🚀 手把手教你从 0 到 1 完成 Flutter 蓝牙通信(无硬件实战)

适合人群

  • Flutter 开发者,想入门 BLE
  • 手上没有任何蓝牙硬件
  • 经常遇到「设备能连上,但怎么都通信不了」

本教程将带你 从 0 到 1 跑通一套完整的 BLE 通信流程

  • Android 手机 + nRF Connect 👉 模拟蓝牙服务端
  • iOS + Flutter 👉 作为蓝牙客户端
  • 完成 Write + Notify 的双向通信

🧠 一、先建立一个正确的 BLE 心智模型(很重要)

BLE 并不是「连上设备就能发数据」。

它的真实结构是:

设备(Device)
 └── 服务(Service,UUID)
      └── 特征(Characteristic,UUID + 属性)

这意味着什么?

  • 通信不是对设备,而是对 Characteristic
  • 写数据,必须写到“支持 Write 的 Characteristic”
  • 收数据,必须监听“支持 Notify 的 Characteristic”

👉 Service / Characteristic 选错一个,通信必失败


📦 二、准备工作:安装 nRF Connect(模拟服务端)

我们使用 Nordic 官方工具 nRF Connect 来模拟一个 BLE 外设(Server)。

1️⃣ 下载地址(APK)

github.com/NordicSemic…

安装到 Android 真机 后打开。


🧩 三、配置 GATT Server(创建服务端能力)

3.1 进入 GATT Server 配置

首页点击:

Configure GATT Server

配置GATT服务.jpg


3.2 使用 Sample configuration(推荐新手)

选择系统内置:

Sample configuration

然后找到 Test Service,复制它的 Service UUID

模版配置.jpg

📌 这个 UUID 后面会反复用到

  • 客户端扫描设备
  • 发现服务
  • 选择特征通信

📡 四、配置广播(让客户端能扫描到)

回到首页,点击 ADVERTISE,新建一个广播。

关键配置点

  • Service UUID 添加到 Scan Response Data
  • 这样客户端在扫描时,才能识别你这个设备提供了什么服务

配置设备.jpg


服务端完成标志 ✅

当你在设备列表看到该设备:

设备列表.jpg

说明:

  • ✅ GATT Server 已启动
  • ✅ 服务 UUID 已广播

📱 五、Flutter 客户端完整流程(新手最关键部分)

5.1 扫描设备(Scan)

Flutter 启动后扫描 BLE 设备:

扫描设备.PNG

📌 此时只是 看到设备,还不能通信。


5.2 点击设备,建立连接(Connect)

点击设备后:

scan → connect

连接成功,才有后续步骤。


5.3 发现服务(Discover Services)

连接完成后,客户端会向服务端请求:

你这个设备,提供了哪些 Service?

获取设备服务.PNG


❗5.4 进入你创建的 Service(UUID 必须一致)

在服务列表中:

  • 找到 UUID 与 Test Service 完全一致的 Service
  • 点击进入

👉 Service 点错,后面全部白做


5.5 查看 Characteristic(真正的通信入口)

进入 Service 后,会看到多个 Characteristic:

  • 有的支持 Read
  • 有的支持 Write
  • 有的支持 Notify

❗❗5.6 客户端必须选对 Characteristic

写数据(客户端 → 服务端)
  • 选择 支持 Write / Write Without Response 的 Characteristic
  • 使用它发送数据
收数据(服务端 → 客户端)
  • 选择 支持 Notify 的 Characteristic
  • 开启监听(subscribe)

📌 对不支持 Write 的 Characteristic 写数据:

不报错,但一定没反应


🔁 六、双向通信验证

6.1 客户端写入数据

客户端通信.PNG


6.2 服务端 Notify 客户端

服务端通信.jpg


⚠️ 七、一个 nRF Connect 的 UI 坑

服务端数据不会自动刷新:

  • 切换页面
  • 再切回 SERVER
  • 才能看到最新数据

❗不是通信失败,是 UI 问题。


📎 示例代码

github.com/chengshixin…


✅ 总结一句话

BLE 通信 = 连设备 + 找对 Service + 用对 Characteristic

告别"移动端重构噩梦":TinyPro移动端适配上线!

2026年1月22日 16:51

本文由TinyPro贡献者王晨光同学原创。

一、背景:让 TinyPro 真正“走到掌心里”

TinyPro 是一套基于 TinyVue 打造的前后端分离后台管理系统,支持菜单配置、国际化、多页签、权限管理等丰富特性。 TinyPro 在桌面端具备良好的体验和模块化架构,但随着移动办公、平板展示等场景增多,移动端体验的短板逐渐显现:

  • 页面缩放不均衡,布局出现溢出或错位;
  • 模态框在小屏上遮挡内容;
  • 图表和表格在横屏与竖屏间切换时无法自适应;
  • 操作区过于密集,不符合触控习惯。

为此启动了 TinyPro 移动端适配项目,目标是在不破坏现有结构的前提下,实现“一次开发,跨端流畅”的体验。

二、技术选型与总体架构

本次移动端适配要求在复杂的中后台系统中实现「一次开发,多端自适应」,既要保证样式灵活,又要维持可维护性和构建性能。

在技术选型阶段,综合评估了三种常见方案:

方案 优点 缺点
纯 CSS 媒体查询 简单直接、依赖少 样式分散、逻辑重复、维护困难
TailwindCSS 响应式类 社区成熟、类名直观、生态完善 样式表体积大、断点固定、不够灵活
UnoCSS 原子化方案 按需生成、性能极轻、断点与变体完全可定制 需要自行配置规范与规则体系

最终选择了 UnoCSS + Less 的混合架构

  • UnoCSS:负责通用布局、间距、排版等高频样式,原子化写法提升开发效率;
  • Less 媒体查询:用于模态框、导航栏等复杂场景的精细控制;
  • 统一断点配置:集中管理屏幕尺寸分级,保持视觉一致性;
  • 自定义变体(max-<bp>:支持“桌面端优先”策略,通过 max-width 实现移动端自适应,样式逻辑更直观。

UnoCSS:轻量、灵活、即时生成

UnoCSS 是一个 按需生成的原子化 CSS 引擎,最大的特点是 零冗余与高度可定制。 不同于 TailwindCSS 的预编译方式,UnoCSS 会在构建阶段根据实际使用的类名即时生成样式规则,从而显著提升构建性能与灵活性.

在配置中通过 presetMini()presetAttributify() 组合使用,使开发者既可以写:

<div class="p-4 text-center bg-gray-100 max-md:p-2"></div>

也可以使用属性化语法:

<div p="4" text="center" bg="gray-100" max-md:p="2"></div>

presetMini 提供轻量原子类体系,presetAttributify 则允许以声明式方式书写样式,更直观、组件化友好。

断点配置与响应式策略

TinyPro 的适配核心之一,是在 uno.config.ts 中建立统一的断点体系,并通过自定义 max-<bp> 前缀实现“桌面端优先”的响应式策略。

const breakpoints = {
  sm: '641px',     // 手机(小屏)
  md: '769px',     // 平板竖屏
  lg: '1025px',    // 平板横屏 / 小型笔电
  xl: '1367px',    // 常规笔电
  '2xl': '1441px', // 高清笔电
  '3xl': '1921px', // 桌面大屏
}

并通过自定义 variants 扩展 max-<bp> 前缀:

variants: [
    (matcher) => {
      const match = matcher.match(/^max-([a-z0-9]+):/)
      if (match) {
        const bp = match[1]
        const value = breakpoints[bp]
        if (!value) return
        return {
          matcher: matcher.replace(`max-${bp}:`, ''),
          parent: `@media (max-width: ${value})`,
        }
      }
    },
  ]

让开发者能自然地书写:

<div class="w-1/2 max-md:w-full"></div>

含义:

默认宽度为 50%,在宽度小于 769px 的设备上改为 100%。

TinyPro 采用「桌面端优先(max-width)」的布局策略:默认以桌面端布局为基础,在移动设备上再进行针对性优化。相比常见的「移动端优先(min-width)」方式,这种做法更符合中后台系统的特性,同时让 UnoCSS 的断点逻辑更直观,并确保主屏体验的稳定性。

三、样式与编码策略

  • 优先级

    • 简单场景:使用 UnoCSS 原子类。
    • 复杂样式:使用 Less 媒体查询。
  • 布局与滚动

    • 首页及核心业务模块完成适配,小屏模式下侧边栏默认收起、导航栏折叠,确保主要内容可见。
    • 页面主要容器避免横向滚动,必要时在小屏下开启局部横向滚动。
    • 表格与大区块在不同断点下自动调整宽度、栅格与间距,小屏下支持横向滚动;分页与密度支持响应式控制。

    布局与滚动.gif

  • 图表自适应

    • 图表组件接入 resize 监听,在侧边栏展开/收起、窗口缩放、语言切换等场景下保持自适应。
    • 小屏下使用 vw 宽度与较小字号,保证图表展示效果与可读性。

    图表自适应.gif

  • 表单与模态框

    • 接入 useResponsiveSize(),控制弹窗在小屏下铺满显示,大屏保持固定宽度。
    • 表单项在不同断点下动态调整排布与间距,优化触控体验。

    表单与模态框.gif

  • 导航与交互

    • 小屏下隐藏导航栏非关键元素,操作聚合到"折叠菜单"。
    • 移动端默认收起侧边菜单栏,提升主要内容展示区域。

    导航与交互.gif

  • 性能优化

    • responsive.ts 中对 resize 事件处理增加节流机制,避免窗口缩放等场景下的频繁无效渲染。

四、常用代码片段

  1. 基于栅格系统 + 响应式断点工具类,通过为 tiny-row 和 tiny-col 添加不同屏幕宽度下的样式规则,实现自适应布局:
<tiny-layout>
    <tiny-row class="flex justify-center max-md:flex-wrap">
        <tiny-col class="w-1/4 max-md:w-1/2 max-sm:w-full max-md:mb-4">···</tiny-col>
        ···
        <tiny-col class="w-1/4 max-md:w-1/2 max-sm:w-full max-md:mb-4">···</tiny-col>
    </tiny-row>
</tiny-layout>

<div class="theme-line flex max-sm:grid max-sm:grid-cols-4 max-sm:gap-2">
  <div···
  </div>
</div>
  1. 基于 响应式工具类 + 自定义响应式 Hook,解决(1)对话框宽度自适应;(2)表格尺寸和密度自适应;(3)逻辑层响应式控制
<template>
  <section class="p-4 sm:p-6 lg:p-8 max-sm:text-center">
    <tiny-dialog :width="modalSize">...</tiny-dialog>
  </section>
</template>

<script setup lang="ts">
import { useResponsiveSize } from '@/hooks/responsive'
const { modalSize } = useResponsiveSize() // 小屏 100%,大屏 768px
</script>
<template>
  <div class="container">
    <tiny-grid ref="grid" :fetch-data="fetchDataOption" :pager="pagerConfig" :size="gridSize" :auto-resize="true" align="center">
      ···
    </tiny-grid>
  </div>
</template>

<script setup lang="ts">
import { useResponsiveSize } from '@/hooks/responsive'
const { gridSize } = useResponsiveSize() // 小屏为mini grid,大屏为medium grid
</script>
  1. 通过 useResponsive 获取屏幕断点状态 sm/md/lg,如:在模板中结合 v-if="!lg" 控制分隔线的渲染,从而实现了小屏下纵向菜单才显示分隔线的效果
<template>
  <ul class="right-side" :class="{ open: menuOpen }">
    <!-- 小屏下才显示分隔线 -->
    <li v-if="!lg">
      <div class="divider"></div>
    </li>
    ···
  </ul>
</template>

<script lang="ts" setup>
import { useResponsive } from '@/hooks/responsive'
const { lg } = useResponsive()
</script>

五、结语

通过本次移动端适配, TinyPro 实现了“从桌面到掌心”的统一体验: 开发者可以继续沿用熟悉的组件体系与布局方式,同时享受 UnoCSS 带来的原子化灵活性与性能优势。在不改变核心架构的前提下,TinyPro 变得更轻盈、更顺滑,也更符合移动时代的使用场景。

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyPro源码:github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyPro、TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

深度复刻小米AI官网交互动画

作者 SmartNorth
2026年1月22日 16:47

近日在使用小米AI大模型MIMO时,被其顶部的透视跟随动画深深吸引,移步官网( mimo.xiaomi.com/zh/

效果演示

效果图.gif

1. 交互梳理

  1. 初始状态底部有浅色水印,且水印奇数行和偶数行有错位
  2. 初始状态中间文字为黑色的汉字
  3. 鼠标移入后,会在以鼠标为中心形成一个黑色圆形,黑色圆中有第二种背景水印,且水印依旧奇数行和偶数行有错位
  4. 鼠标移动到中间汉字部分,会有白色英文显示
  5. 鼠标迅速移动时,会根据鼠标移动轨迹有一个拉伸椭圆跟随,然后恢复成圆形的动画效果

现在基于这个交互的拆解,逐步来复刻交互效果

2. 组件结构与DOM设计

2.1 模板结构

采用「静态底层+动态上层」的双层视觉结构,通过CSS绝对定位实现图层叠加,既保证初始状态的视觉完整性,又能让交互效果精准作用于上层,不干扰底层基础展示。两层分工明确,具体如下:

图层 类名 内容 功能
底层 .z-1 中文标题 "你好,世界!" 和灰色 "HELLO" 文字矩阵 静态背景展示
上层 .z-2 英文标题 "Hello , World!" 和白色 "HELLO" 文字矩阵 鼠标交互时的动态效果层

2.2 核心 DOM 结构

<div class="container" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousemove="onMouseMove">
  <!-- 底层内容 -->
  <div class="z-1">
    <div class="line" v-for="line in 13">
      <span class="line-item" v-for="item in 13">HELLO</span>
    </div>
  </div>
  <h1 class="title-1">你好,世界!</h1>
  
  <!-- 上层交互内容 -->
  <div class="z-2" :style="{ 'clip-path': circleClipPath }">
    <div class="hidden-div">
      <div class="line" v-for="line in 13">
        <span class="line-item" v-for="item in 13">HELLO</span>
      </div>
    </div>
    <h1 class="title-2">Hello , World!</h1>
  </div>
</div>

关键说明:hidden-div用于包裹上层文字矩阵,配合.z-2的定位规则,确保遮罩效果精准覆盖;两层文字矩阵尺寸一致,保证视觉对齐,增强透视沉浸感。

3. 技术实现

3.1 核心功能模块

3.1.1 轨迹点系统

轨迹点系统是实现平滑鼠标跟随效果的核心,通过维护6个轨迹点的位置信息,创建出具有弹性延迟的跟随动画。

// 轨迹点系统 
const trailSystem = ref({
  targetX: 0,
  targetY: 0,
  trailPoints: Array(6).fill(null).map(() => ({ x: 0, y: 0 })),
  animationId: 0,
  isInside: false
});

设计思路:6个轨迹点是兼顾流畅度与性能的平衡值——点太少则拖尾效果不明显,点太多则增加计算开销,配合递减阻尼系数,实现“头快尾慢”的自然跟随。

3.1.2 动态 Clip-Path 计算

通过计算鼠标位置和轨迹点的关系,动态生成 clip-path CSS 属性值,实现跟随鼠标的圆形/椭圆形遮罩效果。

// 计算clip-path值
const circleClipPath = computed(() => {
  if (!showCircle.value) {
    return 'circle(0px at -300px -300px)'; // 完全隐藏状态
  }

  // 复制轨迹系统数据进行计算
  const system = JSON.parse(JSON.stringify(trailSystem.value));
  
  // 更新轨迹点
  for (let t = 0; t < 6; t++) {
    const prevX = t === 0 ? system.targetX : system.trailPoints[t - 1].x;
    const prevY = t === 0 ? system.targetY : system.trailPoints[t - 1].y;
    const damping = 0.7 - 0.04 * t; // 阻尼系数,后面的点移动更慢
    
    const deltaX = prevX - system.trailPoints[t].x;
    const deltaY = prevY - system.trailPoints[t].y;
    
    // 平滑插值
    system.trailPoints[t].x += deltaX * damping;
    system.trailPoints[t].y += deltaY * damping;
  }
  
  // 获取第一个点(头部)和最后一个点(尾部)
  const head = system.trailPoints[0];
  const tail = system.trailPoints[5];
  
  const diffX = head.x - tail.x;
  const diffY = head.y - tail.y;
  const distance = Math.sqrt(diffX * diffX + diffY * diffY);
  
  let clipPathValue = '';
  
  if (distance < 10) { // 如果距离很近,显示圆形
    clipPathValue = `circle(200px at ${head.x}px ${head.y}px)`;
  } else {
    // 创建椭圆形的polygon,连接头尾两点
    const angle = Math.atan2(diffY, diffX); // 连接角度
    const points = [];
    
    // 从头部开始,画半个椭圆
    for (let i = 0; i <= 30; i++) {
      const theta = angle - Math.PI / 2 + Math.PI * i / 30;
      const x = head.x + 200 * Math.cos(theta);
      const y = head.y + 200 * Math.sin(theta);
      points.push(`${x}px ${y}px`);
    }
    
    // 从尾部开始,画另半个椭圆
    for (let i = 0; i <= 30; i++) {
      const theta = angle + Math.PI / 2 + Math.PI * i / 30;
      const x = tail.x + 200 * Math.cos(theta);
      const y = tail.y + 200 * Math.sin(theta);
      points.push(`${x}px ${y}px`);
    }
    
    clipPathValue = `polygon(${points.join(', ')})`;
  }
  
  return clipPathValue;
});

3.1.3 鼠标事件处理

实现了完整的鼠标交互逻辑,包括鼠标进入、离开和移动时的状态管理和动画控制。

事件 处理函数 功能
mouseenter onMouseEnter 激活交互效果,初始化轨迹点
mouseleave onMouseLeave 停用交互效果,重置轨迹点
mousemove onMouseMove 更新目标点位置,驱动动画

4. 技术亮点

4.1 轨迹点系统算法

核心原理:使用6个轨迹点,每个点跟随前一个点移动,并应用不同的阻尼系数,实现平滑的拖尾效果。

技术优势

  • 实现了自然的物理运动效果,比简单的线性跟随更具视觉吸引力
  • 通过阻尼系数的递减,创建出层次感和深度感
  • 算法复杂度低,性能消耗小,适合实时交互场景

4.2 动态 Clip-Path 技术

核心原理:利用CSS clip-path属性的动态特性,结合轨迹点位置计算,实时生成不规则遮罩,替代Canvas/SVG的图形绘制方案,用更轻量化的方式实现复杂视觉效果。

技术优势

  • 无依赖轻量化:无需引入任何图形库,纯CSS+JS即可实现,减少项目依赖体积,降低集成成本
  • 平滑过渡无卡顿:通过数值插值计算,实现圆形与椭圆形遮罩的无缝切换,无帧断裂感,视觉连贯性强
  • 渲染性能优化:配合 will-change: clip-path 提示浏览器,提前分配渲染资源,减少重排重绘,提升动画流畅度

5. 性能优化

  1. 渲染性能

    • 使用 will-change: clip-path 提示浏览器优化渲染
    • 合理使用 Vue 的响应式系统,避免不必要的重计算
  2. 事件处理

    • 仅在鼠标在容器内时更新目标点位置,减少计算量
    • 鼠标离开时停止动画,释放资源
  3. 动画性能

    • 使用 requestAnimationFrame 实现流畅的动画效果
    • 鼠标离开时取消动画帧请求,避免内存泄漏

6. 总结与扩展

本次复刻的小米MiMo透视动画,核心价值在于“用简单技术组合实现高级视觉效果”——无需复杂图形库,仅依托Vue3响应式能力与CSS clip-path属性,就能打造出兼具质感与性能的交互组件。其核心亮点可概括为三点:

  • 交互创新:轨迹点系统与动态clip-path结合,打破传统静态标题的交互边界,带来自然流畅的鼠标跟随体验
  • 视觉精致:双层文字矩阵的分层设计,配合遮罩形变,营造出兼具深度感与品牌性的视觉效果
  • 性能可控:轻量化技术方案+多维度优化策略,在保证视觉效果的同时,兼顾页面性能与可维护性

扩展方向

该组件的实现思路可灵活迁移至其他场景:

  • 弹窗过渡动画:将clip-path遮罩用于弹窗进入/退出效果,实现不规则形状的过渡动画。
  • 滚动动效:结合滚动事件替换鼠标事件,实现页面滚动时的元素透视跟随效果。
  • 移动端适配:增加触摸事件支持,将鼠标交互替换为触摸滑动,适配移动端场景。

完整代码

<template>
  <div class="hero-container" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousemove="onMouseMove">
    <div class="z-1">
      <div class="line" v-for="line in 13">
        <span class="line-item" v-for="item in 13">HELLO</span>
      </div>
    </div>
    <h1 class="title-1">你好,世界</h1>

    <!-- 第二个div,鼠标移入后需要显示的内容,通过clip-path:circle(0px at -300px -300px)达到隐藏效果 -->
    <div class="z-2" :style="{ 'clip-path': circleClipPath }">
      <div class="hidden-div">
        <div class="line" v-for="line in 13">
          <span class="line-item" v-for="item in 13">HELLO</span>
        </div>
      </div>
      <h1 class="title-2">HELLO , World</h1>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'

const showCircle = ref(false)
const containerRef = ref(null)

const trailSystem = ref({
  targetX: 0,
  targetY: 0,
  trailPoints: Array(6)
    .fill(null)
    .map(() => ({ x: 0, y: 0 })),
  animationId: 0,
  isInside: false,
})

const circleClipPath = computed(() => {
  if (!showCircle.value) {
    return 'circle(0px at -300px -300px)'
  }

  // 复制轨迹系统数据进行计算
  const system = JSON.parse(JSON.stringify(trailSystem.value))

  // 更新轨迹点
  for (let t = 0; t < 6; t++) {
    const prevX = t === 0 ? system.targetX : system.trailPoints[t - 1].x
    const prevY = t === 0 ? system.targetY : system.trailPoints[t - 1].y
    const damping = 0.7 - 0.04 * t // 阻尼系数,后面的点移动更慢

    const deltaX = prevX - system.trailPoints[t].x
    const deltaY = prevY - system.trailPoints[t].y

    // 平滑插值
    system.trailPoints[t].x += deltaX * damping
    system.trailPoints[t].y += deltaY * damping
  }

  // 获取第一个点(头部)和最后一个点(尾部)
  const head = system.trailPoints[0]
  const tail = system.trailPoints[5]

  const diffX = head.x - tail.x
  const diffY = head.y - tail.y
  const distance = Math.sqrt(diffX * diffX + diffY * diffY)

  let clipPathValue = ''

  if (distance < 10) {
    // 如果距离很近,显示圆形
    clipPathValue = `circle(200px at ${head.x}px ${head.y}px)`
  } else {
    // 创建椭圆形的polygon,连接头尾两点
    const angle = Math.atan2(diffY, diffX) // 连接角度
    const points = []

    // 从头部开始,画半个椭圆
    for (let i = 0; i <= 30; i++) {
      const theta = angle - Math.PI / 2 + (Math.PI * i) / 30
      const x = head.x + 200 * Math.cos(theta)
      const y = head.y + 200 * Math.sin(theta)
      points.push(`${x}px ${y}px`)
    }

    // 从尾部开始,画另半个椭圆
    for (let i = 0; i <= 30; i++) {
      const theta = angle + Math.PI / 2 + (Math.PI * i) / 30
      const x = tail.x + 200 * Math.cos(theta)
      const y = tail.y + 200 * Math.sin(theta)
      points.push(`${x}px ${y}px`)
    }

    clipPathValue = `polygon(${points.join(', ')})`
  }

  return clipPathValue
})

// 动画循环函数
const animate = () => {
  if (showCircle.value) {
    // 更新轨迹点
    for (let t = 0; t < 6; t++) {
      const prevX = t === 0 ? trailSystem.value.targetX : trailSystem.value.trailPoints[t - 1].x
      const prevY = t === 0 ? trailSystem.value.targetY : trailSystem.value.trailPoints[t - 1].y
      const damping = 0.7 - 0.04 * t // 阻尼系数,后面的点移动更慢

      const deltaX = prevX - trailSystem.value.trailPoints[t].x
      const deltaY = prevY - trailSystem.value.trailPoints[t].y

      // 平滑插值
      trailSystem.value.trailPoints[t].x += deltaX * damping
      trailSystem.value.trailPoints[t].y += deltaY * damping
    }

    // 请求下一帧
    trailSystem.value.animationId = requestAnimationFrame(animate)
  }
}

const onMouseEnter = (event) => {
  const container = event.currentTarget
  const rect = container.getBoundingClientRect()
  const x = event.clientX - rect.left
  const y = event.clientY - rect.top

  showCircle.value = true

  // 初始化目标位置和轨迹点
  trailSystem.value.targetX = x
  trailSystem.value.targetY = y
  trailSystem.value.isInside = true

  // 初始化所有轨迹点到当前位置
  for (let i = 0; i < 6; i++) {
    trailSystem.value.trailPoints[i] = { x, y }
  }

  // 开始动画
  if (!trailSystem.value.animationId) {
    trailSystem.value.animationId = requestAnimationFrame(animate)
  }
}

const onMouseLeave = (event) => {
  const container = event.currentTarget
  const rect = container.getBoundingClientRect()
  const x = event.clientX - rect.left
  const y = event.clientY - rect.top

  showCircle.value = false
  trailSystem.value.isInside = false

  // 将目标点移出容器边界,使轨迹点逐渐拉回
  let targetX = x
  let targetY = y

  if (x <= 0) targetX = -400
  else if (x >= rect.width) targetX = rect.width + 400

  if (y <= 0) targetY = -400
  else if (y >= rect.height) targetY = rect.height + 400

  trailSystem.value.targetX = targetX
  trailSystem.value.targetY = targetY

  // 停止动画
  if (trailSystem.value.animationId) {
    cancelAnimationFrame(trailSystem.value.animationId)
    trailSystem.value.animationId = 0
  }
}

const onMouseMove = (event) => {
  if (showCircle.value) {
    const container = event.currentTarget
    const rect = container.getBoundingClientRect()
    const x = event.clientX - rect.left
    const y = event.clientY - rect.top

    trailSystem.value.targetX = x
    trailSystem.value.targetY = y
  }
}
</script>

<style scoped>
.hero-container {
  cursor: crosshair;
  background: #faf7f5;
  border-bottom: 1px solid #000;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 500px;
  display: flex;
  position: relative;
  overflow: hidden;
}

.z-1 {
  pointer-events: auto;
  -webkit-user-select: none;
  user-select: none;
  flex-direction: column;
  justify-content: flex-start;
  width: 100%;
  height: 100%;
  display: flex;
  position: absolute;
  top: 0;
  left: 0;
  overflow: hidden;
}

.z-1 .line {
  display: flex;
  align-items: center;
  white-space: nowrap;
  color: #0000000d;
  letter-spacing: 0.3em;
  flex-wrap: nowrap;
  font-size: 52px;
  font-weight: 700;
  line-height: 1.6;
  display: flex;
}

.z-1 .line-item {
  cursor: default;
  flex-shrink: 0;
  margin-right: 0.6em;
  transition:
    color 0.3s,
    text-shadow 0.3s;
  font-family: inherit !important;
}

.z-1 .line:nth-child(odd) {
  margin-left: -2em;
  background-color: rgb(245, 235, 228);
}

.title-1 {
  z-index: 1;
  color: #000;
  letter-spacing: 0.02em;
  text-align: center;
  margin: 0;
  font-size: 72px;
  font-weight: 700;
}

.z-2 {
  pointer-events: none;
  z-index: 10;
  will-change: clip-path;
  background: #000;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  display: flex;
  position: absolute;
  top: 0;
  left: 0;
}

.z-2 .hidden-div {
  pointer-events: none;
  -webkit-user-select: none;
  user-select: none;
  flex-direction: column;
  justify-content: flex-start;
  width: 100%;
  height: 100%;
  display: flex;
  position: absolute;
  top: 0;
  left: 0;
  overflow: hidden;
}

.z-2 .hidden-div .line {
  white-space: nowrap;
  color: #ffffff1f;
  letter-spacing: 0.3em;
  flex-wrap: nowrap;
  font-size: 32px;
  font-weight: 700;
  line-height: 1.6;
  display: flex;
}

.z-2 .hidden-div .line:nth-child(odd) {
  margin-left: -0.5em;
}

.title-2 {
  font-size: 72px;
  color: #fff;
  letter-spacing: 0.02em;
  text-align: center;
  white-space: nowrap;
  margin: 0;
  font-size: 72px;
  font-weight: 700;
}
</style>

小米的前端一直很牛,非常有创意,我也通过F12学习源码体会到了新的思路,希望大家也多多关注小米和小米的技术~

TS异步编程

2026年1月22日 16:45

Gemini生成

第一部分:核心概念 (Why & What)

在编程世界里,代码的执行方式主要分两种:同步 (Synchronous)异步 (Asynchronous)

1. 同步 (Synchronous) —— “死心眼的排队者”

概念: 代码从上到下,一行一行执行。上一行代码没有执行完,下一行代码绝对不会开始。

生活类比: 想象你在银行柜台办理业务。

  1. 你前面有一个人正在办业务(代码行 A)。
  2. 不管他办得有多慢,你(代码行 B)都只能在后面干站着等。
  3. 你不能玩手机,不能去上厕所,只能阻塞 (Block) 在那里,直到他结束。

代码表现:

console.log("1. 开始点餐");
alert("我是同步的弹窗,我不关掉,你什么都做不了!"); // 这里会卡住
console.log("2. 吃饭");

如果不点击弹窗的确定,"2. 吃饭" 永远不会打印出来。这就是“阻塞”。


2. 异步 (Asynchronous) —— “拿着取餐器的食客”

概念: 遇到耗时的任务(比如从网络下载图片、读取文件),程序不会傻等,而是把任务交给“别人”(浏览器或操作系统)去处理,自己继续往下执行后面的代码。等耗时任务做完了,再通知程序回来处理结果。

生活类比: 想象你在奶茶店点单。

  1. 你点了一杯制作很复杂的奶茶(耗时任务)。
  2. 店员没有让你站在柜台前盯着他做,而是给了你一个取餐器(回调/Promise),然后说:“你先去旁边坐着玩手机,好了震动叫你。”
  3. 你找个位置坐下(继续执行后续代码)。
  4. 过了一会儿,奶茶好了,取餐器震动,你去拿奶茶(处理异步结果)。

代码表现:

console.log("1. 点单:我要一杯奶茶");

// 这是一个模拟异步的函数,假设需要 2 秒钟
setTimeout(() => {
    console.log("3. 奶茶好了!(这是异步回来的结果)");
}, 2000);

console.log("2. 找个位置坐下玩手机");

控制台的打印顺序是:

  1. 1. 点单...
  2. 2. 找个位置... (注意:这里直接跳过了等待,先执行了!)
  3. (过了2秒后) 3. 奶茶好了...

3. 为什么 JavaScript/TypeScript 必须要有异步?

你可能会问:“同步多简单啊,逻辑清晰,为什么要搞这么复杂的异步?”

这和 JS 的出身有关:

  1. 单线程 (Single Thread):JavaScript(以及编译后的 TS)是单线程的。也就是说,它只有一个“大脑”,同一时间只能做一件事。它不像 Java 或 C++ 那样可以开启多条线程同时工作。
  2. 浏览器的体验
    • 假设你打开一个网页,它需要去服务器请求“用户列表”。
    • 如果使用同步:在数据请求回来的这 1-2 秒内,网页会完全卡死。你点击按钮没反应,滚动条滚不动,甚至无法关闭网页。这对用户体验是灾难性的。
    • 如果使用异步:请求发出去后,浏览器继续响应你的鼠标点击和滚动,等数据回来了,再悄悄把列表渲染到屏幕上。

总结第一部分:

  • 同步 = 顺序执行,会卡住(阻塞)。
  • 异步 = 不等待,继续往下走,回头再处理结果。
  • TS/JS 的特性 = 单线程,为了不让网页/程序卡死,必须大量使用异步。

第二部分:异步的演进史 (History)

JavaScript/TypeScript 的异步演进史,其实就是一部与“代码可读性”抗争的历史。我们的目标始终未变:让异步代码看起来像同步代码一样简单易懂。

我们分三个阶段来讲:


1. 第一阶段:上古时代 —— 回调函数 (Callback)

在 Promise 出现之前(大约是 2015 年 ES6 标准发布前),我们处理异步只有一种办法:回调函数

什么是回调? 简单来说,就是你定义一个函数,但你自己不调用它,而是把它作为参数传给另一个函数(比如网络请求函数)。你告诉对方:“等你做完你的事,回头(Call back)调用一下我这个函数,把结果传给我。”

场景模拟: 我们要去数据库获取用户信息。

// 定义一个回调函数的类型:接收 string 类型的数据,没有返回值
type MyCallback = (data: string) => void;

function getUserData(callback: MyCallback) {
    console.log("1. 开始向服务器请求数据...");
    
    // 模拟耗时 1 秒
    setTimeout(() => {
        console.log("2. 服务器返回数据了");
        const data = "张三";
        // 关键点:任务做完后,手动调用传进来的函数
        callback(data); 
    }, 1000);
}

// 使用
getUserData((name) => {
    console.log(`3. 拿到用户名:${name}`);
});

问题在哪里? 如果是这一层简单的调用,看起来还不错。但现实往往很残酷。


2.第二阶段:黑暗时代 —— 回调地狱 (Callback Hell)

场景升级: 现在的业务逻辑变成了这样,必须严格按顺序执行:

  1. 先获取用户名(比如 "张三")。
  2. 拿到用户名后,去数据库查他的ID
  3. 拿到 ID 后,去查他的订单

代码会变成什么样?

// 伪代码演示,注意看缩进的形状
getUserName((name) => {
    console.log(`拿到名字: ${name}`);
    
    // 在回调里面嵌套第二个请求
    getUserId(name, (id) => {
        console.log(`拿到ID: ${id}`);
        
        // 在回调里面嵌套第三个请求
        getUserOrders(id, (orders) => {
            console.log(`拿到订单: ${orders}`);
            
            // 如果还有第四步... 屏幕就要炸了
            getOrderDetails(orders[0], (detail) => {
                // ...
            });
        });
    });
});

这就是著名的“回调地狱”(也就是“厄运金字塔”):

  1. 代码横向发展:缩进越来越深,阅读极其困难。
  2. 错误处理灾难:你需要在每一层回调里单独写 if (error) ...,极其容易漏掉。
  3. 维护困难:想调整一下顺序?你要小心翼翼地拆括号,很容易改崩。

3.第三阶段:曙光初现 —— Promise (承诺)

为了解决“回调地狱”,社区提出了一种新的规范,后来被纳入了 ES6 标准,这就是 Promise

什么是 Promise? 它是一个对象,代表了“一个未来才会知道结果的操作”。 你可以把它想象成一张披萨店的取餐小票。 当你拿到这个 Promise(小票)时,披萨还没好,但它承诺未来会给你两个结果中的一个:

  1. Fulfilled (成功):披萨做好了,给你披萨。
  2. Rejected (失败):烤箱炸了,给你一个错误原因。

Promise 最大的贡献:链式调用 (Chaining) 它把“回调地狱”的横向嵌套,拉直成了纵向的链条。

TypeScript 中的 Promise 写法:

我们看看上面的“回调地狱”用 Promise 改写后是什么样:

// 假设这些函数现在返回的是 Promise,而不是接受回调
// getUserName() -> 返回 Promise<string>

getUserName()
    .then((name) => {
        console.log(`拿到名字: ${name}`);
        // 返回下一个异步任务,继续往下传
        return getUserId(name); 
    })
    .then((id) => {
        console.log(`拿到ID: ${id}`);
        return getUserOrders(id);
    })
    .then((orders) => {
        console.log(`拿到订单: ${orders}`);
    })
    .catch((error) => {
        // 重点:这里一个 catch 可以捕获上面任何一步发生的错误!
        console.error("出错了:", error);
    });

完整代码

// 1. 获取用户名的函数
// 返回值类型:Promise<string> -> 承诺未来会给出一个 string
function getUserName(): Promise<string> {
    return new Promise((resolve, reject) => {
        console.log("--- 1. 开始请求用户名 ---");
        
        // 模拟网络耗时 1秒
        setTimeout(() => {
            const isSuccess = true; // 模拟:假设请求成功

            if (isSuccess) {
                // 成功了!调用 resolve,把数据 "张三" 传出去
                // 这个 "张三" 会传给下一个 .then((name) => ...) 里的 name
                resolve("张三"); 
            } else {
                // 失败了!调用 reject
                // 这会跳过后面的 .then,直接进入最后的 .catch
                reject("获取用户名失败:网络连接断开"); 
            }
        }, 1000);
    });
}

// 2. 获取用户ID的函数
// 接收参数 name,返回 Promise<number>
function getUserId(name: string): Promise<number> {
    return new Promise((resolve, reject) => {
        console.log(`--- 2. 正在查 ${name} 的ID ---`);

        setTimeout(() => {
            // 假设我们查到了 ID 是 10086
            resolve(10086);
        }, 1000);
    });
}

// 3. 获取订单的函数
// 接收参数 id,返回 Promise<string[]> (字符串数组)
function getUserOrders(id: number): Promise<string[]> {
    return new Promise((resolve, reject) => {
        console.log(`--- 3. 正在查 ID:${id} 的订单 ---`);

        setTimeout(() => {
            // 返回订单列表
            resolve(["奶茶", "炸鸡", "Switch游戏机"]);
        }, 1000);
    });
}

// --- 实际调用部分(就是你刚才看到的那段代码) ---

console.log("程序启动...");

getUserName()
    .then((name) => {
        // 这里接收到的 name 就是 resolve("张三") 里的 "张三"
        console.log(`✅ 拿到名字: ${name}`);
        
        // 关键点:这里 return 了下一个 Promise 函数的调用
        // 这样下一个 .then 才会等到 getUserId 完成后才执行
        return getUserId(name); 
    })
    .then((id) => {
        // 这里接收到的 id 就是 resolve(10086) 里的 10086
        console.log(`✅ 拿到ID: ${id}`);
        return getUserOrders(id);
    })
    .then((orders) => {
        // 这里接收到的 orders 就是那个数组
        console.log(`✅ 拿到订单: ${orders}`);
    })
    .catch((error) => {
        console.error(`❌ 流程中断: ${error}`);
    })
    .finally(() => {
        // (可选) finally 不管成功失败都会执行
        console.log("--- 流程结束 ---");
    });

Promise 的核心状态(面试常考): 一个 Promise 一定处于以下三种状态之一:

  1. Pending (进行中):刚初始化,还没结果。
  2. Fulfilled / Resolved (已成功):操作成功,调用了 .then
  3. Rejected (已失败):操作失败,调用了 .catch

TS 类型小贴士: 在 TypeScript 中,Promise 是有泛型的。 如果一个异步函数最终返回一个字符串,它的类型是 Promise<string>。 如果返回一个数字,类型是 Promise<number>

// 这是一个返回 Promise 的函数定义示例
function wait(ms: number): Promise<string> {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve("时间到!");
        }, ms);
    });
}

总结第二部分:

  • 回调函数:最原始,容易导致嵌套过深(回调地狱)。
  • Promise:通过 .then() 链式调用,把代码拉直了,解决了缩进问题,并且统一了错误处理(.catch())。

但是……你有没有发现,Promise 虽然比回调好,但还是有很多 .then()?代码里充斥着很多小括号和箭头函数,看起来依然不像我们要的“同步代码”。

这就是为什么我们需要第三部分:Async/Await(终极解决方案)。


第三部分:现代标准写法 (Async/Await)

好的,来到最激动人心的部分了!Async/Await 是现代 JavaScript/TypeScript 开发的标配

学会了这个,你就不再需要写那些繁琐的 .then 链条了。代码会变得像写同步代码(比如 Java 或 Python)一样直观。


1. 什么是 Async/Await?

  • Async/Await 是在 ES2017 (ES8) 引入的新语法。
  • 它本质上是 Promise 的语法糖
    • 也就是说,底层依然在跑 Promise,只是写法变了,机器执行的逻辑没变。
  • async:放在函数定义前,表示“这个函数内部有异步操作”。
  • await:放在 Promise 前面,表示“等一下,直到这个 Promise 出结果(resolve)了,再往下走”。

2. 代码对比:从 Promise 到 Async/Await

让我们用刚才定义的三个函数(getUserName, getUserId, getUserOrders)来演示。它们本身的定义不需要改,只需要改调用的方式

旧写法 (Promise 链式调用)

哪怕逻辑再清晰,依然有很多回调函数嵌套。

function runOldWay() {
    getUserName()
        .then(name => getUserId(name))
        .then(id => getUserOrders(id))
        .then(orders => console.log(orders))
        .catch(err => console.error(err));
}

新写法 (Async/Await)

看!没有回调函数了!全是赋值语句!

// 1. 必须在函数前加 async 关键字
async function runNewWay() {
    try {
        console.log("开始任务...");

        // 2. 使用 await 等待结果,直接赋值给变量
        // JS 引擎运行到这里会暂停,直到 getUserName 里的 resolve 被调用
        const name = await getUserName(); 
        console.log(`拿到名字: ${name}`);

        // 上一行没拿到结果前,这一行绝不会执行
        const id = await getUserId(name);
        console.log(`拿到ID: ${id}`);

        const orders = await getUserOrders(id);
        console.log(`拿到订单: ${orders}`);

    } catch (error) {
        // 3. 错误处理回归原始的 try...catch
        // 只要上面任何一个 await 的 Promise 被 reject,就会跳到这里
        console.error("出错了:", error);
    }
}

// 调用这个异步函数
runNewWay();

3. 深度解析:await 到底做了什么?

当你写下 const name = await getUserName(); 时,发生了什么?

  1. 暂停执行:函数 runNewWay 的执行被暂停在这一行。
  2. 让出线程:虽然 runNewWay 停了,但主线程没有卡死(没有阻塞)。浏览器可以去处理点击事件、渲染动画,或者执行 runNewWay 外面的其他代码。
  3. 等待结果getUserName 在后台跑(比如等待网络请求)。
  4. 恢复执行:一旦 getUserName 完成并 resolve 了结果,runNewWay 会被“唤醒”。结果被赋值给 name,然后继续执行下一行代码。

注意: await 只能用在 async 函数内部。(虽然最新的 TS/JS 支持 Top-level await,但在普通函数里还是不行的)。


4. TypeScript 里的 Async 函数类型

在 TypeScript 中,async 函数的返回值类型永远是 Promise

就算你 return 的是一个普通数字,TS 也会自动帮你不装成 Promise。

// 普通函数
function add(a: number, b: number): number {
    return a + b;
}

// Async 函数
// 虽然看起来 return 3,但 TS 推断出的返回类型是 Promise<number>
async function addAsync(a: number, b: number): Promise<number> {
    return a + b; 
}

// 调用时必须处理 Promise
const result = addAsync(1, 2); // result 是 Promise<number>
// 正确用法:
// await addAsync(1, 2) 或 addAsync(1, 2).then(...)

代码分析

const result = addAsync(1, 2);: 无论有没有加await, async函数都是返回Promise<>对象. 如果没有添加await, 依然会执行该异步函数, 但是不会在这里等待, 会立刻执行下面的函数, 这个addAsync函数就在后台默默执行. 有时候会故意不写await, 比如下面这个场景:

async function initPage() {
    // 发送两个请求,但我不想串行等待(不希望 A 完了才做 B)
    const taskA = getUserInfo(); // 没写 await,请求发出去了
    const taskB = getBanners();  // 没写 await,请求也发出去了
    
    console.log('两个请求都已经发出去了,正在后台跑...');

    // 稍后我再一起等它们的结果
    const user = await taskA;
    const banner = await taskB;
}

总结第三部分

  1. 写法更像同步:用 try...catch 替代 .catch,用赋值替代 .then
  2. 可读性飞跃:代码逻辑从上到下,符合人类阅读习惯。
  3. 调试方便:在 await 这一行打断点,你可以清楚地看到之前的变量状态,这在 .then 链条里是很难做到的。

现在,你已经掌握了最主流的异步写法。

接下来,我们要进入第四部分:TypeScript 里的异步类型。这部分会教你如何在真实的工作中(比如调用后端 API)定义那些复杂的数据接口。这一步是 TS 开发者的日常。


第四部分:TypeScript 里的异步类型 (TS Specifics)

前面的内容其实大都也是 JavaScript 的知识(除了简单的类型标注)。到了这里,我们要讲只有 TypeScript 才能提供的强大功能:如何在异步操作中获得完美的类型提示和安全保障。

这一部分对于前端开发(尤其是对接后端 API)至关重要。


1. Promise 的泛型:Promise<T>

我们在前面的例子里稍微提到了这个。Promise 是一个泛型类。 这就好比 Array<number> 表示“装数字的数组”,Promise<User> 表示“承诺未来给你一个 User 对象”。

基本语法:

// 函数返回值类型
function fetchData(): Promise<TypeOfTheResult> { ... }

实战场景:定义 API 响应结构

假设后端给你这样一个 JSON 数据结构:

// 后端返回的用户数据
{
    "id": 1,
    "username": "admin",
    "isActive": true
}

步骤一:定义 Interface (接口) 我们要先告诉 TS,这个数据长什么样。

interface User {
    id: number;
    username: string;
    isActive: boolean;
    // 甚至可以有可选属性
    avatarUrl?: string; 
}

步骤二:在异步函数中使用

// 这里的返回值类型 Promise<User> 非常重要!
async function fetchCurrentUser(): Promise<User> {
    const response = await fetch('/api/user');
    // 解析 JSON
    const data = await response.json();
    
    // 这里其实有一个类型断言的过程,告诉 TS 这个 data 就是 User
    // 在实际项目中,通常 fetch 封装库(如 axios)会帮我们做泛型传递
    return data as User; 
}

步骤三:享受类型提示

当你调用这个函数时,神奇的事情发生了:

async function main() {
    const user = await fetchCurrentUser();
    
    // 当你敲下 user. 的时候,VS Code 会自动弹窗提示:
    // - id
    // - username
    // - isActive
    // - avatarUrl
    console.log(user.username); 
    
    // 如果你拼写错误,立刻报错!
    console.log(user.usrname); // ❌ 报错:User 类型上不存在 usrname
}

2. 实战技巧:配合 Axios (最常用的请求库)

在真实工作中,我们通常使用 axios 库来发请求。axios 的类型定义非常完善,支持传入泛型。

import axios from 'axios';

// 1. 定义接口
interface Article {
    title: string;
    content: string;
    views: number;
}

// 2. 发送请求
async function getArticle(id: number) {
    // axios.get 是个泛型函数:axios.get<T>(url)
    // 我们传入 <Article>,告诉 axios 返回的数据体 data 是 Article 类型
    const response = await axios.get<Article>(`/api/articles/${id}`);
    
    // response.data 现在的类型就是 Article
    return response.data;
}

// 3. 调用
async function showArticle() {
    const article = await getArticle(101);
    // 此时 article 就是 Article 类型
    console.log(article.title); // ✅ 安全
}

3. 处理“可能是多种类型”的情况

有时候异步操作可能会返回不同的结果,或者可能失败。

场景: 搜索用户,可能找到,也可能没找到(null)。

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

// 返回值类型是 UserInfo 或者 null
async function findUser(name: string): Promise<UserInfo | null> {
    if (name === 'Ghost') {
        return null;
    }
    return { name: 'RealUser', age: 18 };
}

async function check() {
    const user = await findUser('Ghost');
    
    // 这里 user 可能是 null,TS 会强迫你做检查
    // console.log(user.name); // ❌ 报错:user 可能为 null

    if (user) {
        console.log(user.name); // ✅ 现在安全了
    }
}

总结第四部分

  1. 核心思维:写异步函数时,第一件事不是写逻辑,而是先想好返回值类型Promise<T>)。
  2. 接口先行:把后端返回的 JSON 数据结构定义为 interface
  3. 工具库配合:使用 Axios 等支持泛型的库,把 interface 传进去,这样从请求结果里拿到的数据就会自带类型提示。

这一步做好了,你的代码健壮性会提升一个档次,再也不用担心拼错字段名或者不知道后端返回了啥。

准备好进入最后一部分了吗?我们将讨论实战中的错误处理并行技巧(比如怎么让两个请求同时发,而不是一个等一个)。


第五部分:实战与错误处理 (Best Practices)

好,我们进入最后一部分:实战与错误处理 (Best Practices)

这部分是区分“新手”和“熟练工”的分水岭。新手写的异步代码往往在网络正常时能跑,一旦网络抖动或者需要优化性能时就崩了。


1. 优雅的错误处理 (try...catch)

在 Async/Await 模式下,我们使用传统的 try...catch 来捕获异步错误。

基本套路:

async function safeGetData() {
    try {
        // 可能会炸的代码放在 try 里
        const data = await fetchData();
        console.log("成功:", data);
    } catch (error) {
        // 1. 网络断了
        // 2. 服务器 500 了
        // 3. JSON 解析失败了
        // 所有错误都会汇聚到这里
        console.error("出大问题了:", error);
        
        // TS 小坑:catch(error) 这里的 error 默认类型是 unknown 或 any
        // 如果要访问 error.message,最好断言一下
        if (error instanceof Error) {
            console.log("错误信息:", error.message);
        }
    } finally {
        // (可选) 无论成功失败都会执行,适合关闭 loading 动画
        console.log("关闭 Loading 转圈圈");
    }
}

为什么这很重要? 如果不写 try...catch,一旦 await 的 Promise 失败(Rejected),整个函数会抛出异常。如果上层也没人捕获,你的程序可能会崩溃(在 Node.js 中可能会导致进程退出,在前端会导致控制台报红且后续逻辑中断)。


2. 并行处理 (Promise.all) —— 性能优化神器

这是面试和实战中极高频的考点。

场景: 你需要在一个页面同时展示“用户信息”和“最近订单”。这俩接口互不相关。

新手写法 (串行 - 慢): 就像排队,先买奶茶,买完再排队买炸鸡。

async function loadPageSerial() {
    console.time("串行耗时");
    
    const user = await getUser();       // 假设耗时 1s
    const orders = await getOrders();   // 假设耗时 1s
    
    // 总耗时:1s + 1s = 2s
    console.timeEnd("串行耗时");
}

高手写法 (并行 - 快): 我和朋友分头行动,我买奶茶,他买炸鸡,最后一起吃。

async function loadPageParallel() {
    console.time("并行耗时");
    
    // 技巧:Promise.all 接收一个 Promise 数组
    // 它会同时启动数组里的所有任务
    const [user, orders] = await Promise.all([
        getUser(),      // 任务 A
        getOrders()     // 任务 B
    ]);
    
    // 总耗时:max(1s, 1s) = 1s
    // 只有当两个都完成了,await 才会继续往下走
    console.timeEnd("并行耗时");
    
    console.log(user, orders);
}

Promise.all 的特点:

  1. 全成则成:只有数组里所有 Promise 都成功了,它才成功。
  2. 一败则败:只要有一个失败了,整个 Promise.all 直接抛出错误(进入 catch),其他的成功了也没用。

进阶:Promise.allSettled (ES2020) 如果你不希望“一败则败”(比如用户信息挂了,但我还是想展示订单),可以使用 Promise.allSettled。它会等待所有任务结束,不管成功还是失败,并返回每个任务的状态。


3. 一个常见的循环陷阱

需求: 有一个用户 ID 列表 [1, 2, 3],要依次获取他们的详细信息。

错误写法 (forEach):

async function wrongLoop() {
    const ids = [1, 2, 3];
    
    // ❌ 这种写法 await 不生效!forEach 不支持 async 回调
    ids.forEach(async (id) => {
        const user = await getUser(id);
        console.log(user);
    });
    
    console.log("结束了?"); 
    // 实际结果:先打印 "结束了?",然后那 3 个请求才在后台慢慢跑。
}

正确写法 1 (for...of) —— 串行(一个接一个):

async function serialLoop() {
    const ids = [1, 2, 3];
    
    for (const id of ids) {
        // ✅ 能够正确暂停,拿完 id:1 再拿 id:2
        const user = await getUser(id);
        console.log(user);
    }
    console.log("真·结束了");
}

正确写法 2 (map + Promise.all) —— 并行(同时跑):

async function parallelLoop() {
    const ids = [1, 2, 3];
    
    // 1. 先把 ID 数组映射成 Promise 数组
    const promises = ids.map(id => getUser(id));
    
    // 2. 再用 Promise.all 等待它们全部完成
    const users = await Promise.all(promises);
    
    console.log("所有用户都拿到:", users);
}

全文大总结

恭喜你,你已经走完了 TypeScript 异步编程的完整路径!

  1. 核心概念:JS 是单线程的,为了不阻塞,必须用异步。
  2. 演进史:Callback (回调地狱) -> Promise (链式) -> Async/Await (最终形态)
  3. TS 类型:使用 Promise<T> 和接口 (Interface) 来约束异步数据的形状,获得极致的代码提示。
  4. 实战技巧
    • try...catch 兜底错误。
    • Promise.all 做并发优化。
    • 千万别在 forEach 里用 await

\

Dialog组件状态建模规则

作者 希图
2026年1月22日 16:42

本文所说的组件状态建模规则,特别适用于:Dialog 生命周期长、渲染早于数据的组件

核心设计目标

UI 状态建模(template)的第一目标不是语义最精确,而是结构稳定、可渲染、可推导

简单说,template绑定的变量初始值不能为undefined或者null,最好是预定义的空模板。

二、基础概念划分(这是地基)

区分三种“状态层级”

层级 例子 规则
UI 结构状态 表单字段、列表项、dialog 内容 必须结构稳定
UI 行为状态 visible / loading / disabled 可 boolean / enum
业务数据状态 接口返回对象 可 null / undefined

template建模只会和UI结构状态和行为状态有关,和业务数据状态无关。

三、最重要的规则(90% 的坑在这里)

规则 1:**template 绑定的数据,禁止null,推荐属性确定的空数据结构

不推荐

const element = ref(null)
{{ element.id }}

推荐

const element = ref({
  id: '',
  name: '',
})

理由不是“防报错”这么简单,而是:

render / computed / watch(immediate)
会在“业务数据尚未准备好”之前运行

规则 2:null 表示“概念不存在”,而 UI 中很少真的“不存在”

状态 推荐建模
UI状态尚未准备好 空的属性确定的数据结构
业务对象不存在 null
接口失败 error state

四、关于 computed / watch 的建模规则

规则 3:template绑定的computed = 一开始就要有稳定的数据结构

computed从undefined或者null变化为{id:'xxx'},这就称作不稳定

// 不稳定
const id = computed(() => props.element.id)

稳定方案一(首选)

props.element = { id: '' }

稳定方案二(兜底)

const id = computed(() => props.element?.id ?? '')

方案二是 防御,不是建模优雅

规则 4:watch(immediate) 必须当作“setup 同步代码”对待

watch(
  () => props.element,
  (el) => {
    // 这里 ≈ setup 中直接访问
  },
  { immediate: true }
)

所以规则是:

凡是会被 watch(immediate) 读取的数据
都必须在 setup 结束前是安全的

安全的意思是watch的回调函数中需要用guard子句排除到props.element是undefined或者null这种情况。不然会报错。

规则 5:composable 永远假设“调用方是不可靠的”

useSomething(element)

composable 内部必须:

  • guard 参数
  • 不假设结构存在
  • 不信任生命周期顺序
if (!element || !element.id) return

这是 composable 的防御职责 如果你在组件内部写满 if (!xxx) return
那说明状态模型有问题

规则 6:弹框类组件 = 提前存在,延后可见

visible = false // 控制显示
element = {id:"", ...}    // 内容占位

不要用 visible = false 的同时element=ref(null)

这里又一次说明null和空数据结构的区别:null表示不存在,空数据结构表示存在,但内容未准备好。不存在的就不能正常渲染,空的数据结构是可以正常渲染的。

相关知识

vue组件首次渲染执行任务顺序 vue列表渲染设计决策

小程序跳转H5页面实现指定页面回跳小程序 - Uniapp项目解决方案

作者 碎碎念念
2026年1月22日 16:36

小程序跳转H5页面实现指定页面回跳小程序 - Uniapp项目解决方案

⚡ 快速开始

如果你只想快速实现功能,只需三步:

  1. 确保已安装依赖npm install weixin-js-sdk
  2. 确保 main.js 已引入Vue.prototype.wxdk = wxdk(项目已配置)
  3. 在页面中引入 mixin
import miniProgramBackMixin from '@/common/mixins/miniProgramBack'

export default {
  mixins: [miniProgramBackMixin],
  // ... 其他代码
}

就这么简单!mixin 会自动处理所有逻辑。


📋 问题背景

在微信小程序开发中,我们经常会遇到这样的场景:小程序通过 web-view 组件跳转到 H5 页面,用户完成 H5 操作后,希望点击浏览器返回键能够返回到小程序。然而,默认情况下,H5 页面的返回操作只会触发浏览器历史记录的回退,无法返回到小程序,这与我们的期望效果不符。

根据微信官方文档,我们可以使用 wx.miniProgram.navigateBack() 接口来实现从 H5 页面返回到小程序的功能。

🎯 解决方案

通过监听 H5 页面的 popstate 事件(浏览器返回操作),在检测到返回行为时,调用微信小程序的 navigateBack 接口,实现返回到小程序的效果。

核心思路

  1. 环境检测:使用 wx.miniProgram.getEnv() 检测当前是否在小程序 WebView 环境中
  2. 历史记录注入:在页面加载时注入一个历史记录,用于拦截返回操作
  3. 返回拦截:监听 popstate 事件,当触发返回时调用小程序返回接口
  4. 层级管理:记录页面入口层级,避免子页面返回误触发小程序返回

📦 实现步骤

步骤一:安装依赖

npm install weixin-js-sdk

步骤二:在 main.js 中引入并挂载

// main.js
import wx from 'weixin-js-sdk'

// Vue 2.x
Vue.prototype.wx = wx

// Vue 3.x (Composition API)
app.config.globalProperties.wx = wx

步骤三:在 H5 页面中实现返回拦截

在需要实现返回小程序功能的 Vue 页面中添加以下代码:

<template>
  <!-- 你的页面内容 -->
</template>

<script>
export default {
  name: 'YourPage',
  data() {
    return {
      // 小程序返回拦截相关状态
      miniProgramBackHandler: null,      // popstate 事件处理器
      miniProgramBackHooked: false,      // 是否已注册拦截
      miniProgramBackDepth: 0            // 记录进入页面时的 history depth
    }
  },
  onLoad() {
    // #ifdef H5
    // 页面加载时初始化拦截,避免首次返回未被接管
    this.setupMiniProgramWebviewBack()
    // #endif
  },
  onUnload() {
    // 页面卸载时清理事件监听器
    if (this.miniProgramBackHandler && typeof window !== 'undefined') {
      window.removeEventListener('popstate', this.miniProgramBackHandler)
      this.miniProgramBackHandler = null
      this.miniProgramBackHooked = false
    }
  },
  methods: {
    /**
     * 小程序webview场景:拦截H5返回行为,返回小程序
     * @description 通过监听 popstate 事件,在用户点击返回时调用小程序返回接口
     */
    setupMiniProgramWebviewBack() {
      // 防止重复注册
      if (this.miniProgramBackHooked || typeof window === 'undefined') {
        return
      }

      // 获取微信小程序桥接对象
      const bridge = this.wx?.miniProgram
      if (!bridge?.getEnv) {
        console.warn('[小程序返回拦截] wxdk.miniProgram.getEnv 不存在,无法拦截返回')
        return
      }

      // 检测当前环境
      bridge.getEnv((res = {}) => {
        console.log('[小程序返回拦截] getEnv 返回:', res)
      
        // 非小程序 WebView 环境,跳过拦截
        if (!res.miniprogram) {
          console.warn('[小程序返回拦截] 当前非小程序 WebView,跳过拦截')
          return
        }

        // 注入历史记录,用于拦截返回操作
        window.history.pushState({ miniProgramBack: true }, '')
      
        // 记录当前层级,只有回退到该层级及以下才触发返回小程序
        this.miniProgramBackDepth = window.history.length
        console.log('[小程序返回拦截] 已注入拦截,入口层级:', this.miniProgramBackDepth)

        // 创建返回拦截处理器
        this.miniProgramBackHandler = () => {
          // 获取当前历史栈深度
          const currentDepth = window.history.length
        
          // 若当前历史栈仍高于入口层级,说明只是从子页面返回,不拦截
          if (currentDepth > this.miniProgramBackDepth) {
            console.log('[小程序返回拦截] 子页回退,不拦截', { 
              currentDepth, 
              base: this.miniProgramBackDepth 
            })
            return
          }

          // 定义降级方案
          const fallback = () => {
            if (this.wx?.closeWindow) {
              console.log('[小程序返回拦截] 调用 closeWindow 作为回退')
              this.wx.closeWindow()
            } else {
              console.log('[小程序返回拦截] 使用 history.go(-1) 作为回退')
              window.history.go(-1)
            }
          }

          // 尝试调用小程序返回接口
          try {
            bridge.navigateBack({
              delta: 1,  // 返回的页面数,默认为1
              success: () => {
                console.log('[小程序返回拦截] navigateBack 成功触发返回小程序')
              },
              fail: (err) => {
                console.warn('[小程序返回拦截] navigateBack 失败,尝试 fallback', err)
                fallback()
              }
            })
          } catch (err) {
            console.error('[小程序返回拦截] navigateBack 异常,fallback', err)
            fallback()
          }
        }

        // 注册 popstate 事件监听器
        window.addEventListener('popstate', this.miniProgramBackHandler)
        this.miniProgramBackHooked = true
        console.log('[小程序返回拦截] popstate 监听已注册')
      })
    }
  }
}
</script>

🔍 代码说明

关键点解析

  1. 环境检测:使用 wx.miniProgram.getEnv() 检测是否在小程序 WebView 中,避免在普通浏览器中执行无效操作。
  2. 历史记录注入:通过 window.history.pushState() 注入一个历史记录,这样当用户点击返回时,会先触发 popstate 事件,而不是直接返回。
  3. 层级管理:记录页面加载时的历史栈深度(miniProgramBackDepth),当用户从子页面返回时,历史栈深度会大于入口层级,此时不触发小程序返回,避免误操作。
  4. 降级方案:如果 navigateBack 调用失败,提供 closeWindowhistory.go(-1) 作为降级方案,确保用户体验。
  5. 内存清理:在 onUnload 生命周期中移除事件监听器,防止内存泄漏。

⚠️ 注意事项

1. 条件编译

使用 #ifdef H5#endif 确保代码只在 H5 平台编译,避免在小程序端执行。

2. 页面跳转

如果页面内部有路由跳转(如使用 router.push),需要注意:

  • 子页面跳转会增加历史栈深度
  • 从子页面返回时不会触发小程序返回(通过层级判断)
  • 只有在入口页面点击返回才会触发小程序返回

3. 兼容性

  • 确保 weixin-js-sdk 版本 >= 1.6.0
  • 微信小程序基础库版本 >= 1.6.0(支持 navigateBack 接口)

4. 调试建议

  • 在开发环境中添加详细的 console 日志,便于排查问题
  • 使用微信开发者工具的真机调试功能测试
  • 注意区分小程序 WebView 和普通浏览器环境

🚀 优化建议

1. 封装为 Mixin(推荐)

如果多个页面都需要此功能,可以封装为 mixin。项目已提供 src/common/mixins/miniProgramBack.js

export default {
  data() {
    return {
      // 小程序返回拦截相关状态
      miniProgramBackHandler: null,      // popstate 事件处理器
      miniProgramBackHooked: false,      // 是否已注册拦截
      miniProgramBackDepth: 0             // 记录进入页面时的 history depth,防止子页返回误触发
    }
  },
  onLoad() {
    // #ifdef H5
    // 直接在页面加载时初始化拦截,避免首次返回未被接管
    this.setupMiniProgramWebviewBack()
    // #endif
  },
  onUnload() {
    // 页面卸载时清理事件监听器,防止内存泄漏
    if (this.miniProgramBackHandler && typeof window !== 'undefined') {
      window.removeEventListener('popstate', this.miniProgramBackHandler)
      this.miniProgramBackHandler = null
      this.miniProgramBackHooked = false
    }
  },
  methods: {
    /**
     * 小程序webview场景:拦截H5返回行为,返回小程序
     * @description 通过监听 popstate 事件,在用户点击返回时调用小程序返回接口
     * @param {Object} options - 配置选项
     * @param {Boolean} options.enable - 是否启用拦截,默认 true
     * @param {Number} options.delta - 返回的页面数,默认 1
     * @param {Function} options.onBeforeBack - 返回前的回调函数
     * @param {Function} options.onBackSuccess - 返回成功的回调函数
     * @param {Function} options.onBackFail - 返回失败的回调函数
     */
    setupMiniProgramWebviewBack(options = {}) {
      const {
        enable = true,
        delta = 1,
        onBeforeBack = null,
        onBackSuccess = null,
        onBackFail = null
      } = options

      // 如果已注册或禁用,直接返回
      if (!enable || this.miniProgramBackHooked || typeof window === 'undefined') {
        return
      }

      // 获取微信小程序桥接对象
      const bridge = this.wx?.miniProgram
      if (!bridge?.getEnv) {
        console.warn('[小程序返回拦截] wxdk.miniProgram.getEnv 不存在,无法拦截返回')
        return
      }

      // 检测当前环境
      bridge.getEnv((res = {}) => {
        console.log('[小程序返回拦截] getEnv 返回:', res)
        
        // 非小程序 WebView 环境,跳过拦截
        if (!res.miniprogram) {
          console.warn('[小程序返回拦截] 当前非小程序 WebView,跳过拦截')
          return
        }

        // 注入历史记录,用于拦截返回操作
        window.history.pushState({ miniProgramBack: true }, '')
        
        // 记录当前层级,只有回退到该层级及以下才触发返回小程序
        this.miniProgramBackDepth = window.history.length
        console.log('[小程序返回拦截] 已注入拦截,入口层级:', this.miniProgramBackDepth)

        // 定义降级方案
        const fallback = () => {
          if (this.wx?.closeWindow) {
            console.log('[小程序返回拦截] 调用 closeWindow 作为回退')
            this.wx.closeWindow()
          } else {
            console.log('[小程序返回拦截] 使用 history.go(-1) 作为回退')
            window.history.go(-1)
          }
        }

        // 创建返回拦截处理器
        this.miniProgramBackHandler = () => {
          // 获取当前历史栈深度
          const currentDepth = window.history.length
          
          // 若当前历史栈仍高于入口层级,说明只是从子页面返回,不拦截
          if (currentDepth > this.miniProgramBackDepth) {
            console.log('[小程序返回拦截] 子页回退,不拦截', { 
              currentDepth, 
              base: this.miniProgramBackDepth 
            })
            return
          }

          // 执行返回前的回调
          if (onBeforeBack && typeof onBeforeBack === 'function') {
            const shouldContinue = onBeforeBack()
            if (shouldContinue === false) {
              console.log('[小程序返回拦截] onBeforeBack 返回 false,取消返回')
              return
            }
          }

          // 尝试调用小程序返回接口
          try {
            bridge.navigateBack({
              delta,
              success: () => {
                console.log('[小程序返回拦截] navigateBack 成功触发返回小程序')
                if (onBackSuccess && typeof onBackSuccess === 'function') {
                  onBackSuccess()
                }
              },
              fail: (err) => {
                console.warn('[小程序返回拦截] navigateBack 失败,尝试 fallback', err)
                if (onBackFail && typeof onBackFail === 'function') {
                  onBackFail(err)
                }
                fallback()
              }
            })
          } catch (err) {
            console.error('[小程序返回拦截] navigateBack 异常,fallback', err)
            if (onBackFail && typeof onBackFail === 'function') {
              onBackFail(err)
            }
            fallback()
          }
        }

        // 注册 popstate 事件监听器
        window.addEventListener('popstate', this.miniProgramBackHandler)
        this.miniProgramBackHooked = true
        console.log('[小程序返回拦截] popstate 监听已注册')
      })
    }
  }
}

可直接使用:

使用方式:

import miniProgramBackMixin from '@/common/mixins/miniProgramBack'

export default {
  mixins: [miniProgramBackMixin],
  // ... 其他代码
}

自定义配置:

export default {
  mixins: [miniProgramBackMixin],
  onLoad() {
    // 自定义配置
    this.setupMiniProgramWebviewBack({
      enable: true,              // 是否启用
      delta: 1,                  // 返回的页面数
      onBeforeBack: () => {
        // 返回前的回调,返回 false 可取消返回
        console.log('即将返回小程序')
        // return false  // 取消返回
      },
      onBackSuccess: () => {
        console.log('成功返回小程序')
      },
      onBackFail: (err) => {
        console.error('返回失败', err)
      }
    })
  }
}

2. 在现有页面中应用

如果页面已经实现了相关逻辑,可以替换为使用 mixin:

替换前:

// 页面中已有相关代码
data() {
  return {
    miniProgramBackHandler: null,
    miniProgramBackHooked: false,
    miniProgramBackDepth: 0
  }
},
onLoad() {
  this.setupMiniProgramWebviewBack()
},
onUnload() {
  // 清理代码...
},
methods: {
  setupMiniProgramWebviewBack() {
    // 实现代码...
  }
}

替换后:

import miniProgramBackMixin from '@/common/mixins/miniProgramBack'

export default {
  mixins: [miniProgramBackMixin],
  // 移除 data 中的相关字段
  // 移除 onLoad 中的调用(mixin 会自动调用)
  // 移除 onUnload 中的清理(mixin 会自动清理)
  // 移除 methods 中的 setupMiniProgramWebviewBack 方法
}

3. 全局注册 Mixin(可选)

如果希望所有 H5 页面都自动启用此功能,可以在 main.js 中全局注册:

// main.js
import miniProgramBackMixin from '@/common/mixins/miniProgramBack'

// Vue 2.x
Vue.mixin(miniProgramBackMixin)

// 注意:全局注册后,如果某个页面不需要此功能,可以在 onLoad 中禁用:
// this.setupMiniProgramWebviewBack({ enable: false })

4. 错误处理优化(高级用法)

如果需要更完善的错误处理和重试机制,可以扩展 mixin:

// 在页面中扩展方法
export default {
  mixins: [miniProgramBackMixin],
  methods: {
    setupMiniProgramWebviewBackWithRetry() {
      const MAX_RETRY = 3
      let retryCount = 0
    
      const tryNavigateBack = () => {
        if (retryCount >= MAX_RETRY) {
          // 使用 mixin 的降级方案
          return
        }
      
        const bridge = this.wx?.miniProgram
        bridge.navigateBack({
          delta: 1,
          success: () => {
            retryCount = 0
            console.log('[小程序返回拦截] navigateBack 成功')
          },
          fail: (err) => {
            retryCount++
            console.warn(`[小程序返回拦截] navigateBack 失败,重试 ${retryCount}/${MAX_RETRY}`, err)
            setTimeout(tryNavigateBack, 100)
          }
        })
      }
    
      // 调用 mixin 的方法,但使用自定义的返回逻辑
      this.setupMiniProgramWebviewBack({
        onBeforeBack: () => {
          tryNavigateBack()
          return false  // 阻止默认行为
        }
      })
    }
  }
}

📚 相关文档

🔗 项目文件

  • Mixin 文件src/common/mixins/miniProgramBack.js

✅ 总结

通过以上方案,我们可以实现小程序跳转 H5 页面后,用户点击返回键能够返回到小程序的功能。关键点在于:

  1. ✅ 正确检测小程序 WebView 环境
  2. ✅ 合理使用历史记录 API 拦截返回操作
  3. ✅ 通过层级管理避免误触发
  4. ✅ 提供降级方案保证兼容性
  5. ✅ 及时清理事件监听器防止内存泄漏

希望本文能帮助你在 Uniapp 项目中实现小程序与 H5 页面的无缝跳转体验!

shell 短信接口开发对接技巧:Shell 环境下短信发送功能集成详解

2026年1月22日 16:28

在 Shell 运维自动化、批量脚本执行场景中,短信通知是告警、状态反馈的核心手段,但多数开发者在对接 shell 短信接口时,常因参数拼接格式错误、HTTP 请求语法不规范、异常处理缺失导致短信发送功能失效,甚至因频率控制不当触发接口限流。本文聚焦 shell 短信接口的开发对接技巧,从底层 HTTP 通信逻辑拆解到完整对接实现,再到优化与排错,解决参数配置、请求方式选择、异常处理等核心痛点,帮助你在 Shell 环境下快速集成稳定、高效的短信发送功能。

一、理解 Shell 对接 shell 短信接口的核心逻辑

1.1 短信接口的 HTTP 通信本质

shell 短信接口的核心是通过 Shell 自带的 curl 工具发起 HTTP 请求,主流服务商(如互亿无线)的 shell 短信接口均支持 POST/GET 双请求方式,字符编码固定为 utf-8。完整的对接流程包含三个核心环节:

  1. 参数构造:按服务商规范拼接 account(APIID)、password(APIKEY)、mobile(手机号)、content(短信内容)等核心参数;
  2. 请求发送:通过 curl 设置请求头(Content-Type: application/x-www-form-urlencoded)和请求方式;
  3. 响应解析:提取返回的 code 和 msg 字段,判断发送结果,核心成功标识为 code=2。

1.2 核心参数与响应码解读

1.2.1 必选参数规范

shell 短信接口对接的核心参数直接决定请求成败,需重点关注:

  • account:APIID,需从服务商后台获取;
  • password:APIKEY 或动态密码(动态密码需搭配 Unix 时间戳);
  • mobile:接收手机号,格式为 11 位数字(如 138****9999),需提前校验;
  • content/templateid:短信内容,支持完整内容(无模板时)或模板变量(需指定 templateid)两种方式。

1.2.2 关键响应码解析

响应中的 code 字段是结果判断的核心,高频码解读:

  • code=2:提交成功,返回 smsid(流水号);
  • code=405:API ID/KEY 错误(最常见);
  • code=4052:访问 IP 与备案 IP 不符;
  • code=4085:同一手机号验证码发送超限(10 条 / 天)。

二、shell 短信接口基础对接实现

2.1 环境准备与工具校验

Shell 对接 shell 短信接口仅依赖系统自带的 curl 工具(Linux/macOS 默认安装),无需额外依赖,先验证 curl 可用性:

bash

# 验证curl是否安装并查看版本
curl --version

若提示 “command not found”,需通过对应系统包管理器安装:

bash

# CentOS/RHEL系统安装curl
yum install curl -y
# Ubuntu/Debian系统安装curl
apt-get install curl -y

2.2 GET 请求对接示例(调试场景)

GET 请求参数直接拼接在 URL 中,适合开发 / 测试阶段快速验证接口连通性,以下是完整可复用示例:

bash

#!/bin/bash
# shell短信接口对接示例(GET请求,调试专用)
# 注:account和password需从服务商注册获取,注册入口:http://user.ihuyi.com/?udcpF6

# 接口基础配置
API_URL="https://api.ihuyi.com/sms/Submit.json"
ACCOUNT="your_api_id"  # 替换为实际APIID
PASSWORD="your_api_key"  # 替换为实际APIKEY
MOBILE="139****8888"     # 接收手机号(脱敏格式)
CONTENT="您的验证码是:6789。请不要把验证码泄露给其他人。"  # 完整短信内容

# 对短信内容进行URL编码,避免特殊字符导致请求失败
ENCODED_CONTENT=$(curl -s -o /dev/null -w %{url_effective} --get --data-urlencode "content=${CONTENT}" "" | cut -d '?' -f2 | cut -d '=' -f2)

# 发起GET请求
RESPONSE=$(curl -s "${API_URL}?account=${ACCOUNT}&password=${PASSWORD}&mobile=${MOBILE}&content=${ENCODED_CONTENT}")

# 解析响应结果
CODE=$(echo ${RESPONSE} | grep -o '"code":[0-9]*' | cut -d ':' -f2)
MSG=$(echo ${RESPONSE} | grep -o '"msg":"[^"]*"' | cut -d ':' -f2 | sed 's/["]//g')

# 输出结果判断
if [ ${CODE} -eq 2 ]; then
    echo "短信发送成功,响应详情:${RESPONSE}"
else
    echo "短信发送失败,错误码:${CODE},错误信息:${MSG}"
fi

demo-shell.png

2.3 POST 请求对接示例(生产场景)

POST 请求参数放在请求体中,安全性更高,适合生产环境,尤其适配长短信、敏感内容场景,以下是模板变量方式示例:

bash

#!/bin/bash
# shell短信接口对接示例(POST请求,生产环境专用)
# 适配场景:运维告警、批量订单通知

# 核心配置项
API_URL="https://api.ihuyi.com/sms/Submit.json"
ACCOUNT="your_api_id"
PASSWORD="your_api_key"
MOBILE="137****6666"
VERIFY_CODE="8899"  # 模板变量内容(匹配templateid=1)
TEMPLATE_ID="1"     # 系统默认验证码模板ID
TIME=$(date +%s)    # 获取Unix时间戳(动态密码方式必填)

# 构造POST请求参数
POST_DATA="account=${ACCOUNT}&password=${PASSWORD}&mobile=${MOBILE}&content=${VERIFY_CODE}&templateid=${TEMPLATE_ID}&time=${TIME}"

# 发起POST请求(设置10秒超时,避免脚本阻塞)
RESPONSE=$(curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "${POST_DATA}" --max-time 10 "${API_URL}")

# 兼容解析(适配jq未安装的服务器环境)
CODE=$(echo ${RESPONSE} | jq -r .code 2>/dev/null || echo "0")
if [ "${CODE}" = "0" ] || [ "${CODE}" = "null" ]; then
    CODE=$(echo ${RESPONSE} | grep -o '"code":[0-9]*' | cut -d ':' -f2)
fi
MSG=$(echo ${RESPONSE} | grep -o '"msg":"[^"]*"' | cut -d ':' -f2 | sed 's/["]//g')

# 异常处理与结果输出
if [ $? -ne 0 ]; then
    echo "接口请求异常:curl命令执行失败"
    exit 1
fi

if [ ${CODE} -eq 2 ]; then
    SMSID=$(echo ${RESPONSE} | grep -o '"smsid":"[^"]*"' | cut -d ':' -f2 | sed 's/["]//g')
    echo "短信发送成功,流水号:${SMSID}"
else
    echo "发送失败:错误码=${CODE},错误信息=${MSG}"
    exit 1
fi

三、shell 短信接口对接优化技巧

3.1 GET/POST 请求方式对比分析

请求方式 核心优势 主要劣势 适配场景
GET 代码简洁、调试便捷(可直接浏览器访问)、无需复杂参数处理 参数暴露在 URL,存在安全风险;内容长度受限(约 2048 字符) 开发 / 测试阶段接口连通性验证
POST 参数安全、支持 500 字长短信、符合生产规范 需处理请求体拼接,代码稍复杂 生产环境(运维告警、批量通知)

核心结论:生产环境对接 shell 短信接口时,优先选择 POST 请求方式,避免敏感参数泄露。

3.2 核心优化策略(清单形式)

  1. 参数脱敏与日志规范:避免日志泄露敏感信息,对手机号脱敏、密码隐藏:

bash

# 手机号脱敏函数(通用复用)
desensitize_mobile() {
    local mobile=$1
    echo ${mobile} | sed 's/(^\d{3})\d{4}(\d{4})/\1****\2/'
}

# 结构化日志记录(仅保留脱敏信息)
SAFE_MOBILE=$(desensitize_mobile ${MOBILE})
LOG_TIME=$(date +'%Y-%m-%d %H:%M:%S')
echo "${LOG_TIME} | 手机号:${SAFE_MOBILE} | 发送结果码:${CODE}" >> /var/log/sms_send.log

2. 自动重试机制:应对网络波动导致的请求失败,设置 3 次以内重试:

bash

# 带重试的短信发送函数
send_sms_with_retry() {
    local retry_times=3
    local count=0
    while [ ${count} -lt ${retry_times} ]; do
        RESPONSE=$(curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "${POST_DATA}" --max-time 10 "${API_URL}")
        CODE=$(echo ${RESPONSE} | grep -o '"code":[0-9]*' | cut -d ':' -f2)
        if [ ${CODE} -eq 2 ]; then
            return 0  # 发送成功,退出重试
        fi
        count=$((count+1))
        sleep 1  # 间隔1秒重试,避免高频请求触发限流
    done
    return 1  # 重试失败
}

3. 频率限制控制:避免触发 4085 错误(同一手机号 10 条 / 天),提前校验发送次数:

bash

# 校验手机号当日发送频率
check_send_frequency() {
    local mobile=$1
    local log_file="/var/log/sms_send.log"
    # 统计当日该手机号发送次数
    send_count=$(grep "$(date +'%Y-%m-%d') | 手机号:$(desensitize_mobile ${mobile})" ${log_file} | wc -l)
    if [ ${send_count} -ge 10 ]; then
        echo "错误:同一手机号当日发送次数已达上限(10次),拒绝发送"
        exit 1
    fi
}

api.png

四、shell 短信接口常见问题排查与调试方法

4.1 高频错误码及解决方案

  1. code=405:API ID/KEY 不正确 → 核对服务商后台的 account(APIID)和 password(APIKEY),确认未混淆两者;
  2. code=4052:访问 IP 与备案 IP 不符 → 在服务商后台添加服务器公网 IP 到白名单,确保请求 IP 与备案一致;
  3. code=407:短信内容含敏感字符 → 提前过滤敏感词库,或使用服务商审核通过的模板(指定 templateid);
  4. code=4085:同一手机号验证码发送超限 → 集成 3.2 中的频率限制函数,避免短时间内重复发送。

4.2 高效调试技巧

  1. 开启 curl 调试模式:打印完整请求 / 响应详情,定位参数或请求头错误:

bash

# 调试POST请求(添加-v参数输出详细日志)
curl -v -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "${POST_DATA}" "${API_URL}"

2. 验证参数编码:确保 content 参数 URL 编码正确,避免中文 / 特殊字符导致请求失败:

bash

# 验证短信内容的URL编码结果
echo -n "您的验证码是:6789。请不要把验证码泄露给其他人。" | xxd -p

3. 离线校验参数格式:提前校验手机号、时间戳等参数格式,减少接口请求失败:

bash

# 手机号格式校验函数
check_mobile_format() {
    local mobile=$1
    if ! [[ ${mobile} =~ ^1[3-9][0-9]{9}$ ]]; then
        echo "错误:手机号格式不正确(正确示例:139****8888)"
        exit 1
    fi
}

总结

  1. shell 短信接口对接的核心是通过 curl 工具规范发起 HTTP 请求,生产环境优先选择 POST 方式,开发测试阶段可使用 GET 方式快速验证;
  2. 基础对接需重点关注参数 URL 编码、响应解析兼容、超时设置,优化策略需覆盖脱敏日志、自动重试、频率限制三大核心;
  3. 排查问题时优先核对 405(鉴权)、4052(IP 白名单)、4085(频率)等高频错误码,通过 curl 调试模式可快速定位根因。

本文提供的 shell 短信接口对接方案覆盖调试、生产、优化全场景,代码可直接复用,适配各类 Shell 运维场景,帮助开发者避开对接陷阱,快速落地稳定的短信发送功能。

Vue 2 与 Vue 3 的全面对比分析

2026年1月22日 16:26

Vue 2 和 Vue 3 是 Vue 框架的两个主要版本,Vue 3 在性能、架构、开发体验和类型支持等方面做了重大升级,是 Vue 官方推荐的长期维护版本。

以下是 Vue 2 与 Vue 3 的全面对比分析,涵盖核心差异、新特性、迁移建议,帮助你快速掌握两者区别。


🌟 一、核心区别概览

对比项 Vue 2 Vue 3
底层实现 基于 Object.defineProperty(响应式) 基于 Proxy(响应式)
组件语法 选项式 API(data, methods, computed 支持 组合式 API(Composition API) + 选项式 API
性能 一般 ⭐⭐⭐⭐⭐(提升 30%~50%)
Tree Shaking 不支持 ✅ 支持(按需引入,体积更小)
TypeScript 支持 一般 ✅ 原生支持(官方推荐)
多根组件 ❌ 不支持 ✅ 支持(<template> 可以有多个根节点)
生命周期钩子 beforeCreate, created, mounted 新增 onMounted, onBeforeUnmount 等(组合式写法)
全局 API Vue.use(), Vue.component() 改为 createApp() 全局创建应用
构建工具 Vue CLI 支持 Vite(默认推荐)
生态 成熟但逐渐淘汰 新一代生态(如 @vueuse, Vite

🧩 二、关键差异详解

1. ✅ 响应式系统:Proxy 代替 Object.defineProperty

项目 Vue 2 Vue 3
响应式原理 Object.defineProperty Proxy
限制 无法检测新增/删除属性,无法监听数组索引变化 支持动态添加/删除属性,支持监听数组索引
示例:动态添加属性
// Vue 2 ❌ 无法响应
this.obj.newProp = 'hello'; // 不会触发视图更新

// Vue 3 ✅ 可响应
this.obj.newProp = 'hello'; // ✅ 视图会更新

🔍 优势:Vue 3 的响应式系统更强大、更灵活、更高效。


2. ✅ 组合式 API(Composition API)——核心升级

✅ Vue 2:选项式 API(逻辑分散)

<!-- Counter.vue (Vue 2) -->
<template>
  <div>
    <p>计数: {{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

<script>
export default {
  data() {
    return { count: 0 };
  },
  methods: {
    increment() {
      this.count++;
    }
  },
  computed: {
    doubleCount() {
      return this.count * 2;
    }
  },
  watch: {
    count(newVal) {
      console.log('count changed:', newVal);
    }
  }
};
</script>

❗ 问题:逻辑按“选项”拆分,复用困难。


✅ Vue 3:组合式 API(逻辑聚合)

<!-- Counter.vue (Vue 3) -->
<template>
  <div>
    <p>计数: {{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue';

// 响应式数据
const count = ref(0);

// 方法
const increment = () => {
  count.value++;
};

// 计算属性
const doubleCount = computed(() => count.value * 2);

// 监听
watch(count, (newVal) => {
  console.log('count changed:', newVal);
});
</script>

✅ 优势:

  • 逻辑按功能组织(如“计数逻辑”集中在一个地方)
  • 更易复用(可通过 useCount() 封装)
  • 更适合大型项目

3. ✅ 支持多根组件(Multi-root Components)

<!-- Vue 3 支持 -->
<template>
  <header>头部</header>
  <main>主体</main>
  <footer>底部</footer>
</template>

✅ Vue 2:必须包裹一个根 <div>,否则报错。


4. ✅ 新的 setup 语法糖(<script setup>

<script setup>
import { ref } from 'vue';

const count = ref(0);
</script>

✅ 优势:

  • 无需 export default,代码更简洁
  • 自动暴露变量给模板使用
  • vite 配合完美

5. ✅ 更好的 TypeScript 支持

// Vue 3 支持类型推导
const count = ref<number>(0); // 类型安全
const doubleCount = computed(() => count.value * 2);

✅ Vue 3 官方构建工具(Vite)和 IDE(VSCode)对 TS 支持极佳。


6. ✅ 支持 Tree Shaking(按需引入)

Vue 3 采用模块化设计,支持 Tree Shaking,只打包用到的代码。

// 只引入需要的 API
import { ref, reactive } from 'vue';

⚠️ Vue 2:Vue 对象是全局的,无法按需打包。


7. ✅ 全局 API 改为 createApp()

// Vue 2
Vue.use(Vuex);
Vue.component('MyComp', MyComp);
Vue.config.productionTip = false;

// Vue 3
import { createApp } from 'vue';
const app = createApp(App);
app.use(Vuex);
app.component('MyComp', MyComp);
app.mount('#app');

✅ 更清晰、更可控,支持多个应用实例。


🔄 三、迁移建议(从 Vue 2 → Vue 3)

步骤 说明
1. 使用 vue-next 工具 npm install -g @vue/cli + vue upgrade
2. 检查兼容性 使用 Vue 3 Migration Helper
3. 逐步替换语法 优先使用 setup + Composition API
4. 升级依赖 vuexvuex@nextvue-routervue-router@4
5. 测试 重点测试 v-model$refs$emit 等用法变化

✅ 推荐:新项目直接使用 Vue 3 + Vite + TypeScript


📌 四、适用场景建议

场景 推荐版本
新项目开发 Vue 3(强烈推荐)
老项目维护 ✅ 逐步迁移到 Vue 3
快速原型开发 ✅ Vue 3 + Vite
团队有 Vue 2 经验 ✅ 用 setup 语法过渡学习
需要极致性能 ✅ Vue 3(响应式 + Tree Shaking)

✅ 总结:一句话对比

维度 Vue 2 Vue 3
响应式 Object.defineProperty Proxy(更强)
语法 选项式 API 支持组合式 API + setup
性能 一般 ⭐⭐⭐⭐⭐(提升显著)
类型支持 一般 ✅ 原生支持 TypeScript
构建 Vue CLI ✅ Vite(默认推荐)
生态 成熟但逐渐淘汰 新一代生态(如 @vueuse

🎯 最终建议:

新项目一律使用 Vue 3,尤其是:

  • 需要高性能
  • 有 TypeScript 需求
  • 团队希望代码更可维护、可复用

Vue和React的全面对比分析

2026年1月22日 16:23

Vue 和 React 是当前前端开发中最主流的两个框架(或库),它们在设计理念、语法结构、生态和开发体验上既有相似之处,也有显著差异。以下是 Vue 与 React 的全面对比分析,帮助你理解两者的核心区别,适用于项目选型与技术决策。


🌟 一、核心定位差异

项目 Vue React
类型 渐进式前端框架 UI 库(视图层库)
定位 提供完整解决方案(模板 + 组件 + 路由 + 状态管理) 仅关注“视图层”,需搭配其他库使用
是否必须搭配其他库 否(可独立使用) 是(需搭配 React Router、Redux、Zustand 等)

简单理解

  • Vue 像“全栈式框架”,开箱即用。
  • React 像“乐高积木”,你需要自己拼装。

📚 二、语法与开发方式对比

对比项 Vue(2.x / 3.x) React(函数式 + JSX)
模板语法 支持 HTML 模板 + 指令(v-if, v-for, v-model 使用 JSX(JavaScript + HTML 混合)
组件结构 单文件组件 .vue(含 <template>, <script>, <style> 通常为 .js.tsx 文件
数据绑定 双向绑定(v-model 单向数据流 + 手动 setStateuseState
状态管理 内置 datacomputedwatch;可选 Vuex / Pinia 依赖外部状态库(Redux、MobX、Zustand)
生命周期 选项式 API(mounted, updated)或组合式 API(setup useEffect, useRef, useState 等 Hook

示例对比:显示一个计数器

✅ Vue 2.x(选项式 API)

<!-- Counter.vue -->
<template>
  <div>
    <p>计数: {{ count }}</p>
    <button @click="count++">+1</button>
  </div>
</template>

<script>
export default {
  data() {
    return { count: 0 };
  }
};
</script>

✅ React(函数式 + Hook)

// Counter.jsx
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>计数: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

export default Counter;

🔍 对比点

  • Vue 更贴近 HTML,适合前端新手。
  • React 更“JavaScript 化”,适合有 JS 基础的开发者。

🧩 三、开发体验对比

维度 Vue React
学习成本 ⭐⭐⭐⭐☆(低) ⭐⭐⭐☆☆(中)
开发效率 ✅ 高(模板 + 双向绑定) ✅ 高(JSX + Hook)
可维护性 ✅ 好(单文件组件 + 模板清晰) ✅ 好(逻辑可复用,Hook 强大)
调试工具 ✅ DevTools 强大,支持组件树、状态追踪 ✅ DevTools 支持良好,但需额外配置
类型支持 ✅ Vue 3 支持 TypeScript(推荐) ✅ 原生支持 TypeScript(更成熟)

🛠 四、生态系统对比

项目 Vue React
官方工具 Vue CLI、Vite、Vite Plugin Create React App、Vite、Next.js
路由 Vue Router React Router
状态管理 Vuex、Pinia(官方推荐) Redux、MobX、Zustand、Recoil
UI 库 Element Plus、Naive UI、Ant Design Vue Ant Design、Material UI、Chakra UI
SSR 支持 Nuxt.js(官方) Next.js(极强)
移动端开发 Vue Native、uni-app(跨平台) React Native

亮点

  • Vue 的 uni-app 支持 一套代码多端运行(H5、小程序、App)。
  • React 的 Next.js 在服务端渲染(SSR)、SEO、静态站点生成方面非常强大。

📈 五、适用场景推荐

场景 推荐技术
快速搭建后台管理系统 ✅ Vue(Element Plus + Vue Router)
企业级大型应用(中后台) ✅ React(Next.js + Redux/Zustand)
小程序 / 跨平台 App ✅ Vue(uni-app)或 React(Taro)
高性能、复杂交互应用(如社交平台) ✅ React(React + TypeScript + Zustand)
新手入门 / 快速原型开发 ✅ Vue(语法直观,易上手)

📌 总结:一句话对比

维度 Vue React
风格 模板驱动 + 渐进式 JavaScript 驱动 + 函数式
学习曲线 平缓 中等偏上
灵活性 中等 极高
生态成熟度 良好 极强
社区活跃度 高(尤其国内) 极高(全球)

✅ 选择建议

你的需求 推荐技术
快速开发、团队协作、中后台系统 Vue
构建大型 SPA、需 SSR、SEO 优化 React + Next.js
跨平台开发(App + 小程序) Vue(uni-app)
想深度掌控代码、拥抱函数式编程 React
项目团队有 JS 基础、追求极致性能 React

前端性能监控Performance

2026年1月22日 16:23

**前端性能监控(Real User Monitoring, RUM)**是一种通过实时采集和分析用户与Web应用交互时的性能数据,帮助开发者定位性能瓶颈、优化用户体验的技术。其核心目标是量化页面加载速度、交互流畅度等关键指标,确保应用在不同网络环境、设备类型下的稳定性。

实现原理

前端性能监控的实现基于浏览器提供的底层API,结合数据采集、传输、存储与分析的完整链路,具体原理如下:

1. 数据采集:浏览器 API 的深度利用

  • Performance API:提供页面加载各阶段的时间戳(如navigationStartdomContentLoadedEventStart),通过performance.timing对象可计算服务器响应时间、DOM解析时间等。例如:

    • const loadTime = performance.timing.loadEventEnd - performance.timing.navigationStart;
      
  • Resource Timing API:记录资源(图片、脚本、样式表)的加载耗时,通过performance.getEntriesByType('resource')获取详细数据。

  • Paint Timing API:标记页面渲染关键节点,如首次内容绘制(FCP)、最大内容绘制(LCP),通过performance.getEntriesByType('paint')实现。

  • Long Task API:检测主线程阻塞超过50ms的任务,识别卡顿源。

  • 错误捕获

    • JS 错误:通过window.onerror监听语法错误、运行时错误。

    • Promise错误:通过window.addEventListener('unhandledrejection')捕获未处理的Promise异常。

    • 资源错误:监听imgscript元素的error事件,记录加载失败。

2. 数据传输:高效上报策略

  • 即时上报:关键错误(如JS崩溃)采用同步POST请求,确保数据及时送达。

  • 定时批量上报:性能指标和用户行为数据每5秒集中发送一次,平衡实时性与性能开销。

  • 网络缓存:网络不稳定时,利用localStorageIndexedDB缓存数据,待网络恢复后自动重传。

3. 数据存储与处理

  • 时序数据库:如InfluxDB、Prometheus,优化时间序列数据存储与查询。

  • 数据清洗:去除重复数据、修正异常值(如负数时间戳)。

  • 聚合分析:计算指标的平均值、分位数(如P90、P95),识别性能波动规律。

4. 数据分析 与可视化

  • 趋势分析:通过折线图展示LCP、FID等指标随时间的变化,定位性能退化时段。

  • 对比分析:对比不同页面、浏览器或地区的性能数据,找出优化优先级。

  • 告警机制:当错误率超过阈值或LCP持续超标时,触发邮件、短信告警。

具体实现步骤

1. 确定监控指标

根据业务需求选择核心指标,例如:

  • 加载性能:首屏加载时间、LCP、TTI(可交互时间)。

  • 交互性能:FID(首次输入延迟)、滚动卡顿率。

  • 稳定性:JS错误率、资源加载失败率。

  • 用户体验:白屏时长、用户停留时长。

2. 选择监控工具

  • 开源方案

    • Lighthouse:集成在Chrome DevTools中,提供实验室环境下的性能评分。
    • Sentry:专注错误监控,支持源码映射和堆栈分析。
  • 商业服务

    • New RelicDynatrace:提供前后端一体化监控。

    • 腾讯云RUM:支持小程序监控,与后端APM联动。

3. 部署监控代码

  • 手动集成:在页面<head>中插入SDK脚本,初始化时配置上报地址、抽样率等参数:

    • <script src="https://cdn.example.com/rum-sdk.js"></script>
      <script>
        RUM.init({
          appKey: 'YOUR_APP_KEY',
          sampleRate: 100, // 100%采样
          urlWhitelist: ['*.example.com']
        });
      </script>
      
  • 框架集成

    • Vue:在mounted生命周期中调用数据采集函数。

    • React:通过自定义Hooks(如usePerformance)封装监控逻辑。

4. 采集与上报数据

  • 性能指标采集

    • function getPageLoadTime() {
        return performance.timing.loadEventEnd - performance.timing.navigationStart;
      }
      window.addEventListener('load', () => {
        const loadTime = getPageLoadTime();
        RUM.sendMetric('page_load_time', loadTime);
      });
      
  • 错误信息采集

    • window.onerror = function(message, source, lineno, colno, error) {
        RUM.sendError({
          type: 'js_error',
          message,
          stack: error?.stack,
          position: `${source}:${lineno}:${colno}`
        });
      };
      

5. 分析与优化

  • 瓶颈定位:通过瀑布图分析资源加载顺序,优化关键路径(如内联CSS、预加载JS)。

  • 代码优化

    • 减少HTTP请求:合并文件、使用CSS Sprites。
    • 延迟加载非关键资源:图片loading="lazy"、异步加载JS(defer/async)。
    • 缓存策略:对静态资源设置Cache-Control: max-age=31536000
  • A/B测试:对比优化前后的性能数据,验证改进效果。

案例:腾讯云RUM的实现

腾讯云RUM通过SDK采集用户端数据,利用流计算Oceanus实时处理指标,结合时序数据库CTSDB存储历史数据。其核心功能包括:

  • 无侵入监控:SDK自动采集性能数据,无需修改业务代码。

  • 多维分析:支持按地区、浏览器、设备类型分组统计性能指标。

  • 智能告警:基于机器学习预测性能趋势,提前发现潜在问题。

注意事项

  • 性能开销:监控代码体积应控制在30KB以内,避免影响页面加载。

  • 数据安全:对用户ID等敏感信息脱敏,上报时使用HTTPS加密。

  • 兼容性:测试SDK在低版本浏览器(如IE11)中的表现,提供降级方案。

Performance API 简介

Performance API 是一组用于衡量 Web 应用性能的 JavaScript 标准接口,它通过高精度时间戳和性能条目(Performance Entries)记录页面加载、资源加载、交互响应等关键事件,帮助开发者量化用户体验。其核心优势在于:

  • 高精度时间戳performance.now() 提供微秒级精度(Chrome 中精度达 0.1ms),远超传统 Date.now() 的 1ms 精度。

  • 性能条目(Performance Entries) :通过 performance.getEntries() 获取导航、资源、绘制等事件的详细数据。

  • 支持 Core Web Vitals:可直接捕获 LCP、FID、CLS 等关键性能指标。

兼容性分析

  • 浏览器支持

    • 主流浏览器:Chrome、Firefox、Edge、Safari 均支持 Performance API 的核心功能(如 timinggetEntries)。
    • IE 限制:IE9 以下不支持,IE11 仅部分支持(如 timing 属性,但资源列表可能不完整)。
    • 移动端:部分 API 在移动端浏览器或 WebView 中可能受限(如 getEntries() 返回数据不完整)。
  • API 差异

    • 资源列表:Firefox 返回所有 HTTP 请求(包括失败请求),Chrome 仅返回成功请求。

    • 跨域限制:获取跨域资源的详细时间数据需服务端设置 Timing-Allow-Origin 响应头。

Performance API 使用方法

1. 基础时间测量

使用 performance.now() 测量代码执行时间:

const start = performance.now();
// 执行待测代码(如循环、异步操作)
for (let i = 0; i < 10000; i++) {
  console.log(i);
}
const end = performance.now();
console.log(`执行耗时: ${(end - start).toFixed(3)}ms`);

2. 导航与资源时间

通过 performance.timing 获取页面加载各阶段时间戳:

window.onload = function() {
  const timing = performance.timing;
  console.log('DNS 查询耗时:', timing.domainLookupEnd - timing.domainLookupStart);
  console.log('TCP 连接耗时:', timing.connectEnd - timing.connectStart);
  console.log('白屏时间:', timing.responseStart - timing.navigationStart);
  console.log('DOM 解析耗时:', timing.domComplete - timing.domInteractive);
};

3. 资源加载详情

使用 performance.getEntriesByType('resource') 获取静态资源(图片、脚本等)的加载时间:

const resources = performance.getEntriesByType('resource');
resources.forEach(resource => {
  console.log(`资源: ${resource.name}, 加载耗时: ${resource.duration.toFixed(2)}ms`);
});

获取 FCP、LCP 等关键指标

1. 首次内容绘制(FCP)

通过 PerformanceObserver 监听 paint 事件,捕获 FCP 时间:

new PerformanceObserver((list) => {
  const entries = list.getEntries();
  entries.forEach(entry => {
    if (entry.name === 'first-contentful-paint') {
      console.log('FCP 时间:', entry.startTime);
    }
  });
}).observe({ type: 'paint', buffered: true });

2. 最大内容绘制(LCP)

监听 largest-contentful-paint 事件,获取 LCP 时间(取最后一次候选元素):

let lcpValue = 0;
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  lcpValue = lastEntry.renderTime || lastEntry.loadTime;
  console.log('LCP 时间:', lcpValue);
}).observe({ type: 'largest-contentful-paint', buffered: true });

// 用户交互后停止监听(避免误报)
window.addEventListener('click', () => {
  observer.disconnect();
});

3. 首次输入延迟(FID)

监听 first-input 事件,计算用户首次交互的延迟时间:

new PerformanceObserver((list) => {
  const entries = list.getEntries();
  entries.forEach(entry => {
    const delay = entry.processingStart - entry.startTime;
    console.log('FID 延迟:', delay.toFixed(2) + 'ms');
  });
}).observe({ type: 'first-input', buffered: true });

4. 累积布局偏移(CLS)

监听 layout-shift 事件,计算页面布局稳定性:

let clsValue = 0;
let sessionEntries = [];
let sessionValue = 0;

new PerformanceObserver((list) => {
  const entries = list.getEntries();
  entries.forEach(entry => {
    if (!entry.hadRecentInput) {
      const firstSessionEntry = sessionEntries[0];
      const lastSessionEntry = sessionEntries[sessionEntries.length - 1];

      if (sessionValue &&
          entry.startTime - lastSessionEntry.startTime < 1000 &&
          entry.startTime - firstSessionEntry.startTime < 5000) {
        sessionValue += entry.value;
        sessionEntries.push(entry);
      } else {
        sessionValue = entry.value;
        sessionEntries = [entry];
      }

      if (sessionValue > clsValue) {
        clsValue = sessionValue;
        console.log('CLS 值:', clsValue);
      }
    }
  });
}).observe({ type: 'layout-shift', buffered: true });

完整示例代码

// 初始化 PerformanceObserver 监听关键指标
function initPerformanceObservers() {
  // FCP 监听
  new PerformanceObserver((list) => {
    const entries = list.getEntries();
    entries.forEach(entry => {
      if (entry.name === 'first-contentful-paint') {
        console.log('FCP:', entry.startTime + 'ms');
      }
    });
  }).observe({ type: 'paint', buffered: true });

  // LCP 监听
  let lcpValue = 0;
  const lcpObserver = new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];
    lcpValue = lastEntry.renderTime || lastEntry.loadTime;
    console.log('LCP:', lcpValue + 'ms');
  });
  lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

  // 用户交互后停止 LCP 监听
  window.addEventListener('click', () => {
    lcpObserver.disconnect();
  });

  // FID 监听
  new PerformanceObserver((list) => {
    const entries = list.getEntries();
    entries.forEach(entry => {
      const delay = entry.processingStart - entry.startTime;
      console.log('FID:', delay.toFixed(2) + 'ms');
    });
  }).observe({ type: 'first-input', buffered: true });

  // CLS 监听
  let clsValue = 0;
  let sessionEntries = [];
  let sessionValue = 0;

  new PerformanceObserver((list) => {
    const entries = list.getEntries();
    entries.forEach(entry => {
      if (!entry.hadRecentInput) {
        const firstSessionEntry = sessionEntries[0];
        const lastSessionEntry = sessionEntries[sessionEntries.length - 1];

        if (sessionValue &&
            entry.startTime - lastSessionEntry.startTime < 1000 &&
            entry.startTime - firstSessionEntry.startTime < 5000) {
          sessionValue += entry.value;
          sessionEntries.push(entry);
        } else {
          sessionValue = entry.value;
          sessionEntries = [entry];
        }

        if (sessionValue > clsValue) {
          clsValue = sessionValue;
          console.log('CLS:', clsValue);
        }
      }
    });
  }).observe({ type: 'layout-shift', buffered: true });
}

// 初始化性能监控
initPerformanceObservers();

// 页面加载完成后输出导航时间
window.onload = function() {
  const timing = performance.timing;
  console.log('DNS 查询耗时:', timing.domainLookupEnd - timing.domainLookupStart + 'ms');
  console.log('TCP 连接耗时:', timing.connectEnd - timing.connectStart + 'ms');
  console.log('白屏时间:', timing.responseStart - timing.navigationStart + 'ms');
  console.log('DOM 解析耗时:', timing.domComplete - timing.domInteractive + 'ms');
};

注意事项

  1. 性能开销PerformanceObserver 会持续监听事件,避免在低性能设备上过度使用。

  2. 兼容性 处理:对不支持 PerformanceObserver 的浏览器(如 IE),需降级使用 performance.getEntriesByType

  3. 数据上报:实际项目中需将性能数据发送至后端存储,可使用 fetchnavigator.sendBeacon

  4. 用户交互影响:LCP 和 CLS 的计算可能受用户交互影响,需在用户首次交互后停止监听(如示例中的 click 事件)。

监控代码位置对性能数据的影响及Performance API数据记录机制解析

一、监控代码位置对性能数据的影响

  1. 数据完整性不受位置影响

    1. Performance API的数据记录是浏览器在页面加载过程中自动完成的,与监控代码的位置无关。例如:

      • performance.timing对象在页面开始加载时即开始填充时间戳(如navigationStartresponseStart等),无论代码放在<head>还是<body>中,只要在window.onload事件触发后执行,都能获取完整数据。

      • 资源加载时间(如resource.duration)、绘制时间(如FCP/LCP)等均由浏览器在加载过程中实时记录,代码位置仅影响何时访问这些数据,而非数据本身。

  2. 执行时机的关键性

    1. **window.onload事件:确保所有资源(图片、样式表、脚本等)加载完成后才触发,此时所有Performance数据已完整记录。无论代码放在<head>还是<body>底部,只要绑定在window.onload上,结果一致。

    2. 异步加载脚本:若使用asyncdefer加载监控代码,需确保其在页面加载完成后执行(如通过window.onload),否则可能提前访问未填充的Performance数据。

  3. 潜在差异场景

    1. 早期执行:若代码放在<head>中且未绑定window.onload,可能在页面未加载完成时执行,导致部分数据(如loadEventEnd)未记录,出现undefined0值。

    2. 资源加载顺序:异步加载的资源(如图片)可能延迟触发window.onload,但Performance API会持续记录这些资源的加载时间,最终数据仍完整。

二、Performance API 数据记录机制

  1. 核心原理

    1. 高精度时间戳performance.now()提供微秒级精度,不受系统时间调整影响。

    2. 性能时间线(Performance Timeline) :浏览器自动记录页面加载各阶段(导航、资源加载、绘制等)的时间戳,存储为PerformanceEntry对象。

    3. 自动收集:无需手动触发,浏览器在页面加载过程中持续填充performance.timingperformance.getEntries()等接口的数据。

  2. 关键数据记录流程

    1. 导航阶段:从用户发起请求(navigationStart)到页面开始响应(responseStart),记录DNS查询、TCP连接等耗时。

    2. 资源加载:通过Resource Timing API记录图片、脚本、样式表等资源的加载耗时(duration)。

    3. 绘制与交互:通过Paint Timing API记录FCP(首次内容绘制)、LCP(最大内容绘制),通过Event Timing API记录FID(首次输入延迟)等。

  3. 数据访问时机

    1. 同步访问:在window.onload事件中访问performance.timing,可获取完整导航时间(如loadEventEnd - navigationStart)。

    2. 异步访问:通过PerformanceObserver监听特定事件(如paintresource),可实时获取动态数据(如LCP的最终值)。

三、实验验证:代码位置对结果的影响

通过以下实验可验证位置无关性:

  1. 实验设计

    1. 将监控代码分别放在<head><body>底部,均绑定window.onload事件。

    2. 对比输出的性能数据(如DNS查询耗时、白屏时间、资源加载时间)。

  2. 实验结果

    1. 无论代码位置如何,只要在window.onload中执行,输出的性能数据完全一致。

    2. 示例代码:

      • // 放在 <head> 或 <body> 中均可
        window.onload = function() {
          const timing = performance.timing;
          console.log('DNS查询耗时:', timing.domainLookupEnd - timing.domainLookupStart);
          console.log('TCP连接耗时:', timing.connectEnd - timing.connectStart);
          console.log('白屏时间:', timing.responseStart - timing.navigationStart);
        };
        

四、 最佳实践 建议

  1. 代码位置选择

    1. 推荐位置:将监控代码放在<body>底部或使用defer属性,确保在DOM解析完成后执行,避免阻塞页面渲染。

    2. 避免位置:避免将监控代码直接放在<head>中且未绑定window.onload,可能导致数据不完整。

  2. 数据访问时机

    1. 优先使用window.onloadPerformanceObserver确保数据完整性。

    2. 对于动态数据(如LCP),通过PerformanceObserver监听largest-contentful-paint事件,并在用户交互后停止监听(避免误报)。

  3. 兼容性 与降级

    1. 针对不支持PerformanceObserver的浏览器(如IE),使用performance.getEntriesByType('paint')获取FCP/LCP数据。

    2. 使用navigator.sendBeaconfetch将数据发送至后端,避免阻塞页面卸载。

结论:监控代码的位置不会影响Performance API记录的性能数据,因为数据是浏览器在页面加载过程中独立记录的。关键在于确保代码在页面加载完成后(如window.onload事件)访问这些数据,从而保证数据的完整性和准确性。

React Native 蓝牙打印机实战:从“页面级连接”到“全局 Context 自动托管”的架构演进

2026年1月22日 16:19

React Native 蓝牙打印机实战:从“页面级连接”到“全局 Context 自动托管”的架构演进

在开发 React Native 应用对接蓝牙硬件(如打印机)时,很多开发者初期都会遇到一个典型问题:连接逻辑写在哪里最合适?

本文记录了一次真实的架构重构过程:从最初的“页面级手动连接”,踩坑“App.js 阻塞卡顿”,最终演进为“Context 全局自动托管”。

🛑 第一阶段:页面级连接(刀耕火种)

最初的实现思路非常直观:哪个页面要打印,就在哪个页面连接。

代码实现

Reception.js(收货页面)中:

// Reception.js
useEffect(() => {
  initPrinter(); // 进页面就初始化
}, []);

const initPrinter = async () => {
  // 1. 检查连接
  // 2. 没连就去连
  // 3. 连不上报错
};

遇到的痛点

  1. 重复连接:用户从“收货页”跳转到“发货页”,打印机需要断开重连,或者需要重新检查状态,浪费资源。
  2. 体验割裂:每次进入业务页面都要转圈圈等待连接,用户体验极差。
  3. 状态丢失:页面销毁后,连接状态和监听器也随之销毁。如果打印机意外断开,App 只有在下次打印时才会发现。

🚧 第二阶段:App.js 强行挂载(性能陷阱)

为了解决“重复连接”的问题,我们尝试把逻辑上移到根组件 App.js

代码实现

// App.js
useEffect(() => {
  connectToLastDevice(); // App 启动就连接
}, []);

遇到的痛点

  1. UI 卡顿App.js 是应用的根基。蓝牙扫描、连接、读取本地存储这些异步操作,混合在根组件的渲染周期里,极易导致 App 启动时掉帧、卡死(JS 线程阻塞)。
  2. 状态传递地狱:虽然 App 连上了,但深层页面(如 Detail.js)怎么知道连没连上?只能通过 Props 一层层透传,或者依赖全局变量(不仅丑陋而且不安全)。

✅ 第三阶段:Context 全局托管(终极方案)

最终,我们引入了 React 的 Context API,将打印机服务封装成一个独立的“黑盒”,实现了逻辑与 UI 的完全解耦

核心架构图

graph TD
  A[App Root] --> B[PrinterProvider]
  B --> C[AuthProvider]
  C --> D[NavigationContainer]
  D --> E[Page A]
  D --> F[Page B]
  
  B -.->|提供全局连接状态| E
  B -.->|提供打印方法| F

关键实现步骤

1. 创建 PrinterContext

建立一个独立的上下文环境,专门管理打印机的一切。

// src/context/PrinterContext.js
export const PrinterProvider = ({ children }) => {
  // 状态只在这里维护,不污染 App.js
  const [isConnected, setIsConnected] = useState(false);
  const isConnectedRef = useRef(false); // 解决闭包/竞态问题的关键

  // 初始化:只运行一次
  useEffect(() => {
    // 1. 启动蓝牙事件监听
    const listener = NativeEventEmitter.addListener('onStatusChange', handleStatus);
  
    // 2. 自动尝试重连上次设备
    autoConnectToLastDevice();

    return () => listener.remove();
  }, []);

  return (
    <PrinterContext.Provider value={{ isConnected, printText }}>
      {children}
    </PrinterContext.Provider>
  );
};
2. 接入 App 根节点

App.js 中包裹 Provider。注意它的位置:包裹在业务逻辑之外

// src/App.js
export default function App() {
  return (
    <PrinterProvider>  {/* 👈 只要这一行,服务就启动了 */}
      <AuthContext.Provider>
        <AppContainer />
      </AuthContext.Provider>
    </PrinterProvider>
  );
}
3. 业务页面“零负担”使用

业务页面不再需要关心连接逻辑,直接“拿来即用”。

// src/hooks/usePrinter.js
export const usePrinter = () => useContext(PrinterContext);

// 任意业务页面.js
const { isConnected, printText } = usePrinter();

const handlePrint = () => {
  if (!isConnected) {
    alert('请先连接'); // 状态是全局实时的
    return;
  }
  printText('Hello World');
};

🏗️ Provider 放置策略(关键知识点)

在重构过程中,我们将 PrinterProvider 放置在了 App.js 的最外层,这个位置的选择是有深刻讲究的。

// src/App.js
<PrinterProvider> {/* 👈 放在最外层 */}
  <AuthContext.Provider>
    <Provider>
      {/* ... */}
    </Provider>
  </AuthContext.Provider>
</PrinterProvider>

为什么放在最外层?

  1. 独立性(Independence)

    • 蓝牙硬件连接属于“基础设施服务”,它通常不依赖于用户身份
    • 即使放在 AuthContext 外部,无论当前用户是谁(甚至未登录状态),后台的蓝牙连接都可以保持活跃。
    • 反面教材:如果放在 AuthContext 内部,当用户“退出登录”时,PrinterProvider 会被卸载,导致连接断开;再次登录时又要重新连接,体验中断。
  2. 生命周期(Lifecycle)

    • 最外层的 Provider 拥有全应用级生命周期(App Lifecycle)。
    • 它的存活时间 = App 运行时间。这意味着只要 App 不被杀死,连接就永远不会因为页面跳转、路由重置或用户登出而意外断开。
  3. 性能(Performance)

    • 顶层组件通常是静态的,Props 变化极少。
    • 放在最外层可以避免因为中间层(如 NavigationContainerAuthProvider)的状态变化而导致 Provider 意外卸载或重渲染。

🌟 方案优势总结

  1. 无感连接(Silent Connection)

    • 用户在登录页输入密码时,后台就已经悄悄连上打印机了。
    • 进入业务页面时,设备已经是 Ready 状态,操作零等待
  2. 性能优化(Performance)

    • 连接状态的变化只会触发使用了 usePrinter 的组件重渲染,不会导致整个 App 树重绘
    • 解决了 App.js 阻塞导致的启动卡顿问题。
  3. 状态保活(Keep-Alive)

    • 无论路由如何跳转,Context 始终存在。
    • 全局单例维护连接,避免了重复创建销毁连接的资源消耗。
  4. 竞态条件处理(Race Condition)

    • 我们在重构中还发现了一个经典 Bug:蓝牙底层已连接,但 JS 层因为超时报错。
    • 解法:引入 useRef 追踪真实状态,在 catch 块中二次确认状态,成功解决了“假报错”问题。

💡 给开发者的建议

对于蓝牙打印机、WebSocket 连接、全局定位这类**“应用级服务”**,不要犹豫,直接上 Context。不要试图在页面组件或根组件里手动管理它们的生命周期,将它们抽离成独立的 Provider 是 React 架构的最佳实践。

❌
❌