普通视图

发现新文章,点击刷新页面。
今天 — 2025年7月1日掘金 前端

browser-tools-mcp前端开发调试利器

作者 dmy
2025年7月1日 21:54

如果你有过前端项目开发的经历,那么一定会经常打开浏览器自带开发者工具,查看网络请求或者控制台日志等等。遇到问题还会复制粘贴里面信息去搜索引擎搜索信息。即使当前ai非常强大,你也不得不手动告知ai你遇到的上下文情景。来来回回操作会非常繁琐,幸运的是这个mcp工具——browser-tools-mcp转为解决上面的问题而生。

怎么用呢?

1. 前提条件

由于是javascript开发,确保电脑上有安装node

2. step1: 浏览器安装插件

需要注意的是,如果你直接在chrome扩展商店是搜不到的,应该没上架。所以要手动去github.com/AgentDeskAI… 下载扩展安装包,解压后;在浏览器中通过打开开发者模式手动安装。

3. step2: 使用工具中添加mcp

假设你使用的是cursor,那么进入设置界面

image.png

添加添加New Mcp server配置如下信息

{
  "mcpServers": {
    "browser-tools": {
      "command": "npx",
      "args": ["@agentdeskai/browser-tools-mcp@latest"]
    }
  }
}

4. step3: 终端启动工具服务

image.png 这一步必不可少,它是一个中间服务,用于与你浏览器中的插件通信;启动后你也能看到些日志信息

5. step4: 直接使用

  1. 首先打开我们安装的插件
  2. 打开页面的开发者工具窗口

image.png 3. 在你的IDE中调用mcp即可获取到相关的调试信息

image.png 可以看到成功获取了浏览器开发工具中的信息。

最后

它的主要好处是打通了ai和浏览器调试的鸿沟,ai能直接获取到调试信息,大大加快代码调试速度。

🚀🚀🚀 惊了,Gemini Pro 2.5 可以在终端使用了!Gemini Cli 初体验~

2025年7月1日 19:52

前言

最近,Google 发布了一个名为 Gemini CLI 的开源项目,它将 Gemini 的强大功能直接带入你的终端。

本文将带你深入了解 Gemini CLI 的功能、使用方法以及它如何帮助你提高开发效率!

往期精彩推荐

正文

什么是 Gemini CLI?

通过简单的命令行操作,你可以与 Gemini 模型交互,执行各种任务,包括查询和编辑代码库、生成应用程序、自动化操作任务等!

它不仅适合个人开发者,也为团队协作提供了灵活的配置选项!

使用方法

安装

要使用 Gemini CLI,你需要先安装 Node.js 18 或更高版本。然后,通过以下命令安装:

npx https://github.com/google-gemini/gemini-cli

或全局安装:

npm install -g @google/gemini-cli

安装完成后,运行 gemini 命令即可启动。

启动之后需要选择一个颜色主题,我选择的 atom one dark!

选择主题

选择登录方式,支持三种登录方式:

  • google 个人认证
  • gemini api
  • Vertex AI

我最后采用的 google 个人认证,相对来说比较简单!

登录方式

google 个人认证

Gemini CLI 支持通过个人 Google 账户认证,免费提供每分钟 60 次模型请求和每天 1,000 次模型请求的额度!

首先得启用 Gemini for Cloud API:

Gemini for Cloud API

地址:console.cloud.google.com/marketplace…

然后配置 access permissions,具体步骤如下:

access permissions

地址:console.cloud.google.com/projectsele…

注意 Gemini for Google Cloud User 在其他选项里:

然后我们复制新建项目之后的项目 ID,在终端将环境变量放入全局:

echo 'export GEMINI_API_KEY="YOUR_GEMINI_API_KEY"' >> ~/.zshrc
source ~/.zshrc

现在运行启动命令,即可正常访问了!

主要功能

Gemini CLI 提供了多种功能,使其成为开发者工具箱中的强大补充。以下是其核心功能:

  1. 查询和编辑代码库

Gemini CLI 允许你查询和编辑大型代码库,即使代码量超出 Gemini 的 1M 令牌上下文窗口。你可以轻松导航代码库、分析架构或进行修改。例如,你可以询问文件目录结构!

查询文件目录

  1. 生成应用程序
    利用 Gemini 的多模态能力,Gemini CLI 可以从 PDF 文件或手绘草图生成可运行的应用程序代码。这为快速原型开发提供了全新的可能性!

  2. 自动化任务
    Gemini CLI 支持自动化各种操作任务,如查询 GitHub 拉取请求、处理复杂的 rebase 操作等。这大大减少了手动操作的时间!

查询git历史记录

  1. 工具和 MCP 服务器支持

它可以连接到其他工具和服务,如 Imagen、Veo 或 Lyria,用于媒体生成等任务。这种扩展性使其适用于更广泛的场景! 你可以在项目目录新建 .gemini/settings.json 文件,添加自己或公共的 MCP 服务

{
  "mcpServers": {
    "httpServer": {
      "httpUrl": "http://localhost:3000/mcp",
      "timeout": 5000
    }
  }
}
  1. 基于 Google 搜索的查询

Gemini CLI 内置 Google 搜索工具,可以为你的查询提供更准确的上下文信息,确保回答更贴合实际需求。

最后

Gemini CLI 工具帮助开发者从查询代码到生成应用程序,再到自动化工作流,Gemini CLI 都能提供便捷的解决方案!

今天的分享就这些了,感谢大家的阅读,如果文章中存在错误的地方欢迎指正!

往期精彩推荐

从SSE到打字机——AI场景下前端的实现逻辑与实践

2025年7月1日 19:34

随着Deepseek的横空出世,让每个人都有了构建自己AI知识库的机会,作为一个前端开发者,完全可以通过大模型构建自己的日常学习知识库,然后自己写一个AI的交互页面构建自己的 ChatGPT ,当然说到这,肯定有人说现在有一键构建的开源项目为什么不用呢,说白了技术还是要自己实现才能更加深入地理解,并且更加灵活地运用到日常学习或者实际业务场景中去。

本篇文章只从前端的角度出发,分析实现一个AI的交互页面能用到哪些技术,最后再去实现一个AI场景页面。

当然,你也可以点击这里直接查看本篇文章实现的页面。

如果打不开,这里还有贴心国内服务器的备用链接

PS:上面两个演示链接都是用 vuepress 实现的个人博客,感觉用这套框架实现自定义组件里面的坑还挺多了,有机会可以再写一篇关于 vuepress 的开发避坑文章。

当然,关于IM的交互逻辑在我之前的文章 【从零开始实现一个腾讯IM即时通讯组件(无UI设计方案)~】中已经详细描述了实现过程,所以,这篇文章就从已经实现了IM交互的页面基础上开始实现AI场景下的IM。

技术选型

涉及到AI场景必然会联想到打字机效果的流式输出文本,那么前端实现这种效果有哪些方式呢?

协议对比

首先最简单的,通过轮询接口不断获取数据,其次通过websocket不断获取监听到的数据,最后通过服务端消息推送获取数据。这三种思路对应着三种通讯协议:HTTP、WebSocket、SSE。

先对比一下这三种协议:

基本概念与通信模式

特性 HTTP SSE (Server-Sent Events) WebSocket
协议类型 无状态的请求 - 响应协议 基于 HTTP 的单向事件流协议 基于 TCP 的全双工实时协议
通信方向 客户端→服务器(单向) 服务器→客户端(单向) 双向(全双工)
连接特性 短连接(每次请求新建连接) 长连接(单次请求,持续响应) 长连接(一次握手,持续通信)
发起方式 客户端主动请求 客户端主动请求,服务器持续推送 客户端发起握手,后续双向通信
典型场景 静态资源请求、API 调用 实时通知、股票行情、新闻推送 实时聊天、在线游戏、协作工具

技术细节对比

特性 HTTP SSE WebSocket
协议基础 HTTP/1.1 或 HTTP/2 HTTP/1.1 或 HTTP/2 WebSocket 协议 (RFC 6455)
端口 80 (HTTP) / 443 (HTTPS) 80/443 80 (ws) / 443 (wss)
数据格式 文本、JSON、二进制等 纯文本(text/event-stream) 文本或二进制(帧格式)
二进制支持 支持,但需额外处理 不支持(需编码为文本) 原生支持
自动重连 否(需客户端实现) 是(内置机制) 否(需手动实现)
心跳机制 否(需轮询) 否(需自定义) 是(Ping/Pong 帧)
浏览器兼容性 全兼容 现代浏览器(IE 不支持) 现代浏览器(IE 10+)

性能与效率

特性 HTTP SSE WebSocket
连接开销 高(每次请求需重新建立连接) 中(一次连接,长期保持) 低(一次握手,持续通信)
协议 overhead 高(HTTP 头信息冗余) 低(仅初始头) 中(帧头开销较小)
实时性 低(依赖客户端轮询) 高(服务器主动推送) 极高(双向实时)
带宽利用率 低(轮询导致无效请求) 中(单向持续传输) 高(按需双向传输)
延迟 高(请求响应周期) 中(推送延迟) 低(长连接直接通信)

API选择

再来回看一下我们的需求,AI场景说白了一问一答的方式,那么我们希望发送一次请求后,能够持续获取数据,本次请求后端也只需要知道我的问题即可,不需要和前端进行其他交互,所以 SSE 在这种场景下的优势就显而易见了。

前端要在浏览器中实现 SSE 的方式有两种:

  • EventSource API
  • fetch API

EventSourcefetch 都是现代 Web 开发中用于与服务器通信的 API。

特性 EventSource (SSE) Fetch API
通信模式 单向(服务器→客户端) 双向(请求→响应)
连接特性 长连接(持续接收服务器推送) 短连接(每次请求新建连接)
数据流类型 事件流(持续不断) 一次性响应(请求完成即结束)
数据格式 文本(事件流格式) 任意(JSON、Blob、文本等)
自动重连 内置支持(自动重连机制) 需手动实现

EventSource API实现了 SSE 。换句话说 EventSource API是 Web 内容与服务器发送事件通信的接口。一个EventSource 实例会对HTTP服务器开启一个持久化的连接,以 text/event-stream 格式发送事件,此连接会一直保持开启直到通过调用 EventSource.close() 关闭。

但是它有一些限制:

  • 无法传递请求体 request body ,必须将执行请求所需的所有信息编码到 URL 中,而大多数浏览器对 URL 的长度限制为 2000 个字符。
  • 无法传递自定义请求头。
  • 只能进行 GET 请求,无法指定其他方法。
  • 如果连接中断,无法控制重试策略,浏览器会自动进行几次尝试然后停止。

而AI场景常常会有一些其他需求,如上文记忆、接口 token 验证等等,于是 fetch 成了我们的最佳选择。

fetch API可以通过设置 headers 支持流式数据的接收,然后通过 ReadableStreamDefaultReader 对象,逐块读取响应的数据。

大模型选择

作为前端开发我们更注重于模型的定制化配置和页面的展示效果与交互,通过第三方模型可以快速满足我们的需求,这里我选用的是阿里云百炼

它直接提供了支持流式输出的接口,只需要在请求头加上 X-DashScope-SSE:true 。比较坑的是阿里云文档里面只提供了 node 的写法,实际浏览器中 axios 并不支持流式传输。

image-20250621145616487

API解析

AbortController

前面我们说到 SSE 的数据传输是单向的,有时候我们会想中断推送信息的接收,实际需求就是中断AI当前回答,所以我们需要一个控制器来更加精细地控制我们的请求。

AbortController 对象的作用是对一个或多个 Web 请求进行中止操作,像 fetch 请求、ReadableStream 以及第三方库的操作都可以取消。

核心机制:借助 AbortSignal 来实现操作的中止。AbortController 会生成一个信号对象,该对象可被传递给请求,当调用 abort() 方法时,就会触发请求的取消操作。

有了这个API我们就可以实现中断回答按钮的实现。

const controller = new AbortController()
const response = await fetch(
  url: 'url',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer token`,
      'X-DashScope-SSE': 'enable', // 允许实时推送
    },
    signal: controller.signal, // 信号对象绑定请求
    body: "{...}",
  }
)

setTimeout(()=> controller.abort(), 1000) // 一秒钟后自动中断请求

Reader

在请求发出后,我们需要一个能持续获取推送信息的入口,fetchresponse.body.getReaderJavaScript 中用于处理 fetch 请求响应流的方法,它允许你以可控的方式逐块读取响应数据,而非一次性处理整个响应。这在处理大文件下载、实时数据流(如视频、SSE)或需要渐进式解析数据的场景中特别有用。

// 获取一个 ReadableStreamDefaultReader 对象,用于逐块读取响应的二进制数据(Uint8Array)。
const reader = response.body.getReader()

while (true) {
  // 读取数据块 流是一次性的,读取完成后无法再次读取
const {done, value} = await reader.read();
  if (done) {
    console.log('流读取完成');
    break;
  }
}

循环调用 read() 以达到获取完整数据的需求,根据 done 判断是否已经读取完毕。

TextDecoder

TextDecoderJavaScript 中用于将二进制数据(如 ArrayBufferUint8Array)解码为人类可读的文字字符串的内置对象。它支持多种字符编码(如 UTF-8ISO-8859-1GBK 等),是处理网络响应、文件读取等二进制数据转换的标准工具。

// 任意二进制数据
const value = ...

// 流式解码:支持分块处理二进制数据(通过多次调用 decode 方法)。
const decoder = new TextDecoder('UTF-8')
// 解码二进制数据为文本
const chunk = decoder.decode(value, { stream: true })

值得注意的是 decodestream 参数设置为 true ,这是为了防止乱码的情况,因为我们知道 UTF-8 是一种变长编码,ASCII 字符(0-127)用 1 个字节表示,而其他字符(如中文、 emoji)可能用 2-4 个字节表示。例如:

  • 的 UTF-8 编码是 [228, 184, 150](3 个字节)。
  • 😊 的 UTF-8 编码是 [240, 159, 152, 138](4 个字节)。

当数据分块传输时,一个字符可能被截断在不同的块中。例如:

块1: [228, 184]    // "中" 的前两个字节(不完整)
块2: [150]         // "中" 的最后一个字节

stream 选项决定了解码器如何处理可能不完整的多字节字符:

stream 行为描述
false 默认值。假设输入是完整的,直接解码所有字节。若遇到不完整字符,会用 替换。
true 假设输入是数据流的一部分,保留未完成的多字节字符,等待后续数据。

实际情况可以参考下段代码:

// 错误情况
const decoder = new TextDecoder();
const chunk1 = new Uint8Array([228, 184]); // "中" 的前两个字节
const chunk2 = new Uint8Array([150]);      // "中" 的最后一个字节

console.log(decoder.decode(chunk1)); // 输出: "�"(错误:截断的字符被替换为乱码)
console.log(decoder.decode(chunk2)); // 输出: "�"(错误:单独的第三个字节无法组成有效字符)

// 正确情况

const decoder = new TextDecoder();
const chunk1 = new Uint8Array([228, 184]); // "中" 的前两个字节
const chunk2 = new Uint8Array([150]);      // "中" 的最后一个字节

console.log(decoder.decode(chunk1, { stream: true })); // 输出: ""(无输出,保留未完成字符)
console.log(decoder.decode(chunk2));                   // 输出: "中"(合并后正确解码)

处理流式输出

结合上述API的分析,fetch 实现处理流式数据的代码如下:

const controller = new AbortController()

const response = await fetch(
  url,
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer sk-dd0e8892eb0445149fd21fd9b1d6176c`,
      'X-DashScope-SSE': 'enable',
    },
    signal: controller.signal,
    body: JSON.stringify({
      input: {
        prompt: text
      },
      parameters: {
        'incremental_output' : 'true' // 增量输出
      },
    }),
  }
)
if (!response.ok) {
  message.error('AI返回错误')
  loadingSend.value = false
  return
}

const decoder = new TextDecoder('UTF-8')
const reader = response.body.getReader()

while (true) {
  const {done, value} = await reader.read();

  if (done) {
    console.log('流读取完成');
    // 中断fetch请求
    controller.abort()
    // 资源释放:释放读取器锁
    reader.releaseLock()
    break;
  }

  // 解码二进制数据为文本
  const chunk = decoder.decode(value, { stream: true })
  console.log('chunk:===>', chunk)
}

处理流式数据

通过 reader 读取到的数据经过 decoder 处理后格式如下:

id:1
event:result
:HTTP_STATUS/200
data:{"output":{"session_id":"0837b503363c4525a6609f868f3f6afa","finish_reason":"null","text":"我是","reject_status":false},"usage":{"models":[{"input_tokens":370,"output_tokens":1,"model_id":"deepseek-v3"}]},"request_id":"ecea2ce7-3867-9074-aa67-92b39ba9253a"}

id:2
event:result
:HTTP_STATUS/200
data:{"output":{"session_id":"0837b503363c4525a6609f868f3f6afa","finish_reason":"null","text":"你的","reject_status":false},"usage":{"models":[{"input_tokens":370,"output_tokens":2,"model_id":"deepseek-v3"}]},"request_id":"ecea2ce7-3867-9074-aa67-92b39ba9253a"}

当然这个是阿里云的返回格式,但流式数据格式都大差不差,接下来我们来分析这段文本。

首先,reader 获取的数据可能会有多段,如上文中的就是 id:1id:2 两段数据。

其中关键字段为:data.output.text ,所以我们需要根据返回数据的结构特点通过正则把有效信息给过滤出来。

// 全局贪婪匹配 "text":" 到 ","reject_status": 之间的内容,确保多段数据也能准确提取所有的有效信息
const regex = /"text":"(.*?)","reject_status":/gs;

这里使用正则而不是 JSON 化的原因是流式数据的处理讲究高效与准确JSON 化更加地消耗性能,而且存在异常报错的可能,为了最大可能保证主流程的持续输出,用正则是更优的选择。当然具体业务场景具体处理,这里仅作个人见解。

根据上述正则,实现一个数据处理函数:

const extractText = (jsonString) => {
  try {
    const regex = /"text":"(.*?)","reject_status":/gs;
    let match;
    let result = '';
    // 利用regex.exec()在字符串里循环查找所有匹配结果,把每次匹配得到的捕获组内容(也就是text字段的值)添加到result字符串中。
    while ((match = regex.exec(jsonString)) !== null) {
      // 将字符串里的\n转义序列转换为真正的换行符,把\"转义序列还原为普通的双引号。
      result += match[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
    }
    return result
  } catch (error) {
    console.log('error', error)
    return ''
  }
}

最后把数据处理函数加到流式输出代码中,通过缓存持续获取有用的信息:

...
// 用于累计接收到的数据
let accumulatedText = ''

while (true) {
  const {done, value} = await reader.read();

  if (done) {
    ...
    break;
  }

  const chunk = decoder.decode(value, { stream: true })
  // 累加并渲染数据
  const newText = extractText(chunk)
  if (newText) {
  accumulatedText += newText
  }
}

转换MD文本

这里用到几个库来实现:

  • markdown-it 一个快速、功能丰富的 Markdown 解析器,基于 JavaScript 实现。它的主要作用是把 Markdown 文本转换成 HTML。
  • @vscode/markdown-it-katex VS Code 团队开发的插件,用于在 Markdown 中渲染 LaTeX 数学公式,它集成了 KaTeX 这个快速的数学公式渲染引擎。
  • markdown-it-link-attributes 为 Markdown 中的链接添加自定义属性,比如为外部链接添加target="_blank"rel="noopener noreferrer"属性。
  • mermaid-it-markdown 用于在 Markdown 中集成 Mermaid 图表,Mermaid 是一种用文本语法描述图表的工具。

三方库使用

结合上述各种库结合,处理接口返回的信息流:

import MarkdownIt from 'markdown-it'
import MdKatex from '@vscode/markdown-it-katex'
import MdLinkAttributes from 'markdown-it-link-attributes'
import MdMermaid from 'mermaid-it-markdown'
import hljs from 'highlight.js'

const mdi = new MarkdownIt({
  html: false,
  linkify: true,
  highlight(code, language) {
    const validLang = !!(language && hljs.getLanguage(language))
    if (validLang) {
      const lang = language ?? ''
      return highlightBlock(hljs.highlight(code, { language: lang }).value, lang)
    }
    return highlightBlock(hljs.highlightAuto(code).value, '')
  },
})
mdi.use(MdLinkAttributes, { attrs: { target: '_blank', rel: 'noopener' } }).use(MdKatex).use(MdMermaid)

// 实现代码块快速复制
function highlightBlock(str, lang) {
  return `<pre class="code-block-wrapper">
            <div class="code-block-header">
                <span class="code-block-header__lang">${lang}</span>
                <span class="code-block-header__copy">复制代码</span>
            </div>
            <code class="hljs code-block-body ${lang}"><br>${str}</code>
          </pre>`
}

const renderToAI = (text) => {
  // 对数学公式进行处理,自动添加 $$ 符号
  const escapedText = escapeBrackets(escapeDollarNumber(text))
  return mdi.render(escapedText)
}

const escapeBrackets = (text) => {
  const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\]|\\\((.*?)\\\)/g
  return text.replace(pattern, (match, codeBlock, squareBracket, roundBracket) => {
    if (codeBlock)
      return codeBlock
    else if (squareBracket)
      return `$$${squareBracket}$$`
    else if (roundBracket)
      return `$${roundBracket}$`
    return match
  })
}

const escapeDollarNumber = (text) => {
  let escapedText = ''

  for (let i = 0; i < text.length; i += 1) {
    let char = text[i]
    const nextChar = text[i + 1] || ' '

    if (char === '$' && nextChar >= '0' && nextChar <= '9')
      char = '\\$'

    escapedText += char
  }

  return escapedText
}

复制代码块

快速复制代码实现:

// 聊天列表主体元素
const textRef = ref()

// 构建textarea,将内容复制到剪切板
const copyToClip = (text) => {
  return new Promise((resolve, reject) => {
    try {
      const input = document.createElement('textarea')
      input.setAttribute('readonly', 'readonly')
      input.value = text
      document.body.appendChild(input)
      input.select()
      if (document.execCommand('copy'))
        document.execCommand('copy')
      document.body.removeChild(input)
      resolve(text)
    }
    catch (error) {
      reject(error)
    }
  })
}

// 为所有的复制代码按钮添加复制事件
const addCopyEvents = () => {
  if (textRef.value) {
    const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy')
    copyBtn.forEach((btn) => {
      btn.addEventListener('click', () => {
        const code = btn.parentElement?.nextElementSibling?.textContent
        if (code) {
          copyToClip(code).then(() => {
            btn.textContent = '复制成功'
            setTimeout(() => {
              btn.textContent = '复制代码'
            }, 1000)
          })
        }
      })
    })
  }
}

// 移除页面中所有的复制事件
const removeCopyEvents = () => {
  if (textRef.value) {
    const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy')
    copyBtn.forEach((btn) => {
      btn.removeEventListener('click', () => { })
    })
  }
}

// 在合适的生命周期里注册或卸载重新事件

// 可以在流式输出完成,页面渲染完成的时候手动调用,避免性能浪费,更加合理
onUpdated(() => {
  addCopyEvents()
})

onUnmounted(() => {
  removeCopyEvents()
})

自定义MD样式

MD样式:

.ai-message {
    background-color: transparent;
    font-size: 14px;
}
.ai-message p {
    white-space: pre-wrap;
}
.ai-message ol {
    list-style-type: decimal;
}
.ai-message ul {
    list-style-type: disc;
}
.ai-message pre code,
.ai-message pre tt {
    line-height: 1.65;
}
.ai-message .highlight pre,
.ai-message pre {
    background-color: #fff;
}
.ai-message code.hljs {
    padding: 0;
}
.ai-message .code-block-wrapper {
    position: relative;
    padding: 0 12px;
    border-radius: 8px;
}
.ai-message .code-block-header {
    position: absolute;
    top: 5px;
    right: 0;
    width: 100%;
    padding: 0 1rem;
    display: flex;
    justify-content: flex-end;
    align-items: center;
    color: #b3b3b3;
}
.ai-message .code-block-header__copy {
    cursor: pointer;
    margin-left: 0.5rem;
    user-select: none;
}
.ai-message .code-block-header__copy:hover {
    color: #65a665;
}
.ai-message div[id^='mermaid-container'] {
    padding: 4px;
    border-radius: 4px;
    overflow-x: auto !important;
    background-color: #fff;
    border: 1px solid #e5e5e5;
}
.ai-message li {
    margin-left: 16px;
    box-sizing: border-box;
}

最后,把处理函数追加到处理流式数据后面:

let mdHtml = ''

...
const chunk = decoder.decode(value, { stream: true })
const newText = extractText(chunk)
if (newText) {
  accumulatedText += newText
  mdHtml += renderToAI(accumulatedText)
}

打字机

到目前为止我们已经流式地拿到了接口返回的数据并且转换成了页面可以展示的MD风格HTML字符串。

打字机的基本思路就是按照一定频率把内容添加到页面上,并且在内容最前面加个打字的光标。

直接上代码:

<template>
  <div v-html="displayText + `${ showCursor || adding ? `<span class='cursors'>_</span>`:'' }`"></div>
</template>

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

const props = defineProps({
  // 要显示的完整文本
  text: {
    type: String,
    required: true
  },
  // 打字速度(毫秒/字符)
  speed: {
    type: Number,
    default: 10
  },
  showCursor: {
    type: Boolean,
    default: false
  },
  break: {
    type: Boolean,
    default: false
  },
});
const emits = defineEmits(['update', 'ok'])

const displayText = ref('');
const adding = ref(false);
let timer = null;

// 更新显示的文本
const updateDisplayText = () => {
  if (displayText.value.length < props.text.length) {
    adding.value = true;
    displayText.value = props.text.substring(0, displayText.value.length + 1);
    emits('update')
    timer = setTimeout(updateDisplayText, props.speed);
  } else {
    adding.value = false;
    setTimeout(() =>{
      emits('ok')
    } ,600)
  }
};

// 增量更新
watch(() => props.text, (newText) => {
  // 如果新文本比当前显示的文本长,则继续打字
  if (newText.length > displayText.value.length) {
    clearTimeout(timer);
    updateDisplayText();
  }
});

// 停止回答
watch(() => props.break, (val) => {
  if (val) {
    displayText.value = props.text + ''
    clearTimeout(timer);
    adding.value = false;
    setTimeout(() =>{
      emits('ok')
    } ,600)
  }
});

// 初始化
updateDisplayText();

// 组件卸载时清理定时器
onUnmounted(() => {
  clearTimeout(timer);
});
</script>

<style>

.cursors {
  font-weight: 700;
  vertical-align: baseline;
  animation: blink 1s infinite;
  color: #3a5ccc;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}
</style>  

我们只需要把上述转换的MD文本传入这个组件就能实现打字机效果。

<temlate>
<div class="ai-message">
    <TypingEffect :text="text" :showCursor="!ready" :break="break" @update="updateAIText" @ok="textAllShow" />
  </div>
</temlate>

需要注意的是,打字机打印的速度是按照恒定速度执行的,流式数据是不规则时间返回的,有可能返回很快,也有可能返回很慢,所以两边就会有时间差。

这就造成了一种现象,有时候我们点了停止回答的按钮,页面上还在不断输出内容,好像没有打断这次回答,这里我们只需要在点击停止回答的时候终止打字机的轮询,直接展示完整数据即可。

最后优化显示,需要自动滚动到底部:

const scrollToBottom = () => {
  try {
    const { height } = textRef.value.getBoundingClientRect()
    textRef.value.scrollTo({
      top: textRef.value.scrollHeight - height,
      behavior: 'smooth',
    })
  } catch (e) {}
}

总结

前端AI场景下总结来说就两个平时不常见的技术点:

  • 流式输出
  • 请求中断

当然本篇文章只是实现了基本的AI场景,像上下文记忆、多对话框以及更大模型的微调等等并未涉及到,这些更加深入地功能,可以后面慢慢研究,那么,这次就到这里~

Vue项目中评论发送表情功能的实现与问题解决总结

作者 code二极管
2025年7月1日 18:25

Vue项目中评论发送表情功能的实现与问题解决总结

问题情境

在开发音乐类Web项目时,评论区是用户互动的重要场景。为了提升用户体验,越来越多的产品支持在评论中插入表情(emoji)。但在实际开发中,"评论发送表情"这个看似简单的功能,往往会遇到一些细节问题,比如:

  • 如何优雅地集成表情选择器?
  • 如何让表情插入到光标处而不是总是追加到末尾?
  • 如何兼容移动端和PC端的输入体验?
  • 表情插入后,如何保证输入框光标位置的正确?

下面结合实际开发过程,详细讲解表情评论功能的实现与常见问题的解决方案。


方案一:集成 emoji-picker-element 实现表情选择

1. 组件引入与基本用法

本项目采用了 emoji-picker-element 作为表情选择器。它是一个 Web Components 组件,使用简单,兼容性好。

import 'emoji-picker-element'

在评论输入区旁边放置一个表情按钮,点击后弹出 emoji-picker:

<div class="icon cursor-pointer relative">
  <div @click="changeEmojiStatus">🙂</div>
  <transition name="slide-fade">
    <emoji-picker
      v-show="emojiStatus"
      @emoji-click="handleEmojiClick"
      class="absolute left-0 z-50"
      :class="{ 'w-[300px] h-[400px]': isMobile() }"
    ></emoji-picker>
  </transition>
</div>

2. 让表情插入到光标处

用户希望表情能插入到当前输入光标的位置,而不是总是追加到末尾。实现思路如下:

  • 通过 ref 获取 el-input 的 textarea DOM。
  • 监听 emoji-picker 的 emoji-click 事件,拿到选中的 emoji。
  • 读取 textarea 的 selectionStart/selectionEnd,拼接字符串插入 emoji。
  • 用 nextTick 保证插入后光标移动到表情后面。

核心代码:

const handleEmojiClick = (event) => {
  const emoji = event.detail.emoji.unicode
  const textarea = commentInputRef.value.textarea
  if (textarea) {
    const startPos = textarea.selectionStart
    const endPos = textarea.selectionEnd
    commentContent.value =
      commentContent.value.substring(0, startPos) +
      emoji +
      commentContent.value.substring(endPos)
    nextTick(() => {
      textarea.focus()
      textarea.setSelectionRange(
        startPos + emoji.length,
        startPos + emoji.length
      )
    })
  } else {
    // 兼容兜底:直接追加
    commentContent.value += emoji
  }
}

3. 兼容移动端与PC端

  • 通过 isMobile() 判断设备类型,动态调整 emoji-picker 的尺寸和样式。
  • 使用绝对定位和 z-index 保证表情选择器不会遮挡输入框。
  • 通过动画(如 slide-fade)提升弹出体验。

4. 其他注意事项

  • 输入框获取焦点:插入表情后要让输入框重新聚焦,提升用户体验。
  • 表情选择器关闭:插入表情后可自动关闭选择器,避免误操作。
  • 表情与文本混输:无需特殊处理,emoji 本质是 Unicode 字符。
  • 最大长度限制:如有字数限制,需在插入前判断长度。

方案二:常见问题与解决思路

1. 表情插入后光标错乱

  • 解决:插入后用 setSelectionRange 手动设置光标。

2. el-input 组件无法直接获取 textarea

  • 解决:通过 ref 拿到 el-input 实例,再访问其 textarea 属性。

3. emoji-picker 在移动端遮挡输入框

  • 解决:用媒体查询和绝对定位调整弹窗位置和尺寸。

4. 兼容性问题

  • emoji-picker-element 作为 Web Components,需确保项目支持(Vite 默认支持)。

代码片段参考

<el-input
  ref="commentInputRef"
  maxlength="200"
  show-word-limit
  type="textarea"
  rows="2"
  v-model="commentContent"
  placeholder="勇敢的少年啊快去创造热评~"
  class="w-full text-black text-base"
/>
<!-- 表情按钮和选择器 -->
<div class="icon cursor-pointer relative">
  <div @click="changeEmojiStatus">🙂</div>
  <transition name="slide-fade">
    <emoji-picker
      v-show="emojiStatus"
      @emoji-click="handleEmojiClick"
      class="absolute left-0 z-50"
      :class="{ 'w-[300px] h-[400px]': isMobile() }"
    ></emoji-picker>
  </transition>
</div>

总结与心得

评论发送表情功能的实现,既考验细节处理能力,也能提升产品体验。遇到问题时,建议多查阅官方文档和社区经验,善用 Web Components 及现代前端特性。希望本总结能帮助你快速实现高质量的表情评论功能!

web 使用rem方案适配PC端和移动端

2025年7月1日 18:04

介绍及原理

原理

rem单位的特点,是1rem对应的px值等于根元素html的font-size值。也就是说,当根元素html的font-size值变化时,1rem的值会跟随着动态变化。比如:

有一个div,他的高度设置为1rem,当页面内根元素html的font-size为16px时,这个div的高度就是16px;当页面内的根元素htmlfont-size为32px时,这个div的高度就是32px。

postcss-pxtorem插件会将我们写在样式中的px根据我们在vite.config.js中设置的rootValue值,按比例转化为rem。(要注意哦,行内样式中的px不会被转化为rem)

介绍

postcss-pxtorem:

为了进一步简化开发流程,amfe-flexible通常与postcss-pxtorem插件一起使用。

postcss-pxtorem是一个PostCSS插件,它可以自动将CSS文件中的px单位转换为rem单位。这样,开发者可以直接在CSS中使用px单位,而postcss-pxtorem会自动完成单位转换。

插件安装

npm install postcss-pxtorem

如果运行项目后报错 PostCSS plugin postcss-pxtorem requires PostCSS 8.

需要降低 postcss-pxtorem 的版本

卸载默认安装的版本 
npm uninstall postcss-pxtorem
 
安装指定版本
npm i postcss-pxtorem@5.1.1

postcss-pxtorem 插件配置

在vite.config.js中进行配置

// 引入pxtorem插件
import postCssPxToRem from "postcss-pxtorem";
 
export default defineConfig({
  css: {
    postcss: {
      plugins: [
        postCssPxToRem({
          // 配置在将px转化为rem时 1rem等于多少px
rootValue: 1920 / 10, // 1920px设计稿的10分之一
          // 所有px均转化为rem
          propList: ["*"]
          // 若想设置部分样式不转化 可以在配置项中写出
          // 例如: 除 border和font-size外 所有px均转化为rem
          // propList: ["*", "!border","!font-size"], 
          // exclude: /node_modules/i, // 排除node_modules内的文件
        })
      ]
    }
  }
})

补充

忽略单个属性的最简单方法是在像素单位声明中使用大写字母,将px写为Px或PX

.ignore {
    border: 1Px solid; 
    border-width: 2PX; 
}

postcss-pxtorem更多配置项:

flexible.js 文件

在和main.js文件同级的目录中新建一个flexible.js 文件

内容如下:

(function flexible(window, document) {
var docEl = document.documentElement;

// 断点区间配置(与 variables.scss 保持一致)
var breakpoints = [
// 超小屏手机
{ name: 'mobile-sm', min: 0, max: 480 },
// 普通手机
{ name: 'mobile', min: 481, max: 767 },
// 平板
{ name: 'tablet', min: 768, max: 1023 },
// 小型笔记本/桌面
{ name: 'laptop', min: 1024, max: 1279 },
// 普通桌面显示器
{ name: 'desktop', min: 1280, max: 1919 },
// 超大桌面显示器
{ name: 'large-desktop', min: 1920, max: Infinity },
];
var remBase = 100; // 每个区间最大宽度时 1rem = 100px

function setRemUnit() {
var width = docEl.clientWidth;
var rem;
for (var i = 0; i < breakpoints.length; i++) {
var bp = breakpoints[i];
if (width >= bp.min && width <= bp.max) {
if (bp.max === Infinity) {
// 最大区间,rem为remBase
rem = remBase;
} else {
// 线性缩放
var range = bp.max - bp.min;
rem = (remBase * (width - bp.min)) / range + remBase;
}
break;
}
}
// 如果小于最小断点,继续缩放
if (rem === undefined) {
var minBp = breakpoints[0];
rem = (remBase * (width - minBp.min)) / (minBp.max - minBp.min);
}
docEl.style.fontSize = rem + 'px';
}

setRemUnit();

window.addEventListener('resize', setRemUnit);
window.addEventListener('pageshow', function (e) {
if (e.persisted) {
setRemUnit();
}
});
})(window, document);

flexible.js 文件配置

在main.js中进行引入

import './flexible.js';

Vue3 中watch和computed

2025年7月1日 17:56

Vue 3 中 computedwatch 深度解析

在 Vue 3 组合中,响应式工具的类型安全使用至关重要。以下是详细说明

一、watch 侦听器

1. 基础类型监听

<template>
  <div>实际参数1={{count}}</div>
  <div>
    <button @click="count++">点击</button>
  </div>
</template>

<script setup lang="ts">
import {
  reactive,
  ref,
  watch
} from "vue";

const count = ref<number>(0)
const state = reactive({items: [] as string[]});


watch(count, (newVal:number, oldVal:number) => {
  state.items.push(String(count.value))
  console.log('newVal, oldVal === ', newVal, oldVal)
})

watch(() => state.items, () => {
  console.log('state.items ===', state.items)
},{deep: true})

</script>

2. 多源监听

<template>
  <div>实际参数1={{count}}</div>
  <div>
    <button @click="count++">点击</button>
  </div>
</template>

<script setup lang="ts">
import {
  reactive,
  ref,
  watch
} from "vue";

const count = ref<number>(0)
const state = reactive({items: [] as string[]});


watch(count, (newVal:number, oldVal:number) => {
  state.items.push(String(count.value))
  console.log('newVal, oldVal === ', newVal, oldVal)
})

watch(() => state.items, () => {
  console.log('state.items ===', state.items)
},{deep: true})

watch([count, state], ([newCount, newState]:[number, object], [oldCount, oldState]:[number, object])=> {
  console.log('[newCount, newState], [oldCount, oldState] =', newCount, newState, oldCount, oldState)
})
</script>

3. 深度监听对象

<template>
  <div>实际参数1={{product}}</div>
  <div>
    <button @click="product.price++">点击</button>
  </div>
</template>

<script setup lang="ts">
import {
  reactive,
  watch
} from "vue";
interface Product {
  id: number,
  price: number,
  name: string,
  specs: {
    color: string,
    weight: number
  }
}

const product = reactive<Product>({
  id: 1,
  price: 131,
  name: 'Bwm',
  specs: {
    color: 'red',
    weight: 80
  }
})

watch(() => product.price, // 创建新引用触发深度监听
  (newProduct,oldProduct) => {
    console.log('newVal,oldVal === ', newProduct,oldProduct)
  },{deep: true})
</script>


二、watchEffect 高级用法

watchEffect() 允许我们自动跟踪回调的响应式依赖,且会立即执行。

1. 基本用法

<template>
  <div>实际参数1={{product}}</div>
  <div>
    <button @click="product.price++">点击</button>
  </div>
</template>

<script setup lang="ts">
import {
  reactive,
  ref,
  watch, watchEffect
} from "vue";
interface Product {
  id: number,
  price: number,
  name: string,
  specs: {
    color: string,
    weight: number
  }
}

const product = reactive<Product>({
  id: 1,
  price: 131,
  name: 'Bwm',
  specs: {
    color: 'red',
    weight: 80
  }
})
const totalPrice = ref<number>(0)

watchEffect(()=> {
  totalPrice.value = product.price + 2
  console.log('totalPrice === ', totalPrice)
})
</script>

2. 清理副作用

<template>
  <div>实际参数1={{product}}</div>
  <div>
    <button @click="product.price+=10">点击</button>
  </div>
</template>

<script setup lang="ts">
import {
  reactive,
  ref,
  watchEffect,
  onWatcherCleanup
} from "vue";

interface Product {
  id: number,
  price: number,
  name: string,
  specs: {
    color: string,
    weight: number
  }
}

const product = reactive<Product>({
  id: 1,
  price: 131,
  name: 'Bwm',
  specs: {
    color: 'red',
    weight: 80
  }
})
const totalPrice = ref<number>(0)

watchEffect(()=> {
  totalPrice.value = product.price + 2
  console.log('totalPrice === ', totalPrice)
  onWatcherCleanup(() => {
    console.log('onWatcherCleanup ===')
  })
})
</script>

三、computed 计算属性

1. 对象类型计算

<template>
  <div>实际参数1={{totalPrice}}</div>
  <div>
    <button @click="product.price+=10">点击</button>
  </div>
</template>

<script setup lang="ts">
import {
  reactive,
  ref,
  computed
} from "vue";
interface Product {
  id: number,
  price: number,
  name: string,
  specs: {
    color: string,
    weight: number
  }
}

const product = reactive<Product>({
  id: 1,
  price: 131,
  name: 'Bwm',
  specs: {
    color: 'red',
    weight: 80
  }
})
const totalPrice = computed<number>(()=>product.price += 10)
</script>

2. 可写计算属性

<template>
  <div>{{fullName}}</div>
  <div>姓名: <el-input v-model="fullName"/></div>
</template>

<script setup lang="ts">
import {
  reactive,
  ref,
  computed
} from "vue";
const firstName = ref<string>('Jane')
const lastName = ref<string>('Smith')

const fullName = computed<string>({
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  set(fullName:string) {
    const [newFirstName, newLastName] = fullName.split(' ')
    firstName.value = newFirstName
    lastName.value = newLastName
  }
})
</script>

四、computed vs watch vs watchEffect 对比

特性 computed watch watchEffect
目的 派生值 响应变化执行操作 自动追踪依赖执行操作
返回值 ref对象 停止函数 停止函数
初始化执行 立即计算 可配置(immediate) 立即执行
依赖声明 自动 显式指定 自动
缓存
异步支持
清理机制
调试钩子
适合场景 数据转换/格式化 精确控制的操作 自动追踪依赖的副作用

总结

  1. computed

    • 用于派生状态
    • 具有缓存机制
    • 适合数据转换和格式化
    • 模板中优先使用
  2. watch

    • 用于执行副作用
    • 提供精确控制
    • 适合异步操作、DOM操作
    • 需要显式声明依赖
  3. watchEffect

    • 自动追踪依赖
    • 立即执行
    • 适合多个依赖的简单副作用
    • 提供清理机制

黄金法则

  • 需要派生值 → 用 computed
  • 需要响应变化执行操作 → 用 watchwatchEffect
  • 需要精确控制依赖 → 用 watch
  • 需要自动追踪多个依赖 → 用 watchEffect
  • 避免computed 中产生副作用
  • 总是在副作用中清理资源

通过合理选择和使用这些 API,可以构建出高效、可维护的 Vue 3 应用程序。

如何在 SSE 流式返回中提取 JSON 数组对象?

作者 ak啊
2025年7月1日 17:50

如何在 SSE 流式返回中提取 JSON 数组对象?以 steps 字段为例的容错解析实践

在实际开发中,我们经常会使用 Server-Sent Events(SSE)来实现服务端向前端推送流式数据。尤其是在与大模型交互时(如 ChatGPT、Claude 等 API),服务端往往以逐字或逐字段的形式返回 JSON 内容。

这本身没有问题,直到你需要从这个不完整、未闭合的 JSON 字符串中,提前提取某些结构——比如一个对象数组中的每一项。

本文就以我的一个真实需求为例,详细讲解:如何从 SSE 返回的“未闭合 JSON 字符串”中,提取出 steps 数组中的每一个对象项,哪怕它是不完整的结构。


场景背景

服务端返回的数据格式如下,是典型的结构化指令输出:

{
  "locale": "zh-CN",
  "thought": "用户需要一个医疗减肥方案,目前信息不足以直接给出方案,需收集相关信息。",
  "title": "收集制定医疗减肥方案所需信息",
  "steps": [
    {
      "need_search": true,
      "title": "收集医疗减肥基础信息",
      "description": "...",
      "step_type": "research"
    },
    {
      "need_search": true,
      "title": "收集减也

注意最后一项:这是一段 SSE 正在流式返回的内容,steps 数组还没有返回完,甚至字段 "title": "收集减也 也没有闭合。

目标是:在不使用 JSON.parse 的前提下,尽可能多地提取出结构完整的 step,并且对未闭合但有价值的片段也尽可能解析字段。


常规做法行不通

很多人第一反应是:

const data = JSON.parse(buffer);
const steps = data.steps;

问题在于:JSON.parse 在结构未闭合时会直接抛错,你根本拿不到数据。

即便尝试正则匹配 { ... } 块,也只能匹配闭合结构,无法提取“收集减也”这样的不完整数据块


目标拆解

我们想要:

  1. "steps": [ 开始的位置截取出后续数据;
  2. "steps" 数组中的每一项(即以 "need_search" 开头的对象)逐个分块
  3. 对每个分块做字段提取(不要求完整闭合);
  4. 至少有一个字段存在,就认为这是一个有效的 step,加入最终数组。

实现方案(纯字符串解析)

以下是最终验证可用的实现:

function extractStepsLoosely(buffer) {
  const steps = [];

  const stepsStart = buffer.indexOf('"steps": [');
  if (stepsStart === -1) return steps;

  const stepsPart = buffer.slice(stepsStart);

  // 按每个 step 的 "need_search" 分割
  const stepParts = stepsPart.split(/(?="need_search"\s*:)/g).slice(1);

  for (const raw of stepParts) {
    const getField = (field) => {
      const match = raw.match(new RegExp(`"${field}"\s*:\s*"([^"]*)`));
      return match ? match[1] : null;
    };

    const getBoolField = (field) => {
      const match = raw.match(new RegExp(`"${field}"\s*:\s*(true|false)`));
      return match ? match[1] === "true" : null;
    };

    const step = {
      title: getField("title"),
      description: getField("description"),
      step_type: getField("step_type"),
      need_search: getBoolField("need_search")
    };

    if (
      step.title !== null ||
      step.description !== null ||
      step.step_type !== null ||
      step.need_search !== null
    ) {
      steps.push(step);
    }
  }

  return steps;
}

使用效果演示

const buffer = `
"steps": [
  {
    "need_search": true,
    "title": "收集医疗减肥基础信息",
    "description": "...",
    "step_type": "research"
  },
  {
    "need_search": true,
    "title": "收集减也
`;

const steps = extractStepsLoosely(buffer);
console.log(steps);

输出:

[
  {
    title: "收集医疗减肥基础信息",
    description: "...",
    step_type: "research",
    need_search: true
  },
  {
    title: "收集减也",
    description: null,
    step_type: null,
    need_search: true
  }
]

优点总结

特性 说明
✔️ 不依赖 JSON.parse 纯字符串操作,适配 SSE 拼接中间态
✔️ 可提取不完整结构 字段缺失或 JSON 未闭合不影响
✔️ 兼容性强 JS 环境通用,无依赖
✔️ 可拓展性强 可轻松加入更多字段,如 status, priority

应用场景扩展

除了 SSE,在以下场景也非常适用:

  • WebSocket 推送结构化 JSON 片段
  • AI 大模型 API 返回结构未闭合的思维链数据
  • 日志/中间文件中逐行输出对象数组结构
  • 流式生成 markdown/json 的编辑器

结语

SSE 是前端和服务端交互中一种极为常见的技术,但由于其非一次性返回完整结构的特性,我们在处理结构化 JSON 数据时需要更细致地设计处理逻辑。

🧠 Vue 懒加载实践:为什么使用 IntersectionObserver + Promise?它解决了哪些性能问题?

作者 梦语花
2025年7月1日 17:46

📌 背景介绍

在现代 Web 开发中,页面性能优化是一个非常重要的课题。尤其是在数据量大、内容多的场景下(如报表系统、商品列表页、图片墙等),如果所有内容都在页面初始化时一次性加载完成,会导致:

  • 页面加载速度慢;
  • 用户体验差;
  • 流量浪费(尤其是移动端);
  • 服务器压力大;

为了解决这些问题,我们引入了 懒加载技术(Lazy Load)


🧩 示例解析:懒加载 + 动态渲染

以下是我们要分析的核心组件结构:

vue
深色版本
<template>
  <div class="container w200px h400px mb20px bg-red">
    <!-- 使用 v-has-views 指令实现懒加载 -->
    <div v-has-views="{ threshold: 0.5, Lazy: true, callback: handleImageLoad }">
      {{ dataList }}
    </div>
  </div>
</template>

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

const dataList = ref('');

function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = true;
      if (success) {
        resolve({ data: '这是从服务器获取的数据', status: 200 });
      } else {
        reject(new Error('无法获取数据'));
      }
    }, 500);
  });
}

const handleImageLoad = async (flag) => {
  if (flag) {
    const res = await fetchData();
    dataList.value = res.data;
  }
}
</script>

✅ 核心功能点:

技术 功能
v-has-views 指令 判断元素是否进入视口
IntersectionObserver API 实现懒加载逻辑
Promise 异步请求 模拟真实数据获取过程
ref 响应式绑定 数据变化自动更新视图

🔍 为什么要用 IntersectionObserver?

传统的判断元素是否可视的方法是通过 window.scroll + getBoundingClientRect() 来实现的。但这种方式存在两个严重问题:

❌ 缺陷:

  1. 频繁触发 scroll 事件,性能开销大
  2. 需要手动管理监听器和节流机制

IntersectionObserver 是浏览器原生提供的 API,专门用于监听一个元素与视口或父容器的交叉状态,具备以下优势:

优势 描述
高性能 不依赖 scroll 事件,由浏览器内部优化
简洁易用 只需注册一次观察器即可监听多个元素
支持阈值控制 可设置元素多少比例可见后才触发回调

💡 为什么要封装成自定义指令?

Vue 的核心理念之一就是“关注分离”。把懒加载逻辑封装成自定义指令 v-has-views,有以下好处:

✅ 优点:

好处 描述
复用性强 在任意组件中都可以直接调用 v-has-views
逻辑解耦 模板层不关心实现细节,只负责绑定行为
易于维护 如果未来要换方案,只需修改指令一处
生命周期可控 可以在 mounted 和 unmounted 中处理资源回收

🚀 为什么要用 Promise + async/await?

在这个示例中,我们模拟了一个异步请求 fetchData,并通过 async/await 实现异步流程控制。

✅ 为什么这样设计?

  • 模拟真实网络请求:延迟加载数据,模拟真实业务场景。
  • 避免阻塞主线程:异步操作不会影响页面正常渲染。
  • 可扩展性好:后续可以轻松替换为真实的 API 请求。
  • 清晰的错误处理:可以通过 .catch() 或 try/catch 控制异常流程。

🎯 解决了什么问题?

问题 解决方式 效果
页面加载慢 懒加载未展示的内容 提升首屏加载速度
内容冗余加载 只加载用户能看到的部分 减少不必要的请求
代码重复复杂 封装成指令复用 提高开发效率
用户体验差 按需加载内容 更流畅的交互体验

🧩 应用场景举例

这个懒加载方案非常适合以下几种场景:

场景 说明
图片墙、商品列表 只有图片进入视口才加载真实地址
表格/报表页 滚动到底部再加载下一页数据
视频/音频播放器 用户滚动到视频区域后再加载资源
动态表单 子表或复杂字段按需加载

📦 拓展:结合 DmoBox 渲染报表列表

在完整页面中,你可能看到类似这样的结构:

html
深色版本
<ul>
  <DmoBox></DmoBox>
  <DmoBox></DmoBox>
  <DmoBox></DmoBox>
  <!-- 循环生成多个卡片 -->
</ul>

每个 <DmoBox> 组件都可以使用 v-has-views 指令,确保只有当用户滚动到该卡片位置时,才会发起网络请求并显示具体内容,从而大大提升整体性能。


✅ 总结:这种写法的价值在哪?

价值维度 说明
性能优化 按需加载减少初始请求,加快首屏渲染
代码结构 指令封装让逻辑清晰,易于复用
用户体验 内容随滚动逐步展现,交互更自然
可维护性 所有懒加载逻辑统一管理,便于后期升级

🚀 后续建议优化方向

如果你希望进一步完善这套懒加载系统,还可以考虑:

  • 添加 loading 状态提示;
  • 加入错误重试机制;
  • 对加载失败的元素做兜底处理;
  • 结合骨架屏(Skeleton Screen)提升体验;
  • 支持动态配置加载阈值(threshold);
  • 自动取消未完成的异步请求(AbortController);

如果你正在开发一个低代码平台、报表系统、或是企业级管理系统,这种懒加载方案是非常实用且高效的。希望这篇文章能帮助你理解背后的设计思路,并应用到实际项目中!

如需我帮你封装成完整的懒加载组件库、或提供 Vue3 + Vite + UnoCSS 的模板工程结构,也可以继续提问 😊

如何在markdown中,将JSON渲染成自定义React组件

作者 乐悠悠2
2025年7月1日 17:40

背景:

  • AI助手返回的markdown格式文本,渲染成markdown文件,并将代码高亮展示
  • markdown格式文本中JSON配置,按照自定义组件样式展示

markdown展示图

image.png

image.png

image.png

将JSON渲染成自定义组件效果图

image.png

解决思路

  • 引入marked、marked-highlight、highlight.js渲染markdown格式文本
  • 使用react-dom/server的ReactDOMServer将React组件渲染成静态HTML标签
  • 代码块添加按钮、事件

详细实现

import { useEffect, useState } from 'react'
import ReactDOMServer from 'react-dom/server';
import { Button, message } from 'antd';
// 自定义组件
import StaticDraggerTree from '@/components/DraggerTree/StaticTree'
import { Provider } from 'react-redux';
import { Marked, Renderer } from 'marked'
import { markedHighlight } from 'marked-highlight'
import hljs from 'highlight.js'
import 'highlight.js/styles/base16/darcula.css'

import './index.less';

const mockStore = {
  subscribe: () => {},
  dispatch: () => {},
  getState: () => ({}),
};
 

const AIDialogue = (props) => {

  const { content } = props;
  const [parseHtml, setParseHtml] = useState('')

  function removeCodeBlockMarker(code) {
    return code.replace(/```[\w\s-]*\n([\s\S]+?)\n```/g, '$1');
  }

  const renderer = new Renderer()
  renderer.code = function (code, lang) {
    const highlightedCode = hljs.highlightAuto(removeCodeBlockMarker(code.raw)).value;

    // 判断是否是需要渲染成自定义组件的配置json,如果是,需将json转换成自定义组件的输入格式
    let isServiceOrch = false;
    try{ // content为流式文件返回时,添加try catch防止JSON不完整时解析报错
      if (code.lang == 'json') {
        // 解析成JSON对象
        const dataJson = JSON.parse(removeCodeBlockMarker(code.raw));
        if (dataJson) { // 具体为JSON满足的自定义渲染条件
          isServiceOrch = true;
        }
      }
    }catch(e){
      console.log(e)
    }
    
    if (isServiceOrch) {
      const randomId = Math.random().toString(36).substring(2, 10)
      const jsonObj = JSON.parse(removeCodeBlockMarker(code.raw));
      const children = jsonObj.children || jsonObj.defaultConfig?.children;
      const reactEle = ReactDOMServer.renderToString(
        <div class="mermaid-block" data-mermaid-id={randomId}>
          <Provider store={mockStore}>
            <StaticDraggerTree nodeTreeData={{children}} />
          </Provider>
          <Button class="so-service-action" data-action="create" data-mermaid-id={randomId}>
            创建编排
          </Button>
          <div class="mermaid-code-view" id={`mermaid-code-${randomId}`}>
            {/* 大模型生成的原始json,创建编排时使用。此处不需展示,故隐藏 */}
            <pre style={{"display": "none"}}><coderaw class="hljs language-mermaid">{removeCodeBlockMarker(code.raw)}</coderaw></pre>
          </div>
        </div>
      );
      return `${reactEle}`
    } 
    else {
      if (code.lang) {
        return `<pre><code class="hljs language-${code.lang}">${highlightedCode}</code></pre>`
      } else {
        return `<pre><code class="hljs">${highlightedCode}</code></pre>`
      }
    }
  }

  const marked = new Marked(
    markedHighlight({
      langPrefix: 'hljs language-',
      highlight(code, lang) {
        const language = hljs.getLanguage(lang) ? lang : 'plaintext'
        return hljs.highlight(code, { language }).value
      }
    })
  )
  marked.use({
    extensions: [
      {
        name: 'thinkBlock',
        level: 'block',
        start(src) {
          return src.match(/<think>/i)?.index
        },
        tokenizer(src) {
          const rule = /^<think>([\s\S]*?)<\/think>/i
          const match = rule.exec(src)
          if (match) {
            return {
              type: 'thinkBlock',
              raw: match[0],
              text: match[1].trim()
            }
          }
        },
        renderer(token) {
          return `<think>${token.text}</think>`
        }
      }
    ]
  })
  marked.setOptions({
    renderer,  //默认render,如需特殊配置,可打开注释
    highlight: function (code) {
      return hljs.highlightAuto(code).value
    },
    gfm: true, // 允许 Git Hub标准的markdown.
    pedantic: false, // 不纠正原始模型任何的不良行为和错误(默认为false)
    sanitize: false, // 对输出进行过滤(清理),将忽略任何已经输入的html代码(标签)
    tables: true, // 允许支持表格语法(该选项要求 gfm 为true)
    breaks: false, // 允许回车换行(该选项要求 gfm 为true)
    smartLists: true, // 使用比原生markdown更时髦的列表
    smartypants: false, // 使用更为时髦的标点
  })

  // 添加代码块按钮
  const addCodeBlockButtons = (outputIndex) => {
    const outputDiv = document.getElementById(`outPut${outputIndex}`)
    if (!outputDiv) return

    const preBlocks = outputDiv.querySelectorAll('pre:not(.processed)')
    console.log("preBlocks:", preBlocks)
    preBlocks.forEach((preBlock, i) => {
      const blockIndex = `${outputIndex}_${i}`
      preBlock.id = `output_pre_${blockIndex}`
      const div = document.createElement('div')
      div.classList.add('insert-code-btn')
      div.innerHTML = `
      <div class="inner-btns">
        <div class="copybtn-icn" id="codeCopy_${blockIndex}" title="复制">
          <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.9998 6V3C6.9998 2.44772 7.44752 2 7.9998 2H19.9998C20.5521 2 20.9998 2.44772 20.9998 3V17C20.9998 17.5523 20.5521 18 19.9998 18H16.9998V20.9991C16.9998 21.5519 16.5499 22 15.993 22H4.00666C3.45059 22 3 21.5554 3 20.9991L3.0026 7.00087C3.0027 6.44811 3.45264 6 4.00942 6H6.9998ZM5.00242 8L5.00019 20H14.9998V8H5.00242ZM8.9998 6H16.9998V16H18.9998V4H8.9998V6Z"/></svg>
        </div>
        <div class="copybtn-icn" id="codeInsert_${blockIndex}" title="插入">
          <svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM20 12L16.4645 15.5355L15.0503 14.1213L17.1716 12L15.0503 9.87868L16.4645 8.46447L20 12ZM6.82843 12L8.94975 14.1213L7.53553 15.5355L4 12L7.53553 8.46447L8.94975 9.87868L6.82843 12ZM11.2443 17H9.11597L12.7557 7H14.884L11.2443 17Z"/></svg>
        </div>
        <div class="copybtn-icn" id="codeReplace_${blockIndex}" title="替换">
          <svg viewBox="0 0 1024 1024" fill="currentColor"><path d="M480.036571 464.018286v-384a16.018286 16.018286 0 0 0-16.091428-16.018286h-384a16.018286 16.018286 0 0 0-15.945143 16.018286v384c0 8.777143 7.168 15.945143 16.018286 15.945143h384c8.777143 0 15.945143-7.094857 15.945143-15.945143zM399.945143 144.018286v256h-256v-256h256zM512 156.013714v71.972572h220.013714V384h-44.032a8.045714 8.045714 0 0 0-6.363428 12.8l80.018285 106.642286a8.045714 8.045714 0 0 0 12.8 0l79.945143-106.642286a8.045714 8.045714 0 0 0-6.363428-12.8h-44.032V208.018286a51.931429 51.931429 0 0 0-51.931429-52.004572H512zM175.981714 640a8.045714 8.045714 0 0 1-6.363428-12.8l79.945143-106.642286a8.045714 8.045714 0 0 1 12.8 0l80.018285 106.642286a8.045714 8.045714 0 0 1-6.363428 12.8h-44.032v156.013714H512v71.972572H272.018286a51.931429 51.931429 0 0 1-52.004572-51.931429V640h-44.032z m784.018286 303.981714v-384a16.018286 16.018286 0 0 0-16.018286-15.945143h-384a16.018286 16.018286 0 0 0-15.945143 15.945143v384c0 8.850286 7.094857 16.018286 15.945143 16.018286h384c8.850286 0 16.018286-7.168 16.018286-16.018286z m-80.018286-320v256h-256v-256h256z"></path></svg>
        </div>
      </div>`
      preBlock.prepend(div)
      preBlock.classList.add('processed')
    })
  }

  // 代码块按钮事件
  function codeBlockButtonEvent(outputIndex) {
    const outputDiv = document.getElementById(`outPut${outputIndex}`)
    if (!outputDiv) return

    const preBlocks = outputDiv.querySelectorAll('pre')

    preBlocks.forEach((preBlock, i) => {
      const blockIndex = `${outputIndex}_${i}`
      // 复制代码
      const codeCopyBtn = document.getElementById(`codeCopy_${blockIndex}`)

      if (codeCopyBtn && !codeCopyBtn.dataset.listenerAdded) {
        codeCopyBtn.addEventListener('click', e => {
          e.stopPropagation()
          const codeElement = preBlock.querySelector('code')
          const code = codeElement?.textContent || ''
          props.onCopy ? props.onCopy({
            message: code,
            id: props.id,
            chatId: props.chatId
          }) : null;
        })
        codeCopyBtn.dataset.listenerAdded = 'true'
      }

      // 插入代码
      const codeInsertBtn = document.getElementById(`codeInsert_${blockIndex}`)

      if (codeInsertBtn && !codeInsertBtn.dataset.listenerAdded) {
        codeInsertBtn.addEventListener('click', e => {
          e.stopPropagation()
          const codeElement = preBlock.querySelector('code')
          const code = codeElement?.textContent || ''
          props.onCodeInsert ? props.onCodeInsert(code) : null;
        })
        codeInsertBtn.dataset.listenerAdded = 'true'
      }

      // 替换代码
      const codeReplaceBtn = document.getElementById(`codeReplace_${blockIndex}`)

      if (codeReplaceBtn && !codeReplaceBtn.dataset.listenerAdded) {
        codeReplaceBtn.addEventListener('click', e => {
          e.stopPropagation()
          const codeElement = preBlock.querySelector('code')
          const code = codeElement?.textContent || ''
          props.onCodeReplace ? props.onCodeReplace(code) : null;
        })
        codeReplaceBtn.dataset.listenerAdded = 'true'
      }
    })
  }

  /**
   * 根据不同语言,设置不同的对应的代码提示
   * */
  useEffect(() => {
    let contentMock = "根据您的需求,我们需要创建一个服务编排配置,该配置首先查询用户表的信息,然后将查询结果导出到Excel文件。以下是详细的JSON配置:\n\n```json\n{\n  \"title\": \"查询用户表信息并导出到Excel\",\n  \"nodeType\": \"START\",\n  \"children\": [\n    {\n      \"title\": \"查询用户表信息\",\n      \"nodeType\": \"DATASOURCE\",\n      \"children\": [],\n      \"expression\": \"\",\n      \"id\": \"1\",\n      \"parentNodeType\": \"START\",\n      \"setVerify\": true,\n      \"formName\": \"SQLForm\",\n      \"dsKey\": \"yourDataSourceKey\",  # 替换为实际的数据源ID\n      \"sql\": \"SELECT id, username, email FROM users;\",  # 替换为实际的SQL查询语句\n      \"targetName\": \"output.users\",\n      \"shareType\": 0,\n      \"justArray\": true\n    },\n    {\n      \"title\": \"JS代码 - 数据格式转换\",\n      \"nodeType\": \"JS\",\n      \"children\": [],\n      \"expression\": \"\",\n      \"id\": \"2\",\n      \"parentNodeType\": \"START\",\n      \"setVerify\": true,\n      \"formName\": \"JSCodeForm\",\n      \"source\": \"// 将查询结果转换为适合导出Excel的格式\\nlet users = state.output.users;\\nlet excelData = [['ID', 'Username', 'Email']];\\nusers.forEach(user => {\\n    excelData.push([user.id, user.username, user.email]);\\n});\\nstate.output.excelData = excelData;\\nreturn state;\"\n    },\n    {\n      \"title\": \"导出Excel\",\n      \"nodeType\": \"EE\",\n      \"children\": [],\n      \"expression\": \"\",\n      \"id\": \"3\",\n      \"parentNodeType\": \"START\",\n      \"setVerify\": true,\n      \"formName\": \"DeriveExcelForm\",\n      \"multipleSheets\": false,\n      \"exportData\": \"{output.excelData}\",\n      \"targetName\": \"output.filePath\"\n    },\n    {\n      \"title\": \"JS代码 - 输出文件地址\",\n      \"nodeType\": \"JS\",\n      \"children\": [],\n      \"expression\": \"\",\n      \"id\": \"4\",\n      \"parentNodeType\": \"START\",\n      \"setVerify\": true,\n      \"formName\": \"JSCodeForm\",\n      \"source\": \"// 返回文件路径\\nstate.output.response = {\\n    filePath: state.output.filePath,\\n    status: 'Success'\\n};\\nreturn state;\"\n    }\n  ],\n  \"expression\": null,\n  \"id\": \"0\",\n  \"parentNodeType\": null,\n  \"setVerify\": null,\n  \"formName\": null\n}\n```\n\n### 配置说明\n\n1. **查询用户表信息**:\n   - 使用`DATASOURCE`节点执行SQL查询,查询用户表的所有记录。\n\n2. **JS代码 - 数据格式转换**:\n   - 使用`JS`节点将查询结果转换为适合导出Excel的格式。这里我们将查询结果转换成了一个二维数组的形式,其中第一行为表头,后续行为数据行。\n\n3. **导出Excel**:\n   - 使用`EE`节点将转换好的数据导出为Excel文件,并将文件路径存储在`output.filePath`中。\n\n4. **JS代码 - 输出文件地址**:\n   - 最后一步再次使用`JS`节点,将文件路径和其他相关信息构造成最终的响应对象,方便查看和使用。\n\n请注意替换`yourDataSourceKey`为您实际的数据源ID,并调整SQL查询语句以适应您数据库的实际表结构。"
    // 此处测试用contentMock,实际用content
    setParseHtml(marked.parse(contentMock));

    setTimeout(() => {
      // 在这里执行依赖于最新DOM的操作
      addCodeBlockButtons(props.id) // 添加代码块操作按钮
      codeBlockButtonEvent(props.id) // 添加代码块按钮事件
      setupMermaidActions() // 添加自定义组件按钮事件
    }, 100);

  }, [content])


  return (
    <>
      <div className="ai-markdown">
        <div className="markdown-view" dangerouslySetInnerHTML={{ __html: parseHtml }} id={'outPut' + props.id}></div>
      </div >
    </>
  );
};
export default AIDialogue;

TypeScript入门(七)高级类型:解锁TypeScript的"终极形态"

2025年7月1日 17:28

第7章 高级类型:解锁TypeScript的"终极形态"

想象你正在探索一座神秘的类型魔法学院——高级类型(Advanced Types) 就是这里最强大的魔法咒语。如果说基础类型是编程世界的砖瓦,那么高级类型就是建筑大师的蓝图工具。这一章,我们将学会如何运用这些类型魔法,让TypeScript从优秀走向卓越!

7.1 类型别名——给复杂类型起个"好记的名字"

类型别名(Type Aliases)就像给你的类型定制一张专属名片——复杂类型从此有了简洁易记的称呼。

🏷️ 基础类型别名:简化复杂类型

// 1. 基础类型别名 - 联合类型的简化
type UserID = string | number;
type Coordinate = [number, number];

// 使用示例
const userId1: UserID = "U1001";
const userId2: UserID = 12345;
const position: Coordinate = [100, 200];

console.log(userId1); // "U1001"
console.log(userId2); // 12345
console.log(position); // [100, 200]

// 2. 对象类型别名 - 复杂结构的命名
type UserProfile = {
    id: UserID;
    name: string;
    email?: string;
    createdAt: Date;
};

// 使用示例
const user: UserProfile = {
    id: "U1001",
    name: "技术宅",
    email: "tech@example.com",
    createdAt: new Date()
};

console.log(user.name); // "技术宅"
console.log(user.id); // "U1001"
console.log(typeof user.createdAt); // "object"

// 3. 函数类型别名 - 函数签名的命名
type StringProcessor = (input: string) => string;
type NumberValidator = (value: number) => boolean;

// 实现函数
const toUpper: StringProcessor = (str) => {
    const result = str.toUpperCase();
    console.log(`转换:"${str}" -> "${result}"`);
    return result;
};

const isPositive: NumberValidator = (num) => {
    const result = num > 0;
    console.log(`验证 ${num} > 0: ${result}`);
    return result;
};

// 使用示例
console.log(toUpper("hello")); 
// "转换:"hello" -> "HELLO""
// 返回:"HELLO"

console.log(isPositive(-5)); 
// "验证 -5 > 0: false"
// 返回:false

console.log(isPositive(10)); 
// "验证 10 > 0: true"
// 返回:true

🔄 类型别名的特性与注意事项

// 别名不是新建类型 - 只是引用
type ID = UserID;
const newUserId: ID = 1002; // 完全合法
console.log(typeof newUserId); // "number"

// 递归类型别名
type TreeNode = {
    value: string;
    children?: TreeNode[];
};

const tree: TreeNode = {
    value: "根节点",
    children: [
        { value: "子节点1" },
        { 
            value: "子节点2", 
            children: [
                { value: "孙节点" }
            ]
        }
    ]
};

console.log(tree.value); // "根节点"
console.log(tree.children?.[0].value); // "子节点1"
console.log(tree.children?.[1].children?.[0].value); // "孙节点"

// 泛型类型别名
type ApiResponse<T> = {
    success: boolean;
    data: T;
    message: string;       
};

const userResponse: ApiResponse<UserProfile> = {
    success: true,
    data: user,
    message: "获取用户成功"
};

console.log(userResponse.success); // true
console.log(userResponse.data.name); // "技术宅"
console.log(userResponse.message); // "获取用户成功"

🚨 重要提醒:类型别名不会创建新类型,它只是现有类型的引用(就像给文件创建快捷方式)。使用typeof检查时,仍然是原始类型。

🎯 类型别名的应用场景

  • 简化复杂联合类型:让代码更易读
  • 统一接口定义:保持项目类型一致性
  • 函数签名复用:避免重复定义
  • 递归数据结构:树形、链表等结构

7.2 字符串字面量类型——精确到字符的"选择题"

字符串字面量类型(String Literal Types)把字符串从"任意文本"变成"选择题选项",让你的代码更加精确和安全。

🎯 基础字面量类型:限定选项范围

// 1. 状态管理 - 精确的状态定义
type LightStatus = "on" | "off" | "dimmed";
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Theme = "light" | "dark" | "auto";

// 灯光控制函数
function setLight(status: LightStatus) {
    console.log(`灯光状态设置为:${status}`);
    // 模拟硬件控制
    switch(status) {
        case "on":
            console.log("💡 灯光已开启");
            break;
        case "off":
            console.log("🌙 灯光已关闭");
            break;
        case "dimmed":
            console.log("🔅 灯光已调暗");
            break;
    }
}

// 使用示例
setLight("on");    
// "灯光状态设置为:on"
// "💡 灯光已开启"

setLight("dimmed"); 
// "灯光状态设置为:dimmed"
// "🔅 灯光已调暗"

// 错误示例:不在选项中
// setLight("flash"); // 错误!"flash"不在LightStatus类型中

// 2. HTTP请求处理
function makeRequest(method: HttpMethod, url: string) {
    console.log(`发送 ${method} 请求到:${url}`);
    // 模拟网络请求
    return { method, url, timestamp: Date.now() };
}

const getResult = makeRequest("GET", "/api/users");
console.log(getResult); 
// { method: "GET", url: "/api/users", timestamp: 1704067200000 }

const postResult = makeRequest("POST", "/api/users");
console.log(postResult.method); // "POST"

🏗️ 实战应用:Redux Action系统

// Redux Action类型定义
type ActionType = "ADD_TODO" | "REMOVE_TODO" | "EDIT_TODO" | "TOGGLE_TODO";

interface BaseAction {
    type: ActionType;
    timestamp: number;
}

interface AddTodoAction extends BaseAction {
    type: "ADD_TODO";
    payload: {
        id: string;
        text: string;
        completed: boolean;
    };
}

interface RemoveTodoAction extends BaseAction {
    type: "REMOVE_TODO";
    payload: {
        id: string;
    };
}

type TodoAction = AddTodoAction | RemoveTodoAction;

// Action创建器
function createAddTodoAction(text: string): AddTodoAction {
    const action = {
        type: "ADD_TODO" as const,
        payload: {
            id: `todo_${Date.now()}`,
            text,
            completed: false
        },
        timestamp: Date.now()
    };
    console.log(`创建添加Todo Action:`, action);
    return action;
}

function createRemoveTodoAction(id: string): RemoveTodoAction {
    const action = {
        type: "REMOVE_TODO" as const,
        payload: { id },
        timestamp: Date.now()
    };
    console.log(`创建删除Todo Action:`, action);
    return action;
}

// 使用示例
const addAction = createAddTodoAction("学习TypeScript高级类型");
// "创建添加Todo Action:" { type: "ADD_TODO", payload: { id: "todo_1704067200000", text: "学习TypeScript高级类型", completed: false }, timestamp: 1704067200000 }

const removeAction = createRemoveTodoAction("todo_123");
// "创建删除Todo Action:" { type: "REMOVE_TODO", payload: { id: "todo_123" }, timestamp: 1704067200000 }

console.log(addAction.type); // "ADD_TODO"
console.log(addAction.payload.text); // "学习TypeScript高级类型"

🎨 模板字面量类型(TypeScript 4.1+)

// 模板字面量类型 - 动态生成字符串类型
type EventName<T extends string> = `on${Capitalize<T>}`;
type ButtonEvent = EventName<"click" | "hover" | "focus">; // "onClick" | "onHover" | "onFocus"

// CSS属性生成
type CSSProperty = "margin" | "padding";
type CSSDirection = "top" | "right" | "bottom" | "left";
type CSSPropertyWithDirection = `${CSSProperty}-${CSSDirection}`;
// "margin-top" | "margin-right" | "margin-bottom" | "margin-left" | "padding-top" | "padding-right" | "padding-bottom" | "padding-left"

// 使用示例
function setCSSProperty(property: CSSPropertyWithDirection, value: string) {
    console.log(`设置CSS属性:${property} = ${value}`);
    return { [property]: value };
}

const marginTop = setCSSProperty("margin-top", "10px");
// "设置CSS属性:margin-top = 10px"
console.log(marginTop); // { "margin-top": "10px" }

const paddingLeft = setCSSProperty("padding-left", "20px");
// "设置CSS属性:padding-left = 20px"
console.log(paddingLeft); // { "padding-left": "20px" }

💡 智能提示加成:在VSCode中,当你输入字面量类型时,编辑器会自动提示所有可选值,大大提升开发效率!

7.3 元组类型——数组的"精确版本"

元组(Tuples)是数组的升级版——它精确规定了数组的长度和每个位置的类型,就像给数组穿上了"定制西装"。

📏 基础元组:固定长度和类型

// 1. 基础元组定义
type PersonData = [string, number]; // [姓名, 年龄]
type Coordinate = [number, number]; // [x, y]
type RGB = [number, number, number]; // [红, 绿, 蓝]

// 使用示例
const userData: PersonData = ["张三", 30];
const position: Coordinate = [100, 200];
const redColor: RGB = [255, 0, 0];

console.log(userData[0]); // "张三"
console.log(userData[1]); // 30
console.log(position); // [100, 200]
console.log(`RGB颜色:rgb(${redColor.join(", ")})`); // "RGB颜色:rgb(255, 0, 0)"

// 错误示例:类型或长度不匹配
// const wrongData: PersonData = ["李四", "三十"]; // 错误!第二个元素应为数字
// const wrongData2: PersonData = ["王五"]; // 错误!缺少第二个元素

// 2. 解构赋值的类型安全
const [name, age] = userData;
console.log(`姓名:${name},年龄:${age}`); // "姓名:张三,年龄:30"
console.log(typeof name); // "string"
console.log(typeof age); // "number"

const [x, y] = position;
console.log(`坐标:(${x}, ${y})`); // "坐标:(100, 200)"

const [r, g, b] = redColor;
console.log(`红色分量:${r}`); // "红色分量:255"

🔧 可选元素与剩余元素

// 1. 带可选元素的元组
type RGBA = [number, number, number, number?]; // 最后的alpha通道可选

const white: RGBA = [255, 255, 255];
const semiTransparent: RGBA = [0, 0, 0, 0.5];

console.log(white); // [255, 255, 255]
console.log(semiTransparent); // [0, 0, 0, 0.5]
console.log(white.length); // 3
console.log(semiTransparent.length); // 4

// 颜色处理函数
function formatColor(color: RGBA): string {
    const [r, g, b, a] = color;
    if (a !== undefined) {
        const result = `rgba(${r}, ${g}, ${b}, ${a})`;
        console.log(`格式化RGBA颜色:`, result);
        return result;
    } else {
        const result = `rgb(${r}, ${g}, ${b})`;
        console.log(`格式化RGB颜色:`, result);
        return result;
    }
}

console.log(formatColor(white)); 
// "格式化RGB颜色:" "rgb(255, 255, 255)"
// 返回:"rgb(255, 255, 255)"

console.log(formatColor(semiTransparent)); 
// "格式化RGBA颜色:" "rgba(0, 0, 0, 0.5)"
// 返回:"rgba(0, 0, 0, 0.5)"

// 2. 剩余元素元组 (TypeScript 4.0+)
type Student = [string, ...number[]]; // 姓名 + 任意数量的分数
type DatabaseRow = [number, string, ...any[]]; // ID + 名称 + 其他字段

const tom: Student = ["Tom", 95, 88, 92, 87];
const lucy: Student = ["Lucy", 100];
const bob: Student = ["Bob", 85, 90, 88, 92, 94, 89];

console.log(tom[0]); // "Tom"
console.log(tom.slice(1)); // [95, 88, 92, 87]

// 计算平均分函数
function calculateAverage(student: Student): number {
    const [name, ...scores] = student;
    const average = scores.reduce((sum, score) => sum + score, 0) / scores.length;
    console.log(`${name}的平均分:${average.toFixed(2)}`);
    return average;
}

console.log(calculateAverage(tom)); 
// "Tom的平均分:90.50"
// 返回:90.5

console.log(calculateAverage(lucy)); 
// "Lucy的平均分:100.00"
// 返回:100

🎯 实战应用:React Hooks模拟

// React useState Hook的类型定义
type StateHook<T> = [T, (newValue: T) => void];
type EffectHook = [() => void, any[]];

// 模拟useState实现
function useState<T>(initialValue: T): StateHook<T> {
    let value = initialValue;
    
    const setValue = (newValue: T) => {
        const oldValue = value;
        value = newValue;
        console.log(`状态更新:${oldValue} -> ${newValue}`);
    };
    
    const getter = () => {
        console.log(`获取当前状态:${value}`);
        return value;
    };
    
    return [getter() as T, setValue];
}

// 使用示例
const [count, setCount] = useState(0);
// "获取当前状态:0"

console.log(count); // 0

setCount(5);
// "状态更新:0 -> 5"

const [name, setName] = useState("Alice");
// "获取当前状态:Alice"

console.log(name); // "Alice"

setName("Bob");
// "状态更新:Alice -> Bob"

// 数据库查询结果元组
type QueryResult = [boolean, any[], string?]; // [成功状态, 数据, 错误信息]

function mockQuery(sql: string): QueryResult {
    console.log(`执行SQL:${sql}`);
    
    if (sql.includes("SELECT")) {
        const mockData = [{ id: 1, name: "用户1" }, { id: 2, name: "用户2" }];
        console.log(`查询成功,返回 ${mockData.length} 条记录`);
        return [true, mockData];
    } else {
        console.log(`查询失败:不支持的SQL语句`);
        return [false, [], "不支持的SQL语句"];
    }
}

const [success, data, error] = mockQuery("SELECT * FROM users");
// "执行SQL:SELECT * FROM users"
// "查询成功,返回 2 条记录"

console.log(success); // true
console.log(data.length); // 2
console.log(error); // undefined

const [success2, data2, error2] = mockQuery("DROP TABLE users");
// "执行SQL:DROP TABLE users"
// "查询失败:不支持的SQL语句"

console.log(success2); // false
console.log(data2.length); // 0
console.log(error2); // "不支持的SQL语句"

7.4 枚举类型——常量的"豪华套装"

枚举(Enums)是为数值常量提供语义化名称的最佳工具,就像给一堆数字穿上了"有意义的外衣"。

🔢 数字枚举:自动递增的常量

// 1. 基础数字枚举
enum Direction {
    Up = 1,    // 指定起始值
    Down,      // 自动递增为2
    Left,      // 自动递增为3
    Right      // 自动递增为4
}

// 使用示例
function move(direction: Direction) {
    console.log(`移动方向:${Direction[direction]} (值: ${direction})`);
    
    switch(direction) {
        case Direction.Up:
            console.log("⬆️ 向上移动");
            break;
        case Direction.Down:
            console.log("⬇️ 向下移动");
            break;
        case Direction.Left:
            console.log("⬅️ 向左移动");
            break;
        case Direction.Right:
            console.log("➡️ 向右移动");
            break;
    }
}

move(Direction.Up);
// "移动方向:Up (值: 1)"
// "⬆️ 向上移动"

move(Direction.Right);
// "移动方向:Right (值: 4)"
// "➡️ 向右移动"

// 反向映射
console.log(Direction[1]); // "Up"
console.log(Direction[4]); // "Right"
console.log(Direction.Up); // 1
console.log(Direction.Right); // 4

// 2. 状态码枚举
enum HttpStatus {
    OK = 200,
    NotFound = 404,
    InternalServerError = 500
}

function handleResponse(status: HttpStatus) {
    console.log(`处理HTTP状态码:${status}`);
    
    if (status === HttpStatus.OK) {
        console.log("✅ 请求成功");
    } else if (status === HttpStatus.NotFound) {
        console.log("❌ 资源未找到");
    } else if (status === HttpStatus.InternalServerError) {
        console.log("💥 服务器内部错误");
    }
}

handleResponse(HttpStatus.OK);
// "处理HTTP状态码:200"
// "✅ 请求成功"

handleResponse(HttpStatus.NotFound);
// "处理HTTP状态码:404"
// "❌ 资源未找到"

📝 字符串枚举:语义化的常量

// 1. 媒体类型枚举
enum MediaType {
    JSON = "application/json",
    XML = "application/xml",
    TEXT = "text/plain",
    HTML = "text/html"
}

// HTTP请求处理
function setContentType(type: MediaType) {
    console.log(`设置Content-Type: ${type}`);
    // 模拟设置HTTP头部
    return {
        "Content-Type": type,
        "X-Timestamp": new Date().toISOString()
    };
}

const jsonHeaders = setContentType(MediaType.JSON);
// "设置Content-Type: application/json"
console.log(jsonHeaders);
// { "Content-Type": "application/json", "X-Timestamp": "2024-01-01T12:00:00.000Z" }

const xmlHeaders = setContentType(MediaType.XML);
// "设置Content-Type: application/xml"
console.log(xmlHeaders["Content-Type"]); // "application/xml"

// 2. 日志级别枚举
enum LogLevel {
    ERROR = "error",
    WARN = "warn",
    INFO = "info",
    DEBUG = "debug"
}

function log(level: LogLevel, message: string) {
    const timestamp = new Date().toISOString();
    const logEntry = `[${timestamp}] ${level.toUpperCase()}: ${message}`;
    console.log(logEntry);
    
    // 根据级别选择不同的输出方式
    switch(level) {
        case LogLevel.ERROR:
            console.error("🔴 错误日志");
            break;
        case LogLevel.WARN:
            console.warn("🟡 警告日志");
            break;
        case LogLevel.INFO:
            console.info("🔵 信息日志");
            break;
        case LogLevel.DEBUG:
            console.debug("⚪ 调试日志");
            break;
    }
}

log(LogLevel.INFO, "系统启动成功");
// "[2024-01-01T12:00:00.000Z] INFO: 系统启动成功"
// "🔵 信息日志"

log(LogLevel.ERROR, "数据库连接失败");
// "[2024-01-01T12:00:00.000Z] ERROR: 数据库连接失败"
// "🔴 错误日志"

⚡ 常量枚举:编译时优化

// 常量枚举(编译时完全删除)
const enum Color {
    Red,
    Green,
    Blue
}

// 使用常量枚举
function paintPixel(color: Color) {
    console.log(`绘制像素,颜色值:${color}`);
    
    // 编译后这里会直接使用数字值
    if (color === Color.Red) {
        console.log("🔴 绘制红色像素");
    } else if (color === Color.Green) {
        console.log("🟢 绘制绿色像素");
    } else if (color === Color.Blue) {
        console.log("🔵 绘制蓝色像素");
    }
}

paintPixel(Color.Red);
// "绘制像素,颜色值:0"
// "🔴 绘制红色像素"

paintPixel(Color.Blue);
// "绘制像素,颜色值:2"
// "🔵 绘制蓝色像素"

// 编译后的JavaScript代码中,Color.Red会被替换为0,Color.Blue会被替换为2
console.log(Color.Red); // 编译后:console.log(0);

🆚 现代替代方案:联合类型 vs 枚举

// 方案1:传统枚举
enum TraditionalLogLevel {
    Error,
    Warn,
    Info,
    Debug
}

// 方案2:现代联合类型
const ModernLogLevel = {
    Error: 0,
    Warn: 1,
    Info: 2,
    Debug: 3
} as const;

type ModernLogLevel = typeof ModernLogLevel[keyof typeof ModernLogLevel]; // 0 | 1 | 2 | 3

// 比较两种方案
function compareApproaches() {
    // 传统枚举使用
    const traditionalLevel = TraditionalLogLevel.Error;
    console.log(`传统枚举值:${traditionalLevel}`); // "传统枚举值:0"
    console.log(`传统枚举名称:${TraditionalLogLevel[traditionalLevel]}`); // "传统枚举名称:Error"
    
    // 现代联合类型使用
    const modernLevel = ModernLogLevel.Error;
    console.log(`现代联合类型值:${modernLevel}`); // "现代联合类型值:0"
    
    // 运行时大小比较
    console.log(`传统枚举在运行时存在:${typeof TraditionalLogLevel}`); // "传统枚举在运行时存在:object"
    console.log(`现代联合类型在运行时存在:${typeof ModernLogLevel}`); // "现代联合类型在运行时存在:object"
}

compareApproaches();

⚖️ 选择建议

  • 使用枚举:需要反向映射、与外部API交互、团队习惯枚举
  • 使用联合类型:追求最小运行时开销、现代TypeScript项目、函数式编程风格

7.5 类型保护与类型守卫——类型世界的"安检门"

类型保护(Type Guards)是TypeScript的类型收窄机制,让你在特定代码块内获得更精确的类型信息,就像给代码装上了"智能识别系统"。

🔍 内置类型守卫:基础类型检查

// 1. typeof 守卫 - 处理基本类型
function processValue(value: string | number | boolean) {
    console.log(`处理值:${value},类型:${typeof value}`);
    
    if (typeof value === "string") {
        // 在这个块中,value被收窄为string类型
        const result = value.toUpperCase();
        console.log(`字符串处理结果:${result}`);
        return result;
    } else if (typeof value === "number") {
        // 在这个块中,value被收窄为number类型
        const result = value.toFixed(2);
        console.log(`数字处理结果:${result}`);
        return result;
    } else {
        // 在这个块中,value被收窄为boolean类型
        const result = value ? "真" : "假";
        console.log(`布尔值处理结果:${result}`);
        return result;
    }
}

console.log(processValue("hello"));
// "处理值:hello,类型:string"
// "字符串处理结果:HELLO"
// 返回:"HELLO"

console.log(processValue(3.14159));
// "处理值:3.14159,类型:number"
// "数字处理结果:3.14"
// 返回:"3.14"

console.log(processValue(true));
// "处理值:true,类型:boolean"
// "布尔值处理结果:真"
// 返回:"真"

// 2. instanceof 守卫 - 处理类实例
class Bird {
    fly() {
        console.log("🐦 鸟儿在飞翔");
    }
    
    makeSound() {
        console.log("🎵 鸟儿在歌唱");
    }
}

class Fish {
    swim() {
        console.log("🐟 鱼儿在游泳");
    }
    
    makeSound() {
        console.log("🫧 鱼儿在吐泡泡");
    }
}

class Dog {
    run() {
        console.log("🐕 狗狗在奔跑");
    }
    
    makeSound() {
        console.log("🐕 汪汪汪!");
    }
}

function handleAnimal(animal: Bird | Fish | Dog) {
    console.log(`处理动物:${animal.constructor.name}`);
    
    // 所有动物都有makeSound方法
    animal.makeSound();
    
    if (animal instanceof Bird) {
        // 在这个块中,animal被收窄为Bird类型
        animal.fly();
    } else if (animal instanceof Fish) {
        // 在这个块中,animal被收窄为Fish类型
        animal.swim();
    } else {
        // 在这个块中,animal被收窄为Dog类型
        animal.run();
    }
}

const bird = new Bird();
const fish = new Fish();
const dog = new Dog();

handleAnimal(bird);
// "处理动物:Bird"
// "🎵 鸟儿在歌唱"
// "🐦 鸟儿在飞翔"

handleAnimal(fish);
// "处理动物:Fish"
// "🫧 鱼儿在吐泡泡"
// "🐟 鱼儿在游泳"

handleAnimal(dog);
// "处理动物:Dog"
// "🐕 汪汪汪!"
// "🐕 狗狗在奔跑"

🔑 属性检查守卫:in 操作符

// 接口定义
interface Circle {
    kind: "circle";
    radius: number;
}

interface Square {
    kind: "square";
    sideLength: number;
}

interface Triangle {
    kind: "triangle";
    base: number;
    height: number;
}

type Shape = Circle | Square | Triangle;

// 使用in守卫检查属性存在性
function calculateArea(shape: Shape): number {
    console.log(`计算图形面积,类型:${shape.kind}`);
    
    if ("radius" in shape) {
        // 在这个块中,shape被收窄为Circle类型
        const area = Math.PI * shape.radius ** 2;
        console.log(`圆形面积:π × ${shape.radius}² = ${area.toFixed(2)}`);
        return area;
    } else if ("sideLength" in shape) {
        // 在这个块中,shape被收窄为Square类型
        const area = shape.sideLength ** 2;
        console.log(`正方形面积:${shape.sideLength}² = ${area}`);
        return area;
    } else {
        // 在这个块中,shape被收窄为Triangle类型
        const area = (shape.base * shape.height) / 2;
        console.log(`三角形面积:(${shape.base} × ${shape.height}) ÷ 2 = ${area}`);
        return area;
    }
}

// 测试不同图形
const circle: Circle = { kind: "circle", radius: 5 };
const square: Square = { kind: "square", sideLength: 4 };
const triangle: Triangle = { kind: "triangle", base: 6, height: 8 };

console.log(calculateArea(circle));
// "计算图形面积,类型:circle"
// "圆形面积:π × 5² = 78.54"
// 返回:78.53981633974483

console.log(calculateArea(square));
// "计算图形面积,类型:square"
// "正方形面积:4² = 16"
// 返回:16

console.log(calculateArea(triangle));
// "计算图形面积,类型:triangle"
// "三角形面积:(6 × 8) ÷ 2 = 24"
// 返回:24

🛡️ 自定义类型守卫:类型谓词

// 自定义类型守卫函数
function isCircle(shape: Shape): shape is Circle {
    const result = shape.kind === "circle";
    console.log(`检查是否为圆形:${result}`);
    return result;
}

function isSquare(shape: Shape): shape is Square {
    const result = shape.kind === "square";
    console.log(`检查是否为正方形:${result}`);
    return result;
}

function isTriangle(shape: Shape): shape is Triangle {
    const result = shape.kind === "triangle";
    console.log(`检查是否为三角形:${result}`);
    return result;
}

// 使用自定义类型守卫
function describeShape(shape: Shape) {
    console.log(`描述图形:${shape.kind}`);
    
    if (isCircle(shape)) {
        console.log(`这是一个半径为 ${shape.radius} 的圆形`);
        console.log(`周长:${(2 * Math.PI * shape.radius).toFixed(2)}`);
    } else if (isSquare(shape)) {
        console.log(`这是一个边长为 ${shape.sideLength} 的正方形`);
        console.log(`周长:${4 * shape.sideLength}`);
    } else if (isTriangle(shape)) {
        console.log(`这是一个底边 ${shape.base},高 ${shape.height} 的三角形`);
        // 计算斜边(假设是直角三角形)
        const hypotenuse = Math.sqrt(shape.base ** 2 + shape.height ** 2);
        console.log(`斜边长度:${hypotenuse.toFixed(2)}`);
    }
}

describeShape(circle);
// "描述图形:circle"
// "检查是否为圆形:true"
// "这是一个半径为 5 的圆形"
// "周长:31.42"

describeShape(square);
// "描述图形:square"
// "检查是否为圆形:false"
// "检查是否为正方形:true"
// "这是一个边长为 4 的正方形"
// "周长:16"

// 复杂的类型守卫:检查对象结构
interface User {
    id: number;
    name: string;
    email: string;
}

interface Admin extends User {
    permissions: string[];
    lastLogin: Date;
}

function isAdmin(user: User | Admin): user is Admin {
    const hasPermissions = 'permissions' in user;
    const hasLastLogin = 'lastLogin' in user;
    const result = hasPermissions && hasLastLogin;
    console.log(`检查是否为管理员:${result}`);
    return result;
}

function handleUser(user: User | Admin) {
    console.log(`处理用户:${user.name} (ID: ${user.id})`);
    
    if (isAdmin(user)) {
        console.log(`管理员权限:${user.permissions.join(", ")}`);
        console.log(`最后登录:${user.lastLogin.toISOString()}`);
    } else {
        console.log(`普通用户,邮箱:${user.email}`);
    }
}

const regularUser: User = {
    id: 1,
    name: "张三",
    email: "zhangsan@example.com"
};

const adminUser: Admin = {
    id: 2,
    name: "李四",
    email: "lisi@example.com",
    permissions: ["read", "write", "delete"],
    lastLogin: new Date()
};

handleUser(regularUser);
// "处理用户:张三 (ID: 1)"
// "检查是否为管理员:false"
// "普通用户,邮箱:zhangsan@example.com"

handleUser(adminUser);
// "处理用户:李四 (ID: 2)"
// "检查是否为管理员:true"
// "管理员权限:read, write, delete"
// "最后登录:2024-01-01T12:00:00.000Z"

7.6 映射类型——类型转换的"流水线工厂"

映射类型(Mapped Types)让你能像操作数据一样批量转换类型,就像拥有了一条专门生产类型的"智能流水线"。

🏭 内置映射类型:TypeScript的"标准工具"

// 基础用户接口
interface User {
    id: number;
    name: string;
    email: string;
    age: number;
    isActive: boolean;
}

// 1. Partial<T> - 将所有属性变为可选
type PartialUser = Partial<User>;
/* 等价于:
{
    id?: number;
    name?: string;
    email?: string;
    age?: number;
    isActive?: boolean;
}
*/

// 使用Partial类型
function updateUser(id: number, updates: PartialUser) {
    console.log(`更新用户 ${id},更新字段:`, Object.keys(updates));
    
    // 模拟数据库更新
    const updatedFields: string[] = [];
    if (updates.name !== undefined) {
        console.log(`更新姓名:${updates.name}`);
        updatedFields.push("name");
    }
    if (updates.email !== undefined) {
        console.log(`更新邮箱:${updates.email}`);
        updatedFields.push("email");
    }
    if (updates.age !== undefined) {
        console.log(`更新年龄:${updates.age}`);
        updatedFields.push("age");
    }
    if (updates.isActive !== undefined) {
        console.log(`更新状态:${updates.isActive ? "激活" : "禁用"}`);
        updatedFields.push("isActive");
    }
    
    return { success: true, updatedFields };
}

const result1 = updateUser(1, { name: "新姓名", age: 25 });
// "更新用户 1,更新字段:" ["name", "age"]
// "更新姓名:新姓名"
// "更新年龄:25"
console.log(result1); // { success: true, updatedFields: ["name", "age"] }

const result2 = updateUser(2, { isActive: false });
// "更新用户 2,更新字段:" ["isActive"]
// "更新状态:禁用"
console.log(result2); // { success: true, updatedFields: ["isActive"] }

// 2. Required<T> - 将所有属性变为必需
interface OptionalConfig {
    host?: string;
    port?: number;
    ssl?: boolean;
    timeout?: number;
}

type RequiredConfig = Required<OptionalConfig>;
/* 等价于:
{
    host: string;
    port: number;
    ssl: boolean;
    timeout: number;
}
*/

function createConnection(config: RequiredConfig) {
    console.log(`创建连接:${config.ssl ? 'https' : 'http'}://${config.host}:${config.port}`);
    console.log(`超时设置:${config.timeout}ms`);
    
    return {
        url: `${config.ssl ? 'https' : 'http'}://${config.host}:${config.port}`,
        timeout: config.timeout,
        connected: true
    };
}

const connection = createConnection({
    host: "api.example.com",
    port: 443,
    ssl: true,
    timeout: 5000
});
// "创建连接:https://api.example.com:443"
// "超时设置:5000ms"
console.log(connection.connected); // true

// 3. Readonly<T> - 将所有属性变为只读
type ReadonlyUser = Readonly<User>;

function createReadonlyUser(userData: User): ReadonlyUser {
    console.log(`创建只读用户:${userData.name}`);
    const readonlyUser = Object.freeze({ ...userData });
    console.log(`用户已设为只读模式`);
    return readonlyUser;
}

const originalUser: User = {
    id: 1,
    name: "张三",
    email: "zhangsan@example.com",
    age: 30,
    isActive: true
};

const readonlyUser = createReadonlyUser(originalUser);
// "创建只读用户:张三"
// "用户已设为只读模式"

console.log(readonlyUser.name); // "张三"
// readonlyUser.name = "李四"; // 错误!无法修改只读属性

🎨 自定义映射类型:打造专属工具

// 1. 生成Getter方法类型
type Getters<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

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

// 实现Getter类
class UserWithGetters implements UserGetters {
    constructor(private user: User) {
        console.log(`创建带Getter的用户:${user.name}`);
    }
    
    getId(): number {
        console.log(`获取ID:${this.user.id}`);
        return this.user.id;
    }
    
    getName(): string {
        console.log(`获取姓名:${this.user.name}`);
        return this.user.name;
    }
    
    getEmail(): string {
        console.log(`获取邮箱:${this.user.email}`);
        return this.user.email;
    }
    
    getAge(): number {
        console.log(`获取年龄:${this.user.age}`);
        return this.user.age;
    }
    
    getIsActive(): boolean {
        console.log(`获取状态:${this.user.isActive ? "激活" : "禁用"}`);
        return this.user.isActive;
    }
}

const userWithGetters = new UserWithGetters(originalUser);
// "创建带Getter的用户:张三"

console.log(userWithGetters.getName()); 
// "获取姓名:张三"
// 返回:"张三"

console.log(userWithGetters.getAge()); 
// "获取年龄:30"
// 返回:30

// 2. 过滤特定类型的属性
type StringProperties<T> = {
    [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type UserStringProps = StringProperties<User>;
/* 等价于:
{
    name: string;
    email: string;
}
*/

function extractStringProperties(user: User): UserStringProps {
    console.log(`提取用户的字符串属性`);
    const stringProps = {
        name: user.name,
        email: user.email
    };
    console.log(`提取的属性:`, stringProps);
    return stringProps;
}

const stringProps = extractStringProperties(originalUser);
// "提取用户的字符串属性"
// "提取的属性:" { name: "张三", email: "zhangsan@example.com" }
console.log(stringProps.name); // "张三"
console.log(stringProps.email); // "zhangsan@example.com"

// 3. 创建可空版本的类型
type Nullable<T> = {
    [K in keyof T]: T[K] | null;
};

type NullableUser = Nullable<User>;
/* 等价于:
{
    id: number | null;
    name: string | null;
    email: string | null;
    age: number | null;
    isActive: boolean | null;
}
*/

function createNullableUser(partial: Partial<User>): NullableUser {
    console.log(`创建可空用户,输入字段:`, Object.keys(partial));
    
    const nullableUser: NullableUser = {
        id: partial.id ?? null,
        name: partial.name ?? null,
        email: partial.email ?? null,
        age: partial.age ?? null,
        isActive: partial.isActive ?? null
    };
    
    console.log(`创建的可空用户:`, nullableUser);
    return nullableUser;
}

const nullableUser = createNullableUser({ name: "测试用户", age: 25 });
// "创建可空用户,输入字段:" ["name", "age"]
// "创建的可空用户:" { id: null, name: "测试用户", email: null, age: 25, isActive: null }

console.log(nullableUser.name); // "测试用户"
console.log(nullableUser.id); // null

🔧 高级映射类型:复杂转换

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

interface NestedUser {
    id: number;
    profile: {
        name: string;
        contact: {
            email: string;
            phone: string;
        };
    };
    preferences: {
        theme: string;
        language: string;
    };
}

type DeepReadonlyUser = DeepReadonly<NestedUser>;

function createDeepReadonlyUser(user: NestedUser): DeepReadonlyUser {
    console.log(`创建深度只读用户:${user.profile.name}`);
    
    // 深度冻结对象
    function deepFreeze<T>(obj: T): T {
        Object.getOwnPropertyNames(obj).forEach(prop => {
            const value = (obj as any)[prop];
            if (value && typeof value === 'object') {
                deepFreeze(value);
            }
        });
        return Object.freeze(obj);
    }
    
    const frozenUser = deepFreeze({ ...user });
    console.log(`用户已深度冻结`);
    return frozenUser as DeepReadonlyUser;
}

const nestedUser: NestedUser = {
    id: 1,
    profile: {
        name: "深度用户",
        contact: {
            email: "deep@example.com",
            phone: "123-456-7890"
        }
    },
    preferences: {
        theme: "dark",
        language: "zh-CN"
    }
};

const deepReadonlyUser = createDeepReadonlyUser(nestedUser);
// "创建深度只读用户:深度用户"
// "用户已深度冻结"

console.log(deepReadonlyUser.profile.name); // "深度用户"
console.log(deepReadonlyUser.profile.contact.email); // "deep@example.com"
// deepReadonlyUser.profile.name = "新名字"; // 错误!深度只读
// deepReadonlyUser.profile.contact.email = "new@example.com"; // 错误!深度只读

// 2. 条件映射类型
type ApiEndpoints<T> = {
    [K in keyof T as T[K] extends Function ? `api${Capitalize<string & K>}` : never]: T[K];
};

interface UserService {
    create: (user: User) => Promise<User>;
    update: (id: number, user: Partial<User>) => Promise<User>;
    delete: (id: number) => Promise<void>;
    name: string; // 这个不是函数,会被过滤掉
    version: number; // 这个也不是函数,会被过滤掉
}

type UserApiEndpoints = ApiEndpoints<UserService>;
/* 等价于:
{
    apiCreate: (user: User) => Promise<User>;
    apiUpdate: (id: number, user: Partial<User>) => Promise<User>;
    apiDelete: (id: number) => Promise<void>;
}
*/

class UserApi implements UserApiEndpoints {
    apiCreate(user: User): Promise<User> {
        console.log(`API: 创建用户 ${user.name}`);
        return Promise.resolve({ ...user, id: Date.now() });
    }
    
    apiUpdate(id: number, user: Partial<User>): Promise<User> {
        console.log(`API: 更新用户 ${id},字段:`, Object.keys(user));
        return Promise.resolve({ id, ...user } as User);
    }
    
    apiDelete(id: number): Promise<void> {
        console.log(`API: 删除用户 ${id}`);
        return Promise.resolve();
    }
}

const userApi = new UserApi();

// 异步测试
async function testUserApi() {
    const newUser = await userApi.apiCreate({
        id: 0, // 会被覆盖
        name: "API用户",
        email: "api@example.com",
        age: 28,
        isActive: true
    });
    // "API: 创建用户 API用户"
    console.log(`创建的用户ID:${newUser.id}`);
    
    await userApi.apiUpdate(newUser.id, { age: 29 });
    // "API: 更新用户 1704067200000,字段:" ["age"]
    
    await userApi.apiDelete(newUser.id);
    // "API: 删除用户 1704067200000"
}

testUserApi();

7.7 条件类型——类型系统的"智能决策器"

条件类型(Conditional Types)让类型也能拥有逻辑判断能力,就像给类型系统装上了"人工智能"。

🧠 基础条件类型:类型的三目运算符

// 基础语法:T extends U ? X : Y
type IsString<T> = T extends string ? true : false;
type IsNumber<T> = T extends number ? true : false;
type IsArray<T> = T extends any[] ? true : false;

// 测试类型判断
type Test1 = IsString<"hello">; // true
type Test2 = IsString<42>;      // false
type Test3 = IsNumber<100>;     // true
type Test4 = IsArray<string[]>; // true

// 实际使用示例
function processData<T>(data: T): T extends string ? string : T extends number ? number : unknown {
    console.log(`处理数据:${data},类型:${typeof data}`);
    
    if (typeof data === "string") {
        const result = data.toUpperCase();
        console.log(`字符串处理结果:${result}`);
        return result as any;
    } else if (typeof data === "number") {
        const result = data * 2;
        console.log(`数字处理结果:${result}`);
        return result as any;
    } else {
        console.log(`未知类型,原样返回`);
        return data as any;
    }
}

const stringResult = processData("hello");
// "处理数据:hello,类型:string"
// "字符串处理结果:HELLO"
console.log(stringResult); // "HELLO"

const numberResult = processData(42);
// "处理数据:42,类型:number"
// "数字处理结果:84"
console.log(numberResult); // 84

const unknownResult = processData(true);
// "处理数据:true,类型:boolean"
// "未知类型,原样返回"
console.log(unknownResult); // true

🔄 分布式条件类型:批量处理联合类型

// 分布式条件类型 - 自动分发到联合类型的每个成员
type ToArray<T> = T extends any ? T[] : never;

// 当T是联合类型时,条件类型会分布到每个成员
type StringOrNumberArray = ToArray<string | number>; // string[] | number[]
type BooleanArray = ToArray<boolean>; // boolean[]

// 实际应用:过滤联合类型
type NonNullable<T> = T extends null | undefined ? never : T;

type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>; // string

// 使用示例
function filterNullable<T>(value: T): NonNullable<T> | null {
    if (value === null || value === undefined) {
        console.log(`过滤掉空值:${value}`);
        return null;
    }
    console.log(`保留有效值:${value}`);
    return value as NonNullable<T>;
}

const result1 = filterNullable("hello");
// "保留有效值:hello"
console.log(result1); // "hello"

const result2 = filterNullable(null);
// "过滤掉空值:null"
console.log(result2); // null

const result3 = filterNullable(undefined);
// "过滤掉空值:undefined"
console.log(result3); // null

🔍 类型推断:infer 关键字的魔法

// 1. 提取函数返回类型
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

type StringFunction = () => string;
type NumberFunction = (x: number) => number;
type VoidFunction = () => void;

type StringReturn = ReturnType<StringFunction>; // string
type NumberReturn = ReturnType<NumberFunction>; // number
type VoidReturn = ReturnType<VoidFunction>; // void

// 实际使用
function createTypedFunction<F extends (...args: any[]) => any>(
    fn: F
): (...args: Parameters<F>) => ReturnType<F> {
    return (...args) => {
        console.log(`调用函数,参数:`, args);
        const result = fn(...args);
        console.log(`函数返回:`, result);
        return result;
    };
}

const add = (a: number, b: number) => a + b;
const typedAdd = createTypedFunction(add);

const sum = typedAdd(5, 3);
// "调用函数,参数:" [5, 3]
// "函数返回:" 8
console.log(sum); // 8

// 2. 提取数组元素类型
type ArrayElement<T> = T extends (infer U)[] ? U : never;

type StringArrayElement = ArrayElement<string[]>; // string
type NumberArrayElement = ArrayElement<number[]>; // number
type MixedArrayElement = ArrayElement<(string | number)[]>; // string | number

// 实际应用:数组处理函数
function processArray<T extends any[]>(
    arr: T,
    processor: (item: ArrayElement<T>) => ArrayElement<T>
): T {
    console.log(`处理数组,长度:${arr.length}`);
    const result = arr.map(item => {
        const processed = processor(item);
        console.log(`处理项目:${item} -> ${processed}`);
        return processed;
    }) as T;
    console.log(`处理完成`);
    return result;
}

const numbers = [1, 2, 3, 4, 5];
const doubled = processArray(numbers, x => x * 2);
// "处理数组,长度:5"
// "处理项目:1 -> 2"
// "处理项目:2 -> 4"
// "处理项目:3 -> 6"
// "处理项目:4 -> 8"
// "处理项目:5 -> 10"
// "处理完成"
console.log(doubled); // [2, 4, 6, 8, 10]

const strings = ["hello", "world", "typescript"];
const uppercased = processArray(strings, s => s.toUpperCase());
// "处理数组,长度:3"
// "处理项目:hello -> HELLO"
// "处理项目:world -> WORLD"
// "处理项目:typescript -> TYPESCRIPT"
// "处理完成"
console.log(uppercased); // ["HELLO", "WORLD", "TYPESCRIPT"]

// 3. 提取Promise的值类型
type Awaited<T> = T extends Promise<infer U> ? U : T;

type StringPromise = Promise<string>;
type NumberPromise = Promise<number>;
type NestedPromise = Promise<Promise<boolean>>;

type StringValue = Awaited<StringPromise>; // string
type NumberValue = Awaited<NumberPromise>; // number
type BooleanValue = Awaited<NestedPromise>; // Promise<boolean> (只解包一层)

// 深度解包Promise
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;

type DeepBooleanValue = DeepAwaited<NestedPromise>; // boolean

// 实际应用:异步函数包装器
async function wrapAsync<T extends Promise<any>>(
    promise: T
): Promise<{ success: boolean; data: Awaited<T> | null; error: string | null }> {
    console.log(`包装异步操作`);
    try {
        const data = await promise;
        console.log(`异步操作成功:`, data);
        return { success: true, data, error: null };
    } catch (error) {
        console.log(`异步操作失败:`, error);
        return { success: false, data: null, error: String(error) };
    }
}

// 测试异步包装器
async function testAsyncWrapper() {
    const successPromise = Promise.resolve("成功数据");
    const failPromise = Promise.reject("失败原因");
    
    const result1 = await wrapAsync(successPromise);
    // "包装异步操作"
    // "异步操作成功:" "成功数据"
    console.log(result1); // { success: true, data: "成功数据", error: null }
    
    const result2 = await wrapAsync(failPromise);
    // "包装异步操作"
    // "异步操作失败:" "失败原因"
    console.log(result2); // { success: false, data: null, error: "失败原因" }
}

testAsyncWrapper();

🎯 实战应用:智能表单验证系统

// 字段类型定义
type FieldType = "text" | "number" | "email" | "date" | "boolean";

// 基础字段配置
interface BaseFieldConfig {
    name: string;
    type: FieldType;
    required?: boolean;
    label?: string;
}

// 根据字段类型确定值类型的条件类型
type FieldValue<T extends BaseFieldConfig> = 
    T extends { type: "number" } ? number :
    T extends { type: "date" } ? Date :
    T extends { type: "boolean" } ? boolean :
    string; // 默认为string (text, email)

// 根据required属性确定是否可选
type FieldValueWithRequired<T extends BaseFieldConfig> = 
    T extends { required: true } 
        ? FieldValue<T> 
        : FieldValue<T> | undefined;

// 表单值类型生成器
type FormValues<Fields extends readonly BaseFieldConfig[]> = {
    [K in Fields[number]["name"]]: FieldValueWithRequired<
        Extract<Fields[number], { name: K }>
    >;
};

// 验证规则类型
type ValidationRule<T> = {
    validate: (value: T) => boolean;
    message: string;
};

type FieldValidation<T extends BaseFieldConfig> = {
    [K in T["name"]]: ValidationRule<FieldValueWithRequired<T>>[];
};

// 表单配置示例
const userFormConfig = [
    { name: "username", type: "text", required: true, label: "用户名" },
    { name: "email", type: "email", required: true, label: "邮箱" },
    { name: "age", type: "number", required: false, label: "年龄" },
    { name: "birthdate", type: "date", required: false, label: "生日" },
    { name: "isActive", type: "boolean", required: true, label: "是否激活" }
] as const;

type UserFormValues = FormValues<typeof userFormConfig>;
/* 等价于:
{
    username: string;
    email: string;
    age: number | undefined;
    birthdate: Date | undefined;
    isActive: boolean;
}
*/

// 验证器实现
class FormValidator<T extends readonly BaseFieldConfig[]> {
    constructor(private config: T) {
        console.log(`创建表单验证器,字段数量:${config.length}`);
    }
    
    validate(values: FormValues<T>): { isValid: boolean; errors: string[] } {
        console.log(`开始验证表单`);
        const errors: string[] = [];
        
        for (const field of this.config) {
            const value = (values as any)[field.name];
            console.log(`验证字段 ${field.name}${value}`);
            
            // 检查必填字段
            if (field.required && (value === undefined || value === null || value === "")) {
                const error = `${field.label || field.name} 是必填字段`;
                console.log(`验证失败:${error}`);
                errors.push(error);
                continue;
            }
            
            // 类型特定验证
            if (value !== undefined && value !== null && value !== "") {
                switch (field.type) {
                    case "email":
                        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
                        if (!emailRegex.test(String(value))) {
                            const error = `${field.label || field.name} 格式不正确`;
                            console.log(`验证失败:${error}`);
                            errors.push(error);
                        }
                        break;
                    case "number":
                        if (typeof value !== "number" || isNaN(value)) {
                            const error = `${field.label || field.name} 必须是有效数字`;
                            console.log(`验证失败:${error}`);
                            errors.push(error);
                        }
                        break;
                    case "date":
                        if (!(value instanceof Date) || isNaN(value.getTime())) {
                            const error = `${field.label || field.name} 必须是有效日期`;
                            console.log(`验证失败:${error}`);
                            errors.push(error);
                        }
                        break;
                }
            }
            
            console.log(`字段 ${field.name} 验证通过`);
        }
        
        const isValid = errors.length === 0;
        console.log(`表单验证${isValid ? "成功" : "失败"},错误数量:${errors.length}`);
        return { isValid, errors };
    }
}

// 使用示例
const validator = new FormValidator(userFormConfig);
// "创建表单验证器,字段数量:5"

// 测试有效数据
const validData: UserFormValues = {
    username: "techuser",
    email: "tech@example.com",
    age: 25,
    birthdate: new Date("1998-01-01"),
    isActive: true
};

const result1 = validator.validate(validData);
// "开始验证表单"
// "验证字段 username:techuser"
// "字段 username 验证通过"
// "验证字段 email:tech@example.com"
// "字段 email 验证通过"
// "验证字段 age:25"
// "字段 age 验证通过"
// "验证字段 birthdate:Wed Jan 01 1998 00:00:00 GMT+0800"
// "字段 birthdate 验证通过"
// "验证字段 isActive:true"
// "字段 isActive 验证通过"
// "表单验证成功,错误数量:0"
console.log(result1); // { isValid: true, errors: [] }

// 测试无效数据
const invalidData: UserFormValues = {
    username: "", // 必填但为空
    email: "invalid-email", // 格式错误
    age: undefined, // 可选字段
    birthdate: undefined, // 可选字段
    isActive: true
};

const result2 = validator.validate(invalidData);
// "开始验证表单"
// "验证字段 username:"
// "验证失败:用户名 是必填字段"
// "验证字段 email:invalid-email"
// "验证失败:邮箱 格式不正确"
// "验证字段 age:undefined"
// "字段 age 验证通过"
// "验证字段 birthdate:undefined"
// "字段 birthdate 验证通过"
// "验证字段 isActive:true"
// "字段 isActive 验证通过"
// "表单验证失败,错误数量:2"
console.log(result2); // { isValid: false, errors: ["用户名 是必填字段", "邮箱 格式不正确"] }

🎯 高级类型最佳实践与设计模式

📋 类型设计原则对比表

原则 说明 好的例子 避免的例子
类型安全优先 优先保证类型安全,避免any type ID = string | number type ID = any
语义化命名 类型名称要有明确含义 type UserRole = "admin" | "user" type T1 = "a" | "b"
组合优于继承 使用联合类型和交叉类型 type Response<T> = Success<T> | Error 复杂的类继承链
渐进式增强 从简单类型开始,逐步增强 先定义基础类型,再扩展 一开始就定义复杂类型
可读性重要 复杂类型要有注释和示例 带有详细注释的类型定义 没有说明的复杂类型

🛠️ 实用工具类型库

// 1. 深度部分类型
type DeepPartial<T> = {
    [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

// 2. 深度必需类型
type DeepRequired<T> = {
    [P in keyof T]-?: T[P] extends object ? DeepRequired<T[P]> : T[P];
};

// 3. 选择性Pick
type PickByType<T, U> = {
    [K in keyof T as T[K] extends U ? K : never]: T[K];
};

// 4. 排除性Omit
type OmitByType<T, U> = {
    [K in keyof T as T[K] extends U ? never : K]: T[K];
};

// 5. 可空类型转换
type Nullish<T> = {
    [K in keyof T]: T[K] | null | undefined;
};

// 6. 函数参数提取
type Head<T extends any[]> = T extends [infer H, ...any[]] ? H : never;
type Tail<T extends any[]> = T extends [any, ...infer R] ? R : never;

// 使用示例
interface ComplexUser {
    id: number;
    profile: {
        name: string;
        settings: {
            theme: string;
            notifications: boolean;
        };
    };
    permissions: string[];
    createdAt: Date;
}

type PartialUser = DeepPartial<ComplexUser>;
type StringFields = PickByType<ComplexUser, string>;
type NonStringFields = OmitByType<ComplexUser, string>;

console.log("工具类型演示完成"); // "工具类型演示完成"

🎨 类型体操进阶挑战

// 挑战1:字符串操作类型
type Reverse<S extends string> = S extends `${infer First}${infer Rest}` 
    ? `${Reverse<Rest>}${First}` 
    : S;

type ReversedHello = Reverse<"hello">; // "olleh"

// 挑战2:数组长度计算
type Length<T extends readonly any[]> = T['length'];
type ArrayLength = Length<[1, 2, 3, 4, 5]>; // 5

// 挑战3:对象键值转换
type Flip<T extends Record<string, string>> = {
    [K in keyof T as T[K]]: K;
};

type Original = { a: "x", b: "y", c: "z" };
type Flipped = Flip<Original>; // { x: "a", y: "b", z: "c" }

// 挑战4:递归计数器
type Counter<N extends number, C extends any[] = []> = 
    C['length'] extends N ? C : Counter<N, [...C, any]>;

type FiveItems = Counter<5>; // [any, any, any, any, any]

console.log("类型体操挑战完成"); // "类型体操挑战完成"

📚 本章核心收获总结

🎯 掌握的核心技能

  1. 类型别名 🏷️

    • 简化复杂类型定义
    • 提高代码可读性和维护性
    • 支持泛型和递归定义
  2. 字符串字面量类型 🎯

    • 精确控制字符串值范围
    • 模板字面量类型的强大功能
    • 与Redux、状态管理的完美结合
  3. 元组类型 📏

    • 固定长度和类型的数组
    • 支持可选元素和剩余元素
    • React Hooks等场景的类型安全
  4. 枚举类型 🔢

    • 数字枚举和字符串枚举
    • 常量枚举的编译时优化
    • 现代联合类型替代方案
  5. 类型保护 🛡️

    • typeof、instanceof、in守卫
    • 自定义类型谓词函数
    • 运行时类型收窄机制
  6. 映射类型 🏭

    • 内置工具类型的使用
    • 自定义映射类型的创建
    • 批量类型转换的强大能力
  7. 条件类型 🧠

    • 类型系统的逻辑判断
    • infer关键字的类型推断
    • 分布式条件类型的特性

🚀 实战应用场景

  • API接口设计:使用映射类型生成请求/响应类型
  • 状态管理:字面量类型确保Action类型安全
  • 表单验证:条件类型实现智能表单系统
  • 工具函数:类型保护确保函数参数安全
  • 组件开发:元组类型支持Hooks等模式

💡 进阶学习建议

  1. 多练习类型体操:提升类型思维能力
  2. 阅读优秀库源码:学习实际应用模式
  3. 关注TypeScript更新:掌握最新特性
  4. 结合实际项目:在真实场景中应用
  5. 分享交流经验:与团队共同成长

🎉 恭喜你! 你已经掌握了TypeScript高级类型的核心技能,这些强大的类型工具将让你的代码更加安全、优雅和高效。接下来,我们将探索模块与命名空间的世界,学习如何组织大型TypeScript项目!

手写call全解析:从原理到实现,让this指向不再迷路~

作者 十盒半价
2025年7月1日 17:28

一、JS this 之谜:call 为何是救星?

在 JavaScript 的世界里,this就像一个调皮的精灵,函数在哪调用,它就指向哪。比如:

const obj = { name: '稀土掘金' };
function sayHi() {
  console.log(`Hello, ${this.name}!`); // 直接调用时,this指向window/global
}
sayHi(); // 输出:Hello, undefined!(尴尬~)

这时候,call闪亮登场!它能强行改变this的指向,就像给this戴上 GPS 定位器:

sayHi.call(obj); // 输出:Hello, 稀土掘金!(完美~)

核心作用:让函数在指定的上下文(context)中执行,精准控制this的指向。

二、call、apply、bind 大不同:先搞懂兄弟仨的分工

在动手写call之前,先快速理清这三个高频方法的区别(避免后续混淆):

2.1 相同点:都是 this 的 “搬运工”

  • 作用:动态修改函数内部this的指向。
  • 原则:绝不修改原函数的this,只在调用时临时生效(bind除外)。

2.2 不同点:执行方式、传参、绑定性质大揭秘

特性 call apply bind
执行时机 立即执行(同步) 立即执行(同步) 返回新函数(延迟执行)
传参方式 逐个传参(arg1, arg2 数组传参([arg1, arg2] 可预传参(bind(ctx, a)(b)
this 绑定 临时绑定(仅本次调用) 临时绑定(仅本次调用) 永久绑定(返回新函数)

小剧场

  • callapply就像急性子,一说 “改 this” 就立刻执行;
  • bind则像慢性子,先记下来(返回新函数),等你喊 “执行” 才动~

三、手写 call 的核心思路:三步搞定 this 绑定

现在进入正题:如何手写一个myCall,实现和原生call一样的效果?
核心原理:把函数 “寄生” 到目标对象上,通过调用对象属性的方式执行函数,这样函数内的this就会指向该对象。

3.1 第一步:处理 context(防止迷路的保底方案)

  • 场景 1:如果用户没传context,或者传了null/undefined,默认指向window(严格模式下为undefined,但这里简化处理)。

  • 场景 2:如果传的是原始值(如string/number),需要用Object()包装成对象(这是 JS 的隐式转换规则)。

Function.prototype.myCall = function(context) {
  // 1. 处理context:默认指向window,原始值转对象
  context = context !== null && context !== undefined ? Object(context) : window;

3.2 第二步:寄生函数:用 Symbol 防止属性冲突

  • 问题:如果目标对象context本身有同名方法(比如context.fn),直接挂载函数会覆盖原有属性。

  • 解决方案:用 ES6 的Symbol生成唯一键名(Symbol 就像身份证号,绝对不会重复)。

  // 2. 生成唯一键名,避免覆盖context原有属性
  const fnKey = Symbol('临时函数');
  // 3. 将当前函数“寄生”到context上
  context[fnKey] = this; // this指向调用myCall的函数(比如sayHi)

3.3 第三步:执行函数 + 清理现场(做个有素质的 JS 开发者)

  • 传参处理:用剩余参数...args收集call的参数(第一个参数是context,从第二个开始是函数的实参)。

  • 清理现场:执行完函数后,删除context上的临时属性,避免污染对象。

  // 4. 收集参数(第一个参数是context,从第二个开始是函数的参数)
  const args = [...arguments].slice(1); // 例如:myCall(obj, a, b) → args = [a, b]
  // 5. 执行函数,并接收返回值
  const result = context[fnKey](...args);
  // 6. 清理现场:删除临时属性
  delete context[fnKey];
  // 7. 返回函数执行结果
  return result;
};

四、完整代码 + 测试:验证 myCall 是否靠谱

4.1 完整实现(带详细注释)

Function.prototype.myCall = function(context) {
  // 处理context:null/undefined→window,原始值→对象(如:'稀土'→new String('稀土'))
  context = context !== null && context !== undefined ? Object(context) : window;
  // 生成唯一键名,避免与context原有属性冲突
  const fnKey = Symbol('myCall-temp');
  // 将当前函数(this)挂载到context上
  context[fnKey] = this;
  // 收集参数:排除第一个参数(context),剩余的作为函数参数
  const args = [...arguments].slice(1);
  // 执行函数并获取结果
  const result = context[fnKey](...args);
  // 删除临时属性,保持context纯净
  delete context[fnKey];
  // 返回函数执行结果
  return result;
};

4.2 测试案例:验证不同场景

案例 1:普通对象绑定

const obj = { name: '稀土掘金' };
function sayHi(hello) {
  return `${hello}, ${this.name}!`;
}
console.log(sayHi.myCall(obj, 'Hi')); // 输出:Hi, 稀土掘金!(正确~)

案例 2:context 为 null/undefined

function sayHello() {
  return `Hello, ${this.defaultName}`;
}
const globalObj = { defaultName: 'Global' };
// 当context为null/undefined时,myCall默认指向window(这里用globalObj模拟window)
console.log(sayHello.myCall(null)); // 输出:Hello, Global!(正确~)

案例 3:原始值作为 context

function logType() {
  console.log(this instanceof String); // true(包装成String对象)
  console.log(`类型:${this}`); // 类型:稀土掘金
}
logType.myCall('稀土掘金'); // 输出:true 和 类型:稀土掘金(正确~)

五、常见坑点与优化:让 myCall 更健壮

5.1 坑点 1:忘记绑定函数类型

  • 错误场景:如果调用myCall的不是函数(比如null.myCall()),会报错。

  • 解决方案:调用前校验this是否为函数类型。

if (typeof this !== 'function') {
  throw new TypeError('myCall must be called on a function');
}

5.2 坑点 2:严格模式下的 this 处理

  • 规则:在严格模式中,callcontext如果是null/undefined,函数内的this就是null/undefined,而不是window

  • 优化:移除context = ... || window的默认处理,严格遵循规范。

// 严格模式下的处理(可选优化)
context = context === null || context === undefined ? context : Object(context);

六、扩展思考:apply 和 bind 该怎么写?(留给聪明的你)

6.1 apply 的实现(与 call 的唯一区别:参数是数组)

Function.prototype.myApply = function(context, args) {
  context = context !== null && context !== undefined ? Object(context) : window;
  const fnKey = Symbol('myApply-temp');
  context[fnKey] = this;
  // apply的参数是数组,直接展开即可
  const result = context[fnKey](...(args || []));
  delete context[fnKey];
  return result;
};

6.2 bind 的实现(返回新函数,永久绑定 this)

Function.prototype.myBind = function(context) {
  const fn = this;
  const args = [...arguments].slice(1);
  // 返回新函数,支持new调用(通过原型链继承)
  return function NewFn() {
    // 如果是new调用,this指向实例对象,否则指向context
    const ctx = this instanceof NewFn ? this : Object(context);
    return fn.apply(ctx, args.concat([...arguments]));
  };
};

七、总结:手写 call 的灵魂三问

  1. 为什么要用 Symbol?
    防止临时属性与目标对象的原有属性冲突,就像给临时变量起了个独一无二的名字。

  2. call 和 apply 的本质区别是什么?
    只是参数形式不同,call传散落的参数,apply传数组,底层原理完全一致。

  3. bind 为什么返回新函数?
    因为它需要 “记住” 绑定的this和参数,等后续调用时再执行,就像一个 “延迟执行的函数包裹器”。

通过手写call,我们不仅深入理解了 JS 的this机制,还掌握了函数 “寄生”、属性隔离、参数处理等核心技巧。下次遇到this指向问题,再也不用慌啦~ 😉

岁寒之松柏:小程序的canvas如何绘制视频

2025年7月1日 17:27

背景

最近公司开展了新的业务,制作一个数字人,但是因为没有建模和游戏相关开发人员,所以想要做一个很简单的项目,用户在小程序上点击长按按钮,和数字人对话,然后录音发送到后台,后台接收到反馈后 会把语音流和要执行的动作发送到小程序,而小程序端需要播放对应的语音流和根据动作切换相应的视频。我接受到需求的时候也觉得很简单,但是没想到噩梦才刚刚开始。

小小梦魇:录音

因为项目使用到录音并且需通过websocket将960字节PCM帧进行发送,所以我就看了小程序的文档找到如下API:

image.png

看文档似乎可以满足我的需求因为可以设置录音格式和frameSize 这样就可以在如下的

image.png

回调中获取到需要发送的PCM帧数据。但是问题就是这个frameSize 因为我需要的960字节,而这个frameSize 在真机上最小值就是 1 ,而需要的设置 960/1024 = 0.9375在模拟器中可以使用但是真机上却不行。没办法了只能自己将录音的数据切成960字节了 源码如下:

    // 对原有API进行了简单封装
    
    this.audioDataBuffer = new Uint8Array(0)
    
    
    // 这个地方的data 就是 官方文档中 onFrameRecorded 回调函数中的data
    this.recorder.on(RECORDER_EVENTS.FRAME_RECORDED, (data) => {
      
      
      if (!data.isLastFrame) {
        let newData = new Uint8Array(data.frameBuffer)
        // 将新数据添加到缓存中
        let combinedData = new Uint8Array(this.audioDataBuffer.length + newData.length)
        combinedData.set(this.audioDataBuffer)
        combinedData.set(newData, this.audioDataBuffer.length)

        // 分片发送,每次最多960字节
        const maxChunkSize = 960
        let offset = 0

        while (offset + maxChunkSize <= combinedData.length) {
          let chunk = combinedData.slice(offset, offset + maxChunkSize)
          let buffer = chunk.buffer
          mylog.log("发送音频数据块,大小:", buffer.byteLength)
          this.scoket.sendBuffer(buffer)
          offset += maxChunkSize
        }

        // 保存剩余数据到缓存中
        if (offset < combinedData.length) {
          this.audioDataBuffer = combinedData.slice(offset)
          mylog.log("缓存剩余数据,大小:", this.audioDataBuffer.length)
        } else {
          this.audioDataBuffer = new Uint8Array(0)
        }
      }
      //  this.scoket.send(data)
    })

层层恐惧:播放语音流

另外项目需要把服务端发送的PCM帧进行播放,刚刚接收到需求的时候我其实是不太确定小程序是否可以播放PCM的帧数据。研究之后发现了如下API:

image.png

这个API可以创建类似web端使用AudioContext进行PCM帧数据进行播放。但是web端如何进行播放呢,查询一下发现了如下仓库 pcm-player,但是并不直接支持小程序,查看源码发现如下片段。

image.png

结合上面的小程序API合理推测this.audioCtx 换成 wx.createWebAudioContext() 应该就可以直接在小程序中使用这个仓库了 也就改成这样


class PCMPlayer {
  constructor(option) {
    this.init(option)
  }

  init(option) {
    const defaultOption = {
      inputCodec: 'Int16', // 传入的数据是采用多少位编码,默认16位
      channels: 1, // 声道数
      sampleRate: 8000, // 采样率 单位Hz
      flushTime: 1000, // 缓存时间 单位 ms
      fftSize: 2048 // analyserNode fftSize 
    }

    this.option = Object.assign({}, defaultOption, option) // 实例最终配置参数
    this.samples = new Float32Array() // 样本存放区域
    this.interval = setInterval(this.flush.bind(this), this.option.flushTime)
    this.convertValue = this.getConvertValue()
    this.typedArray = this.getTypedArray()
    this.initAudioContext()
    this.bindAudioContextEvent()
  }

  getConvertValue() {
    // 根据传入的目标编码位数
    // 选定转换数据所需要的基本值
    const inputCodecs = {
      'Int8': 128,
      'Int16': 32768,
      'Int32': 2147483648,
      'Float32': 1
    }
    if (!inputCodecs[this.option.inputCodec]) throw new Error('wrong codec.please input one of these codecs:Int8,Int16,Int32,Float32')
    return inputCodecs[this.option.inputCodec]
  }

  getTypedArray() {
    // 根据传入的目标编码位数
    // 选定前端的所需要的保存的二进制数据格式
    // 完整TypedArray请看文档
    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
    const typedArrays = {
      'Int8': Int8Array,
      'Int16': Int16Array,
      'Int32': Int32Array,
      'Float32': Float32Array
    }
    if (!typedArrays[this.option.inputCodec]) throw new Error('wrong codec.please input one of these codecs:Int8,Int16,Int32,Float32')
    return typedArrays[this.option.inputCodec]
  }

  initAudioContext() {
    /*
     * 这个地方改成小程序的版本
     */
    this.audioCtx = wx.createWebAudioContext()
    // 控制音量的 GainNode
    // https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/createGain
    this.gainNode = this.audioCtx.createGain()
    this.gainNode.gain.value = 0.1
    this.gainNode.connect(this.audioCtx.destination)
    this.startTime = this.audioCtx.currentTime
    this.analyserNode = this.audioCtx.createAnalyser() 
    this.analyserNode.fftSize = this.option.fftSize;
  }

  static isTypedArray(data) {
    // 检测输入的数据是否为 TypedArray 类型或 ArrayBuffer 类型
    return (data.byteLength && data.buffer && data.buffer.constructor == ArrayBuffer) || data.constructor == ArrayBuffer;
  }

  isSupported(data) {
    // 数据类型是否支持
    // 目前支持 ArrayBuffer 或者 TypedArray
    if (!PCMPlayer.isTypedArray(data)) throw new Error('请传入ArrayBuffer或者任意TypedArray')
    return true
  }

  feed(data) {
    this.isSupported(data)

    // 获取格式化后的buffer
    data = this.getFormattedValue(data);
    // 开始拷贝buffer数据
    // 新建一个Float32Array的空间
    const tmp = new Float32Array(this.samples.length + data.length);
    // console.log(data, this.samples, this.samples.length)
    // 复制当前的实例的buffer值(历史buff)
    // 从头(0)开始复制
    tmp.set(this.samples, 0);
    // 复制传入的新数据
    // 从历史buff位置开始
    tmp.set(data, this.samples.length);
    // 将新的完整buff数据赋值给samples
    // interval定时器也会从samples里面播放数据
    this.samples = tmp;
    // console.log('this.samples', this.samples)
  }

  getFormattedValue(data) {
    if (data.constructor == ArrayBuffer) {
      data = new this.typedArray(data)
    } else {
      data = new this.typedArray(data.buffer)
    }

    let float32 = new Float32Array(data.length)

    for (let i = 0; i < data.length; i++) {
      // buffer 缓冲区的数据,需要是IEEE754 里32位的线性PCM,范围从-1到+1
      // 所以对数据进行除法
      // 除以对应的位数范围,得到-1到+1的数据
      // float32[i] = data[i] / 0x8000;
      float32[i] = data[i] / this.convertValue
    }
    return float32
  }

  volume(volume) {
    this.gainNode.gain.value = volume
  }

  destroy() {
    if (this.interval) {
      clearInterval(this.interval)
    }
    this.samples = null
    this.audioCtx.close()
    this.audioCtx = null
  }

  flush() {
    if (!this.samples.length) return
    const self = this
    var bufferSource = this.audioCtx.createBufferSource()
    if (typeof this.option.onended === 'function') {
      bufferSource.onended = function (event) {
        self.option.onended(this, event)
      }
    }
    const length = this.samples.length / this.option.channels
    const audioBuffer = this.audioCtx.createBuffer(this.option.channels, length, this.option.sampleRate)

    for (let channel = 0; channel < this.option.channels; channel++) {
      const audioData = audioBuffer.getChannelData(channel)
      let offset = channel
      let decrement = 50
      for (let i = 0; i < length; i++) {
        audioData[i] = this.samples[offset]
        /* fadein */
        if (i < 50) {
          audioData[i] = (audioData[i] * i) / 50
        }
        /* fadeout*/
        if (i >= (length - 51)) {
          audioData[i] = (audioData[i] * decrement--) / 50
        }
        offset += this.option.channels
      }
    }

    if (this.startTime < this.audioCtx.currentTime) {
      this.startTime = this.audioCtx.currentTime
    }
    // console.log('start vs current ' + this.startTime + ' vs ' + this.audioCtx.currentTime + ' duration: ' + audioBuffer.duration);
    bufferSource.buffer = audioBuffer
    bufferSource.connect(this.gainNode)
    bufferSource.connect(this.analyserNode) // bufferSource连接到analyser
    bufferSource.start(this.startTime)
    this.startTime += audioBuffer.duration
    this.samples = new Float32Array()
  }

  async pause() {
    await this.audioCtx.suspend()
  }

  async continue() {
    await this.audioCtx.resume()
  }

  bindAudioContextEvent() {
    const self = this
    if (typeof self.option.onstatechange === 'function') {
      this.audioCtx.onstatechange = function (event) {
        self.audioCtx && self.option.onstatechange(this, event, self.audioCtx.state)
      }
    }
  }

}

export default PCMPlayer

试了一下果然可以,感谢开源大佬,但是因为我的项目的需要在播放50帧完成后发送一个websocket信息告诉服务端,客户端已经播放完成了50帧,请继续发送。加上需要知道音频是否正在播放,所以我还需要了解一下这个项目的原理,还好大佬写的比较清晰,加上查询资料,基本了解了在web端播放音频的原理。总体的流程基本大纲如下:

image.png

但是如何播放数据帧还需要其他的设计,比如上文的pcm-player就是实现一个缓存接收帧数据然后使用一个定时器定时刷新缓存的数据进行播放,当然也可以有其他设计比如采取递归的方式每次播放完成就在onended回调中检查是否存在缓存数据如果有就再进行一次播放,比如还可以设计当设计接收到多少缓存的数据之后再进行播放,总体来说知道了具体的使用流程,读者可以对上文的player进行魔改实现各种功能。

最终恐惧:小程序如何平滑的播放和切换视频

大体说一下我的悲催历程,因为项目需要频繁的切换视频,在苹果端一直会有一秒左右的黑屏现象。我简单猜了一下感觉是因切换视频时候视频还没有加载出来导致的。顺着这个思路,我就想能不能不切换时候就把播放的视频切换了呢,我想大家应该知道我的意思了,我如果能实现一个画布(canvas)把视频的内容绘制到canvas上不就可以了吗,然后就去小程序的官方文档找能否可以把视频绘制到画布的方案。找了一遍,说实话没有找到。好在我没有死心找到如下内容:

image.png

咱就是说微信官方能不能单独弄了tab 你在示例代码里面真的很难崩呀。打开代码片段看了一下如下

const w = 300
let h = 200

Page({
  data: {
    h,
  },
  onLoad: function () {
    console.log('代码片段是一种迷你、可分享的小程序或小游戏项目,可用于分享小程序和小游戏的开发经验、展示组件和 API 的使用、复现开发问题和 Bug 等。可点击以下链接查看代码片段的详细文档:')
    console.log('https://mp.weixin.qq.com/debug/wxadoc/dev/devtools/devtools.html')
  },
  onReady() {
    wx.showLoading({
      title: '加载中',
    })
    wx.downloadFile({
      url: 'http://wxsnsdy.tc.qq.com/105/20210/snsdyvideodownload?filekey=30280201010421301f0201690402534804102ca905ce620b1241b726bc41dcff44e00204012882540400&bizid=1023&hy=SH&fileparam=302c020101042530230204136ffd93020457e3c4ff02024ef202031e8d7f02030f42400204045a320a0201000400',
      success: (e) => {
        this.setData({src: e.tempFilePath})
      },
      fail(e) {
        console.log('download fail', e)
      },
      complete() {
        wx.hideLoading()
      }
    })
  },
  loadedmetadata(e) {
    h = w / e.detail.width * e.detail.height
    this.setData({
      h,
    }, () => {
      this.draw()
    })
  },
  draw() {
    const dpr = wx.getSystemInfoSync().pixelRatio
    wx.createSelectorQuery().select('#video').context(res => {
      console.log('select video', res)
      const video = this.video = res.context

      wx.createSelectorQuery().selectAll('#cvs1').node(res => {
        console.log('select canvas', res)
        const ctx1 = res[0].node.getContext('2d')
        res[0].node.width = w * dpr
        res[0].node.height = h * dpr

        setInterval(() => {
          ctx1.drawImage(video, 0, 0, w * dpr, h * dpr);
        }, 1000 / 24)
      }).exec()
    }).exec()
  },
})

<view style="text-align: center;">
  <video id="video" autoplay="{{true}}" controls="{{false}}" style="width: 300px; height: 200px;" src="{{src}}" bindloadedmetadata="loadedmetadata"></video>
  <view style="display: inline-block; width: 300px; height: 200px;">
    <canvas id="cvs1" type="2d" style="display: inline-block; border: 1px solid #CCC; width: 300px; height: {{h}}px;"></canvas>
  </view>
</view>

总结一下就是drawImage 是可以传送videoContext 获取当前视频播放的帧 ,然后videoContext可以通过 SelectQuery获取,但是我的场景中要想连贯播放就需要两个video,和一个canvas,结论就是非常卡顿,能明显的感受到。所以这个方案不行 虽然不行但是有一个还是要说一下 这个方案的视频必须缓存到本地才可以播放,所以示例中需要download 。


那除了这个还有办法用canvas绘制视频吗?查看小程序的官方文档找到如下的API,给了我一点希望。

image.png

视频解码器,似乎可以对视频进行解码然后获取到每一帧的数据进一步查看

image.png

这个接口还支持解码在线视频,进一步调研发现这个接口使用的逻辑大概是这个流程 解码成功开始之后每调用一次getFrameData就可以获取到一帧的图片数据,那我需要做的就是把这一帧的数据渲染到画布上应该就可以了,再结合 createImageData的文档可以实现如下代码

   let canvas = null
   let ctx = null
   this.createSelectorQuery()
    .select('#myCanvas')
    .fields({ node: true, size: true })
    .exec(res => {
      canvas = res[0].node
      ctx = canvas.getContext('2d')
      canvas.width = res[0].width * dpr
      canvas.height = res[0].height * dpr
    })

  const decoder = wx.createVideoDecoder()
  
  /*
   * 必须使用2d类型只要2d类型存在createImageData方法 
   *
   */
  const offCanvas = wx.createOffscreenCanvas({
        type: "2d", 
        width: imgWidth,
        height: imgHeight
  })
  const offctx = offCanvas.getContext("2d")
  decoder.start({
    source:"视频地址",
    abortAudio:true //不需要音频数据
  })
  
  
  const loop = ()=>{
    // 获取到当前帧数据
    const frameData = this.decoderManager.getFrameData()
    // 创建ImageData类型数据
    const imageData = offCanvas.createImageData(imgWidth, imgHeight)
    // 把帧数据传入iamgeData中
    imageData.data.set(new Uint8ClampedArray(frameData.data))
    // 把imageData绘制到离屏canvas上
    offctx.putImageData(this.reusableImageData, 0, 0)
    
    
    const canvasRatio = canvas.width / canvas.height
    const imageRatio = imgWidth / imgHeight

    let drawWidth, drawHeight, offsetX = 0, offsetY = 0

    if (canvasRatio > imageRatio) {
          drawWidth = canvas.width
          drawHeight = canvas.width / imageRatio
          offsetY = (canvas.height - drawHeight) / 2
     } else {
          drawHeight = canvas.height
          drawWidth = canvas.height * imageRatio
          offsetX = (canvas.width - drawWidth) / 2
     }
     
    // 
    ctx.drawImage(offCanvas, offsetX, offsetY,drawWidth,drawHeight) 
    // 绘制下一帧
    canvas.requestAnimationFrame(this.loop.bind(this))
  }
  decoder.on("start",()=>{
      loop()
  })
  
  decoder.on("end",()=>{
      // 解码完成 重新回到0点 可以实现循环播放
      decoder.seek(0)
  })

<!--components/avatar-interface/avatar-sdk/avatar-decoder-canvas/avatar-decoder-canvas.wxml-->
<canvas type="2d" id="myCanvas" class="canvas-video"></canvas>

这是实现绘制解码数据的简单逻辑,因为需要适配不同的手机所以需要一个离屏canvas先把图片绘制出来然后通过计算再绘制到真正的canvas上,然后可以通过解码完成后把播放时间拨回0实现循环播放,另外createImageData只有web端的RendererContext存在该方法 所以canvas和离屏canvas都只能使用2d的。


但是不幸上面这个方案存在掉帧严重的问题,虽然这两个方案都没有切换的问题但是都有新的问题,真的就没有办法了吗?是的,我又要看文档了,重新整理了一下思路,或许不是视频的问题呢,现在我所有的问题个人感觉都是web端的性能太差导致的,或者因为web端的技术负债导致的,如果可以不使用web技术渲染是不是就可以了?是的,对于小程序开发者我们还有skyline呀。

我参考 webview迁移的文档把当前页面切换使用skyline 是的根本不要什么绘制两个视频(双缓存),也不需要绘制两个canvas 就可以实现简单流畅的视频切换 但是不能所有的界面都使用skyline 我们只需要进行视频切换的页面json配置文件加上这个

{
  "usingComponents": {

  },
  "renderer": "skyline",
  "rendererOptions": {
    "skyline": {
      "disableABTest": true,
      "sdkVersionBegin": "3.0.1", 
      "sdkVersionEnd": "15.255.255"
    }
  },
  "componentFramework": "glass-easel"
}

就可以单独指定某个页面任何版本都使用skyline渲染引擎了

AI总结

一、录音功能的实现

  • 面临的问题:需要通过WebSocket发送960字节的PCM帧数据,但小程序的录音API不支持直接设置如此精确的frameSize。
  • 解决方案:自行将录音数据切分为960字节的小块进行发送,并在回调函数中处理数据分片和缓存。

二、播放语音流的探索

  • 初步尝试:利用小程序的Web Audio API结合开源的PCM播放器库实现PCM帧数据的播放。
  • 深入研究:理解音频播放原理后,对PCM播放器进行改造,以满足项目需求,如播放进度反馈和播放状态监控。

三、视频平滑切换的难题与突破

  • 遇到的挑战:频繁切换视频时出现黑屏现象,影响用户体验。
  • 尝试的方案:
    • 使用Canvas绘制视频帧,但由于性能问题未能实现流畅切换。
    • 探索使用VideoDecoder API解码视频并绘制到Canvas,虽有所改善但仍存在掉帧问题。
  • 最终解决方案:采用Skyline渲染引擎,通过修改页面配置实现流畅的视频切换,避免了复杂的绘制逻辑和性能瓶颈

Vue:渐进式JavaScript框架

作者 前端微白
2025年7月1日 17:20

作为一名现代前端开发者,我将Vue.js视为一种开发理念的革新。Vue是由尤雨溪(Evan You)在2014年创建的开源框架,如今已成为三大主流前端框架之一,被支付宝、GitLab、小米等知名公司广泛采用。

核心设计哲学

1. 渐进式框架

Vue的核心魅力在于其渐进式设计,这意味着你可以根据项目需求灵活选用其功能。Vue可以无缝集成到现有项目中,也可以构建完整的单页面应用。

graph LR
    A[核心库] --> B[视图层渲染]
    A --> C[组件系统]
    C --> D[客户端路由]
    C --> E[状态管理]
    E --> F[构建工具链]

2. 响应式数据绑定

Vue通过Object.defineProperty(Vue 2)或Proxy(Vue 3)实现了精妙的数据响应系统:

// Vue 3响应式示例
const app = Vue.createApp({
  setup() {
    const count = Vue.ref(0);
    
    const increment = () => {
      count.value++;
    }
    
    return { count, increment }
  }
});

app.mount('#app');

3. 组件化架构

Vue的单文件组件(SFC)将HTML、CSS和JavaScript封装在一个文件中:

<!-- ExampleComponent.vue -->
<template>
  <div class="card">
    <h2>{{ title }}</h2>
    <p>{{ content }}</p>
    <button @click="handleClick">点击</button>
  </div>
</template>

<script>
export default {
  props: {
    title: String,
    content: String
  },
  methods: {
    handleClick() {
      this.$emit('action', { action: 'clicked' });
    }
  }
}
</script>

<style scoped>
.card {
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  padding: 20px;
}
</style>

Vue核心技术特性深度解析

1. 虚拟DOM与高效渲染

Vue的虚拟DOM实现通过智能的diff算法最小化DOM操作:

graph LR
    A[数据变化] --> B[生成新VNode]
    B --> C[与旧VNode进行Diff]
    C --> D[计算最小更新操作]
    D --> E[应用变更到真实DOM]

Vue 3的优化措施:

  • 编译时优化:静态节点提升
  • 区块树:减少动态节点遍历
  • 缓存事件处理:减少不必要更新

2. 组合式API (Vue 3)

Vue 3引入的组合式API解决了大型项目中逻辑复用和组织问题:

import { ref, onMounted, computed } from 'vue';

export default function useUserData(userId) {
  const user = ref(null);
  const loading = ref(false);
  
  const fetchUser = async () => {
    loading.value = true;
    try {
      user.value = await fetch(`/api/users/${userId}`).then(r => r.json());
    } catch (e) {
      console.error('加载失败', e);
    } finally {
      loading.value = false;
    }
  };
  
  const isAdmin = computed(() => {
    return user.value?.role === 'admin';
  });
  
  onMounted(fetchUser);
  
  return {
    user,
    loading,
    isAdmin,
    refetch: fetchUser
  };
}

3. 生态系统与工具链

Vue的完整技术栈:

  1. 路由:Vue Router
  2. 状态管理:Vuex (Vue 2) / Pinia (Vue 3)
  3. 构建工具:Vite / Webpack
  4. UI框架:Element Plus, Vuetify, Quasar
  5. 测试工具:Vitest, Vue Test Utils
graph TD
    Vue[Vue核心] --- Router[Vue路由]
    Vue --- State[状态管理]
    Vue --- Build[构建工具]
    Vue --- UI[UI库]
    Vue --- Test[测试工具]
    
    State --> Vuex
    State --> Pinia
    Build --> Vite
    Build --> Webpack
    UI --> Element
    UI --> Vuetify
    UI --> Quasar
    Test --> Vitest
    Test --> Jest

Vue实战应用场景

场景1:企业级管理后台

<!-- AdminDashboard.vue -->
<template>
  <div class="dashboard">
    <Sidebar />
    <div class="main">
      <Header />
      <router-view />
    </div>
    <NotificationCenter />
  </div>
</template>

<script>
import Sidebar from './Sidebar.vue';
import Header from './Header.vue';
import NotificationCenter from './NotificationCenter.vue';

export default {
  components: { Sidebar, Header, NotificationCenter }
}
</script>

场景2:交互式数据可视化

// DataVisualization.vue
import * as d3 from 'd3';
import { onMounted, ref, watch } from 'vue';

export default {
  props: ['dataset'],
  setup(props) {
    const chartRef = ref(null);
    
    const renderChart = () => {
      if (!chartRef.value || !props.dataset) return;
      
      d3.select(chartRef.value).selectAll("*").remove();
      
      // 使用D3创建复杂的数据可视化
      // ...
    };
    
    watch(() => props.dataset, renderChart);
    onMounted(renderChart);
    
    return { chartRef };
  }
}

Vue 2 vs Vue 3:演进之路

特性 Vue 2 Vue 3
架构 Options API Composition API
响应式 Object.defineProperty Proxy
性能 良好 更优(包体积小40%)
TypeScript 一般支持 原生支持
片段 不支持 支持多根节点
生命周期 传统钩子 setup + 新钩子
全局API Vue.xxx createApp实例
graph LR
    A[Vue 2] -->|Options API| B[简单易用]
    A -->|Object.defineProperty| C[有限响应式]
    D[Vue 3] -->|Composition API| E[逻辑复用]
    D -->|Proxy| F[完整响应式]
    D -->|Vite| G[极速构建]

Vue开发经验总结

1. 状态管理选择

  • 小型应用:使用组件内状态
  • 中型应用:Vue的provide/inject
  • 大型应用:Pinia(Vue3首选)或Vuex
// Pinia示例
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({ user: null }),
  actions: {
    async login(credentials) {
      this.user = await authService.login(credentials);
    }
  },
  getters: {
    isAuthenticated: state => !!state.user
  }
});

2. 性能优化策略

  • 组件懒加载
const Login = () => import('./Login.vue');
  • 虚拟滚动长列表
  • v-once静态内容
  • Keep-alive缓存组件

3. 代码组织准则

src/
├── assets/
├── components/
│   ├── ui/           # 通用UI组件
│   └── features/     # 功能组件
├── composables/      # 组合式函数
├── stores/           # 状态管理
├── router/           # 路由配置
├── services/         # API服务层
└── views/            # 页面级组件

Vue开发的心得体会

  1. 学习曲线平缓:从HTML/CSS/JS基础到构建完整应用,Vue的学习路径极为平滑

  2. 文档质量卓越:中文文档完整详尽,降低入门门槛

  3. 社区生态丰富:从问题解决到插件扩展,社区支持强大

  4. 灵活性与约束平衡:提供强大功能同时不强制代码风格

  5. 性能出色:在真实项目中表现优异的运行时性能

pie
    title Vue开发体验优势
    "优雅API设计" : 30
    "开发体验流畅" : 25
    "社区支持" : 20
    "性能表现" : 15
    "灵活性" : 10

Vue的发展方向

  1. Vite成为标准构建工具:取代Webpack作为Vue生态的默认构建方案
  2. TypeScript深度集成:更完善的类型检查与推导
  3. 微前端架构支持:增强Vue在微前端领域的解决方案
  4. Server Components探索:服务端组件支持
  5. 响应性优化进化:更细粒度的依赖跟踪与更新

小结

Vue在现代前端开发中的独特优势在于:

"足够灵活的渐进增强策略,既能让新手轻松上手,又能让专业人士构建复杂应用"

无论是小型内容网站还是企业级复杂应用,Vue都能提供优雅高效的解决方案。其精心设计的API、优秀的性能表现和活跃的社区生态,使我作为开发者能够专注于业务逻辑而非框架细节。

CSS伪元素实战指南:让你的页面交互更优雅

2025年7月1日 17:15

前言

在前端开发中,CSS伪元素是一个强大而优雅的工具,它能让我们在不增加HTML标签的情况下实现丰富的视觉效果。本文将通过实际案例,带你深入理解伪元素的核心概念和实战应用。

什么是CSS伪元素?

CSS伪元素(Pseudo-elements)是一种特殊的选择器,它允许我们选择元素的特定部分并为其添加样式。最常用的伪元素包括 ::before::after,它们分别在元素内容的开始之前和结束之后插入内容。

伪元素的核心特点:

  • 不需要在HTML中声明额外的标签
  • 可以像真实的DOM元素一样参与文档流
  • 完全依赖CSS实现,具有良好的可维护性
  • content 属性是必需的,通常设置为空字符串

实战案例一:打造优雅的悬停下划线效果

让我们从一个实际的导航链接效果开始。传统做法可能需要额外的span标签来实现下划线动画,但使用伪元素可以让代码更加简洁:

<div class="container"> <h1>这是一个标题</h1>
<p>这是一个段落 这是一个段落 这是一个段落 这是一个段落</p> 
<a href="#" class="more">查看更多</a> </div>
.container .more {
  display: inline-block;
  background-color: #007bff;
  color: #fff;
  text-decoration: none;
  position: relative;
  transition: all 0.3s ease;
}

.container .more::before {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 2px;
  background-color: #f00;
  transform: scaleX(0);
  transform-origin: bottom right;
  transition: transform 0.3s ease;
}

.container .more:hover::before {
  transform: scaleX(1);
  transform-origin: bottom left;
}

这个效果的精妙之处在于:

  1. 初始状态:下划线通过 scaleX(0) 完全隐藏
  2. 变换原点transform-origin: bottom right 确保动画从右侧开始
  3. 悬停效果:改变原点为左侧,创造出从右到左的流畅动画

image.png

鼠标悬停--->

image.png

实战案例二:创建纯CSS箭头图标

在很多UI场景中,我们需要向右的箭头来表示"查看更多"或"进入下一步"。使用伪元素可以轻松实现:

.box {
  height: 100px;
  background-color: rgb(192, 238, 112);
  padding: 0 10px;
  position: relative;
}

.box::before {
  content: "";
  position: absolute;
  width: 10px;
  height: 10px;
  right: 10px;
  top: 45px;
  border: 1px solid #000;
  border-left-color: transparent;
  border-bottom-color: transparent;
  transform: rotate(45deg);
}

技术解析:

  • 创建一个10x10像素的正方形
  • 通过设置左边框和下边框为透明,只保留右边框和上边框
  • 45度旋转后形成完美的向右箭头
  • 使用绝对定位精确控制箭头位置

image.png

Stylus:让CSS更像编程

Stylus是一个CSS预处理器,它让CSS编写变得更加简洁和模块化。看看同样的效果用Stylus如何实现:

.container
  text-align center
  min-width 600px
  margin 0 auto
  padding 20px
  font-family Arial, sans-serif
  
  h1
    text-align center
  
  p
    line-height 1.6
  
  .more 
    display inline-block
    background-color #007bff
    color white
    text-decoration none
    position relative
    transition all .3s ease

    &::before
      content ""
      position absolute
      left 0
      bottom 0
      width 100%
      height 2px
      background-color red
      transform scaleX(0)
      transform-origin bottom right
      transition transform .3s ease
      
    &:hover::before
      transform scaleX(1)
      transform-origin bottom left

Stylus的优势:

  • 省略分号和大括号,代码更简洁
  • 支持嵌套语法,结构更清晰
  • 使用 & 符号引用父选择器
  • 支持变量、函数等编程特性

伪元素的最佳实践

1. 语义化考虑

伪元素适合纯装饰性内容,如图标、装饰线条等。避免用于承载重要信息的内容。

2. 性能优化

/* 推荐:使用transform进行动画 */
.element::before {
  transform: scaleX(0);
  transition: transform 0.3s ease;
}

/* 避免:频繁改变layout属性 */
.element::before {
  width: 0;
  transition: width 0.3s ease;
}

3. 浏览器兼容性

现代浏览器对 ::before::after 支持良好,但注意双冒号语法(推荐)vs单冒号语法(旧版本)的区别。

实际应用场景

  1. 导航装饰:悬停效果、下划线动画
  2. 图标系统:箭头、关闭按钮、装饰图形
  3. 内容增强:引号、序号、分隔符
  4. 布局辅助:清除浮动、创建几何形状

总结

CSS伪元素是前端开发者工具箱中的瑞士军刀,它能在保持HTML结构简洁的同时实现丰富的视觉效果。结合Stylus等预处理器,我们可以写出更加优雅和可维护的样式代码。

关键要点回顾:

  • 伪元素不会增加DOM节点,性能友好
  • content 属性是使用伪元素的必要条件
  • 适合实现装饰性和交互性效果
  • 结合CSS动画可以创造出色的用户体验

掌握伪元素的使用技巧,将让你的前端开发技能更上一层楼。在下一个项目中,不妨尝试用伪元素替换一些不必要的HTML标签,体验代码简洁带来的快感!

🧱 优雅封装 Axios 请求:从错误处理到统一响应

作者 梦语花
2025年7月1日 17:10

在前端开发中,我们经常需要与后端 API 进行交互。为了提高代码的可维护性、减少重复逻辑,并提升开发效率,对 axios 的请求进行合理的封装是非常有必要的。

本文将带你一步步了解如何通过一个通用的 to 函数,优雅地封装 axios 请求,实现:

  • 统一处理业务异常;
  • 简化异步调用流程;
  • 避免层层嵌套的 if (res.code !== 0) 判断;
  • 提高代码可读性和复用性。

🧩 背景问题:传统写法的痛点

假设你有一个接口调用如下:

js
深色版本
let res = await getMerchantOrder(data);
if (res.code != 0) {
  showToast(res.msg);
  return false;
}

这种写法虽然功能正常,但存在几个明显的问题:

  1. 每个请求都需要手动判断 res.code
  2. 错误提示分散,不易统一管理
  3. 返回值结构不一致,不利于后续处理
  4. 难以集中处理网络层和业务层的错误

这会导致你的业务代码中充斥大量“防御性判断”,严重影响可读性。


✨ 解决方案:使用 to 函数统一处理请求结果

我们可以创建一个名为 to 的辅助函数,用于包装任何基于 Promise 的请求(如 axios),并返回一个标准格式的 [error, data] 结构。

🔨 实现代码如下:

ts
深色版本
/**
 * 将 Promise 包装成 [error, data] 形式,简化异步操作错误处理
 * @param {Promise} promise - 需要包装的 Promise 对象
 * @param {*} errorExt - 可选,附加的错误信息对象
 * @returns {Array} [error, data]
 */
export const to = async (promise, errorExt) => {
  try {
    const res = await promise;

    // 如果业务状态码不是成功状态(例如 code !== 0)
    if (res?.code !== 0) {
      const errorMessage = res?.msg ?? '获取数据失败';
      showToast(errorMessage);
      return [new Error(errorMessage), null];
    }

    // 成功时返回 null 错误 + 数据
    return [null, res.data];

  } catch (err) {
    // 捕获异常并返回 [error, null]
    let parsedError = err;

    if (errorExt) {
      parsedError = Object.assign({}, err, errorExt);
    }

    return [parsedError, null];
  }
};

💡 注意:这里的 showToast 是你项目中已有的 UI 提示方法,比如 uni.showToastElMessage 或自定义 Toast 工具。


📦 使用方式:简洁又直观

现在你可以这样使用:

ts
深色版本
const [err, data] = await to(getMerchantOrder(data));

if (err) {
  console.error('请求失败:', err.message);
  return;
}

// 正常处理 data
console.log('订单数据:', data);

✅ 优势总结:

特性 描述
✅ 统一错误处理 所有错误都在 err 中返回
✅ 清晰结构 返回 [err, data],无需 try/catch 嵌套
✅ 减少冗余 不再需要 if (res.code !== 0) 处理
✅ 可扩展性强 支持自定义错误信息注入

🧪 示例场景对比

❌ 原始写法:

js
深色版本
async function fetchOrder() {
  try {
    const res = await getMerchantOrder(data);
    if (res.code !== 0) {
      showToast(res.msg);
      return;
    }
    // do something with res.data
  } catch (err) {
    showToast('网络异常');
  }
}

✅ 使用 to 后:

js
深色版本
async function fetchOrder() {
  const [err, data] = await to(getMerchantOrder(data));

  if (err) {
    // 统一错误提示 + 日志记录
    console.error('请求出错', err.message);
    return;
  }

  // 直接处理 data
  console.log('订单数据:', data);
}

是不是更加清爽了?


🧰 高级用法:结合 TypeScript 更加安全

如果你使用的是 TypeScript,可以为 to 添加类型支持:

ts
深色版本
type ResponseData<T> = {
  code: number;
  msg?: string;
  data?: T;
};

export async function to<T>(promise: Promise<ResponseData<T>>, errorExt?: any): Promise<[Error | null, T | null]> {
  try {
    const res = await promise;

    if (res.code !== 0) {
      const msg = res.msg || '获取数据失败';
      showToast(msg);
      return [new Error(msg), null];
    }

    return [null, res.data as T];
  } catch (err) {
    const error = errorExt ? Object.assign({}, err, errorExt) : err;
    return [error, null];
  }
}

这样在 IDE 中就可以获得完整的类型提示和自动补全支持!


🧠 总结:让请求更优雅、更可控

通过对 Axios 请求的封装,我们实现了:

  • 统一的错误处理机制
  • 标准化的数据结构返回
  • 更清晰的业务代码逻辑
  • 更强的可维护性和可测试性

无论你是开发小程序、Web 应用,还是构建中后台系统,这样的封装都能极大提升开发体验和代码质量。


📚 扩展建议

如果你希望进一步增强这个封装工具,还可以考虑加入以下功能:

  • 自动重试机制(如网络失败时 retry);
  • 请求拦截器/响应拦截器;
  • 全局错误上报;
  • 接口 Mock 支持;
  • 请求缓存策略;
  • 支持取消请求(AbortController);

📌 最后提醒:
不要把所有请求都写得“千篇一律”,封装是手段,统一和规范才是目的


如需我帮你生成一个完整的 Axios 封装模块(包含拦截器、TypeScript 支持、Mock 等),欢迎继续提问 👍

你可以将这篇文章发布到:

  • 掘金 / CSDN / 博客园 / 知乎 / 微信公众号 / Notion / 内部 Wiki
  • 或者作为团队编码规范的一部分共享给同事

Augment code + Figma MCP,一键生成前端代码。

image.png Augment code是一款 AI 驱动的编程工具,基于 Anthropic 的 Claude Sonnet 4 模型构建,支持高达20万token的上下文窗口,能够深入理解大型项目的完整架构。这款工具在 SWE-bench Verified 基准测试中以 65.4% 的成绩排名第一,展现了其在复杂代码处理上的强大能力。 谈到Augment code就很难不说说cursor,问我为什么用Augment code,因为cursor免费额度用完了,期间使用了字节的trae,最后感觉还是Augment code更好用,好了废话不多说直接开始教学!

一、vscode中安装Augment code插件

image.png

二、注册登录

点击登录,跳转网页注册,刚注册完,会免费发放300次问答机会。如果想要更多,点击Team,邀请别人注册,邀请成功一个获得300个问答机会,如果有qq邮箱,那么可以进入账户安全,申请一个英文邮箱,一个foxmail邮箱,自己就可以给自己发邀请,相当于可以获取900个问答机会。

image.png

image.png

image.png

三、登录figma官网生成token

figma.com 登录账号,点击我的头像下拉 -> settings -> security -> 下拉找到 Generate new token,弹窗中输入token名称,如果scopes里面内容不知道啥意思就直接拉满,给最大权限,复制生成的token。

image.png

image.png

image.png

四、在Augment code中配置mcp

点击右上角菜单图标 -> settings -> Tools -> Import form JSON 粘贴如下配置,配置成功如下图,显示绿色原点,Figma MCP。

{
  "mcpServers": {
    "Framelink Figma MCP": {
      "command": "npx",
      "args": [
        "-y",
        "figma-developer-mcp",
        "--figma-api-key=你的figma token",
        "--stdio"
      ]
    }
  }
}

image.png

五、Augment code解析Figma 链接

打开figma,找到需要生成的设计文档。

1. 一定要选择preview

image.png

2. share 选择 Anyone,然后copy link。

image.png

3. 复制链接到Augment code 对话框。

image.png

4. 开始解析

image.png

vue3 如何做数据埋点

作者 winter_BI
2025年7月1日 15:28

前言

一般情况下,我们需要收集前端的异常,用于分析系统的各个方面的指标。以下我将分享我在工作中做数据埋点的简单思路。

功能

  • 路由跳转和停留时间记录。
  • 页面操作错误记录。
  • 系统全局异常捕获。

思路

第一部分

定义一个Hooks,方便收集系统中的一些参数,例如用户名之类的一般存储在仓库中,所以用Hooks比较方便。

export const useDataRecord = () => {
    // 相关的逻辑和方法
    // todo
}

第二部分

在函数中定义一个BaseClass基类,用于封装通用方法和统一管理系统参数。

export const useDataRecord = () => {
    class BaseClass {
        protected userInfo = computed(() => {
            return {
                userName: '从仓库获取',
                userId: '从仓库获取',
                // ...
            }
        })

        // 获取路由方法
        // todo

        // 数据发送后台方法
        // todo

        // 其他
        // todo
    }
}

第三部分

这部分我们就可以实现具体的业务,例如全局的错误捕获。

export const useDataRecord = () => {
    class BaseClass {
        protected userInfo = computed(() => {
            return {
                userName: '从仓库获取',
                userId: '从仓库获取',
                // ...
            }
        })

        // 获取路由方法
        // todo

        // 数据发送后台方法
        sendData(data: any) {
            // todo
        }

        // 其他
        // todo
    }

    class ErrorClass extends BaseClass {
        /**
         * 记录系统错误事件
         * @param errorMessage 错误信息
         * @param sourceEvent 来源事件
         */
        record(errorMessage: string, sourceEvent: string) {
            const params = {
                errorMessage: errorMessage, // 错误信息
                sourceEvent: sourceEvent, // 来源事件,例如:api、page、error等等
                recordTime: moment().format('YYYY-MM-DD HH:mm:ss'), // 记录时间
            }
            // 发送数据
            this.sendData(params)
        }
    }
    
    return {
        ErrorClass
    }
}

第四部分

这部分就是如何使用我们的数据埋点的方法,我们的使用应该支持在vue文件中使用,也可以在ts中使用。例如在vue文件中使用主要针对页面的记录,在ts文件中,比如路由router.ts中记录路由切换。介绍一下在页面中如何使用:

首先在App.vue文件中,我们可以使用provide/inject配合来使用。

const dataTrack: DataTrackType = useDataTrack(true);
// setup js 中使用:const dataTrack = inject('dataTrack');
// const dataTrack: DataTrackType = inject('dataTrack')!;
provide('dataTrack', dataTrack);

DataTrackType 可以在全局定义类型,也可以单独引入。例如可以在global.d.ts中定义:

declare global {
  type DataTrackType = ReturnType<typeof useDataTrack>;
}

之后在页面中我们使用,例如在404.vue页面中可以这样使用:

const dataTrack: DataTrackType = inject('dataTrack')!;
const errorClass = new dataTrack.ErrorClass();

// ...

errorClass.record('抱歉,您访问的页面不存在或无相关权限', '404');

第五部分

同样的,其他记录api或者全局vue错误捕获同样的道理,我们需要创建不同的class即可使用通用的方法,更复杂的记录需要自己添加进方法里面,有时候前端使用class能解决一些比较复杂的问题。

结语

这套思路能够满足大部分的需求场景,希望大家可以一起来交流。

❌
❌