普通视图

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

10分钟复刻爆火「死了么」App:vibe coding 实战(Expo+Supabase+MCP)

作者 mCell
2026年1月15日 02:38

视频链接:10分钟复刻爆火「死了么」App:vibe coding 实战

仓库地址:github.com/minorcell/s…

202602

最近“死了么”App 突然爆火:内容极简——签到 + 把紧急联系人邮箱填进去。 它的产品形态很轻,但闭环很完整: 你每天打卡即可;如果你连续两天没打,系统就给紧急联系人发邮件。

恰好我最近在做 Supabase 相关调研,就顺手把它当成一次“极限验证”:

  • 我想看看:Expo + Supabase 能不能把后端彻底“抹掉”
  • 我也想看看:Codex + MCP 能不能把“建表 / 配置 / 写代码”这整套流程进一步压缩
  • 以及:vibe coding 到底能不能真的做到:跑起来、能用、闭环通

结论是:能。并且我录了全过程,从建仓库到 App 跑起来能用,全程 10 分钟

我复刻的目标:只保留“核心闭环”

我没打算做一个完整产品,只做最小闭环:

  1. 用户注册 / 登录(邮箱 + 密码 + 邮箱验证码)
  2. 首页打卡:每天只能打一次,展示“连续打卡 xx 天”
  3. 我的:查看打卡记录 / 连续天数
  4. 紧急联系人:设置一个邮箱
  5. 连续两天没打卡就发邮件(定时任务 + 邮件发送)

页面风格:简约、有活力(但不追求 UI 细节)。

技术栈:把“后端”交给 Supabase,把“体力活”交给 Agent

  • 前端:React Native + Expo(TypeScript)
  • 后端:Supabase(Auth + Postgres + RLS)
  • 自动化:Supabase Cron + Edge Functions Supabase 的定时任务本质是 pg_cron,可以跑 SQL / 调函数 / 发 HTTP 请求(包括调用 Edge Function)。(Supabase)
  • Agent:Codex(通过 Supabase MCP 直接连 Supabase) Supabase 官方有 MCP 指南,并且强调了安全最佳实践(比如 scope、权限、避免误操作)。(Supabase)

我整个过程的体验是:

以前你要在“前端 / SQL / 控制台 / 文档”之间来回切。 现在你只需要把需求写清楚,然后盯着它干活,偶尔接管一下关键配置。

两天没打卡发邮件:用 Cron + Edge Function,把事情做完

这是这个 App 最关键的“闭环”。

方案:每天跑一次定时任务

  • Cron:每天固定时间跑(比如 UTC 00:10)
  • 任务内容:找出“已经两天没打卡”的用户
  • 动作:调用 Edge Function 发邮件

Supabase 官方文档推荐的组合是:pg_cron + pg_net,定时调用 Edge Functions。(Supabase)

你也可以不调用 Edge Function,直接让 Cron 发 HTTP webhook 给你自己的服务。 但既然目标是“不写后端”,那就让 Edge Function 处理就行。

Edge Function:负责“发邮件”

注意:Supabase Auth 的邮件(验证码)是它自己的系统邮件; 你要给紧急联系人发提醒,通常需要接第三方邮件服务(Resend / SendGrid / Mailgun / SES 之类)。

Supabase 文档里也提到:定时调用函数时,敏感 token 建议放到 Supabase Vault 里。(Supabase)

Edge Function(伪代码示意):

// 1) 查数据库:哪些人超过 2 天没打卡
// 2) 取紧急联系人邮箱
// 3) 调用邮件服务 API 发送提醒

Cron 每天跑一次就够了: 这个产品的语义不是“立刻报警”,而是“连续两天都没动静”。

MCP + Codex:我觉得最爽的地方

如果你只看结果,你会觉得“这不就是一个 CRUD App 吗”。

但我觉得真正有意思的是过程:

  • 它不仅写前端代码
  • 它还能“像个人一样”去把 Supabase 后台的事情做掉:建表、加约束、开 RLS、写策略、甚至提示你哪里要手动补配置

而 Supabase MCP 的官方定位,就是让模型通过标准化工具安全地操作你的 Supabase 项目(并且强调先读安全最佳实践)。(Supabase)

我这次几乎没写代码,最大的精力消耗其实是两件事:

  1. 把提示词写清楚(尤其是“规则”和“边界条件”)
  2. 对关键点做人工复核(RLS、唯一约束、邮件配置)

我现在会怎么写提示词

我发现 vibe coding 成功率最高的提示词,不insane,反而“啰嗦”:

  • 先写“模块和流程”
  • 再写“数据约束”(每天只能一次、断档怎么处理)
  • 再写“安全策略”(RLS 怎么开)
  • 最后写“验收标准”(做到什么算跑通)

你给得越具体,它越像一个靠谱同事; 你给得越模糊,它越容易“自作主张”。

附录

我这次用的提示词(原文)

需求:使用expo和supabase开发一个移动端APP: 死了么

## 功能:

### 用户注册:

1. 描述:在app进入页面,用户需要输入邮箱和密码以及确认密码,进行注册。
2. 流程:
   - 使用supabase的auth进行校验,发送验证码注册邮箱到用户邮箱,用户需要在页面输入邮箱中的验证码。
   - 注册成功之后即可进入app首页

### 首页打卡:

1. 描述:用户进入首页,只有一个大大的打卡功能;“今日活着”,点击即可完成打卡功能
2. 流程:
   - supabase需要记录用户的打卡信息
   - 打开成功时,提示用户已经“你已连续打卡xx日,又活了一天”

### “我的”

1. 用户可以在“我的”页面查看自己的打卡记录,连续打卡时间
2. 用户可以设置紧急联系人,当检测到用户连续两天没有打卡时,会发送一封紧急联系的邮件到紧急联系人邮箱

## 其他:

1. 用户每天只能打卡一次
2. 页面简约、有活力

> 你可以使用supabase的mcp进行所有的操作,
昨天 — 2026年1月14日掘金 前端

3D字体TextGeometry

作者 烛阴
2026年1月14日 22:42

1. 引入字体加载器

由于 TextGeometry 不是核心库的一部分,我们需要单独引入:

import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js';
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js';

2. 加载 JSON 字体并创建

Three.js 不直接读取 .ttf,它读取的是专用的 JSON 字体文件。

import * as THREE from 'three';
import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js';
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js';
import local_font from '../../assets/font/helvetiker_bold.typeface.json';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x222222); // 深灰色背景

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 5;

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const loader = new FontLoader();
console.log('开始加载字体...');

let font = loader.parse(local_font);
const textGeometry = new TextGeometry('Hello Three.js!', {
    font: font,
    size: 1,
    depth: 0.5,
    curveSegments: 12,
    bevelEnabled: false,
});

textGeometry.center();

const textMaterial = new THREE.MeshNormalMaterial();
let textMesh = new THREE.Mesh(textGeometry, textMaterial);
textMesh.position.set(0, 0, 0); // 确保在原点
scene.add(textMesh);
console.log('文字网格已添加到场景');

function animate() {
    requestAnimationFrame(animate);

    if (textMesh) {
        textMesh.rotation.y += 0.01;
    }

    renderer.render(scene, camera);
}
animate();
console.log('动画循环已启动');

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

总结

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

markstream-vue实战踩坑笔记

作者 桜吹雪
2026年1月14日 22:21

最近新开始做了一个ai对话的项目,打算重构一下老项目的markdown渲染和交互。老项目使用marked对md进行渲染成html,然后使用v-html渲染,搞到后面发现太多技术债了,所以打算换一个实现方法。经过一番对比,发现 markstream-vue 的star是最多的,去看了一下官方文档发现特性也很多。所以这里决定就用这个组件了。

需求说明

这里实现的一个项目是ai对话的应用,特点是可能markdown里要混着各种业务组件,以及一些md的节点可以点击交互,比如列表项或者链接点击代替用户自动发送信息。

安装使用

安装就不多浪费口水了,官方文档写的非常详细,我们先看看案例markdown,以及使用库提供的markdown解析成节点:

import { getMarkdown, MarkdownRender, parseMarkdownToStructure } from 'markstream-vue'

const md = `
<thinking>
这是推理内容
</thinking>

请选择办理方式
- 线上办理
- 线下办理

<a data-send="其他办理方式">其他办理方式</a>
`
console.log(JSON.stringify(parseMarkdownToStructure(md, getMarkdown()), null, 2))

image.png

对list_item和link进行自定义

如果只是渲染上面的md,很简单:

<template>
  <MarkdownRender :content="md" />
</template>

但是我需要实现对list_item和link进行自定义渲染,那就需要使用到 setCustomComponents 以及对renderer设置custom-id

参考官方的思考block渲染组件:github.com/Simon-He95/…

我们先试试thinking的渲染:

<script setup lang="ts">
import { MarkdownRender, setCustomComponents } from 'markstream-vue'
import ThinkingNode from './ThinkingNode.vue'
import 'markstream-vue/index.css'

const md = `
<thinking>
这是推理内容
</thinking>


请选择办理方式
- 线上办理
- 线下办理

<a data-send="其他办理方式">其他办理方式</a>
`
setCustomComponents('doc', {
  thinking: ThinkingNode,
})
</script>

<template>
  <MarkdownRender custom-id="doc" :content="md" :custom-html-tags="['thinking']" />
</template>

image.png

没啥问题,接下来对link节点自定义,我们只需要直接去官方仓库copy出来改就好了

github.com/Simon-He95/…

因为我们不需要花里胡哨的特效,这里对源代码进行删减,并且对点击事件进行调整

// LinkNode.vue
<script setup lang="ts">
import { HtmlInlineNode, ImageNode, StrikethroughNode, StrongNode, TextNode } from 'markstream-vue'
import { computed, useAttrs } from 'vue'
import { extractAnchorAttributes } from './utils.ts'

export interface LinkNodeNode {
  type: 'link'
  href: string
  title: string | null
  text: string
  children: { type: string, raw: string }[]
  raw: string
  loading?: boolean
}

// 定义链接节点
export interface LinkNodeProps {
  node: LinkNodeNode
  indexKey?: number | string
  customId?: string
  showTooltip?: boolean
  color?: string
}

// 接收props — 把动画/颜色相关配置暴露为props,并通过CSS变量注入样式
const props = withDefaults(defineProps<LinkNodeProps>(), {
  showTooltip: true,
})

const emits = defineEmits<{
  send: [text: string]
}>()

const cssVars = computed(() => {
  return {
    '--link-color': props.color ?? '#0366d6',
  } as Record<string, string>
})

// Available node components for child rendering
const nodeComponents = {
  text: TextNode,
  strong: StrongNode,
  strikethrough: StrikethroughNode,
  image: ImageNode,
  html_inline: HtmlInlineNode,
}

// forward any non-prop attributes (e.g. custom-id) to the rendered element
const attrs = useAttrs()

const linkAttrs = computed(() => extractAnchorAttributes(props.node.raw))

const linkHref = computed(() => {
  const href = props.node.href
  if (href?.startsWith('javascript:') || !href) {
    // 如果 href 以 javascript: 开头,一律返回 void(0) 防止执行恶意代码
    return 'javascript:void(0)'
  }
  return href
})

const linkTarget = computed(() => {
  if (linkAttrs.value?.target) {
    return linkAttrs.value?.target
  }
  if (!props.node.href) {
    return '_self'
  }
  return '_blank'
})

const title = computed(() => String(props.node.title ?? props.node.href ?? ''))

function onClick() {
  if (linkAttrs.value?.['data-send']) {
    emits('send', linkAttrs.value?.['data-send'])
  }
}
</script>

<template>
  <a
    v-if="!node.loading"
    class="link-node"
    :href="linkHref"
    :target="linkTarget"
    rel="noopener noreferrer"
    :title="showTooltip ? '' : title"
    :aria-label="`Link: ${title}`"
    :aria-hidden="node.loading ? 'true' : 'false'"
    v-bind="attrs"
    :style="cssVars"
    @click="onClick"
  >
    <component
      :is="nodeComponents[child.type]"
      v-for="(child, index) in node.children"
      :key="`${indexKey || 'emphasis'}-${index}`"
      :node="child"
      :custom-id="props.customId"
      :index-key="`${indexKey || 'link-text'}-${index}`"
    />
  </a>
  <span v-else class="link-loading inline-flex items-baseline gap-1.5" :aria-hidden="!node.loading ? 'true' : 'false'" v-bind="attrs" :style="cssVars">
    <span class="link-text-wrapper relative inline-flex">
      <span class="link-text leading-[normal]">
        <span class="link-text leading-[normal]">{{ node.text }}</span>
      </span>
    </span>
  </span>
</template>

<style scoped>
.link-node {
  color: var(--link-color, #0366d6);
  text-decoration: none;
}

.link-loading .link-text-wrapper {
  position: relative;
}

.link-loading .link-text {
  position: relative;
  z-index: 2;
}
</style>
// utils.ts
export function extractAnchorAttributes(str: string) {
  // 1. 基础类型检查
  if (!str)
    return null

  // 去除首尾空格,方便处理
  const trimmed = str.trim()

  // 2. 快速判断:必须以 <a 开头(忽略大小写),且后面必须跟空白字符
  // 这一步只做检测,不提取,避免复杂的正则回溯
  if (!/^<a\s/i.test(trimmed)) {
    return null
  }

  // 3. 提取 <a ... > 中间的内容
  // 既然我们已经确信它是 <a 开头,直接找第一个 > 的位置即可
  // 这种使用 indexOf + slice 的方式是 O(n) 的,绝对没有 ReDoS 风险
  const closeTagIndex = trimmed.indexOf('>')
  if (closeTagIndex === -1)
    return null

  // 截取 <a 和 > 中间的字符串
  // <a href="..."> -> 截取 " href="..."
  // 我们不需要精确去掉 <a 后的空格,因为后面的属性正则会自动处理空格
  const attrString = trimmed.slice(2, closeTagIndex)

  // 4. 解析属性键值对
  const attributes: Record<string, string> = {}

  // 优化后的正则:
  // ([^\s=]+)  匹配属性名 (非空格、非等号)
  // \s*=\s*    匹配等号及周围空格
  // (["'])     捕获引号
  // (.*?)      捕获值 (非贪婪)
  // \2         匹配闭合引号
  const attrRegex = /([^\s=]+)\s*=\s*(["'])(.*?)\2/g

  // 5. 使用 matchAll 替代 while 赋值循环
  // matchAll 返回一个迭代器,完美解决 ESLint no-cond-assign 问题
  const matches = attrString.matchAll(attrRegex)

  for (const match of matches) {
    if (!match[1])
      continue
    // match[1] 是 key, match[3] 是 value
    attributes[match[1]] = match[3] || ''
  }

  return attributes
}

因为我的业务需要将一些属性加到标签的属性上,这里我让哈基米3pro帮我写了一段正则,从raw的html上面提取属性。

很完美,可以正常捕获点击了,但是如果像上面那样配置thinking节点一样配置,我们就没办法拿到事件了,这里我们要取个巧,在外面将这个link组件包多一层,拿到emit:

<script setup lang="ts">
import { h } from 'vue'
import { MarkdownRender, setCustomComponents } from 'markstream-vue'
import ThinkingNode from './ThinkingNode.vue'
import LinkNode from './LinkNode.vue'
import 'markstream-vue/index.css'
import type { LinkNodeNode } from './LinkNode.vue'

const md = `
<thinking>
这是推理内容
</thinking>


请选择办理方式
- 线上办理
- 线下办理

<a data-send="其他办理方式">其他办理方式</a>
`
setCustomComponents('doc', {
  thinking: ThinkingNode,
  link: (props: { node: LinkNodeNode }) => h(LinkNode, {
    ...props,
    onSend(text) {
      console.log(text)
    },
  }),
})
</script>

<template>
  <MarkdownRender custom-id="doc" :content="md" :custom-html-tags="['thinking']" />
</template>

list_item也是类似的操作,不过有点区别是需要自定义list节点,没办法单独自定义list_item,不过方法也是类似的,把源代码copy出来改就可以:

// ListNode.vue
<script setup lang="ts">
import ListItemNode from './ListItemNode.vue'

// 节点子元素类型
interface NodeChild {
  type: string
  raw: string
  [key: string]: unknown
}

// 列表项类型
interface ListItem {
  type: 'list_item'
  children: NodeChild[]
  raw: string
}

const { node, customId, indexKey, typewriter } = defineProps<{
  node: {
    type: 'list'
    ordered: boolean
    start?: number
    items: ListItem[]
    raw: string
  }
  customId?: string
  indexKey?: number | string
  typewriter?: boolean
}>()

defineEmits(['copy'])
</script>

<template>
  <component
    :is="node.ordered ? 'ol' : 'ul'"
    class="list-node"
    :class="{ 'list-decimal': node.ordered, 'list-disc': !node.ordered }"
  >
    <ListItemNode
      v-for="(item, index) in node.items"
      :key="`${indexKey || 'list'}-${index}`"
      v-memo="[item]"
      :item="item"
      :custom-id="customId"
      :index-key="`${indexKey || 'list'}-${index}`"
      :typewriter="typewriter"
      :value="node.ordered ? (node.start ?? 1) + index : undefined"
      @copy="$emit('copy', $event)"
    />
  </component>
</template>

<style scoped>
.list-node {
  @apply my-5 pl-[calc(13/8*1em)];
}
.list-decimal {
  list-style-type: decimal;
}
.list-disc {
  list-style-type: disc;
  @apply max-lg:my-[calc(4/3*1em)] max-lg:pl-[calc(14/9*1em)];
}
</style>
<script setup lang="ts">
import { computed, watchEffect } from 'vue'
import { MarkdownRender } from 'markstream-vue'

// 节点子元素类型
interface NodeChild {
  type: string
  raw: string
  [key: string]: unknown
}

// 列表项类型
interface ListItem {
  type: 'list_item'
  children: NodeChild[]
  raw: string
}

const props = defineProps<{
  item: ListItem
  indexKey?: number | string
  value?: number
  customId?: string
  /** Forwarded flag to enable/disable non-code node enter transition */
  typewriter?: boolean
}>()

defineEmits<{
  copy: [text: string]
}>()

const liValueAttr = computed(() =>
  props.value == null ? {} : { value: props.value },
)

function onClick() {
  console.log(props.item.children[0]?.raw)
}
</script>

<template>
  <li
    class="my-2 list-item pl-1.5"
    dir="auto"
    v-bind="liValueAttr"
    @click.capture="onClick"
  >
    <MarkdownRender :nodes="item.children" />
  </li>
</template>

<style scoped>
ol > .list-item::marker {
  color: var(--list-item-counter-marker, #64748b);
  line-height: 1.6;
}
ul > .list-item::marker {
  color: var(--list-item-marker, #cbd5e1);
}

/* 大列表滚动到视口时,嵌套 NodeRenderer 需要立即绘制内容,避免空白 */
.list-item :deep(.markdown-renderer) {
  content-visibility: visible;
  contain-intrinsic-size: 0px 0px;
  contain: none;
}
</style>

vmr组件

除了这些原生的html节点之外,还有一个叫vmr组件的东西,可以渲染一些自定义的组件,使用文档可以看:

markstream-vue-docs.simonhe.me/zh/guide/co…

我们要接受emit也是同样的道理

const vmrComponents = {
// 你的一些业务组件
}
setCustomComponents(customIdComputed.value, {
  // ...
  vmr_container: ({ node }: { node: any }) => {
    const component = props.vmrComponents?.[node.name] || (() => h('div', {}, 'unknown component'))
    return h(component, {
      node,
      onEvent(value: VmrComponentEvent) {
        emits('event', value)
      },
    })
  },
})

因为我是将marksteam组件封装多了一层,作为公共组件,所以我就将所有业务相关的事件封装到一个emit事件里面了,然后使用ts对事件做区分,实现类型提示:

export type MarkdownComponentEvent = EventA | EventB

interface EventA {
  eventName: 'a'
  eventValue: {
    // ...
  }
}

interface EventB {
  eventName: 'b'
  eventValue: {
    // ...
  }
}

Vue 的 nextTick:破解异步更新的玄机

作者 北辰alk
2026年1月14日 21:42

一、先看现象:为什么数据变了,DOM 却没更新?

<template>
  <div>
    <div ref="message">{{ msg }}</div>
    <button @click="changeMessage">点击我</button>
  </div>
</template>

<script>
export default {
  data() {
    return { msg: '初始消息' }
  },
  methods: {
    changeMessage() {
      this.msg = '新消息'
      console.log('数据已更新:', this.msg)
      console.log('DOM内容:', this.$refs.message?.textContent) // 还是'初始消息'!
    }
  }
}
</script>

执行结果:

数据已更新: 新消息
DOM内容: 初始消息  ← 问题在这里!

数据明明已经改了,为什么 DOM 还是旧值?这就是 nextTick 要解决的问题。

二、核心原理:Vue 的异步更新队列

Vue 的 DOM 更新是异步的。当你修改数据时,Vue 不会立即更新 DOM,而是:

  1. 开启一个队列,缓冲同一事件循环中的所有数据变更
  2. 移除重复的 watcher,避免不必要的计算
  3. 下一个事件循环中,刷新队列并执行实际 DOM 更新
// Vue 内部的简化逻辑
let queue = []
let waiting = false

function queueWatcher(watcher) {
  // 1. 去重
  if (!queue.includes(watcher)) {
    queue.push(watcher)
  }
  
  // 2. 异步执行
  if (!waiting) {
    waiting = true
    nextTick(flushQueue)
  }
}

function flushQueue() {
  queue.forEach(watcher => watcher.run())
  queue = []
  waiting = false
}

三、nextTick 的本质:微任务调度器

nextTick 的核心任务:在 DOM 更新完成后执行回调

// Vue 2.x 中的 nextTick 实现(简化版)
let callbacks = []
let pending = false

function nextTick(cb) {
  callbacks.push(cb)
  
  if (!pending) {
    pending = true
    
    // 优先级:Promise > MutationObserver > setImmediate > setTimeout
    if (typeof Promise !== 'undefined') {
      Promise.resolve().then(flushCallbacks)
    } else if (typeof MutationObserver !== 'undefined') {
      // 用 MutationObserver 模拟微任务
    } else {
      setTimeout(flushCallbacks, 0)
    }
  }
}

function flushCallbacks() {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  copies.forEach(cb => cb())
}

四、四大核心使用场景

场景1:获取更新后的 DOM

<script>
export default {
  methods: {
    async updateAndLog() {
      this.msg = '更新后的消息'
      
      // ❌ 错误:此时 DOM 还未更新
      console.log('同步获取:', this.$refs.message.textContent)
      
      // ✅ 正确:使用 nextTick
      this.$nextTick(() => {
        console.log('nextTick获取:', this.$refs.message.textContent)
      })
      
      // ✅ 更优雅:async/await 版本
      this.msg = '另一个消息'
      await this.$nextTick()
      console.log('await获取:', this.$refs.message.textContent)
    }
  }
}
</script>

场景2:操作第三方 DOM 库

<template>
  <div ref="chartContainer"></div>
</template>

<script>
import echarts from 'echarts'

export default {
  data() {
    return { data: [] }
  },
  
  async mounted() {
    // ❌ 错误:容器可能还未渲染
    // this.chart = echarts.init(this.$refs.chartContainer)
    
    // ✅ 正确:确保 DOM 就绪
    this.$nextTick(() => {
      this.chart = echarts.init(this.$refs.chartContainer)
      this.renderChart()
    })
  },
  
  methods: {
    async updateChart(newData) {
      this.data = newData
      
      // 等待 Vue 更新 DOM 和图表数据
      await this.$nextTick()
      
      // 此时可以安全操作图表实例
      this.chart.setOption({
        series: [{ data: this.data }]
      })
    }
  }
}
</script>

场景3:解决计算属性依赖问题

<script>
export default {
  data() {
    return {
      list: [1, 2, 3],
      newItem: ''
    }
  },
  
  computed: {
    filteredList() {
      // 依赖 list 的变化
      return this.list.filter(item => item > 1)
    }
  },
  
  methods: {
    async addItem(item) {
      this.list.push(item)
      
      // ❌ filteredList 可能还未计算完成
      console.log('列表长度:', this.filteredList.length)
      
      // ✅ 确保计算属性已更新
      this.$nextTick(() => {
        console.log('正确的长度:', this.filteredList.length)
      })
    }
  }
}
</script>

场景4:优化批量更新性能

// 批量操作示例
async function batchUpdate(items) {
  // 开始批量更新
  this.updating = true
  
  // 所有数据变更都在同一个事件循环中
  items.forEach(item => {
    this.dataList.push(processItem(item))
  })
  
  // 只触发一次 DOM 更新
  await this.$nextTick()
  
  // 此时 DOM 已更新完成
  this.updating = false
  this.showCompletionMessage()
  
  // 继续其他操作
  await this.$nextTick()
  this.triggerAnimation()
}

五、性能陷阱与最佳实践

陷阱1:嵌套的 nextTick

// ❌ 性能浪费:创建多个微任务
this.$nextTick(() => {
  // 操作1
  this.$nextTick(() => {
    // 操作2
    this.$nextTick(() => {
      // 操作3
    })
  })
})

// ✅ 优化:合并到同一个回调中
this.$nextTick(() => {
  // 操作1
  // 操作2  
  // 操作3
})

陷阱2:与宏任务混用

// ❌ 顺序不可控
this.msg = '更新'
setTimeout(() => {
  console.log(this.$refs.message.textContent)
}, 0)

// ✅ 明确使用 nextTick
this.msg = '更新'
this.$nextTick(() => {
  console.log(this.$refs.message.textContent)
})

最佳实践:使用 async/await

methods: {
  async reliableUpdate() {
    // 1. 更新数据
    this.data = await fetchData()
    
    // 2. 等待 DOM 更新
    await this.$nextTick()
    
    // 3. 操作更新后的 DOM
    this.scrollToBottom()
    
    // 4. 如果需要,再次等待
    await this.$nextTick()
    this.triggerAnimation()
    
    return '更新完成'
  }
}

六、Vue 3 的变化与优化

Vue 3 的 nextTick 更加精简高效:

// Vue 3 中的使用
import { nextTick } from 'vue'

// 方式1:回调函数
nextTick(() => {
  console.log('DOM 已更新')
})

// 方式2:Promise
await nextTick()
console.log('DOM 已更新')

// 方式3:Composition API
setup() {
  const handleClick = async () => {
    state.value = '新值'
    await nextTick()
    // 操作 DOM
  }
  
  return { handleClick }
}

Vue 3 的优化:

  • 使用 Promise.resolve().then() 作为默认策略
  • 移除兼容性代码,更小的体积
  • 更好的 TypeScript 支持

七、源码级理解

// Vue 2.x nextTick 核心逻辑
export function nextTick(cb, ctx) {
  let _resolve
  
  // 1. 将回调推入队列
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  
  // 2. 如果未在等待,开始异步执行
  if (!pending) {
    pending = true
    timerFunc() // 触发异步更新
  }
  
  // 3. 支持 Promise 链式调用
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

八、实战:手写简易 nextTick

class MyVue {
  constructor() {
    this.callbacks = []
    this.pending = false
  }
  
  $nextTick(cb) {
    // 返回 Promise 支持 async/await
    return new Promise(resolve => {
      const wrappedCallback = () => {
        if (cb) cb()
        resolve()
      }
      
      this.callbacks.push(wrappedCallback)
      
      if (!this.pending) {
        this.pending = true
        
        // 优先使用微任务
        if (typeof Promise !== 'undefined') {
          Promise.resolve().then(() => this.flushCallbacks())
        } else {
          setTimeout(() => this.flushCallbacks(), 0)
        }
      }
    })
  }
  
  flushCallbacks() {
    this.pending = false
    const copies = this.callbacks.slice(0)
    this.callbacks.length = 0
    copies.forEach(cb => cb())
  }
  
  // 模拟数据更新
  async setData(key, value) {
    this[key] = value
    await this.$nextTick()
    console.log(`DOM 已更新: ${key} = ${value}`)
  }
}

总结

nextTick 的三层理解:

  1. 表象层:在 DOM 更新后执行代码
  2. 原理层:Vue 异步更新队列的调度器
  3. 实现层:基于 JavaScript 事件循环的微任务管理器

使用原则:

  1. 需要访问更新后的 DOM 时,必须用 nextTick
  2. 操作第三方库前,先等 Vue 更新完成
  3. 批量操作后,用 nextTick 统一处理副作用
  4. 优先使用 async/await 语法,更清晰直观

一句话概括:

nextTick 是 Vue 给你的一个承诺:"等我把 DOM 更新完,再执行你的代码"。

记住这个承诺,你就能完美掌控 Vue 的更新时机。


思考题: 如果连续修改同一个数据 1000 次,Vue 会触发多少次 DOM 更新? (答案:得益于 nextTick 的队列机制,只会触发 1 次)

Vue 技巧揭秘:一个事件触发多个方法,你竟然还不知道?

作者 北辰alk
2026年1月14日 21:30

解锁 v-on 的高级用法,让你的 Vue 代码更加优雅高效!

前言

在 Vue 开发中,v-on(或 @)是我们最常用的指令之一。但你是否曾经遇到过这样的场景:一个按钮点击后需要执行多个操作?比如点击"提交"按钮时,既要验证表单,又要发送请求,还要显示加载状态。

你会怎么处理?嵌套调用?写一个包装函数?其实,Vue 早就为我们提供了更优雅的解决方案!

一、答案是肯定的:可以绑定多个方法!

让我们直接看答案:Vue 的 v-on 确实可以绑定多个方法,而且有不止一种实现方式。

先来看一个最常见的需求场景:

<template>
  <div>
    <!-- 常见的“不优雅”做法 -->
    <button @click="handleSubmit">
      提交订单
    </button>
    
    <!-- 更优雅的多方法绑定 -->
    <button @click="validateForm(), submitData(), logActivity()">
      智能提交
    </button>
  </div>
</template>

<script>
export default {
  methods: {
    handleSubmit() {
      // 传统方式:把所有逻辑写在一个方法里
      this.validateForm()
      this.submitData()
      this.logActivity()
    },
    
    validateForm() {
      console.log('验证表单...')
    },
    
    submitData() {
      console.log('提交数据...')
    },
    
    logActivity() {
      console.log('记录用户行为...')
    }
  }
}
</script>

二、四种实现方式详解

方式一:直接调用多个方法(最简洁)

<template>
  <button @click="method1(), method2(), method3()">
    点击执行三个方法
  </button>
</template>

特点:

  • ✅ 最直观,直接在模板中调用
  • ✅ 可以传递参数
  • ❌ 模板会显得有点"拥挤"

示例:

<template>
  <div>
    <!-- 传递参数 -->
    <button @click="
      logClick('按钮被点击了'),
      incrementCounter(1),
      sendAnalytics('button_click')
    ">
      带参数的多方法调用
    </button>
    
    <!-- 访问事件对象 -->
    <button @click="
      handleClick1($event),
      handleClick2($event),
      preventDefaults($event)
    ">
      使用事件对象
    </button>
  </div>
</template>

<script>
export default {
  methods: {
    logClick(message) {
      console.log(message)
    },
    incrementCounter(amount) {
      this.count += amount
    },
    sendAnalytics(eventName) {
      // 发送分析数据
    },
    preventDefaults(event) {
      event.preventDefault()
    }
  }
}
</script>

方式二:调用一个包装函数(最传统)

<template>
  <button @click="handleAllMethods">
    包装函数方式
  </button>
</template>

<script>
export default {
  methods: {
    handleAllMethods() {
      this.method1()
      this.method2()
      this.method3()
    },
    method1() { /* ... */ },
    method2() { /* ... */ },
    method3() { /* ... */ }
  }
}
</script>

适用场景:

  • 方法之间有复杂的执行顺序
  • 需要条件判断
  • 需要错误处理

示例:

<script>
export default {
  methods: {
    async handleComplexClick() {
      // 1. 先验证
      const isValid = this.validateForm()
      if (!isValid) return
      
      // 2. 显示加载
      this.showLoading = true
      
      try {
        // 3. 执行多个异步操作
        await Promise.all([
          this.submitData(),
          this.logActivity(),
          this.updateCache()
        ])
        
        // 4. 显示成功提示
        this.showSuccess()
      } catch (error) {
        // 5. 错误处理
        this.handleError(error)
      } finally {
        // 6. 隐藏加载
        this.showLoading = false
      }
    }
  }
}
</script>

方式三:使用对象语法(最灵活)

<template>
  <button v-on="{ click: [method1, method2, method3] }">
    对象语法(数组形式)
  </button>
  
  <!-- 或者 -->
  <button v-on="eventHandlers">
    对象语法(响应式对象)
  </button>
</template>

<script>
export default {
  data() {
    return {
      eventHandlers: {
        click: this.handleClick,
        mouseenter: this.handleMouseEnter,
        mouseleave: this.handleMouseLeave
      }
    }
  },
  methods: {
    handleClick() {
      this.method1()
      this.method2()
    },
    method1() { /* ... */ },
    method2() { /* ... */ }
  }
}
</script>

特点:

  • ✅ 可以动态绑定事件处理器
  • ✅ 支持多个不同事件类型
  • ✅ 适合需要动态切换事件处理逻辑的场景

方式四:修饰符组合(最 Vue)

<template>
  <!-- 结合修饰符 -->
  <button @click.stop.prevent="method1(), method2()">
    带修饰符的多方法
  </button>
  
  <!-- 键盘事件多方法 -->
  <input 
    @keyup.enter="
      validateInput(),
      submitForm(),
      clearInput()
    "
    @keyup.esc="cancelEdit(), resetForm()"
  >
</template>

三、实战案例:一个完整的表单组件

让我们来看一个实际的业务场景:

<template>
  <div class="smart-form">
    <form @submit.prevent="handleSmartSubmit">
      <input v-model="formData.email" placeholder="邮箱">
      <input v-model="formData.password" type="password" placeholder="密码">
      
      <button 
        type="submit"
        :disabled="isSubmitting"
        @click="
          $event.stopPropagation(),
          validateBeforeSubmit(),
          trackButtonClick('submit_button')
        "
        @mouseenter="showTooltip = true"
        @mouseleave="showTooltip = false"
      >
        {{ isSubmitting ? '提交中...' : '智能提交' }}
      </button>
      
      <div v-if="showTooltip" class="tooltip">
        点击后执行:验证 → 提交 → 记录 → 分析
      </div>
    </form>
  </div>
</template>

<script>
export default {
  data() {
    return {
      formData: {
        email: '',
        password: ''
      },
      isSubmitting: false,
      showTooltip: false
    }
  },
  
  methods: {
    async handleSmartSubmit() {
      if (this.isSubmitting) return
      
      this.isSubmitting = true
      
      try {
        // 并行执行多个操作
        await Promise.all([
          this.submitToServer(),
          this.logUserActivity(),
          this.updateLocalStorage()
        ])
        
        // 串行执行后续操作
        this.showSuccessMessage()
        this.redirectToDashboard()
        this.sendAnalytics('form_submit_success')
        
      } catch (error) {
        this.handleError(error)
        this.sendAnalytics('form_submit_error', { error: error.message })
      } finally {
        this.isSubmitting = false
      }
    },
    
    validateBeforeSubmit() {
      if (!this.formData.email) {
        throw new Error('邮箱不能为空')
      }
      // 更多验证逻辑...
    },
    
    trackButtonClick(buttonName) {
      console.log(`按钮被点击: ${buttonName}`)
      // 实际项目中这里可能是发送到分析平台
    },
    
    async submitToServer() {
      // API 调用
      const response = await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(this.formData)
      })
      return response.json()
    },
    
    logUserActivity() {
      // 记录用户行为
      console.log('用户提交表单', this.formData)
    },
    
    updateLocalStorage() {
      // 保存到本地
      localStorage.setItem('lastSubmit', new Date().toISOString())
    },
    
    showSuccessMessage() {
      this.$emit('success', '提交成功!')
    },
    
    redirectToDashboard() {
      setTimeout(() => {
        this.$router.push('/dashboard')
      }, 1500)
    },
    
    sendAnalytics(eventName, data = {}) {
      // 发送分析数据
      console.log(`分析事件: ${eventName}`, data)
    },
    
    handleError(error) {
      console.error('提交错误:', error)
      this.$emit('error', error.message)
    }
  }
}
</script>

<style scoped>
.smart-form {
  max-width: 400px;
  margin: 0 auto;
}

.tooltip {
  background: #f0f0f0;
  padding: 8px;
  border-radius: 4px;
  margin-top: 8px;
  font-size: 12px;
  color: #666;
}
</style>

四、最佳实践和注意事项

1. 保持模板简洁

<!-- 不推荐:模板过于复杂 -->
<button @click="
  validateForm($event, formData, true),
  submitForm(formData, config),
  logEvent('submit', { time: Date.now() }),
  showLoading(),
  redirectAfter(3000)
">
  提交
</button>

<!-- 推荐:复杂逻辑放在方法中 -->
<button @click="handleComplexSubmit">
  提交
</button>

2. 错误处理很重要

<script>
export default {
  methods: {
    safeMultiMethods() {
      try {
        this.method1()
        this.method2()
        this.method3()
      } catch (error) {
        console.error('方法执行失败:', error)
        this.handleGracefully(error)
      }
    }
  }
}
</script>

3. 考虑执行顺序

<template>
  <!-- 注意:方法按顺序执行 -->
  <button @click="
    firstMethod(),  // 先执行
    secondMethod(), // 然后执行
    thirdMethod()   // 最后执行
  ">
    顺序执行
  </button>
</template>

4. 异步方法处理

<script>
export default {
  methods: {
    async handleAsyncMethods() {
      // 并行执行
      await Promise.all([
        this.asyncMethod1(),
        this.asyncMethod2()
      ])
      
      // 串行执行
      await this.asyncMethod3()
      await this.asyncMethod4()
    }
  }
}
</script>

五、性能考虑

1. 避免在模板中执行复杂计算

<!-- 不推荐 -->
<button @click="
  heavyCalculation(data),
  processResults(result)
">
  执行
</button>

<!-- 推荐 -->
<button @click="handleHeavyOperations">
  执行
</button>

2. 使用 computed 属性减少重复调用

<script>
export default {
  computed: {
    // 缓存计算结果
    processedData() {
      return this.heavyCalculation(this.data)
    }
  },
  
  methods: {
    handleClick() {
      // 直接使用缓存结果
      this.processResults(this.processedData)
      this.logAction()
    }
  }
}
</script>

六、Vue 3 中的变化

在 Vue 3 的 Composition API 中,用法基本保持一致:

<template>
  <button @click="method1(), method2()">
    Vue 3 多方法
  </button>
</template>

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

const count = ref(0)

const method1 = () => {
  console.log('方法1')
  count.value++
}

const method2 = () => {
  console.log('方法2')
}
</script>

总结

v-on 绑定多个方法的四种方式:

  1. 直接调用多个方法 - 适合简单场景
  2. 包装函数 - 适合复杂逻辑和复用
  3. 对象语法 - 适合动态事件处理
  4. 修饰符组合 - 适合需要事件修饰的场景

选择建议:

  • 简单逻辑:使用方式一(直接调用)
  • 复杂业务:使用方式二(包装函数)
  • 动态需求:使用方式三(对象语法)
  • 事件控制:使用方式四(修饰符组合)

记住,没有绝对的最佳方式,只有最适合当前场景的方式。关键是保持代码的可读性和可维护性。

希望这篇文章能帮助你更好地使用 Vue 的事件处理机制!如果有更多问题或技巧分享,欢迎在评论区讨论。


Vue 中 computed 和 watch 的深度解析:别再用错了!

作者 北辰alk
2026年1月14日 21:23

大家好!今天我们来聊聊 Vue.js 中两个非常重要的概念:computed 和 watch。很多 Vue 初学者甚至有一定经验的开发者,对这两个功能的使用场景和区别仍然存在困惑。

“什么时候用 computed?什么时候用 watch?” 这可能是 Vue 开发者最常遇到的问题之一。

一、先看一个真实场景

假设我们正在开发一个电商网站,需要计算购物车总价:

// 购物车数据
data() {
  return {
    cartItems: [
      { name'商品A'price100quantity2 },
      { name'商品B'price200quantity1 }
    ],
    discount0.9 // 9折优惠
  }
}

现在我们需要计算:

    1. 商品总价(单价×数量之和)
    1. 折后总价

该怎么实现呢?

二、Computed(计算属性):用于派生数据

computed 的核心思想:基于现有数据计算出一个新的数据值

computed: {
  // 计算商品总价
  totalPrice() {
    return this.cartItems.reduce((sum, item) => {
      return sum + item.price * item.quantity
    }, 0)
  },
  
  // 计算折后价
  finalPrice() {
    return this.totalPrice * this.discount
  }
}

computed 的特点:

  1. 1. 声明式编程:你只需要告诉 Vue "我需要什么数据",Vue 会自动处理依赖和更新
  2. 2. 缓存机制:只有当依赖的数据发生变化时,才会重新计算
  3. 3. 同步计算:适合执行同步操作
  4. 4. 返回一个新值:必须返回一个值

三、Watch(侦听器):用于观察数据变化

watch 的核心思想:当某个数据变化时,执行特定的操作

假设我们希望在购物车商品变化时,自动保存到本地存储:

watch: {
  // 深度监听购物车变化
  cartItems: {
    handler(newVal, oldVal) {
      // 保存到本地存储
      localStorage.setItem('cart'JSON.stringify(newVal))
      // 可以发送到服务器
      this.saveCartToServer(newVal)
    },
    deeptrue// 深度监听
    immediatetrue // 立即执行一次
  },
  
  // 监听总价变化
  totalPrice(newPrice) {
    console.log(`总价变为:${newPrice}`)
    if (newPrice > 1000) {
      this.showDiscountTip() // 显示优惠提示
    }
  }
}

watch 的特点:

  1. 1. 命令式编程:你告诉 Vue "当这个数据变化时,执行这些代码"
  2. 2. 无缓存:每次变化都会执行
  3. 3. 可以执行异步操作:适合 API 调用、复杂业务逻辑
  4. 4. 不返回值:主要目的是执行副作用操作

四、核心区别对比表

特性 computed watch
目的 派生新数据 响应数据变化
缓存 ✅ 有缓存 ❌ 无缓存
返回值 ✅ 必须返回值 ❌ 不返回值
异步 ❌ 不支持异步 ✅ 支持异步
语法 函数形式 对象或函数形式
使用场景 模板中的计算逻辑 数据变化时的副作用

五、什么时候用 computed?什么时候用 watch?

使用 computed 的场景:

  1. 1. 模板中需要复杂表达式时

    <!-- 不推荐 -->
    <div>{{ cartItems.reduce((sum, item) => sum + item.price, 0) }}</div>
    
    <!-- 推荐 -->
    <div>{{ totalPrice }}</div>
    
  2. 2. 一个数据依赖多个数据时

    computed: {
      fullName() {
        return this.firstName + ' ' + this.lastName
      }
    }
    
  3. 3. 需要缓存优化性能时

    // 复杂计算只会在依赖变化时执行
    computed: {
      filteredList() {
        // 假设这是很耗时的筛选操作
        return this.hugeList.filter(item => item.active)
      }
    }
    

使用 watch 的场景:

  1. 1. 数据变化时需要执行异步操作

    watch: {
      searchQuery(newQuery) {
        // 防抖搜索
        clearTimeout(this.timer)
        this.timer = setTimeout(() => {
          this.searchAPI(newQuery)
        }, 500)
      }
    }
    
  2. 2. 数据变化时需要执行复杂业务逻辑

    watch: {
      userLevel(newLevel, oldLevel) {
        if (newLevel === 'vip' && oldLevel !== 'vip') {
          this.showVIPWelcome()
          this.sendVIPNotification()
        }
      }
    }
    
  3. 3. 需要观察对象内部变化时

    watch: {
      formData: {
        handler() {
          this.validateForm()
        },
        deeptrue
      }
    }
    

六、常见误区和最佳实践

误区1:用 watch 实现本该用 computed 的功能

// ❌ 不推荐:用 watch 计算全名
data() {
  return {
    firstName: '张',
    lastName: '三',
    fullName: ''
  }
},
watch: {
  firstName() {
    this.fullName = this.firstName + this.lastName
  },
  lastName() {
    this.fullName = this.firstName + this.lastName
  }
}

// ✅ 推荐:用 computed 计算全名
computed: {
  fullName() {
    return this.firstName + ' ' + this.lastName
  }
}

误区2:在 computed 中执行副作用操作

// ❌ 不推荐:在 computed 中修改数据
computed: {
  processedData() {
    // 不要这样做!
    this.someOtherData = 'changed'
    return this.data.map(item => item * 2)
  }
}

// ✅ 推荐:用 watch 执行副作用
watch: {
  data(newData) {
    this.someOtherData = 'changed'
  }
}

七、性能考量

computed 的缓存机制是 Vue 性能优化的重要手段:

computed: {
  // 假设这是一个计算量很大的函数
  expensiveCalculation() {
    console.log('重新计算!')
    // 复杂计算...
    return result
  }
}

在模板中多次使用:

<div>{{ expensiveCalculation }}</div>
<div>{{ expensiveCalculation }}</div>
<div>{{ expensiveCalculation }}</div>

只会输出一次 "重新计算!",因为 computed 会缓存结果。

八、组合式 API 中的使用

在 Vue 3 的 Composition API 中,使用方式略有不同:

import { ref, computed, watch } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const doubleCount = computed(() => count.value * 2)
    
    watch(count, (newValue, oldValue) => {
      console.log(`count从${oldValue}变为${newValue}`)
    })
    
    return { count, doubleCount }
  }
}

总结

记住这个简单的决策流程

  1. 1. 需要基于现有数据计算一个新值吗?  → 用 computed
  2. 2. 需要在数据变化时执行某些操作吗?  → 用 watch
  3. 3. 这个计算需要在模板中简洁表达吗?  → 用 computed
  4. 4. 需要处理异步操作或复杂业务逻辑吗?  → 用 watch

黄金法则:能用 computed 实现的,优先使用 computed;只有在需要"副作用"操作时,才使用 watch。

希望这篇文章能帮助你更好地理解和使用 Vue 中的 computed 和 watch!在实际开发中灵活运用这两个特性,能让你的代码更加清晰、高效。

2025年终总结-都在喊前端已死,这一年我的焦虑、挣扎与重组:AI 时代如何摆正自己的位置

2026年1月14日 19:32

前言

今年这一年,我整个人一直处于一种紧绷的焦虑状态。

这种焦虑来自于一种真切的危机感:作为前端,我发现自己曾经引以为傲的“技术壁垒”在 AI 面前像纸一样薄。但最近,我突然想通了。当我意识到 AI 只是工具,全栈才是唯一出路,知识广度才是护城河 的时候,我的焦虑缓解了。

这一年我写了很多文章、折腾了开源项目、学了新语言,原本以为是在浪费时间“乱撞”,现在回头看,这恰恰是我加速转型的护城河。

今天想把这一年的思考总结出来,分享一下。


一、 认清时代的洪流:人是阻挡不了趋势的

很多人私下里对 AI 处于一种**“间歇性积极,持续性排斥”**的状态。这种心态通常分为三个阶段:

  1. 轻视/嘲讽: 找 AI 的逻辑错误,通过嘲讽来获得心理安慰。
  2. 焦虑/抗拒: 意识到它很强,但通过拒绝接触来延缓危机,觉得用了它就“输了”。
  3. 妥协/共生: 既然无法打败,就把它当成身体的一部分。

我们要清醒一点:人是阻挡不了时代洪流的。 现在的业务逻辑,你手动写 100 遍也不会有任何提升。把时间浪费在无意义的重复劳动上,不仅是在消耗生命,更是对职业生涯的自杀。


二、 核心思维:从“单一职业”向“学科重组”切换

这是我今年最大的认知提升:作为“岗位”的前端和后端会逐渐消亡,但作为“学科”的前端和后端会发生重组。

1. 摆脱单一职业的束缚

不要把自己锁死在“前端”这个标签里。如果你只盯着那几个 API 和框架,你的路会越走越窄。AI 时代,我们需要的是知识广度

2. 思维改变是第一步,但要有“作品”支撑

不要只是空想,要去实施一套 “AI 驱动的开发流”

  • 你可以不写代码,但你必须能瞬间看出 AI 写的代码哪里有坑。
  • 你的价值不在于“使用 AI 编码”,而在于你拥有全局思考的能力,能定义边界,能把控风险。

三、 实操方法:如何把 AI 练成你的“外骨骼”?

1. 建立场景化的 Prompt 资产库

不要把 AI 当成简单的搜索引擎。你需要针对不同的场景(如:复杂表单逻辑、组件边界设计、性能优化)建立专用的 Prompt。

心得: 明确设计组件的边界,给 AI 定好规矩,它产出的代码才不会跑偏。

2. 在掌控范围内使用

不要把代码完全交给 AI 后就撒手不管。要把 AI 限制在你能把控的范围内,防止代码库由于不受控而崩坏。这种“把控力”就是 senior 和 junior 的分水岭。

3. 数据对比:感知效率的代差

我曾做过对比,传统的 Spec 确认到开发完成,和现在的 Spec Coding(基于规格说明书编程) 模式相比,效率是量级的差别。这种效率红利,就是你转型的动力。


四、 关于全栈:唯一的路,也是最好的练习场

很多人问:我想切全栈,但后端语法、环境配置乱七八糟,怎么学?

  1. 拿公司项目练手: 不要怕环境乱。环境配置、部署链路这些繁琐的事,恰恰是 AI 最擅长的。让 AI 带你跑通公司的后端流程,这是成本最低的练习方式。
  2. 快速切换语法: 不要死背语法书。利用 AI 快速对比新旧语言的异同(比如:TS 的 Interface 在 Go 里怎么实现?)。
  3. 时间规划: 抛弃那种“等我学完再做”的想法。直接带着任务去问 AI,在实战中扩充技术栈。

五、 总结:护城河从未消失,只是换了地方

这一年,我虽然焦虑,但并没闲着。我发现:

  • 焦虑时写的文章,打磨了我的逻辑表达
  • 焦虑时折腾的开源项目,扩充了我的技术底座
  • 焦虑时学的后端语言,提升了我的系统观

这些看似乱撞的经历,最终都转化成了我快速转型的技术能力

当开发模式从传统编程演变成 Spec Coding 时,你的认知、你的经验、你对复杂业务的洞察,依然是 AI 无法拿走的护城河。

不要在没有意义的事情上浪费时间。顺应时代,保持思考。如果这个时代注定要重组,那我们要做的,就是成为那个亲手重组自己的人。

uni-app使用html5+创建webview,可以控制窗口大小、显隐、与uni通信

作者 李剑一
2026年1月14日 19:03

最近使用 uni-app 开发安卓端的 App,因为要嵌入一个原本已经开发完的系统,打算使用 web-view 的方式进行嵌入。

因为项目工期比较紧,所以是两个前端并行开发,最后再合到一起。

结果就是这个过程中出问题了...

需求

需求方面是:

A同学负责通讯方面的工作,所以页面应该在首页加载的时候就被挂载上去,这样一来启动系统的时候就注册了通讯模块,不影响收发信息。

B同学负责其他部分,在首页展示系统内部的统计数据,可以算是一个安卓端的简易大屏。

原本A同学的方案是在系统页面上创建一个 web-view,然后嵌入系统。这样一来也不影响B同学开发新系统。

问题

结果整合代码的时候才发现,web-view标签 是自动铺满整个页面的,而且是无法隐藏的。

这就尴尬了,App进来以后直接看到的就是 A同学的通讯页面,无法看到B同学的大屏了。

问题抛到了我手里,我经过一番简单的调研,再加上一顿猛如虎的操作,又通过 Trae 一顿AI,最后回复他们说:无解

image.png

对此 Trae 给出的原因是:uni-app 中内置的 web-view 标签本身没有隐藏的API,在 web-view 标签外增加 view 标签,绑定 v-show 方法不生效。

只能绑定 v-if 生效,但是绑定 v-if=false 的时候相当于 web-view 直接被销毁了,会导致通讯模块收不到信息。

解决

好在天无绝人之路,uni-app 官方文档中表示:在 html5+ 中其实存在 web-view 的 API,能够操作窗口的大小。

那么既然能够操作窗口的大小,肯定也能够控制显隐。

于是乎我使用 Trae 让他使用 html5+ 中的 create 方法创建 web-view,并给出完整的解决方案。

createWebView() {
    let that = this;
    if (this.webviewIns) return;

    this.webviewIns = plus.webview.create(
        'http://192.168.25.110',
        '1008610010' // ID(必须唯一)
        {
            top: '24px',
            bottom: '0px',
            width: '300px',
            height: '600px',
            scrollIndicator: 'none',
            scalable: false
        }
    );

    // 设置页面默认隐藏
    this.webviewIns.hide();
}

这时候已经成功创建了 web-view 并且处于隐藏状态,不耽误传参。

但是又遇到了一个问题,web-view无法与uni-app通信

Trae 先给出了 uni-app 官方提供的方法,使用 uni.postMessage 方法,但是在通讯系统中插入了这个方法以后,不报错,并且显示发送消息成功,但uni-app 这边却无法接收到。

Trae 又使用 evalJS 方法插入相应的脚本,但是也没有反应。同样是发送成功,但是接收不到。

image.png


两种方案均不可用,我又详细看了看 uni-app 的官方文档,让 Trae 分析 web-view 标签上绑定的 @message 方法应该是经过特殊封装的,能够捕获到 view-view 内使用 uni.postMessage 方法发送的数据。

Trae给出的解释是:由于我现在使用 plus.web-view.create 创建窗口,绕过了uni-app 所以即便是绑定 message 也无效了。

image.png

所以采用 html+ 原生方法发送数据,在 web-view 中使用 plus.webview.postMessageToUniNView 方法发送数据。

在 uni-app 端使用 plus.globalEvent.addEventListener 接收数据。

// 发送数据
pushMessage() {
    plus.webview.postMessageToUniNView({
        type: 'tpUniAPP',
        args: {
            args1: 'test123'
        }
    }, '__uniapp__service');
}

接收数据方法写在 createWebView 方法内。

// 接收数据
createWebView() {
    let that = this;
    if (this.webviewIns) return;

    // 创建一个新的 WebView
    this.webviewIns = plus.webview.create(
        ...
    );
    
    // web-view加载完成
    this.webviewIns.addEventListener('loaded', () => {
        plus.globalEvent.addEventListener('plusMessage', (message)=>{
            let data = message?.data?.args?.data;
            if(data?.name === 'postMessage') {
                if (data?.arg?.type === 'show') {
                    // 显示
                    that.webviewIns.show();
                }
                if (data?.arg?.type === 'hide') {
                    // 隐藏
                    that.webviewIns.hide();
                }
            }
        })
    });
    
    this.webviewIns.hide();
},

结论

目前主要解决了两个问题:

  1. web-view 大小、显隐不可控问题。
  2. web-view 与 uni-app 通信问题。

发送数据方法可以卸载 Pinia 的公共方法中,或者使用 EventBus,统一处理。

Ps: 第二个问题解决方案不是太优雅,如果有兄弟有更好的方案可以分享一下。

深入掌握 AI 全栈项目中的路由功能:从基础到进阶的全面解析

2026年1月14日 18:32

在构建现代 Web 应用的过程中,路由管理是必不可少的一部分。本文将深入探讨 AI 全栈项目中的路由功能,涵盖基础知识、进阶概念以及常见的最佳实践。无论您是初学者还是有一定经验的开发者,希望本文能够帮助您在路由管理方面获得更深入的理解。

路由的基础知识

路由的基本任务是将用户请求的 URL 映射到不同的页面或组件。借助 react-router-dom,我们能够有效地管理 React 应用中的路由。首先,在构建项目之前,请确保已安装 react-router-dom 依赖:

bash

Copy Code

npm install react-router-dom

使用 react-router-dom

在我们的 AI 全栈项目中,路由的基本配置如下:

javascript

Copy Code

import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; function App() { return ( <Router> <Switch> <Route path="/" exact component={Home} /> <Route path="/about" component={About} /> <Route path="/products" component={ProductList} /> <Route path="/product/:id" component={ProductDetail} /> <Route component={NotFound} /> </Switch> </Router> ); }

  • Router:定义应用的路由上下文,是路由的基本容器。
  • Switch:确保只有第一个匹配的路由被渲染。非常适合用于管理多个路由。
  • Route:将特定的路径与组件关联。

路由类型分析

普通路由和动态路由

  • 普通路由:通过path直接定义静态路由,如 /about 会直接加载 About 组件。
  • 动态路由:使用参数捕获的形式,使得路由更加灵活,比如 /product/:id,可以根据产品 ID 动态渲染内容。

嵌套路由与特殊路由

  • 嵌套路由:使用 <Outlet> 实现子路由嵌套,允许组件在其内部再渲染其他路由。

javascript

Copy Code

function Dashboard() { return ( <div> <h2>管理面板</h2> <Outlet /> {/* 渲染嵌套的子路由 */} </div> ); }

  • 特殊路由

    • 鉴权路由:检查用户是否已登录,以决定是否允许访问特定路由。可以使用高阶组件来实现。
    • 重定向路由:通过 Navigate 组件实现用户访问时的自动跳转,非常适合处理过期链接。

进阶路由类型

  1. 通配路由: 通配路由使用 * 来匹配所有路径,适合处理需要捕获未定义路径的场景,例如错误页面或404页面。
  2. 搜索和查询参数: React Router 允许你处理 URL 中的查询参数。我们可以通过 useLocation 获取当前 URL 的查询参数,方便地提取中特定的数据。

javascript

Copy Code

import { useLocation } from 'react-router-dom'; function SearchResults() { const query = new URLSearchParams(useLocation().search); const searchTerm = query.get('search'); return <div>搜索结果: {searchTerm}</div>; }

  1. 使用 Hooks 实现更好的可重用性: 使用 React Router 的内置 Hooks(如 useParams, useHistoryuseLocation)可以轻松访问路由信息、动态参数和历史记录,提升组件内的灵活性。

路由生成访问历史

在单页应用的背景下,路由不仅管理组件的显示,还处理访问历史的管理。使用 history.push() 方法能够在不重新加载页面的情况下更改 URL,这提升了用户体验。

javascript

Copy Code

history.push('/products'); // 改变 URL,并自动加载对应组件

维护良好的历史记录

  • replace 方法:通过 history.replace() 替代当前入口而不在历史记录中创建新的路径,但这种方法通常仅在更新表单等场景下使用。

单页应用的优势

单页应用(SPA)使用前端路由来接收路由变化的事件,并动态渲染相应的组件,避免了重新加载整个页面,从而提升了性能。react-router-dom 实现的路由系统使得路由变化无缝连接。

javascript

Copy Code

<Route path="/home" component={Home} /> // 组件动态更新,无需完整刷新

实际案例:构建产品页面

为了更全面地理解路由的应用,我们将以构建一个简单的产品页面为例,其中包括产品列表和产品详情页面,并利用动态路由来展示不同产品的信息。

产品列表组件示例

javascript

Copy Code

function ProductList() { const products = [{ id: 1, name: 'Product A' }, { id: 2, name: 'Product B' }]; return (<div> <h2>产品列表</h2> <ul> {products.map(product => ( <li key={product.id}> <Link to={`/product/${product.id}`}>{product.name}</Link> </li> ))} </ul> </div> ); }

产品详情组件示例

javascript

Copy Code

function ProductDetail({ match }) { const { id } = match.params; // 获取动态路由的参数 const product = getProductById(id); // 假设有个函数根据 ID 获取产品信息 return ( <div> <h2>{product.name}</h2> <p>{product.description}</p> </div> ); }

最佳实践与小结

在构建应用的过程中,有些最佳实践可以帮助我们更好地管理路由:

  1. 保持路由配置的整洁性:将所有路由配置集中管理,可以提高代码的可读性和可维护性。
  2. 使用懒加载:结合 React.lazySuspense 实现组件的懒加载,提高应用的初始加载速度。
  3. 控件状态管理:使用 Zustand 或 Redux 这样的状态管理库来集中控制路由状态和数据共享,以避免道琼斯效应。

通过对 AI 全栈项目中路由功能的详细解析,希望您能在路由管理方面获得实质性的提升。良好的路由管理不仅能提升开发效率,还能极大改善用户体验。学习这些知识的目的不仅是为了掌握技术,更是为了解决实际问题,并为用户提供更好的产品体验。继续学习和实践,相信您会在现代 Web 开发的领域中有所建树!

Angular 18 核心特性速查表

作者 米诺zuo
2026年1月14日 18:31

📚 Angular 18 核心特性速查表

Angular 18 标志着 Angular 彻底告别“旧时代”(v1.x 的残留和复杂的 Module 系统),全面进入 Signal 响应式Standalone 独立组件 时代。

1. 🔄 全新响应式核心:Signal Inputs

这是 Angular 18 最具革命性的变化,彻底改变了父子组件数据传参的方式。

特性 描述
旧语法 @Input()@Output() 分开定义,配合 (inputChange) 实现双向绑定。
新语法 model() 函数。集输入、输出、变更检测于一体。
优势 代码量减少 80%,自动支持响应式更新,不再依赖 ngOnChanges 钩子。

代码对比

// ❌ 旧写法:繁琐
export class OldComp {
  @Input() count = 0;
  @Output() countChange = new EventEmitter<number>();
  
  // 如果要监听变化,还得写 ngOnChanges
  ngOnChanges(changes: SimpleChanges) { ... }
}
// ✅ Angular 18 写法:极简
import { model } from '@angular/core';
export class NewComp {
  // count 现在是一个 Signal,既是输入也是输出
  // 父组件改了它,它会变;它变了,父组件也会收到通知
  count = model(0);
  
  // 在组件内部直接使用 Signal API
  increment() {
    this.count.update(v => v + 1);
  }
}

2. 🎨 新一代控制流

Angular 17 引入,Angular 18 成为标准。废弃了 *ngIf*ngFor 等指令,改用编译器级的块语法。

指令 旧写法 (指令式) 新写法 (块语法) 关键点
条件判断 <div *ngIf="user">...</div> @if (user) { ... } 逻辑更清晰,支持 @else if
列表循环 <div *ngFor="let u of users">...</div> @for (u of users; track u.id) { ... } 强制要求 track,提升渲染性能。
空状态 <div *ngIf="!users.length">...</div> @for (u of users; track u.id) { ... } @empty { ... } 内置空状态块,无需额外判断。

代码对比

<!-- ❌ 旧写法 -->
<div *ngIf="users.length === 0; else list">Loading...</div>
<ng-template #list>
  <div *ngFor="let user of users; trackBy: trackFn">{{user.name}}</div>
</ng-template>
<!-- ✅ Angular 18 写法 -->
@if (users.length === 0) {
  <div>Loading...</div>
} @else {
  @for (user of users; track user.id) {
    <div>{{ user.name }}</div>
  }
}

3. 🧱 独立组件

虽然从 v14 开始引入,但在 Angular 18 中,Standalone 是唯一的默认选项

特性 描述
旧语法 必须声明 @NgModule,在 declarationsimports 中注册组件。
新语法 组件中加上 standalone: true,直接在 imports 数组导入其他组件/指令。
优势 模块化,摇树优化 更友好,降低心智负担。

代码对比

// ❌ 旧写法:Module Hell
@NgModule({
  declarations: [UserComponent],
  imports: [CommonModule, FormsModule],
  exports: [UserComponent]
})
export class UserModule { }
// ✅ Angular 18 写法:清爽
import { CommonModule } from '@angular/common';
@Component({
  selector: 'app-user',
  standalone: true,     // 声明为独立组件
  imports: [CommonModule], // 直接用啥导啥
  template: `...`
})
export class UserComponent { }

4. 🚀 性能与开发体验提升

除了语法变化,Angular 18 还带来了一些底层的优化:

  1. Zone.js 可选:
    • 默认情况下,Angular 依然使用 Zone.js 来自动检测变化。
    • 但新版本提供了更完善的 provideExperimentalZonelessChangeDetection(),允许开发者完全移除 Zone.js,利用 Signal 实现极致性能(类似 React/Svelte 的手动更新模式)。
  2. Hydration(水合)优化:
    • 服务端渲染 (SSR) 的体验大幅提升,减少了客户端接管应用时的闪烁。
  3. 类型检查增强:
    • 新的控制流 @fortrack 表达式会在编译时进行严格类型检查,防止 trackBy 写错导致的 Bug。

5. 📋 语法快速迁移指南

如果您想将旧项目升级到 Angular 18 风格,请参考以下映射表:

场景 查找旧代码... 替换为...
Input 装饰器 @Input() name: string; name = input<string>('');
双向绑定 @Input() val; @Output() valChange val = model(0);
If 指令 *ngIf @if / @else
For 指令 *ngFor @for (...; track ...)
Switch 指令 [ngSwitch] / *ngSwitchCase @switch / @case
Module 导入 AppModule 直接在 main.ts bootstrapApplication

总结

Angular 18 的核心哲学是:

  1. 响应式优先:用 Signal (input, model, computed) 替代 imperative(命令式)的 Getter/Setter。
  2. 模板原生化:用 @if / @for 替代 HTML 指令,让模板更像代码。
  3. 去重化:用 Standalone 组件替代复杂的 NgModule 树。 这种写法让 Angular 代码量大幅减少,运行时性能显著提升,且更容易被 Vue/React 开发者理解。

🌟 JavaScript 数组终极指南:从零基础到工程级实战

作者 Yira
2026年1月14日 18:24

🌟 JavaScript 数组终极指南:从零基础到工程级实战

在 JavaScript 的世界里,数组(Array)  不仅仅是一个“装东西的盒子”,它是一种高度灵活、功能强大、可扩展的数据结构。无论是处理用户列表、管理购物车商品、解析 API 返回数据,还是实现复杂算法,都离不开数组。

本文将带你彻底搞懂 JavaScript 数组——包括它的本质、创建方式、核心特性、常用方法、性能注意事项、常见陷阱,以及在现代开发中的最佳实践。


一、JavaScript 中到底有没有“真正的”数组?

这是一个常被误解的问题。

✅ 答案:有,但和传统语言不同

在 C/C++、Java 等静态语言中,数组是:

  • 连续内存块
  • 固定长度
  • 同类型元素

而 JavaScript 的数组是:

  • 基于对象(Object)实现的特殊对象
  • 动态长度
  • 可存储任意类型混合数据
  • 支持稀疏结构(即索引不连续)

🔍 技术细节:
在 V8 引擎(Chrome/Node.js 使用)中,JavaScript 数组会根据内容自动选择两种内部表示:

  • Fast Elements(快速元素) :当数组是密集、类型一致时,使用类似 C 数组的连续内存优化。
  • Dictionary Elements(字典元素) :当数组稀疏或类型混杂时,退化为哈希表存储,性能下降。

因此,虽然 JS 数组“看起来像”传统数组,但其底层更接近“带数字键的对象”。


二、如何创建数组?四种方式详解

1. 数组字面量(推荐 ✅)

const arr = [1, 'hello', true];
  • 最简洁、最高效
  • 自动推断长度
  • 支持尾随逗号(利于 Git diff)

2. Array 构造函数(谨慎使用 ⚠️)

// 传入多个参数 → 创建包含这些元素的数组
const a1 = new Array(1, 2, 3); // [1, 2, 3]

// 传入单个数字 → 创建指定长度的空数组(⚠️陷阱!)
const a2 = new Array(5); // [empty × 5],不是 [5]!

❗ 危险点:new Array(3) 不等于 [3],前者是长度为 3 的空数组,后者是包含数字 3 的数组。

3. Array.of()(安全替代构造函数)

Array.of(5);        // [5]
Array.of(1, 2, 3);  // [1, 2, 3]
  • 无论传几个参数,都作为元素放入数组
  • 解决 new Array(n) 的歧义问题

4. Array.from()(从类数组或可迭代对象创建)

// 从字符串
Array.from('abc'); // ['a', 'b', 'c']

// 从 NodeList
Array.from(document.querySelectorAll('div'));

// 从 Set
Array.from(new Set([1, 2, 2])); // [1, 2]

// 带映射函数
Array.from({ length: 3 }, (_, i) => i * 2); // [0, 2, 4]

三、数组的核心属性与判断方法

1. length 属性

  • 可读可写
  • 表示“最大整数索引 + 1”,不是实际元素个数(稀疏数组时尤其注意)
let arr = [];
arr[99] = 'last';
console.log(arr.length); // 100
console.log(Object.keys(arr).length); // 1(实际只有1个元素)

2. 如何判断一个变量是数组?

// ❌ 错误方式
typeof []; // "object"

// ✅ 正确方式
Array.isArray([]); // true

// 兼容旧浏览器(不推荐)
Object.prototype.toString.call([]) === '[object Array]';

四、数组操作全景图(按功能分类)

A. 增删改查(CRUD)

表格

操作 方法 是否修改原数组 返回值
末尾添加 push() ✅ 是 新长度
开头添加 unshift() ✅ 是 新长度
末尾删除 pop() ✅ 是 被删元素
开头删除 shift() ✅ 是 被删元素
任意位置增删 splice(start, deleteCount, ...items) ✅ 是 被删元素组成的数组
替换/插入(不修改原数组) toSpliced()(ES2023) ❌ 否 新数组

💡 示例:

const arr = [1, 2, 3];
arr.splice(1, 1, 'a', 'b'); // 从索引11个,插入'a','b'
console.log(arr); // [1, 'a', 'b', 3]

B. 遍历与转换(函数式编程核心)

表格

方法 用途 是否修改原数组 返回值
forEach() 遍历执行副作用 undefined
map() 映射新值 新数组
filter() 过滤符合条件的 新数组
reduce() 聚合计算(求和、扁平化等) 累积值
find() / findIndex() 查找第一个匹配项 元素 / 索引
some() / every() 判断是否存在 / 是否全部满足 布尔值

🧠 关键区别:

  • map 必须返回新值,用于转换
  • forEach 用于执行操作(如 DOM 更新、日志),不返回有用值

C. 搜索与判断

const nums = [10, 20, 30];

nums.indexOf(20);      // 1(找不到返回 -1)
nums.lastIndexOf(20);  // 1(从后往前找)

nums.includes(20);     // true(ES2016,更语义化)

// 支持 NaN
[NaN].includes(NaN);   // true
[NaN].indexOf(NaN);    // -1(因 NaN !== NaN)

D. 连接、切片与复制

表格

方法 说明
concat() 合并多个数组或值,返回新数组
slice(start, end) 截取子数组(end 不包含),返回新数组
[...arr] 展开运算符,浅拷贝数组
Array.from(arr) 浅拷贝(也可用于类数组)

⚠️ 注意:以上均为浅拷贝!嵌套对象仍共享引用。


E. 排序与反转

const letters = ['c', 'a', 'b'];

letters.sort();        // ['a', 'b', 'c'](✅ 修改原数组!)
letters.reverse();     // ['c', 'b', 'a'](✅ 修改原数组!)

// 数字排序需传比较函数
[10, 2, 30].sort((a, b) => a - b); // [2, 10, 30]

❗ 重要:sort() 和 reverse() 会直接修改原数组,若需保留原数据,先复制再操作。


五、高级技巧与工程实践

1. 不可变性(Immutability)原则

在 React、Redux 等现代框架中,强调“不直接修改状态”。因此应避免 pushsplice 等方法,改用:

// ❌ 不推荐(修改原数组)
state.items.push(newItem);

// ✅ 推荐(返回新数组)
const newItems = [...state.items, newItem];

2. 扁平化嵌套数组

const nested = [1, [2, [3, [4]]]];

nested.flat();        // [1, 2, [3, [4]]]
nested.flat(2);       // [1, 2, 3, [4]]
nested.flat(Infinity); // [1, 2, 3, 4](彻底扁平化)

// 或用 reduce 递归实现

3. 去重(Deduplication)

// 基本类型去重
const unique = [...new Set([1, 2, 2, 3])]; // [1, 2, 3]

// 对象数组去重(按 id)
const users = [{id:1}, {id:2}, {id:1}];
const seen = new Set();
const uniqueUsers = users.filter(user => {
  if (seen.has(user.id)) return false;
  seen.add(user.id);
  return true;
});

4. 性能建议

  • 避免频繁在数组开头 unshift/shift(时间复杂度 O(n))
  • 大量数据处理优先用 for 循环(比 forEach 快),但牺牲可读性
  • 稀疏数组慎用,可能触发引擎降级到字典模式

六、常见误区与陷阱

❌ 误区 1:[] == false 所以数组是假值?

Boolean([]); // true!空数组是真值
if ([]) console.log('yes'); // 会执行

原因:所有对象(包括空数组、空对象)在布尔上下文中都是 true

❌ 误区 2:arr.length = 0 会清空数组?

let a = [1, 2, 3];
let b = a;
a.length = 0;
console.log(b); // [] —— 因为 a 和 b 指向同一引用!

❌ 误区 3:delete arr[1] 会缩短数组?

let arr = [1, 2, 3];
delete arr[1];
console.log(arr);        // [1, empty, 3]
console.log(arr.length); // 3(长度不变!)

正确做法:用 splice(1, 1) 删除并收缩数组。


七、总结:数组使用心法

表格

场景 推荐方法
添加元素 push(末尾)、... + concat(不可变)
删除元素 filter(不可变)、splice(可变)
遍历处理 map(转换)、forEach(副作用)
条件筛选 filterfindsome
聚合计算 reduce
安全复制 [...arr]Array.from(arr)
判断类型 Array.isArray()

八、动手练习(巩固理解)

  1. 将字符串 'the quick brown fox' 转为每个单词首字母大写:'The Quick Brown Fox'
  2. 找出数组中重复的元素:[1, 2, 2, 3, 4, 4] → [2, 4]
  3. 实现一个 chunk 函数,将数组每 3 个分一组:[1,2,3,4,5] → [[1,2,3], [4,5]]

💡 提示:多用 mapreduceSet 组合解决!


结语

JavaScript 数组看似简单,实则博大精深。它既是新手入门的第一道关卡,也是高手优化性能的关键战场。理解其本质、掌握其方法、避开其陷阱,你就能在任何 JavaScript 项目中游刃有余。

📚 建议收藏本文,作为日常开发的“数组速查手册”。

如果你希望我针对某个方法(比如 reduce 的 10 种用法)做专题详解,欢迎继续提问!

前端批量请求的并发控制与工程化实践

2026年1月14日 18:18

在上一篇文章中,我们已经完成了数据准备工作。本篇将重点介绍一个在真实业务中几乎绕不开的问题

当需要对一组数据逐个请求接口时,如何优雅、安全地进行批量请求?

如果处理不当,轻则页面卡顿,重则接口被限流甚至封禁。


一、为什么不能直接 Promise.all

最常见的写法是这样:

Promise.all(
  list.map(item => request(item))
);

这个写法的问题在于:

  • 并发数量 不可控

  • 列表一长就会:

    • 打满浏览器并发
    • 压垮后端服务
  • 一旦某个请求失败,整体直接 reject

在区县、设备、用户、文件等数量不确定的场景下,这种方式风险极高。


二、我们真正需要的是什么?

一个成熟的「批量请求」方案,至少应该满足:

  1. 限制并发数量
  2. 支持批次间隔(节流)
  3. 单个失败不影响整体流程
  4. 能够感知整体完成状态
  5. 结果和错误可以分开统计

三、核心设计思路

1️⃣ 把“请求”和“执行”解耦

  • 批量工具只负责 调度
  • 具体请求由调用方传入
(task) => Promise

2️⃣ 固定并发数,分批执行

  • 每一批最多 N 个任务
  • 当前批次完成后,延迟一段时间再执行下一批

3️⃣ 所有任务最终返回一个统一结果

{
  results: [],
  errors: []
}

四、批量请求使用方式(示例)

下面是一个典型的使用方式:

this.batchRequestWithLimit(
  taskList,
  (task) => {
    return apiRequest(task);
  },
  (task, data) => {
    console.log(`任务 ${task} 成功`, data);
  },
  5,   // 最大并发数
  200, // 批次间隔(ms)
)
  .then(({ results, errors }) => {
    console.log(
      `批量请求完成:成功 ${results.length} 个,失败 ${errors.length} 个`
    );
  })
  .catch(err => {
    console.error("批量请求异常:", err);
  });

从使用者视角看,这个方法具备:

  • 调用简单
  • 参数语义清晰
  • 结果可控

五、执行流程拆解(非常关键)

整个批量请求的生命周期可以拆成 5 步:

Step 1:任务队列初始化

const queue = [...taskList];

Step 2:取出当前批次

const batch = queue.splice(0, limit);

Step 3:并发执行当前批次

await Promise.allSettled(
  batch.map(task => executor(task))
);

Step 4:等待节流时间

await sleep(interval);

Step 5:继续下一批,直到队列清空


六、为什么 Promise.allSettled 是关键

相比 Promise.all

方法 任一失败 结果可控
Promise.all ❌ 整体失败
Promise.allSettled ✅ 单独失败

批量请求场景中

失败是常态,而不是异常

我们要做的是:

  • 接受失败
  • 记录失败
  • 不中断流程

七、这种模式适合哪些场景?

这种批量请求模型,适用于:

  • 区县 / 城市 / 设备 / 用户 批量拉取
  • 文件批量上传 / 下载
  • 批量校验、扫描、统计
  • 任何「数量不确定 + 接口有压力」的业务场景

八、总结一句话

批量请求不是“多发请求”,而是“有节制地并发执行任务”。

通过:

  • 限制并发
  • 分批执行
  • 错误隔离
  • 统一收敛结果

你可以把一个“危险操作”,变成一个可控、可扩展、可维护的工程能力

前端项目缓存控制与自动版本检查方案实现

2026年1月14日 18:18

前端项目缓存控制与自动版本检查方案实现

在前端项目部署迭代过程中,浏览器缓存常常导致用户无法及时获取最新版本资源,出现页面错乱、功能异常等问题。本文将分享一套“全方位缓存控制 + 自动版本检查”的完整解决方案,通过 HTML 配置、Vite 构建优化、版本文件生成及定时检查机制,彻底解决缓存困扰,提升用户体验。

一、方案核心思路

本方案从“主动禁止缓存”和“被动版本校验”两个维度保障资源新鲜度:

  1. 通过 HTML 元标签和 Vite 构建配置,从源头控制浏览器缓存策略;
  2. 构建时自动生成版本信息文件,前端项目启动后定时检查版本差异;
  3. 检测到新版本时主动提示用户更新,确保用户使用最新功能。

二、第一步:全方位缓存控制配置

缓存控制需兼顾页面本身和静态资源(JS、CSS、图片等),需分别在 HTML 页面和 Vite 配置中进行设置。

2.1 HTML 页面级缓存控制

在 index.html 的 head 标签中添加缓存控制元标签,告知浏览器不缓存当前页面,同时强制验证后续资源有效性。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="pragma" content="no-cache"&gt; <!-- 禁止浏览器缓存 -->
  <meta http-equiv="Cache-Control" content="no-cache, must-revalidate"&gt; <!-- 禁止浏览器缓存 -->
  &lt;meta http-equiv="expires" content="0"&gt; <!-- 禁止浏览器缓存 -->
  <title>页面标题</title>
</head>
<body>
  <!-- 页面内容 -->
</body>
</html>

说明:HTML 元标签仅对当前页面生效,无法控制页面引入的静态资源缓存,需配合 Vite 构建配置实现全链路缓存控制。

2.2 Vite 构建级缓存优化

通过 Vite 配置实现两点核心优化:一是为静态资源添加哈希命名(内容变更时哈希自动更新),二是为开发/生产环境的资源响应头设置缓存禁止策略。

import { defineConfig } from 'vite';
import { generateVersionFile } from "./scripts/generateVersionFile"; 

// 引入版本文件生成插件
generateVersionFile()

export default defineConfig({
  // 构建配置:通过文件名哈希和资源响应头强化缓存控制
  build: {
    // 为静态资源文件名添加哈希值,避免资源缓存(文件内容变化时哈希值改变,触发重新加载)
    assetsDir: 'assets',
    rollupOptions: {
      output: {
        // 配置静态资源(JS、CSS、图片等)的文件名格式:[name]-[hash].[ext]
        assetFileNames: '[name]-[hash].[ext]',
        chunkFileNames: 'chunks/[name]-[hash].js',
        entryFileNames: 'entry/[name]-[hash].js',
      },
    },
  },
  // 开发服务器配置(本地开发时的缓存控制)
  server: {
    headers: {
      // 禁止服务器返回的资源被缓存
      'Cache-Control': 'no-cache, no-store, must-revalidate',
      'Pragma': 'no-cache',
      'Expires': '0',
    },
  },
  // 生产环境静态资源服务配置(若使用 Vite 预览生产包)
  preview: {
    headers: {
      'Cache-Control': 'no-cache, no-store, must-revalidate',
      'Pragma': 'no-cache',
      'Expires': '0',
    },
  },
});

关键说明:静态资源的哈希命名是核心,当文件内容发生变更时,构建后的文件名会随之改变,浏览器会将其识别为新资源并重新请求,从根本上解决静态资源缓存问题。

三、第二步:自动版本检查机制实现

通过编写脚本生成版本信息文件,并在前端项目中实现定时版本检查逻辑,当检测到新版本时提示用户刷新页面更新资源。

3.1 版本信息生成脚本

编写两个核心脚本:generateVersionInfo.ts(生成版本信息)和 generateVersionFile.ts(Vite 插件,打包时生成版本文件)。

3.1.1 生成版本信息(generateVersionInfo.ts)

从 package.json 中读取项目版本号,结合构建时间生成版本信息对象。

import { readFileSync } from "fs";
import { resolve } from "path";
import { cwd } from "process";

/**
 * 生成版本信息
 * @returns 版本信息对象,包含 version、buildTime、timestamp,失败时返回 null
 */
export function generateVersionInfo() {
  try {
    // 读取 package.json 获取版本号(从项目根目录)
    const packageJsonPath = resolve(cwd(), "package.json");
    const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
    const version = packageJson.version || "1.0.0";
    const buildTime = new Date().toISOString().replace("T", " ").slice(0, 19);

    // 生成版本信息对象
    return {
      version,
      buildTime,
      timestamp: Date.now()
    };
  } catch (error) {
    console.error("❌ 读取版本信息失败:", error);
    return null;
  }
}
3.1.2 生成版本文件(generateVersionFile.ts)

实现 Vite 插件,在打包结束时将版本信息写入 dist 目录的 version.json 文件,供前端项目请求校验。

import { writeFileSync } from "fs";
import { resolve } from "path";
import { cwd } from "process";
import type { Plugin } from "vite";
import { generateVersionInfo } from "./generateVersionInfo";

/**
 * 生成版本号文件的 Vite 插件
 * - 打包时在 dist 目录生成 version.json
 */
export function generateVersionFile(): Plugin {
  return {
    name: "generate-version-file",
    // 打包时生成版本文件到 dist 目录
    closeBundle() {
      try {
        const versionInfo = generateVersionInfo();
        if (versionInfo) {
          const versionJson = JSON.stringify(versionInfo, null, 2);

          // 写入版本号文件到 dist 目录(打包后的根目录)
          const distPath = resolve(cwd(), "dist/version.json");
          writeFileSync(distPath, versionJson, "utf-8");
          console.log(`✅ 版本号文件已生成,版本号: ${versionInfo.version},构建时间: ${versionInfo.buildTime}`);
        }
      } catch (error) {
        console.error("❌ 生成版本号文件失败:", error);
      }
    }
  };
}

3.2 前端版本检查逻辑(App.vue)

在 Vue 根组件中实现版本检查核心逻辑:页面挂载后立即检查版本,之后每隔 24 小时定时检查,检测到新版本时通过弹窗提示用户更新。

3.2.1 代码核心逻辑拆解

App.vue 中的版本检查代码可分为「核心配置定义」「核心功能函数」「生命周期钩子绑定」三个核心部分,各部分职责清晰、耦合度低,便于维护和扩展:

1. 核心配置常量定义

定义版本检查相关的固定配置,集中管理关键参数,后续需调整时可直接修改此处,无需改动核心逻辑:

  • VERSION_STORAGE_KEY:本地存储版本号的键名,用于将当前版本号持久化到 localStorage,避免页面刷新后丢失版本信息;
  • VERSION_CHECK_INTERVAL:版本检查时间间隔,此处设置为 24 小时(单位:毫秒),可根据项目迭代频率灵活调整;
  • versionCheckTimer:定时检查计时器的引用,用于在组件卸载时清理计时器,避免内存泄漏。
2. 核心功能函数实现

包含 3 个核心函数,分别负责版本检查、启动检查流程、清理检查资源,单一函数只做单一职责:

  • checkVersionUpdate(核心版本检查函数): - 环境判断:开发环境直接跳过检查,避免开发过程中频繁触发版本校验; - 路径适配:动态构建 version.json 的请求路径,适配不同部署目录(如根目录、子目录部署),提升通用性; - 缓存禁用:请求版本文件时通过 headers 和 cache 配置禁用缓存,确保获取的是最新版本文件; - 异常处理:对网络错误、文件不存在、数据格式错误等场景做静默处理,不阻塞应用正常运行; - 版本对比:从 localStorage 读取历史版本号,与最新版本号对比,首次访问时直接保存版本号,检测到新版本时提示用户更新并刷新页面。
  • startVersionCheck(启动版本检查函数): - 立即执行一次版本检查,确保用户进入页面后能第一时间获取版本状态; - 启动定时检查计时器,按照设定的时间间隔重复执行版本检查。
  • stopVersionCheck(停止版本检查函数): - 清理定时检查计时器,避免组件卸载后计时器仍在运行导致内存泄漏,符合 Vue 组件的资源管理规范。
3. 生命周期钩子绑定

结合 Vue 组件生命周期,实现版本检查流程的自动启动和资源清理:

  • onMounted:组件挂载完成后调用 startVersionCheck,启动版本检查流程,确保组件渲染完成后再执行异步请求相关逻辑;
  • onBeforeUnmount:组件卸载前调用 stopVersionCheck,清理计时器资源,避免内存泄漏。
import { ref, onMounted, onBeforeUnmount } from "vue";
import { ElMessageBox } from "element-plus";

/** 版本检查相关配置 */
const VERSION_STORAGE_KEY = "app_version"; // 本地存储版本号的 key
const VERSION_CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 版本检查间隔(24小时)
const versionCheckTimer = ref<NodeJS.Timeout | null>(null); // 定时检查计时器

/**
 * 检查版本更新
 */
const checkVersionUpdate = async () => {
  try {
    // 开发环境不进行版本检查
    // @ts-ignore - Vite 内置环境变量
    const isDev = import.meta.env.MODE === "development";
    if (isDev) {
      return;
    }

    // 构建版本文件路径,适配不同部署目录(获取 index.html 所在目录)
    const currentPath = window.location.pathname;
    const lastSlashIndex = currentPath.lastIndexOf("/");
    const directoryPath = lastSlashIndex >= 0 ? currentPath.substring(0, lastSlashIndex + 1) : "/";
    const versionUrl = `${window.location.origin}${directoryPath}version.json?t=${Date.now()}`;

    // 请求版本文件(禁用缓存,确保获取最新版本)
    const response = await fetch(versionUrl, {
      headers: {
        "Cache-Control": "no-cache",
        Pragma: "no-cache"
      },
      cache: "no-store"
    });

    if (!response.ok) {
      console.log("版本文件不存在或网络错误,跳过版本检查", response.status);
      if (response.status === 404) {
        console.warn("版本文件不存在,跳过版本检查");
      }
      return;
    }

    const versionData = await response.json();

    // 验证返回数据格式
    if (!versionData || !versionData.version) {
      console.warn("版本文件格式错误,跳过版本检查");
      return;
    }

    const currentVersion = versionData.version;
    const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);

    // 首次访问,保存版本号到本地存储
    if (!storedVersion) {
      console.log("首次访问,保存版本号", currentVersion);
      localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
      return;
    }

    // 检测到新版本:更新本地版本号并提示用户
    if (currentVersion !== storedVersion) {
      localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
      ElMessageBox.confirm(
        `检测到新版本(${currentVersion}),是否立即更新?`, 
        "版本更新提示", 
        {
          confirmButtonText: "立即更新",
          cancelButtonText: "稍后更新",
          type: "info",
          customClass: "custom-el-message"
        }
      ).then(() => {
        window.location.reload(); // 刷新页面加载最新资源
      });
    } else {
      console.log("当前版本与最新版本一致,跳过版本检查");
    }
  } catch (error) {
    // 静默处理错误,不阻塞应用运行
    console.warn("版本检查失败:", error);
  }
};

/**
 * 启动版本检查(立即检查 + 定时检查)
 */
const startVersionCheck = () => {
  checkVersionUpdate(); // 页面挂载后立即检查一次
  // 设置定时检查
  versionCheckTimer.value = setInterval(() => {
    checkVersionUpdate();
  }, VERSION_CHECK_INTERVAL);
};

/**
 * 清理版本检查定时器(组件卸载时)
 */
const stopVersionCheck = () => {
  if (versionCheckTimer.value) {
    clearInterval(versionCheckTimer.value);
    versionCheckTimer.value = null;
  }
};

// 组件生命周期钩子
onMounted(() => {
  startVersionCheck();
});

onBeforeUnmount(() => {
  stopVersionCheck();
});

三、方案优势与注意事项

3.1 核心优势

  • 全链路缓存控制:覆盖页面、静态资源、接口请求三个层面,彻底杜绝缓存残留;
  • 自动化程度高:版本文件自动生成,版本检查自动触发,无需人工干预;
  • 用户体验友好:新版本静默检测,仅在有更新时提示,不干扰正常使用;
  • 适配多环境:开发环境跳过检查,生产环境精准控制,兼顾开发与部署效率。

3.2 注意事项

  • 版本号管理:需在 package.json 中规范管理版本号(如遵循语义化版本),确保版本变更可追溯;
  • 部署适配:version.json 文件需与 index.html 同级部署,确保前端能正确请求到;
  • 错误兼容:版本检查逻辑添加了完善的错误捕获,避免因网络问题或文件缺失导致应用崩溃;
  • 间隔配置:可根据项目迭代频率调整 VERSION_CHECK_INTERVAL(如迭代频繁可改为 12 小时)。

四、总结

本文提出的“缓存控制 + 版本检查”方案,通过 HTML 元标签、Vite 构建配置、版本文件生成脚本和前端定时检查逻辑的协同工作,完美解决了前端项目部署后的缓存问题。该方案实现简单、通用性强,可直接适配 Vue + Vite 技术栈的各类项目,也可稍作修改移植到其他前端框架中,为用户提供稳定、新鲜的应用体验。

Vibe Coding指南

作者 Gaaraa
2026年1月14日 18:17

Vibe Coding 指南

与 AI 协作的现代编程范式


目录


什么是 Vibe Coding?

Vibe Coding 是一种全新的编程范式,它改变了我们与代码交互的方式。

传统编程 vs Vibe Coding

维度 传统编程 Vibe Coding
工作方式 手写每一行代码 描述意图,AI 生成代码
关注点 如何实现(How) 要实现什么(What)
开发流程 编码 → 调试 → 优化 描述 → 审查 → 迭代
技能要求 精通语法和 API 清晰表达 + 代码审查
效率提升 线性增长 指数级增长

核心价值

Vibe Coding 不是让 AI 替你写代码,而是让 AI 成为你的编程伙伴

  • 你负责:架构设计、业务逻辑、质量把控
  • AI 负责:代码生成、重复劳动、方案探索
  • 共同完成:快速迭代、持续优化、创造价值

核心理念

1. Think in Outcomes, Not Implementation

关注结果,而非实现细节

❌ 传统思维:
"帮我写一个 for 循环,遍历 users 数组,判断 status === 'active',
然后 push 到新数组,最后 map 提取 name 和 email"

✅ Vibe Coding 思维:
"从 users 中筛选出所有活跃用户,返回他们的姓名和邮箱"

为什么这样更好?

  • AI 可能使用更优雅的 filter + map 组合
  • AI 可能考虑到性能优化(如使用 reduce 一次遍历)
  • AI 可能添加类型安全和错误处理

2. Iterate, Don't Perfect

快速迭代,而非追求一次完美

1轮:实现基础功能(能用)
  "实现用户登录功能,支持手机号+验证码"2轮:优化用户体验(好用)
  "添加验证码倒计时、输入框自动聚焦、错误提示"3轮:性能优化(快用)
  "优化登录接口调用,添加请求缓存和防抖"4轮:完善细节(爱用)
  "添加登录动画、记住登录状态、支持生物识别"

3. Context is King

上下文决定代码质量

AI 需要了解你的"世界观":

  • 技术栈:Vue 3 + TypeScript + Pinia
  • 项目结构:页面、组件、API、Store 的组织方式
  • 编码规范:命名规则、代码风格、注释要求
  • 业务逻辑:用户角色、权限系统、业务流程

提供上下文的方式

  • 使用 @文件名 引用相关文件
  • .cursorrules 中定义项目规范
  • 在提示词中说明技术约束
  • 提供参考示例或类似功能

完美提示词的六大要素

1. 明确的目标 (What)

说清楚要做什么

✅ 好的目标:
"实现用户登录功能,支持手机号+验证码和账号+密码两种方式"

❌ 模糊的目标:
"做个登录"

2. 清晰的上下文 (Where & Why)

说明在哪里做,为什么做

✅ 好的上下文:
"在 @src/pages/login/index.vue 中实现,
因为现有的登录只支持账号密码,
产品要求增加验证码登录方式以提升用户体验"

❌ 缺少上下文:
"加个验证码登录"

3. 技术约束 (How)

说明使用什么技术,如何实现

✅ 好的技术约束:
"使用 Vue 3 Composition API + TypeScript,
调用 POST /api/v1/auth/login 接口,
验证码倒计时 60 秒,使用 uni.request 发送请求"

❌ 缺少技术约束:
"用 Vue 做"

4. 边界条件 (Constraints)

说明不能做什么,有什么限制

✅ 好的边界条件:
"不要修改现有的账号密码登录逻辑,
验证码输入框只允许数字,
发送验证码前要校验手机号格式(11位数字),
同一手机号 60 秒内只能发送一次验证码"

❌ 没有边界条件:
(任何约束都没有提)

5. 预期结果 (Success Criteria)

说明完成后应该是什么样子

✅ 好的预期结果:
"完成后应该能够:
1. 输入手机号,点击发送验证码
2. 60秒倒计时,期间按钮禁用显示剩余秒数
3. 输入6位数字验证码
4. 点击登录按钮,显示加载状态
5. 登录成功后保存 token 并跳转到首页
6. 登录失败显示错误提示"

❌ 没有验收标准:
(不知道怎么算完成)

6. 示例或参考 (Examples)

提供参考实现或设计稿

✅ 好的参考:
"参考 @src/pages/login/password.vue 的样式和布局,
验证码输入框类似支付宝的 6 位数字输入(每位一个框),
整体风格保持与现有登录页一致"

❌ 没有参考:
(AI 只能猜测你想要什么样式)

高级技巧

技巧 1:分层提示(Layer Prompting)

不要一次性要求所有细节,而是分层递进

第1层:架构层
"设计一个用户认证系统,包含以下功能:
- 登录(手机号+验证码、账号+密码)
- 注册(手机号+验证码)
- 找回密码
- 修改密码
请给出整体架构设计和文件组织结构"
第2层:功能层
"先实现登录功能,支持手机号+验证码和账号+密码两种方式,
包括:
- 登录页面 UI
- 表单验证
- API 调用
- Token 存储
- 路由跳转"
第3层:实现层
"实现手机号+验证码登录,具体包括:
1. 手机号输入框(带格式校验)
2. 发送验证码按钮(带倒计时)
3. 验证码输入框(6位数字)
4. 登录按钮(带加载状态)
5. 错误提示"
第4层:细节层
"优化验证码输入体验:
- 自动聚焦到第一个输入框
- 输入一位后自动跳到下一位
- 支持粘贴6位验证码自动填充
- 输入完成后自动触发登录
- 支持删除时自动回到上一位"

技巧 2:对话式调试(Conversational Debugging)

把 AI 当作结对编程的伙伴

你:"这个登录功能有问题,点击按钮没反应"

AI:"让我检查一下... 发现 handleLogin 方法没有绑定到按钮的 @click 事件"

你:"修复后还是不行,控制台报错 'token is undefined'"

AI:"看起来 API 返回的数据结构不对,让我检查 @src/api/auth.ts...
    发现接口返回的是 data.access_token,但代码中用的是 data.token"

你:"对,后端改了字段名,同步更新一下类型定义和 mock 数据"

AI:"已更新 @src/types/auth.ts 中的 ILoginResponse 接口,
    同时更新了 @src/mock/data/auth.ts 中的 mock 数据"

技巧 3:模板化常见任务(Template Prompts)

为重复性任务创建提示词模板

模板 1:创建 CRUD 页面
创建 [资源名称] 的 CRUD 页面:

文件结构:
- 列表页:@src/pages/[资源]/list.vue
- 详情页:@src/pages/[资源]/detail.vue
- 编辑页:@src/pages/[资源]/edit.vue
- API:@src/api/[资源].ts
- 类型:@src/types/[资源].ts
- Store:@src/stores/use[资源]Store.ts

列表页功能:
- 搜索栏([字段1]、[字段2])
- 数据表格([列1]、[列2]、操作列)
- 分页组件
- 新增按钮

详情页功能:
- 展示所有字段
- 编辑按钮
- 删除按钮

编辑页功能:
- 表单(所有可编辑字段)
- 表单验证
- 保存按钮
- 取消按钮

使用项目规范,参考 @src/pages/user 的实现
模板 2:创建业务组件
创建 [组件名称] 组件:

文件位置:@src/components/[组件名称]/index.vue

Props:
- [prop1] (类型): 描述
- [prop2] (类型): 描述

Emits:
- [event1]: 描述
- [event2]: 描述

功能:
1. [功能1描述]
2. [功能2描述]

样式要求:
- [样式要求1]
- [样式要求2]

参考:@src/components/[类似组件]
模板 3:API 接口对接
对接 [接口名称] 接口:

接口信息:
- 路径:[METHOD] /api/v1/[path]
- 描述:[接口功能描述]

请求参数:
- [param1] (类型, 必填/可选): 描述
- [param2] (类型, 必填/可选): 描述

返回数据:
- [field1] (类型): 描述
- [field2] (类型): 描述

需要:
1. 在 @src/api/[模块].ts 中添加接口方法
2. 在 @src/types/[模块].ts 中定义类型
3. 在 @src/mock/handlers.ts 中添加 mock
4. 在 [调用位置] 中调用接口

技巧 4:增量式重构(Incremental Refactoring)

不要一次性重构整个文件,而是逐步优化

1步:"提取 @src/pages/user/list.vue 中的搜索逻辑到 useSearch composable"
  ↓
第2步:"将表格配置抽离到 @src/pages/user/config/tableConfig.ts"
  ↓
第3步:"优化分页逻辑,使用 @src/composables/base/usePagination.ts"
  ↓
第4步:"添加列表数据缓存,避免重复请求"
  ↓
第5步:"添加骨架屏加载状态"

技巧 5:约束驱动开发(Constraint-Driven Development)

通过约束引导 AI 生成更好的代码

实现用户列表功能,严格遵守以下约束:

代码质量约束:
1. 单个函数不超过 50 行
2. 单个文件不超过 300 行
3. 所有异步操作必须有错误处理
4. 使用 TypeScript 严格模式,不允许 any
5. 所有函数必须有 JSDoc 注释

架构约束:
1. 组件拆分:搜索栏、表格、分页各自独立
2. 逻辑抽离:使用 composable 管理状态
3. 类型定义:所有接口和数据结构必须定义类型
4. 错误处理:使用统一的 errorHandler

性能约束:
1. 支持 10000 条数据的虚拟滚动
2. 搜索防抖 300ms
3. 图片懒加载
4. 列表数据缓存 5 分钟

可访问性约束:
1. 支持键盘导航(Tab、Enter、Esc)
2. 支持屏幕阅读器
3. 颜色对比度符合 WCAG 2.1 AA 标准

技巧 6:元提示(Meta Prompting)

让 AI 帮你写提示词

"我想实现一个复杂的数据可视化功能,
但不知道如何描述清楚,
请帮我生成一个完整的提示词,
包含所有必要的信息和约束条件。

需求概述:
- 展示用户增长趋势图(折线图)
- 支持按日/周/月切换
- 支持数据导出
- 需要响应式设计

请生成一个结构化的提示词"

或者:

"根据我的需求,生成一个分步骤的实现计划:

需求:实现一个支持多人协作的在线白板
技术栈:Vue 3 + TypeScript + WebSocket + Canvas

请给出:
1. 整体架构设计
2. 详细的实现步骤
3. 每一步的具体提示词
4. 需要注意的技术难点"

三种工作模式

模式 1:探索模式(Exploration Mode)

适用场景:不确定如何实现,需要探索方案

"我想实现一个拖拽排序的功能,
但不确定用什么库比较好,
项目是 Vue 3 + TypeScript + uni-app,
需要支持移动端触摸拖拽,
请给我几个方案对比,包括:
1. 推荐的库(开源、维护活跃、支持 TypeScript)
2. 各方案的优缺点
3. 性能对比
4. 集成难度
5. 你的推荐和理由"

AI 的回应方式

  • 提供多个方案对比
  • 分析每个方案的适用场景
  • 给出具体的推荐和理由

模式 2:执行模式(Execution Mode)

适用场景:明确知道要做什么,快速实现

"在 @src/components/DragList.vue 中使用 Sortable.js 实现拖拽排序:

1. 安装依赖:
   - sortablejs
   - @types/sortablejs

2. 创建 @src/composables/useDraggable.ts 封装拖拽逻辑

3. 功能要求:
   - 支持拖拽排序
   - 拖拽时显示占位符
   - 拖拽结束触发 @change 事件,返回新的排序
   - 支持禁用拖拽(通过 disabled prop)

4. 样式要求:
   - 拖拽中的元素半透明
   - 占位符显示虚线边框
   - 拖拽手柄显示拖拽图标

参考 @src/components/SortableTable.vue 的实现"

AI 的回应方式

  • 直接生成代码
  • 按步骤实现功能
  • 提供必要的说明

模式 3:优化模式(Optimization Mode)

适用场景:功能已实现,需要优化

"优化 @src/components/DragList.vue 的性能和用户体验:

性能优化:
1. 分析当前性能瓶颈(使用 Vue DevTools)
2. 优化拖拽时的重渲染问题(使用 v-memo)
3. 添加虚拟滚动支持大列表(1000+ 项)
4. 优化事件监听(使用事件委托)

体验优化:
1. 优化移动端触摸体验(增大触摸区域)
2. 添加拖拽动画(使用 FLIP 技术)
3. 添加触觉反馈(移动端震动)
4. 改进无障碍支持(键盘操作)

监控:
1. 添加性能监控埋点
2. 记录拖拽操作日志
3. 监控错误和异常"

AI 的回应方式

  • 分析现有代码
  • 指出优化点
  • 提供优化方案
  • 实施优化

常见陷阱与避坑指南

陷阱 1:过度依赖 AI

问题表现

  • 完全不看 AI 生成的代码,直接复制粘贴
  • 不理解代码逻辑,出问题无法调试
  • 代码风格不统一,不符合项目规范

正确做法

✅ 审查每一行代码
✅ 理解核心逻辑
✅ 验证类型定义
✅ 检查错误处理
✅ 确保符合项目规范
✅ 运行测试验证功能

陷阱 2:提示词过于模糊

问题表现

"优化一下性能""改好看一点""修复一下 bug"

正确做法

"优化列表渲染性能,目标:1000条数据渲染时间从 500ms 降到 100ms 以内""按照 Figma 设计稿调整样式,主要是:间距、颜色、圆角、阴影""修复登录按钮点击无反应的问题,控制台报错:'Cannot read property token of undefined'"

陷阱 3:忽略上下文

问题表现

  • 每次都从零开始描述
  • 不引用相关文件
  • AI 不了解项目结构

正确做法

 使用 @文件名 引用相关文件
 说明项目技术栈和规范
 提供参考实现
 说明业务背景

陷阱 4:一次性要求太多

问题表现

"实现整个电商系统,包括:
   商品管理、订单管理、用户管理、
   支付系统、物流系统、营销系统、
   数据分析、客服系统..."

正确做法

✅ 第1步:"先实现商品列表页,包括搜索、筛选、分页"
✅ 第2步:"实现商品详情页,包括图片轮播、规格选择、加入购物车"
✅ 第3步:"实现购物车功能,包括商品列表、数量修改、结算"
✅ ...逐步推进

陷阱 5:不验证结果

问题表现

  • AI 说完成了就完成了
  • 不运行代码测试
  • 不检查类型错误
  • 不测试边界情况

正确做法

✅ 运行代码,测试功能
✅ 检查 TypeScript 类型错误
✅ 测试边界情况(空数据、错误数据、极端数据)
✅ 检查性能(大数据量、慢网络)
✅ 测试兼容性(不同浏览器、不同设备)

陷阱 6:忽视代码质量

问题表现

  • 只关注功能实现,不关注代码质量
  • 代码重复、结构混乱
  • 没有注释、类型定义不完整

正确做法

✅ 要求 AI 遵守代码规范
✅ 要求添加注释和类型定义
✅ 要求代码模块化、可复用
✅ 要求添加错误处理
✅ 定期重构优化

黄金公式

完美提示词 = 7 个要素

[角色设定] + [任务目标] + [上下文] + [技术栈] + [约束条件] + [预期结果] + [参考示例]

实战示例

你是一个 Vue 3 + TypeScript 专家(角色设定),

帮我在 @src/pages/product/list.vue 中实现商品列表功能(任务目标),

这是一个电商小程序项目,需要展示商品列表并支持筛选,
商品数据来自后端 API,用户可以通过分类、价格、销量等条件筛选商品(上下文),

使用 Vue 3 Composition API + TypeScript + Pinia + uni-app,
调用 GET /api/v1/products 接口获取商品列表(技术栈),

要求:
1. 不修改现有的 TabBar 组件和路由配置
2. 支持下拉刷新和上拉加载更多
3. 筛选条件包括:分类、价格区间、销量排序
4. 单个组件不超过 300 行,复杂逻辑抽离到 composable
5. 所有类型必须明确定义,不使用 any
6. 图片使用懒加载
7. 列表数据缓存 5 分钟
(约束条件)

完成后应该能够:
1. 进入页面自动加载第一页数据(20条)
2. 下拉刷新重置列表并重新加载
3. 滚动到底部自动加载下一页
4. 点击筛选按钮打开筛选面板
5. 选择筛选条件后列表自动更新
6. 点击商品卡片跳转到详情页
7. 显示加载状态和空状态
(预期结果)

参考 @src/pages/order/list.vue 的列表实现方式和
@src/components/FilterPanel.vue 的筛选面板样式
(参考示例)

简化版(日常使用)

对于简单任务,可以使用简化版:

@src/components/ProductCard.vue 中实现商品卡片组件:
- Props: product (IProduct 类型)
- 显示:商品图片、名称、价格、销量
- 点击跳转到商品详情页
- 样式参考 @src/components/OrderCard.vue

进阶心法

心法 1:Think Like a Product Manager

不要只想"怎么实现",要想"为什么实现"、"用户会怎么用"

不只是:"实现搜索功能"

而是:
"实现搜索功能,因为用户需要快速找到商品,
支持:
- 模糊搜索(匹配商品名称、描述、标签)
- 搜索历史(最多保存 10 条,支持删除)
- 热门搜索推荐(显示 TOP 10 热搜词)
- 搜索结果高亮关键词
- 无结果时推荐相关商品
- 搜索防抖(300ms)
- 支持语音搜索(调用微信语音识别 API)"

心法 2:Embrace Iteration

第一版不需要完美,快速出原型,然后迭代优化

V1: 基础功能(能用)
  - 实现核心功能
  - 基本的 UI
  - 简单的错误处理

V2: 优化体验(好用)
  - 优化交互流程
  - 改进视觉设计
  - 添加加载状态
  - 完善错误提示

V3: 性能优化(快用)
  - 优化渲染性能
  - 添加缓存机制
  - 图片懒加载
  - 请求防抖节流

V4: 完善细节(爱用)
  - 添加动画效果
  - 优化无障碍支持
  - 完善边界情况
  - 添加埋点统计

心法 3:Build a Knowledge Base

把常用的提示词、代码模板、最佳实践整理成文档

建议创建以下文档:

docs/
├── prompts/
│   ├── component-templates.md      # 组件开发模板
│   ├── api-integration.md          # API 对接模板
│   ├── page-templates.md           # 页面开发模板
│   └── refactoring-checklist.md   # 重构检查清单
├── standards/
│   ├── code-style.md               # 代码风格规范
│   ├── naming-conventions.md      # 命名规范
│   ├── typescript-guide.md        # TypeScript 使用指南
│   └── performance-checklist.md   # 性能优化清单
└── best-practices/
    ├── vue3-patterns.md            # Vue 3 最佳实践
    ├── error-handling.md           # 错误处理最佳实践
    ├── state-management.md         # 状态管理最佳实践
    └── testing-guide.md            # 测试指南

心法 4:Teach the AI Your Style

通过 .cursorrules 或项目规范文件,让 AI 了解你的编码风格

// .cursorrules 示例

# 项目规范

## 技术栈
- Vue 3 + TypeScript + Pinia + uni-app
- 使用 Composition API + <script setup>
- 使用 SCSS 预处理器

## 代码规范
- 组件使用 PascalCase 命名
- 文件名使用 kebab-case
- 所有函数必须有 JSDoc 注释
- 使用 interface 而不是 type(除非必要)
- Props 和 Emits 必须定义 TypeScript 类型
- 不使用 any,必要时使用 unknown

## 错误处理
- 所有异步操作必须有 try-catch
- 使用统一的 errorHandler 处理错误
- 错误信息要用户友好,不暴露技术细节

## 性能要求
- 列表超过 100 项使用虚拟滚动
- 图片必须懒加载
- 请求必须防抖或节流
- 避免不必要的重渲染

## 文件组织
- 页面文件放在 src/pages/
- 公共组件放在 src/components/
- 业务组件放在页面目录下的 components/
- API 接口放在 src/api/
- 类型定义放在 src/types/
- 工具函数放在 src/utils/
- Composables 放在 src/composables/

心法 5:Review Like a Senior Developer

不要盲目信任 AI,要像 Code Review 一样审查代码

Code Review 检查清单
□ 功能正确性
  □ 实现了所有需求
  □ 边界情况处理正确
  □ 错误处理完善

□ 代码质量
  □ 逻辑清晰,易于理解
  □ 命名规范,见名知意
  □ 注释充分,解释清楚
  □ 没有重复代码

□ 类型安全
  □ 所有类型定义完整
  □ 没有使用 any
  □ Props 和 Emits 类型正确
  □ API 响应类型定义

□ 性能
  □ 没有不必要的重渲染
  □ 列表渲染优化(key、v-memo)
  □ 图片懒加载
  □ 请求防抖节流

□ 安全性
  □ 用户输入验证
  □ XSS 防护
  □ 敏感信息不暴露
  □ 权限检查

□ 可维护性
  □ 代码模块化
  □ 职责单一
  □ 易于测试
  □ 易于扩展

□ 用户体验
  □ 加载状态
  □ 错误提示
  □ 空状态处理
  □ 交互反馈

□ 规范遵守
  □ 符合项目编码规范
  □ 符合 Git 提交规范
  □ 符合命名规范
  □ 符合文件组织规范

实战案例

案例 1:从零实现用户登录功能

第1轮:架构设计

提示词

设计用户登录功能的整体架构:
- 支持手机号+验证码登录
- 支持账号+密码登录
- 需要记住登录状态
- 需要 Token 管理和自动刷新
请给出文件组织结构和技术方案

AI 回应

文件组织:
src/
├── pages/login/
│   ├── index.vue           # 登录页(选择登录方式)
│   ├── phone.vue           # 手机号登录
│   └── password.vue        # 密码登录
├── api/auth.ts             # 认证相关 API
├── types/auth.ts           # 认证相关类型
├── stores/useAuthStore.ts  # 认证状态管理
└── composables/
    └── useAuth.ts          # 认证逻辑封装

技术方案:
1. Token 存储:uni.setStorageSync
2. Token 刷新:拦截器自动刷新
3. 登录状态:Pinia Store 管理
4. 路由守卫:检查登录状态
第2轮:实现手机号登录

提示词

@src/pages/login/phone.vue 中实现手机号+验证码登录:

功能:
1. 手机号输入(11位数字,实时校验)
2. 发送验证码按钮(60秒倒计时)
3. 验证码输入(6位数字)
4. 登录按钮(表单验证通过后可点击)

API:
- 发送验证码:POST /api/v1/auth/send-code
- 登录:POST /api/v1/auth/login

技术要求:
- 使用 Vue 3 Composition API + TypeScript
- 表单验证使用 uni-app 内置验证
- 调用 @src/api/auth.ts 中的接口
- 登录成功后保存 token 到 @src/stores/useAuthStore.ts
- 跳转到首页

样式:
- 参考 @src/pages/login/index.vue 的整体风格
- 输入框使用圆角卡片样式
- 按钮使用渐变色
第3轮:优化用户体验

提示词

优化 @src/pages/login/phone.vue 的用户体验:

1. 输入优化:
   - 手机号输入自动添加空格(3-4-4 格式)
   - 验证码输入自动聚焦到下一位
   - 支持粘贴验证码自动填充

2. 反馈优化:
   - 发送验证码成功显示 Toast
   - 登录中显示 Loading
   - 登录失败显示错误提示
   - 表单验证失败高亮错误字段

3. 动画效果:
   - 页面进入淡入动画
   - 按钮点击缩放反馈
   - 错误提示抖动动画

4. 边界情况:
   - 网络错误重试
   - 验证码过期提示
   - 频繁发送验证码限制

案例 2:优化现有商品列表性能

第1轮:性能分析

提示词

分析 @src/pages/product/list.vue 的性能问题:
- 当前有 1000+ 商品数据
- 滚动时有明显卡顿
- 图片加载慢
- 筛选条件改变时整个列表重新渲染

请使用 Vue DevTools 分析性能瓶颈,
并给出优化方案
第2轮:实施优化

提示词

优化 @src/pages/product/list.vue 的性能:

1. 列表渲染优化:
   - 使用虚拟滚动(uni-app 的 recycle-view)
   - 使用 v-memo 避免不必要的重渲染
   - 优化 key 的使用

2. 图片加载优化:
   - 使用图片懒加载
   - 使用缩略图(小尺寸)
   - 添加占位图

3. 筛选优化:
   - 筛选条件改变时只更新数据,不重新渲染整个列表
   - 使用防抖避免频繁筛选

4. 数据优化:
   - 添加列表数据缓存
   - 分页加载,每页 20 条

目标:
- 1000 条数据渲染时间 < 100ms
- 滚动帧率 > 55fps
- 图片加载使用渐进式加载

案例 3:重构混乱的组件

第1轮:代码审查

提示词

审查 @src/pages/order/detail.vue 的代码质量:
- 文件有 800+ 行
- 逻辑混乱,难以维护
- 没有类型定义
- 没有注释

请分析存在的问题,并给出重构方案
第2轮:拆分组件

提示词

重构 @src/pages/order/detail.vue,按以下方案拆分:

1. 拆分组件:
   - OrderHeader.vue(订单头部信息)
   - OrderProducts.vue(商品列表)
   - OrderAddress.vue(收货地址)
   - OrderPrice.vue(价格明细)
   - OrderActions.vue(操作按钮)

2. 抽离逻辑:
   - useOrderDetail.ts(订单详情逻辑)
   - useOrderActions.ts(订单操作逻辑)

3. 类型定义:
   - 在 @src/types/order.ts 中定义所有类型

4. 要求:
   - 每个组件 < 200 行
   - 所有 Props 和 Emits 定义类型
   - 添加 JSDoc 注释
   - 保持功能不变

总结

Vibe Coding 的本质

Vibe Coding 不是让 AI 替你写代码,而是让 AI 成为你的编程伙伴

三个关键点

  1. 清晰表达:用准确的语言描述你的意图
  2. 持续迭代:不追求一次完美,快速迭代优化
  3. 代码审查:始终审查 AI 生成的代码,确保质量

记住这些原则

最好的提示词不是最长的,而是最清晰的

最好的代码不是 AI 生成的,而是你审查过的

最好的开发者不是不用 AI 的,而是会用 AI 的

持续学习

Vibe Coding 是一个不断进化的领域:

  • 关注 AI 能力更新:新功能、新特性
  • 总结最佳实践:记录有效的提示词模板
  • 分享经验:与团队分享 Vibe Coding 技巧
  • 保持好奇:探索 AI 的边界和可能性

性能与成本优化

Token 使用优化策略

在使用 AI 编码工具时,合理控制 Token 消耗不仅能降低成本,还能提升响应速度。

1. 精准的上下文管理

只添加必要的文件到上下文

❌ 不好:让 AI 读取整个项目
"帮我看看项目中所有的登录相关代码"

✅ 更好:精确引用需要的文件
"检查 @src/pages/login/phone.vue 和 @src/api/auth.ts 中的登录逻辑"

及时清理上下文

  • 对话过长时(>50 轮)开启新对话
  • 完成一个功能模块后,总结要点,开启新对话
  • 避免在同一对话中处理多个不相关的任务

分层提问策略

❌ 低效方式:
"帮我实现一个完整的订单系统,包括列表、详情、支付等所有功能"
// 这会生成大量 token,可能不准确

✅ 高效方式:
第1步:"设计订单系统的整体架构和文件组织"2步:"实现订单列表页,参考 @src/pages/product/list.vue"3步:"实现订单详情页,使用 @src/types/order.ts 的类型定义"4步:"集成支付功能"
// 精确引用,减少上下文,提高准确度
2. 减少 Token 消耗的技巧

使用代码引用而非完整代码

❌ 不好:把整个文件内容粘贴进来
"这是我的代码:[粘贴 500 行代码],帮我优化"

✅ 更好:使用文件引用和行号
"优化 @src/pages/order/list.vue 第 45-60 行的列表渲染逻辑"

明确的问题描述

❌ 模糊描述(AI 需要多轮对话确认):
"这个组件有问题,帮我看看"

✅ 精确描述(一次性解决):
"CategoryTabs 组件在切换时状态不更新,
怀疑是 props 响应式问题,
请检查 @src/pages/index/components/CategoryTabs.vue 第 45-60 行"

利用项目规则

  • .cursorrules 中定义项目规范
  • AI 会自动遵循这些规则,无需每次重复
  • 减少"请使用 TypeScript"、"请用 Vue 3 Composition API"等重复指令
3. 提高生成速度的策略

使用快捷指令

根据项目规则定义快捷方式:

// 在 .cursorrules 中定义
- 'figma2vue' - 快速从设计生成页面
- '组件拆分' - 快速拆分组件
- '跑多语言' - 快速处理国际化

批量操作

// ✅ 一次性提问多个相关问题
"请帮我:
1. 创建 OrderList 组件的类型定义
2. 实现分页逻辑
3. 添加加载状态
参考 @src/pages/product/list.vue 的实现"

// 而不是分三次问,每次都要重新理解上下文

使用模板和代码片段

创建常用的代码模板,减少重复生成:

// templates/page-template.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'

// TODO: 添加具体逻辑
</script>

<template>
  <view class="page">
    <!-- TODO: 添加内容 -->
  </view>
</template>

<style lang="scss" scoped>
.page {
  // TODO: 添加样式
}
</style>
4. 智能使用不同的 AI 模型

根据任务选择模型

任务类型 推荐模型 原因
简单重复任务 GPT-3.5 / Claude Haiku 更快更便宜
代码格式化、简单重构 GPT-3.5 成本低,速度快
复杂架构设计 GPT-4 / Claude Sonnet 理解力强,质量高
代码审查 Claude Sonnet 注重细节,准确度高
算法优化 GPT-4 Turbo 推理能力强

Cursor 快捷键优化

Cmd/Ctrl + K - 快速编辑(适合小改动,token 消耗少)
Cmd/Ctrl + L - 聊天模式(适合复杂问题)
Cmd/Ctrl + I - 内联编辑(最省 token)
5. 代码复用和组件化

建立组件库

// 优先使用现有组件,减少重复生成
components/
├── Base/          // 基础组件(高复用)
│   ├── BaseButton.vue
│   ├── BaseInput.vue
│   └── BaseEmpty.vue
├── Business/      // 业务组件(中复用)
│   ├── RegionPicker/
│   └── SkuPopup/
└── [Page]/        // 页面组件(低复用)

使用 Composables 复用逻辑

// composables/useOrder.ts
export function useOrder() {
    // 订单相关逻辑
    const orders = ref([])
    const loading = ref(false)

    const fetchOrders = async () => {
        // ...
    }

    return { orders, loading, fetchOrders }
}

// 在多个页面中复用,而不是重复生成
import { useOrder } from '@/composables'
6. 增量式开发

先骨架后细节

第一步:生成页面结构和路由(快速搭建)
  "创建订单列表页面的基础结构"

第二步:实现核心功能(能用)
  "实现订单列表的数据加载和展示"

第三步:添加交互(好用)
  "添加搜索、筛选、排序功能"

第四步:优化体验(爱用)
  "添加加载状态、空状态、错误处理"

第五步:性能优化(快用)
  "优化列表渲染性能,添加虚拟滚动"

使用 TODO 标记

// ✅ 让 AI 只实现关键部分
const handleSubmit = async () => {
    // TODO: 添加表单验证
    // TODO: 调用 API
    // TODO: 处理错误
    // TODO: 成功后跳转
}

// 然后分别让 AI 实现每个 TODO
// 每次只消耗少量 token
7. 利用缓存和记忆

使用 Cursor 的记忆功能

让 AI 记住:
- 项目特定的约定和规范
- 常用的代码模式
- 你的偏好设置
- 团队的命名规则

这样每次对话不需要重复说明

本地代码搜索优先

工作流程:
1. ✅ 先用 Cmd/Ctrl + P 搜索现有代码
2. ✅ 再用 Cmd/Ctrl + Shift + F 全局搜索
3. ✅ 查看项目文档和注释
4. ❌ 最后才问 AI(如果找不到)

这样可以节省 50-70% 的 AI 调用
8. 成本对比与选择

主流 AI 模型成本对比(2026年1月)

模型 输入成本 输出成本 适用场景
GPT-4 Turbo $0.01/1K tokens $0.03/1K tokens 复杂架构、算法设计
Claude Sonnet $0.003/1K tokens $0.015/1K tokens 代码审查、重构
GPT-3.5 Turbo $0.0005/1K tokens $0.0015/1K tokens 简单任务、格式化
Claude Haiku $0.00025/1K tokens $0.00125/1K tokens 重复性任务

节省成本的策略

1. 简单问题用便宜模型
   - 代码格式化 → GPT-3.5
   - 简单重构 → Claude Haiku
   - 类型定义 → GPT-3.5

2. 复杂问题用高级模型
   - 架构设计 → GPT-4 Turbo
   - 性能优化 → Claude Sonnet
   - 算法实现 → GPT-4

3. 批量处理
   - 一次性处理多个相关任务
   - 减少对话轮次
   - 提高 token 利用率

4. 使用本地工具
   - ESLint(代码检查)
   - Prettier(代码格式化)
   - TypeScript(类型检查)
   - 不需要 AI 的不用 AI
9. 其他 AI 编码工具对比

GitHub Copilot

  • 优势:实时补全,速度快,IDE 集成好
  • 适用:日常编码、快速补全、代码片段
  • 成本:$10/月(固定费用)
  • 推荐场景:作为基础工具,处理 80% 的日常编码

Codeium

  • 优势:免费,支持多种 IDE,功能全面
  • 适用:预算有限的开发者、学习阶段
  • 成本:免费(个人版)
  • 推荐场景:个人项目、学习练习

Tabnine

  • 优势:可本地部署,隐私性好,企业级
  • 适用:企业级项目、隐私敏感项目
  • 成本:$12-39/月
  • 推荐场景:企业项目、对代码隐私有要求

Cursor

  • 优势:深度集成,上下文理解好,多模型支持
  • 适用:复杂项目、架构设计、代码重构
  • 成本:$20/月(Pro)
  • 推荐场景:专业开发、复杂业务逻辑

推荐组合方案

方案 1:性价比最高
- 日常编码:GitHub Copilot($10/月)
- 复杂问题:Cursor + Claude Sonnet($20/月)
- 总成本:$30/月

方案 2:预算有限
- 日常编码:Codeium(免费)
- 复杂问题:Claude API 直接调用(按需付费)
- 总成本:<$10/月

方案 3:专业开发
- 日常编码:GitHub Copilot($10/月)
- 架构设计:Cursor + GPT-4($20/月)
- 代码审查:Claude Sonnet API(按需)
- 总成本:$30-40/月

方案 4:团队协作
- 统一使用 Cursor Team($40/月/人)
- 配置团队规则和知识库
- 共享最佳实践和提示词模板
10. 实战优化示例

优化前(低效方式)

对话轮次:15 轮
Token 消耗:约 50,000 tokens
时间:45 分钟
成本:约 $0.75

问题:
- 每次都重新描述需求
- 没有引用相关文件
- 一次性要求太多功能
- 频繁修改和调整

优化后(高效方式)

对话轮次:5 轮
Token 消耗:约 15,000 tokens
时间:15 分钟
成本:约 $0.23

改进:
- 使用 @文件名 精确引用
- 分步骤实现,每步明确
- 利用项目规则,减少重复说明
- 使用模板和参考实现

节省效果

Token 节省:70%
时间节省:67%
成本节省:69%
质量提升:代码更规范,bug 更少
11. 最佳实践清单

开始编码前

  • 明确任务目标和范围
  • 准备好相关文件引用
  • 检查是否有类似实现可参考
  • 确认技术栈和约束条件
  • 选择合适的 AI 模型

编码过程中

  • 只添加必要文件到上下文
  • 使用 @文件名 精确引用
  • 问题描述清晰具体
  • 分步骤实现,逐步验证
  • 及时清理长对话

代码生成后

  • 审查每一行代码
  • 验证类型定义
  • 测试功能和边界情况
  • 检查性能和安全性
  • 确保符合项目规范

持续优化

  • 总结有效的提示词模板
  • 建立项目知识库
  • 记录常见问题和解决方案
  • 定期回顾和优化工作流
  • 与团队分享最佳实践

性能优化总结

通过以上策略,你可以:

  • 降低 50-70% 的 Token 消耗
  • 提升 2-3 倍的开发效率
  • 减少 60-80% 的成本
  • 提高代码质量和一致性

记住:最好的优化不是少用 AI,而是更智能地使用 AI


附录

常用提示词模板库

1. 功能实现类
在 @[文件路径] 中实现 [功能名称]:

功能描述:
- [功能点1]
- [功能点2]

技术要求:
- [技术要求1]
- [技术要求2]

约束条件:
- [约束1]
- [约束2]

参考:@[参考文件]
2. Bug 修复类
修复 @[文件路径] 中的问题:

问题描述:
- 现象:[问题表现]
- 预期:[期望结果]
- 错误信息:[错误日志]

复现步骤:
1. [步骤1]
2. [步骤2]

环境信息:
- [浏览器/设备/版本]
3. 性能优化类
优化 @[文件路径] 的性能:

当前问题:
- [性能问题1]
- [性能问题2]

优化目标:
- [具体指标]

优化方向:
- [方向1]
- [方向2]
4. 代码重构类
重构 @[文件路径]:

重构目标:
- [目标1]
- [目标2]

重构方案:
- [方案描述]

要求:
- 保持功能不变
- 添加必要的注释
- 提高代码可读性

Keep Vibing, Keep Coding! 🚀

Flutter最佳实践:路由弹窗终极版NSlidePopupRoute

作者 SoaringHeart
2026年1月14日 18:16

一、需求来源

最近需要实现弹窗,动画方向分为:

  1. 从屏幕顶部滑动到屏幕内,top->Center。
  2. 从屏幕底部滑动到屏幕内,bottom->Center。
  3. 从屏幕左侧滑动到屏幕内,left->Center。
  4. 从屏幕右侧滑动到屏幕内,right->Center。
  5. 直接显示在屏幕中间,Center->Center。

最终实现以 Alignment 参数为动画方向,弹窗内容高度和宽度自定义,实现从哪个方向弹出就从哪个方向消失的高自由度。彻底突破了 ModalBottomSheet 的方向显示。 效果图如下:

Simulator Screenshot - iPhone 16 - 2025-12-27 at 11.36.41.png

Simulator Screenshot - iPhone 16 - 2025-12-27 at 11.36.43.png

Simulator Screenshot - iPhone 16 - 2025-12-27 at 11.36.45.png

Simulator Screenshot - iPhone 16 - 2025-12-27 at 11.36.48.png

Simulator Screenshot - iPhone 16 - 2025-12-27 at 11.36.51.png

Simulator Screenshot - iPhone 16 - 2025-12-27 at 11.36.53.png

Simulator Screenshot - iPhone 16 - 2025-12-27 at 11.36.59.png

Simulator Screenshot - iPhone 16 - 2025-12-27 at 11.37.01.png

Simulator Screenshot - iPhone 16 - 2025-12-27 at 11.37.04.png

二、使用示例

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

void main() {
  runApp(const MyApp());
}

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  List<Alignment> alignments = [    Alignment.topLeft,    Alignment.topCenter,    Alignment.topRight,    Alignment.centerLeft,    Alignment.center,    Alignment.centerRight,    Alignment.bottomLeft,    Alignment.bottomCenter,    Alignment.bottomRight,  ];

  var alignment = Alignment.center;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          const Text("direction from Alignment."),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              ...alignments.map((e) {
                var name = e.toString().split('.')[1];
                return ElevatedButton(
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.blue,
                    elevation: 0,
                    tapTargetSize: MaterialTapTargetSize.shrinkWrap,
                    minimumSize: const Size(64, 36),
                  ),
                  onPressed: () {
                    alignment = e;
                    debugPrint("$alignment ${alignment.x} ${alignment.y}");
                    onPopupRoute();
                  },
                  child: Text(
                    name,
                    style: TextStyle(color: Colors.white),
                  ),
                );
              }),
            ],
          )
        ],
      ),
    );
  }

  Future<void> onPopupRoute() async {
    final route = NSlidePopupRoute(
      from: alignment,
      builder: (_) {
        return buildPopupView(alignment: alignment, argsDismiss: {"b": "88"});
      },
    );
    final result = await Navigator.of(context).push(route);
    print(["result", result.runtimeType, result]);
  }

  Widget buildPopupView({required Alignment alignment, Map<String, dynamic>? argsDismiss}) {
    return Align(
      alignment: alignment,
      child: Container(
        width: 300,
        height: 400,
        alignment: Alignment.center,
        decoration: BoxDecoration(
          color: Colors.green,
          border: Border.all(color: Colors.blue),
          borderRadius: BorderRadius.all(Radius.circular(0)),
        ),
        child: ElevatedButton(
          onPressed: () {
            Navigator.of(context).pop(argsDismiss);
          },
          child: Text("dismiss"),
        ),
      ),
    );
  }
}

三、源码

//
//  NSlidePopupRoute.dart
//  n_slide_popup
//
//  Created by shang on 2025/12/27.
//  Copyright © 2025/12/27 shang. All rights reserved.
//


import 'package:flutter/material.dart';

/// 最新滑入弹窗
class NSlidePopupRoute<T> extends PopupRoute<T> {
  NSlidePopupRoute({
    super.settings,
    required this.builder,
    this.from = Alignment.bottomCenter,
    this.barrierColor = const Color(0x80000000),
    this.barrierDismissible = true,
    this.duration = const Duration(milliseconds: 300),
    this.barrierLabel,
    this.curve = Curves.easeOutCubic,
  });

  final WidgetBuilder builder;

  /// 从哪个方向进入(推荐:topCenter / bottomCenter / centerLeft / centerRight)
  final Alignment from;

  final Duration duration;

  final Curve curve;

  @override
  final bool barrierDismissible;

  @override
  final Color barrierColor;

  @override
  final String? barrierLabel;

  @override
  Duration get transitionDuration => duration;

  // 展示
  static Future<T?> show<T>({
    required BuildContext context,
    required WidgetBuilder builder,
    Alignment from = Alignment.bottomCenter,
    Duration duration = const Duration(milliseconds: 300),
    Curve curve = Curves.easeOutCubic,
    Color barrierColor = const Color(0x80000000),
    bool barrierDismissible = true,
    String? barrierLabel,
    bool useRootNavigator = true,
    RouteSettings? routeSettings,
  }) {
    return Navigator.of(context, rootNavigator: useRootNavigator).push(
      NSlidePopupRoute<T>(
        builder: builder,
        from: from,
        duration: duration,
        curve: curve,
        barrierColor: barrierColor,
        barrierDismissible: barrierDismissible,
        barrierLabel: barrierLabel,
        settings: routeSettings,
      ),
    );
  }

  @override
  Widget buildPage(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
  ) {
    // 直接返回背景和内容,不应用动画
    return Material(
      color: barrierColor,
      child: const SizedBox.expand(), // 只负责背景
    );
  }

  @override
  Widget buildTransitions(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
    Widget child,
  ) {
    final content = builder(context);

    // ⭐ 中心弹窗:Fade
    if (from == Alignment.center) {
      return FadeTransition(
        opacity: animation.drive(
          CurveTween(curve: Curves.easeOut),
        ),
        child: content,
      );
    }

    // ⭐ 其余方向:Slide
    return FadeTransition(
      opacity: animation,
      child: SlideTransition(
        position: animation.drive(
          Tween<Offset>(
            begin: _alignmentToOffset(from),
            end: Offset.zero,
          ).chain(
            CurveTween(curve: curve),
          ),
        ),
        child: content,
      ),
    );
  }

  /// Alignment → Offset(关键点)
  Offset _alignmentToOffset(Alignment alignment) {
    return Offset(
      alignment.x.sign,
      alignment.y.sign,
    );
  }
}

最后、总结

  1. NSlidePopupRoute 代码实现极简,没有过多的底层封装,是为了追求极致的自由度。
  2. 当 alignment == Alignment.center 时,它是 Dialog弹窗。
  3. 从顶部滑出时,它可以是顶部通知 Toast。
  4. 从底部滑出时,它可以是 ModalBottomSheet,SnackBar。

github

pub.dev

react native中实现视频转歌

作者 sure282
2026年1月14日 18:11

先放图

微信图片_20260114172105_165_23.jpg

微信图片_20260114172111_166_23.jpg

微信图片_20260114172102_162_23.jpg

微信图片_20260114172103_163_23.jpg

前言

先解释一下视频转歌:它的本质是提取视频文件的音频部分为独立文件,底层技术原理是不是这样我也不是特别清楚。提取后存到设备内存中 ,音乐播放器可以读取播放该文件。该功能是我在某个音乐播放器应用上看到功能菜单有一个视频转歌功能,并简单尝试了,它的效果就是将选择的视频转换为音频,不过该软件转换后的文件是.vts格式,想要使用转换产物肯定要支持.vts或者了解这种文件怎么读取,至于为什么是这样大家应该都能理解,这也不是今天的重点。

项目环境关键依赖

在rn应用中,依赖版本对应性很重要,一旦有一点不匹配就会各种问题,各种失败,而且是使用expo创建的rn项目在需要原生功能时安装的依赖通常需要开发构建,下面是关键依赖以及版本:

  • "expo": "~54.0.31",
  • "kroog-ffmpeg-kit-react-native": "^6.0.9",
  • "react": "19.1.0",

实现视频转歌的核心依赖是kroog-ffmpeg-kit-react-native,这个依赖很明显是folk的分支,原包ffmpeg-kit好像已经不再维护,前段时间有个新闻说谷歌AI发现该依赖的漏洞,一段时间如果不修复,谷歌会公布漏洞,该项目团队回应说(通俗地说)你要是有良心就捐点money,你也是大用户,带头白嫖巴拉巴的。

核心依赖找到后,除了如何使用依赖外,流程非常清晰:选择视频文件->获取uri等信息->交给ffmpeg->转化->监听处理转换进度 ->更新进度->获取转换后的缓存结果->保存到设备:

1.选取视频文件

选取视频可以使用两种依赖库,分别是expo-media-library和expo-document-picker, 这里我们使用前者获取设备中MediaType.video类型的数据渲染成列表,供用户选择,选取后我们拿到uri

import { getAssetsAsync, MediaType, requestPermissionsAsync, type AssetsOptions } from 'expo-media-library';
    /**
     * 请求权限并获取视频列表
     *
     * 此方法会先请求媒体库读取权限,如果授权成功则调用 fetchVideos 方法加载视频数据。
     * 如果未获得权限,则弹出提示框告知用户。
     *
     * @param reset 是否重置当前已有的视频列表,默认为 false
     * @returns Promise<void>
     */
    const requestPermissionAndGetVideos = async (reset = true) => {
        try {
            setLoading(true);
            // 请求媒体库权限
            const { status } = await requestPermissionsAsync();
            if (status !== 'granted') {
                showNotification({ tip: '需要访问媒体库权限才能获取视频文件,请在设置中开启权限。', type: 'warning' });
                setHasPermission(false);
                return;
            }
            setHasPermission(true);
            // 获取视频文件,重新请求权限时默认重置列表
            await fetchVideos(reset);
        } catch (error) {
            showNotification({ tip: '获取视频文件失败,请重试', type: 'error' });
        } finally {
            setLoading(false);
        }
    };
        /**
         * 使用expo-media-library 获取视频文件列表
         * 和expo-document-picker 获取视频文件列表结果不太一样,expo-media-library 会返回更多的视频文件
         * 调用系统 API 获取指定范围内的视频资源,并更新状态以渲染到界面。
         * 支持分页加载与刷新功能。
         *
         * @param reset 是否清空已有数据后重新加载,默认为 false
         * @returns Promise<void>
         */
    const fetchVideos = async (reset = false) => {
        try {
            const options: AssetsOptions = {
                mediaType: MediaType.video, // 只获取视频文件
                first, // 每次加载根据列数动态调整
                after: reset ? undefined : after, // 分页加载
                sortBy: [['modificationTime', true]], // 按修改时间降序排序
            };
            const { assets = [], endCursor, hasNextPage } = await getAssetsAsync(options);
            const videoList = assets.map(asset => ({
                ...asset,
                durationString: formatTime(asset.duration),
            }));
            if (reset) {
                setVideos(videoList);
            } else {
                setVideos(prev => [...prev, ...videoList]);
            }
            setAfter(endCursor);
            setHasMore(hasNextPage);
        } catch (error) {
            showNotification({ tip: '获取视频列表失败,请重试', type: 'error' });
        }
    };

通过以上代码我们拿到视频列表,使用expo-media-library的好处在于可以分批获取视频文件,非常便于分页加载

2.核心操作 使用ffmpeg-kit从指定文件中提取音频: 当用户选取视频后我们存储uri,并使用expo-file-system检查文件uri是否存在,expo-file-system目前有新版和legacy版本,API区别很大,新版有三个核心依赖:Paths File Directory,API不像老版本直观,目前可以使用legacy版本,根据自己情况选择即可 文件存在则将文件存入缓存目录,使用FFmpegKi类执行命令: -i "${nativeInputPath}" -vn -c:a aac -b:a 192k "${nativeOutputPath}"; 目前该依赖库并不能转换成mp3但是可以转换成m4a格式音频:

import { FFmpegKit, type FFmpegSession, type Log, type FFmpegSessionCompleteCallback, type LogCallback } from 'kroog-ffmpeg-kit-react-native';

    /**
     * 异步处理视频转音频的核心逻辑函数。
     * 
     * 主要功能包括:
     * 1. 检查并创建用于 FFmpeg 的本地工作目录;
     * 2. 将选中的视频文件复制到缓存目录以确保访问权限;
     * 3. 使用 FFmpeg 提取音频流并进行格式转换;
     * 4. 在转换过程中更新进度条;
     * 5. 完成后将结果保存至应用私有目录,并提示用户;
     * 6. 包含详细的错误处理与取消机制支持。
     *
     * @param video - 视频资源对象,必须包含 uri 和其他必要元数据(如 duration、filename 等)。
     * @returns 无返回值。副作用包括 UI 更新、文件操作及可能的弹窗提示。
     */
    const handleConvertVideo = async (video: VideoAsset) => {
        /**
         * 检查当前是否有选中的视频文件,没有则提示用户选择视频文件
         */
        if (!video) {
            showNotification({ tip: '请选择视频文件', type: 'warning' });
            return;
        };
        /**保存视频资源对象 */
        convertVideoRef.current = video;
        /**
         * 先检查源文件是否存在,源文件不存在停止执行
         * 后续会检查缓存文件是否存在,
         * 如果存在就不缓存,直接使用缓存文件
         */
        const sourceFile = new File(video.uri);
        if (!sourceFile.exists) {
            showNotification({ tip: '视频文件不存在', type: 'error' });
            return;
        }
        const { document, cache } = Paths;
        /**
         *  存储转换后的音频文件用的FFmpeg 的本地工作目录
         */
        let ffmpegFolder: Directory | null = null;
        /**
         * 检测目录是否存在,不存在则创建
         */
        try {

            /**
             * 新版expo-file-system 检测文件是否是文件夹使用
             * Paths.info(uri)静态方法方法可以检测文件是否是文件夹
             * 返回对象{exists: boolean, isDirectory: boolean}
             * 或者直接使用new Dictory(ffmpegDir)返回对象,目录是否存在,不存在调用返回值create方法创建
             * 如果路径中包含中文文字必须使用encodeURI进行编码,否则报错
             * 使用encodeURI可以完整路径转码,使用encodeURIComponent
             * 可能转换过多导致路径报错,主要是路径中的中文名部分要转换
             * 第二个参数文件夹名前后写不写/都可以
             */

            ffmpegFolder = new Directory(Paths.document, MUSIC_FILE_FILE_NAME);
            if (!ffmpegFolder.exists) {
                let obj: DirectoryCreateOptions = { intermediates: true };
                /**
                 * 创建目录
                 */
                ffmpegFolder.create(obj)
            };
        } catch (error) {
            showNotification({ tip: '无法创建存储文件夹', type: 'error' });
            return;
        };
        /**
         * 开始转换视频为音频
         * 1. 复制视频到缓存目录
         * 2. 构建FFmpeg命令
         * 3. 执行转换
         * 4. 清理临时文件
         * 设置开始转换状态为true,重置进度值为0,并设置取消标志为false
         */
        setIsConverting(true);
        setProgress(0);
        cancelledRef.current = false;
        try {
            const { filename } = video;
            const baseName = filename ? filename.replace(/\.[^/.]+$/, '') : `video_${Date.now()}`;
            /**先将目标文件复制到缓存目录中 */
            const cacheDir = cache.uri ?? document.uri ?? '/';
            //方式1: 手动拼接文件要保存到缓存目录路径
            const cachedInputUri = `${cacheDir}${filename ?? baseName}`;
            cachedInputUriRef.current = cachedInputUri;
            // 方式2: 使用File构造函数链式创建目标文件
            // const cachedInputUri = new File(Paths.cache, filename ?? baseName).uri;
            try {
                /**
                 * 创建缓存目标文件对象检查是否存在
                 * 如果缓存文件存在,直接使用缓存文件,不存在则缓存视频文件
                 */
                const targetFile = new File(cachedInputUri);
                if (!targetFile.exists) {
                    sourceFile.copy(targetFile);
                };
            } catch (error) {
                showNotification({ tip: '复制视频到缓存目录失败', type: 'error' });
            };

            // 添加时间戳到文件名,避免文件名冲突
            const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
            /**
             * 输出文件名,不带extension,
             * 因为要生成图片时使用名称作为文件名,所以不能带扩展名
             * cachedOutputUri: 缓存输出文件路径
             * finalOutputUri: 最终输出文件路径
             */
            const outputFileName = `${baseName}_${timestamp}`;
            const cachedOutputUri = `${cacheDir}${outputFileName}.m4a`;
            const finalOutputUri = `${ffmpegFolder.uri}${outputFileName}.m4a`;
            outFileNameRef.current = outputFileName;
            // 保存临时文件路径用于清理
            cachedOutputUriRef.current = cachedOutputUri;
            lastOutputUriRef.current = finalOutputUri;
            /**
             * 创建进出路径,用于command命令拼接
             */
            const nativeInputPath = cachedInputUri.startsWith('file://') ? cachedInputUri.replace('file://', '') : cachedInputUri;
            const nativeOutputPath = cachedOutputUri.startsWith('file://') ? cachedOutputUri.replace('file://', '') : cachedOutputUri;
            /** 
             * 构建命令 暂不支持mp3,暂时使用m4a
             * 转换命令参考:
             * 
            */
            const command = `-i "${nativeInputPath}" -vn -c:a aac -b:a 192k "${nativeOutputPath}"`;
            /**
             * 转换完成回调函数
             * 1.获取转换码
             * 2.检查转换是否成功
             * 3.如果成功,检查是否取消,取消不保存文件
             * 4.如果未取消,保存输出文件
             * 5.清理临时文件
             * 6.创建视频缩略图保存到cover目录中
             * 7.将歌曲信息和图片信息写入musicList文件夹下的convertList.json文件中
             * @param session FFmpegSession
             * @returns 
             */
            const completeCallback: FFmpegSessionCompleteCallback = async (session: FFmpegSession) => {
                try {
                    // 获取转换码
                    const returnCode = await session.getReturnCode();
                    /**
                     * 检查是否成功
                     * 方法1:调用ReturnCode的成员方法isValueSuccess返回布尔值
                     * 方法2:调用ReturnCode的静态方法isSuccess传递ReturnCode对象实例然后返回
                     * 布尔值
                     * 成员方法getCode 返回ReturnCode对象实例的code属性值,0 是success 255 是cancel
                     */
                    /**方式1 调用成员方法检测 */
                    const success = returnCode.isValueSuccess();
                    /**方式2 调用成员方法getValue获取执行值,再调用静态方法isSuccess 检查是否成功 */
                    // success = ReturnCode.isSuccess(returnCode);
                    if (success) {
                        // 如果转换已取消,则跳过保存
                        if (cancelledRef.current) {
                            // 即使取消也要清理临时文件
                            try {
                                if (cachedInputUri) {
                                    new File(cachedInputUri).delete();
                                }
                            } catch (error) {
                                showNotification({ tip: '清理输入缓存文件失败', type: 'error' });
                            }
                            return;
                        };
                        // 检查输出文件是否存在且非空
                        try {
                            const { exists } = Paths.info(cachedOutputUriRef.current!);
                            if (!exists) {
                                showNotification({ tip: '生成的音频文件为空或不存在', type: 'error' });
                                return;
                            };
                        } catch (error) {
                            showNotification({ tip: '无法验证输出文件', type: 'error' });
                            return;
                        };
                        isSuccessRef.current = true;
                    } else {
                        /**
                         * 转换失败后获取状态码解释原因
                         */
                        const returnCodeValue = returnCode.getValue();
                        const config: Record<number, string> = {
                            255: '转换已取消',
                            1: '输入文件不存在或无法读取',
                            2: '输出路径无效或无写入权限',
                        }
                        let errorMessage = config[returnCodeValue] ?? `转换失败 (错误码: ${returnCodeValue})`;
                        showNotification({ tip: errorMessage, type: 'error' });
                    }
                } catch (e) {
                    showNotification({ tip: '转换过程中发生错误', type: 'error' });
                } finally {
                    cancelledRef.current = false;
                    setIsConverting(false);
                    setProgress(0);
                    if (isSuccessRef.current) {
                        setVisible(true);
                    }
                }
            };
            /**
             * 转换过程中日志处理,更新转换进度
             * 使用生产环境友好的日志回调
             * @param log Log 日志对象
             */
            // 为了兼容现有的日志解析逻辑,保留原有的进度计算
            const legacyLogCallback: LogCallback = (log: Log) => {
                const message = log.getMessage();
                if (message.includes('time=')) {
                    try {
                        const timeMatch = message.match(/time=(\d+):(\d+):(\d+\.\d+)/);
                        if (timeMatch) {
                            const hours = parseFloat(timeMatch[1]);
                            const minutes = parseFloat(timeMatch[2]);
                            const seconds = parseFloat(timeMatch[3]);
                            const currentTimeInSeconds = hours * 3600 + minutes * 60 + seconds;
                            const totalTimeInSeconds = (video && (video.duration ?? 0)) || 120;
                            const calculatedProgress = Math.min(100, (currentTimeInSeconds / totalTimeInSeconds) * 100);
                            setProgress(calculatedProgress);
                        }
                    } catch (error) {
                        if (__DEV__) {
                            showNotification({ tip: '进度解析失败', type: 'error' });
                        }
                    }
                }
            };
            /** 开始转换 */
            try {
                /**
                 * 存在如下方法
                 * FFmpegKit.execute(cmd): Promise<FFmpegSession>
                 * executeAsync(command: string, completeCallback?: FFmpegSessionCompleteCallback, logCallback?: LogCallback, statisticsCallback?: StatisticsCallback): Promise<FFmpegSession>
                 */
                conversionSessionRef.current = await FFmpegKit.executeAsync(command, completeCallback, legacyLogCallback);
            } catch (error) {
                showNotification({ tip: '转换过程中发生错误', type: 'error' });
            }
        } catch (e) {
            showNotification({ tip: '转换过程中发生错误', type: 'error' });
        } finally {
            setProgress(0);
        }
    };

completeCallback中是转换完成时执行的回调函数,而legacyLogCallback回调函数则是转换过程的日志回调函数 ,在该函数中可以做转换进度更新,也就是说我们核心就是使用调用conversionSessionRef.current = await FFmpegKit.executeAsync(command, completeCallback, legacyLogCallback); 并且在转换过程中添加取消转换和转换失败处理:基本效果就是如下:

微信图片_20260114164854_149_23.jpg

微信图片_20260114164855_150_23.jpg

3.取消转换

转换途中万一发现不是自己想要转换的,可以终止转换,我们要清理转换前创建的文件等:

    /**
     * 取消转换
     * @returns Promise<void>
     */
    const handleCancel = async () => {
        // mark as cancelled so callbacks skip saving
        cancelledRef.current = true;
        if (!conversionSessionRef.current) {
            setIsConverting(false);
            setProgress(0);
            return;
        };

        try {
            /**
             * 优先尝试 session.cancel()
             */
            await conversionSessionRef.current.cancel();
        } catch (error) {
            /**
             * 调用cancel方法失败,尝试通过 sessionId 使用 FFmpegKit.cancel
             */
            try {
                const sessionId = conversionSessionRef.current.getSessionId();
                await FFmpegKit.cancel(sessionId)
                showNotification({ tip: '已取消转换', type: 'success' });
            } catch (err) {
                showNotification({ tip: '取消转换失败', type: 'error' });
            }
        } finally {
            // 清理状态
            conversionSessionRef.current = null;
            setIsConverting(false);
            setProgress(0);
            // 删除临时输出文件,避免残留
            if (cachedOutputUriRef.current) {
                try {
                    const file = new File(cachedOutputUriRef.current);
                    if (file.exists) {
                        file.delete();
                    }
                } catch (delErr) {
                    showNotification({ tip: '删除临时输出文件失败', type: 'error' });
                }
                cachedOutputUriRef.current = null;
            }
            if (lastOutputUriRef.current) {
                try {
                    const file = new File(lastOutputUriRef.current);
                    if (file.exists) {
                        file.delete();
                    }
                } catch (delErr) {
                    showNotification({ tip: '删除临时输出文件失败', type: 'error' });
                }
                lastOutputUriRef.current = null;
            }
        }
    };

4.转换完成可以提示用户修改歌曲信息:

    /**
     * 保存转换后的文件
     * 将其从转换完成中提取出来,添加转换后可以填写信息,不填写则使用默认信息
     */
    const saveConvertFile = async (info?: UpdateSongInfoConfig) => {
        if (!convertVideoRef.current || !isSuccessRef.current) {
            return;
        };
        // 复制文件到应用私有目录
        try {
            const { uri, duration, durationString } = convertVideoRef.current;
            const file = new File(cachedOutputUriRef.current!);
            const privateFile = new File(lastOutputUriRef.current!);
            file.copy(privateFile);
            /**创建对应视频的封面图 */
            const coverUrl = await addAudioCover(uri, outFileNameRef.current!) ?? '';
            /**
             * 转换后的音频文件存在
             * 执行添加音频json文件操作
             */
            const { exists, creationTime, md5, uri: musicUri, modificationTime } = privateFile;
            if (exists) {
                /**
                 * 这里必须手动解构,无法直接...rest,File对象上的属性不可枚举
                 * 类的内部是get方法写的
                 * 调整为存储到数据表
                 */
                const { title = outFileNameRef.current!, artist = '未知歌手', album = '未知专辑' } = info ?? {};
                const time = Date.now()
                const obj: SqliteSongInfo = {
                    id: md5 || `${time}`,
                    uri: musicUri,
                    artist,
                    title,
                    duration: duration,
                    durationString,
                    modificationTime: modificationTime ?? time,
                    coverUrl,
                    isCollection: 0,
                    isPrivate: 1,
                    creationTime: creationTime ?? time,
                    album,
                    lyrics: '',
                };
                const bool = await addSong(obj, SQLITE_CONVERTED_TABLE_NAME);
                if (bool) {
                    convertVideoRef.current = null;
                    isSuccessRef.current = false;
                    const cacheInputFile = new File(cachedInputUriRef.current!);
                    if (cacheInputFile.exists) {
                        cacheInputFile.delete();
                        cachedInputUriRef.current = null;
                    }
                    /**删除转换后的缓存音频文件 */
                    file.delete();
                    showNotification({ tip: '音频文件已成功保存', type: 'success' });
                };
            };
        } catch (copyError) {
            showNotification({ tip: '无法保存音频文件到应用文件夹', type: 'error' });
        }
    }

微信图片_20260114164856_151_23.jpg

微信图片_20260114164857_152_23.jpg

用户可以确定和取消,都将保存转换后的文件

5.查看转换后的歌曲列表,在这里可以修改和删除:

微信图片_20260114164859_154_23.jpg

微信图片_20260114164900_155_23.jpg

微信图片_20260114164901_156_23.jpg

当前还可以播放:

微信图片_20260114170201_157_23.jpg

关于音乐播放,当前项目中使用了react-native-audio-pro和expo-audio,在转换管理界面的播放使用了exp-audio,其实目前rn音乐播放器使用react-native-track-player依赖库最好,但是对于新版本rn和expo有难以解决的bug,react-native-audio-pro则bug少一点

6.创建音频文件的封面图

核心使用expo-video-thumbnails依赖库,获取指定时间的图像,并设置图片质量,但是格式就只能是jpg

import { getThumbnailAsync, type VideoThumbnailsOptions } from 'expo-video-thumbnails';
/**
 * 为音频添加封面
 * 使用expo-video-thumbnails库生成音频封面
 * 将图片保存到应用私有目录的cover文件夹中,图片格式.jpg,默认最低画质
 * @param audioUri 音频uri
 * @param coverName 封面名称 通常与音频文件名相同
 * @returns 封面uri
 */
export const addAudioCover = async (audioUri: string, coverName: string, seconds: number = 2000): Promise<string | null> => {
    if (!coverName.trim()) {
        return null;
    }
    const { exists } = Paths.info(audioUri);
    if (!exists) {
        return null;
    };
    try {
        const obj: VideoThumbnailsOptions = {
            time: seconds,
            quality: .7,//0~1,0最低1最高
        };
        /**
         * 获取视频文件指定秒数的音频封面
         */
        const { uri } = await getThumbnailAsync(audioUri, obj);
        /**
         * 获取扩展名便于生成封面uri
         */
        const ext = uri.split('.').at(-1);
        const file = new File(uri);
        /**
         * 创建封面目录
         * 如果目录不存在则创建
         */
        const coverUrl = `${Paths.document.uri + COVER_FILE_FILE_NAME}/`
        const { exists, isDirectory } = Paths.info(coverUrl);
        if (!exists || !isDirectory) {
            new Directory(Paths.document, 'cover').create({ intermediates: true });
        };
        /**
         * 创建封面文件
         * 并copy到封面目录
         */
        const finnalUri = `${coverUrl + coverName}.${ext}`;
        /**
         *  检测是否重名
         */
        const info = Paths.info(finnalUri);
        if (info.exists) {
            return null;
        }
        file.copy(new File(finnalUri));
        file.delete();
        return finnalUri;
    } catch (error) {
        return null
    }
}

7.使用FFmpegKit获取已有mp3文件的封面图,并不是所有文件都有

import { FFmpegKit } from 'kroog-ffmpeg-kit-react-native';
/**
 * 使用FFmpeg提取MP3音频文件的封面图片
 * @param audioUri 音频文件的URI路径
 * @param coverName 封面图片的名称(不带扩展名)
 * @returns 封面图片的完整URI路径,如果提取失败返回null
 */
export const extractMp3Cover = async (audioUri: string, coverName: string): Promise<string | null> => {
    if (!audioUri || !coverName.trim()) {
        return null;
    }

    try {
        // 创建cover目录
        const coverDir = new Directory(Paths.document, 'cover');
        if (!coverDir.exists) {
            coverDir.create({ intermediates: true });
        }

        // 构建输出路径
        const coverPath = `${Paths.document.uri}cover/${coverName}.jpg`;
        const coverFile = new File(coverPath);

        // 检查封面是否已存在
        if (coverFile.exists) {
            return coverPath;
        }

        // 使用FFmpeg提取封面图片
        // -an: 不处理音频
        // -vcodec copy: 直接复制视频流(封面)
        // -y: 覆盖已存在的文件
        const command = `-i "${audioUri}" -an -vcodec copy -y "${coverPath}"`;
        const session = await FFmpegKit.execute(command);
        const returnCode = await session.getReturnCode();

        if (returnCode.isValueSuccess()) {
            // 检查文件是否真的创建成功
            if (coverFile.exists) {
                console.log(`成功提取封面: ${coverName}`);
                return coverPath;
            } else {
                // 如果文件没有创建,说明没有嵌入封面
                return null;
            }
        } else {
            const output = await session.getOutput();
            console.warn('提取封面失败:', output);
            return null;
        }
    } catch (error) {
        console.error('提取MP3封面出错:', error);
        return null;
    }
};

8.完整代码:

import { useRef, useState, useEffect, type FC } from 'react';
import TitleBar from '@/components/music/TitleBar';
import VideoList from '@/components/VideoList';
import { Ionicons } from '@expo/vector-icons';
import type { VideoAsset } from '@/types';
import { router } from 'expo-router';
import { FFmpegKit, type FFmpegSession, type Log, type FFmpegSessionCompleteCallback, type LogCallback } from 'kroog-ffmpeg-kit-react-native';
import { Modal, StyleSheet, View, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemedView, ThemedText } from '@/components/theme/ThemedComponents';
import { useThemeNotification } from '@/hooks/useTheme';
import { File, Paths, Directory, type DirectoryCreateOptions } from 'expo-file-system';
import { addAudioCover } from '@/utils/audioManager';
import type { SqliteSongInfo, UpdateSongInfoConfig } from '@/types';
import UpdateOrDeleteModal from '@/components/music/localComp/UpdateOrDeleteModal';
import ProcessTip from '@/components/ui/ProcessTip';
import { addSong } from '@/libs/sqlite';
import { SQLITE_CONVERTED_TABLE_NAME, MUSIC_FILE_FILE_NAME } from '@/constants/SongList';
/**
 * 视频转歌曲页面
 * @returns 
 */
const FfmpegKitPage: FC = () => {
    const { top, bottom } = useSafeAreaInsets();
    const [isConverting, setIsConverting] = useState<boolean>(false);
    const [progress, setProgress] = useState(0);
    /**是否转换成功 */
    const isSuccessRef = useRef<boolean>(false);
    /**保存需要转换的视频文件 */
    const convertVideoRef = useRef<VideoAsset | null>(null);
    /**保存转换后的文件名 */
    const outFileNameRef = useRef<string | null>(null);
    const cachedInputUriRef = useRef<string | null>(null);
    // 添加状态管理
    const cachedOutputUriRef = useRef<string | null>(null);
    const lastOutputUriRef = useRef<string | null>(null);
    const conversionSessionRef = useRef<FFmpegSession | null>(null);
    const cancelledRef = useRef<boolean>(false);
    const showNotification = useThemeNotification();
    const [visible, setVisible] = useState<boolean>(false);
    const handleBack = () => {
        router.dismiss();
    };
    const cleanUp = async () => {
        if (cachedOutputUriRef.current) {
            const file = new File(cachedOutputUriRef.current);
            if (file.exists) {
                file.delete();
            }
        }
        cachedOutputUriRef.current &&= null;
        conversionSessionRef.current &&= null;
    };
    useEffect(() => {
        return () => {
            cleanUp();
        }
    }, [])
    /**
     * 保存转换后的文件
     * 将其从转换完成中提取出来,添加转换后可以填写信息,不填写则使用默认信息
     */
    const saveConvertFile = async (info?: UpdateSongInfoConfig) => {
        if (!convertVideoRef.current || !isSuccessRef.current) {
            return;
        };
        // 复制文件到应用私有目录
        try {
            const { uri, duration, durationString } = convertVideoRef.current;
            const file = new File(cachedOutputUriRef.current!);
            const privateFile = new File(lastOutputUriRef.current!);
            file.copy(privateFile);
            /**创建对应视频的封面图 */
            const coverUrl = await addAudioCover(uri, outFileNameRef.current!) ?? '';
            /**
             * 转换后的音频文件存在
             * 执行添加音频json文件操作
             */
            const { exists, creationTime, md5, uri: musicUri, modificationTime } = privateFile;
            if (exists) {
                /**
                 * 这里必须手动解构,无法直接...rest,File对象上的属性不可枚举
                 * 类的内部是get方法写的
                 * 调整为存储到数据表
                 */
                const { title = outFileNameRef.current!, artist = '未知歌手', album = '未知专辑' } = info ?? {};
                const time = Date.now()
                const obj: SqliteSongInfo = {
                    id: md5 || `${time}`,
                    uri: musicUri,
                    artist,
                    title,
                    duration: duration,
                    durationString,
                    modificationTime: modificationTime ?? time,
                    coverUrl,
                    isCollection: 0,
                    isPrivate: 1,
                    creationTime: creationTime ?? time,
                    album,
                    lyrics: '',
                };
                const bool = await addSong(obj, SQLITE_CONVERTED_TABLE_NAME);
                if (bool) {
                    convertVideoRef.current = null;
                    isSuccessRef.current = false;
                    const cacheInputFile = new File(cachedInputUriRef.current!);
                    if (cacheInputFile.exists) {
                        cacheInputFile.delete();
                        cachedInputUriRef.current = null;
                    }
                    /**删除转换后的缓存音频文件 */
                    file.delete();
                    showNotification({ tip: '音频文件已成功保存', type: 'success' });
                };
            };
        } catch (copyError) {
            showNotification({ tip: '无法保存音频文件到应用文件夹', type: 'error' });
        }
    }
    /**
     * 异步处理视频转音频的核心逻辑函数。
     * 
     * 主要功能包括:
     * 1. 检查并创建用于 FFmpeg 的本地工作目录;
     * 2. 将选中的视频文件复制到缓存目录以确保访问权限;
     * 3. 使用 FFmpeg 提取音频流并进行格式转换;
     * 4. 在转换过程中更新进度条;
     * 5. 完成后将结果保存至应用私有目录,并提示用户;
     * 6. 包含详细的错误处理与取消机制支持。
     *
     * @param video - 视频资源对象,必须包含 uri 和其他必要元数据(如 duration、filename 等)。
     * @returns 无返回值。副作用包括 UI 更新、文件操作及可能的弹窗提示。
     */
    const handleConvertVideo = async (video: VideoAsset) => {
        /**
         * 检查当前是否有选中的视频文件,没有则提示用户选择视频文件
         */
        if (!video) {
            showNotification({ tip: '请选择视频文件', type: 'warning' });
            return;
        };
        /**保存视频资源对象 */
        convertVideoRef.current = video;
        /**
         * 先检查源文件是否存在,源文件不存在停止执行
         * 后续会检查缓存文件是否存在,
         * 如果存在就不缓存,直接使用缓存文件
         */
        const sourceFile = new File(video.uri);
        if (!sourceFile.exists) {
            showNotification({ tip: '视频文件不存在', type: 'error' });
            return;
        }
        const { document, cache } = Paths;
        /**
         *  存储转换后的音频文件用的FFmpeg 的本地工作目录
         */
        let ffmpegFolder: Directory | null = null;
        /**
         * 检测目录是否存在,不存在则创建
         */
        try {

            /**
             * 新版expo-file-system 检测文件是否是文件夹使用
             * Paths.info(uri)静态方法方法可以检测文件是否是文件夹
             * 返回对象{exists: boolean, isDirectory: boolean}
             * 或者直接使用new Dictory(ffmpegDir)返回对象,目录是否存在,不存在调用返回值create方法创建
             * 如果路径中包含中文文字必须使用encodeURI进行编码,否则报错
             * 使用encodeURI可以完整路径转码,使用encodeURIComponent
             * 可能转换过多导致路径报错,主要是路径中的中文名部分要转换
             * 第二个参数文件夹名前后写不写/都可以
             */

            ffmpegFolder = new Directory(Paths.document, MUSIC_FILE_FILE_NAME);
            if (!ffmpegFolder.exists) {
                let obj: DirectoryCreateOptions = { intermediates: true };
                /**
                 * 创建目录
                 */
                ffmpegFolder.create(obj)
            };
        } catch (error) {
            showNotification({ tip: '无法创建存储文件夹', type: 'error' });
            return;
        };
        /**
         * 开始转换视频为音频
         * 1. 复制视频到缓存目录
         * 2. 构建FFmpeg命令
         * 3. 执行转换
         * 4. 清理临时文件
         * 设置开始转换状态为true,重置进度值为0,并设置取消标志为false
         */
        setIsConverting(true);
        setProgress(0);
        cancelledRef.current = false;
        try {
            const { filename } = video;
            const baseName = filename ? filename.replace(/\.[^/.]+$/, '') : `video_${Date.now()}`;
            /**先将目标文件复制到缓存目录中 */
            const cacheDir = cache.uri ?? document.uri ?? '/';
            //方式1: 手动拼接文件要保存到缓存目录路径
            const cachedInputUri = `${cacheDir}${filename ?? baseName}`;
            cachedInputUriRef.current = cachedInputUri;
            // 方式2: 使用File构造函数链式创建目标文件
            // const cachedInputUri = new File(Paths.cache, filename ?? baseName).uri;
            try {
                /**
                 * 创建缓存目标文件对象检查是否存在
                 * 如果缓存文件存在,直接使用缓存文件,不存在则缓存视频文件
                 */
                const targetFile = new File(cachedInputUri);
                if (!targetFile.exists) {
                    sourceFile.copy(targetFile);
                };
            } catch (error) {
                showNotification({ tip: '复制视频到缓存目录失败', type: 'error' });
            };

            // 添加时间戳到文件名,避免文件名冲突
            const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
            /**
             * 输出文件名,不带extension,
             * 因为要生成图片时使用名称作为文件名,所以不能带扩展名
             * cachedOutputUri: 缓存输出文件路径
             * finalOutputUri: 最终输出文件路径
             */
            const outputFileName = `${baseName}_${timestamp}`;
            const cachedOutputUri = `${cacheDir}${outputFileName}.m4a`;
            const finalOutputUri = `${ffmpegFolder.uri}${outputFileName}.m4a`;
            outFileNameRef.current = outputFileName;
            // 保存临时文件路径用于清理
            cachedOutputUriRef.current = cachedOutputUri;
            lastOutputUriRef.current = finalOutputUri;
            /**
             * 创建进出路径,用于command命令拼接
             */
            const nativeInputPath = cachedInputUri.startsWith('file://') ? cachedInputUri.replace('file://', '') : cachedInputUri;
            const nativeOutputPath = cachedOutputUri.startsWith('file://') ? cachedOutputUri.replace('file://', '') : cachedOutputUri;
            /** 
             * 构建命令 暂不支持mp3,暂时使用m4a
             * 
             */
            const command = `-i "${nativeInputPath}" -vn -c:a aac -b:a 192k "${nativeOutputPath}"`;
            /**
             * 转换完成回调函数
             * 1.获取转换码
             * 2.检查转换是否成功
             * 3.如果成功,检查是否取消,取消不保存文件
             * 4.如果未取消,保存输出文件
             * 5.清理临时文件
             * 6.创建视频缩略图保存到cover目录中
             * 7.将歌曲信息和图片信息写入SQlite数据库表
             * @param session FFmpegSession
             * @returns 
             */
            const completeCallback: FFmpegSessionCompleteCallback = async (session: FFmpegSession) => {
                try {
                    // 获取转换码
                    const returnCode = await session.getReturnCode();
                    /**
                     * 检查是否成功
                     * 方法1:调用ReturnCode的成员方法isValueSuccess返回布尔值
                     * 方法2:调用ReturnCode的静态方法isSuccess传递ReturnCode对象实例然后返回
                     * 布尔值
                     * 成员方法getCode 返回ReturnCode对象实例的code属性值,0 是success 255 是cancel
                     */
                    /**方式1 调用成员方法检测 */
                    const success = returnCode.isValueSuccess();
                    /**方式2 调用成员方法getValue获取执行值,再调用静态方法isSuccess 检查是否成功 */
                    // success = ReturnCode.isSuccess(returnCode);
                    if (success) {
                        // 如果转换已取消,则跳过保存
                        if (cancelledRef.current) {
                            // 即使取消也要清理临时文件
                            try {
                                if (cachedInputUri) {
                                    new File(cachedInputUri).delete();
                                }
                            } catch (error) {
                                showNotification({ tip: '清理输入缓存文件失败', type: 'error' });
                            }
                            return;
                        };
                        // 检查输出文件是否存在且非空
                        try {
                            const { exists } = Paths.info(cachedOutputUriRef.current!);
                            if (!exists) {
                                showNotification({ tip: '生成的音频文件为空或不存在', type: 'error' });
                                return;
                            };
                        } catch (error) {
                            showNotification({ tip: '无法验证输出文件', type: 'error' });
                            return;
                        };
                        isSuccessRef.current = true;
                    } else {
                        /**
                         * 转换失败后获取状态码解释原因
                         */
                        const returnCodeValue = returnCode.getValue();
                        const config: Record<number, string> = {
                            255: '转换已取消',
                            1: '输入文件不存在或无法读取',
                            2: '输出路径无效或无写入权限',
                        }
                        let errorMessage = config[returnCodeValue] ?? `转换失败 (错误码: ${returnCodeValue})`;
                        showNotification({ tip: errorMessage, type: 'error' });
                    }
                } catch (e) {
                    showNotification({ tip: '转换过程中发生错误', type: 'error' });
                } finally {
                    cancelledRef.current = false;
                    setIsConverting(false);
                    setProgress(0);
                    if (isSuccessRef.current) {
                        setVisible(true);
                    }
                }
            };
            /**
             * 转换过程中日志处理,更新转换进度
             * 使用生产环境友好的日志回调
             * @param log Log 日志对象
             */
            // 为了兼容现有的日志解析逻辑,保留原有的进度计算
            const legacyLogCallback: LogCallback = (log: Log) => {
                const message = log.getMessage();
                if (message.includes('time=')) {
                    try {
                        const timeMatch = message.match(/time=(\d+):(\d+):(\d+\.\d+)/);
                        if (timeMatch) {
                            const hours = parseFloat(timeMatch[1]);
                            const minutes = parseFloat(timeMatch[2]);
                            const seconds = parseFloat(timeMatch[3]);
                            const currentTimeInSeconds = hours * 3600 + minutes * 60 + seconds;
                            const totalTimeInSeconds = (video && (video.duration ?? 0)) || 120;
                            const calculatedProgress = Math.min(100, (currentTimeInSeconds / totalTimeInSeconds) * 100);
                            setProgress(calculatedProgress);
                        }
                    } catch (error) {
                        if (__DEV__) {
                            showNotification({ tip: '进度解析失败', type: 'error' });
                        }
                    }
                }
            };
            /** 开始转换 */
            try {
                /**
                 * 存在如下方法
                 * FFmpegKit.execute(cmd): Promise<FFmpegSession>
                 * executeAsync(command: string, completeCallback?: FFmpegSessionCompleteCallback, logCallback?: LogCallback, statisticsCallback?: StatisticsCallback): Promise<FFmpegSession>
                 */
                conversionSessionRef.current = await FFmpegKit.executeAsync(command, completeCallback, legacyLogCallback);
            } catch (error) {
                showNotification({ tip: '转换过程中发生错误', type: 'error' });
            }
        } catch (e) {
            showNotification({ tip: '转换过程中发生错误', type: 'error' });
        } finally {
            setProgress(0);
        }
    };
    const handleConvertManage = () => {
        router.push('/ConvertManage');
    };
    /**
     * 取消转换
     * @returns Promise<void>
     */
    const handleCancel = async () => {
        // 修改标记
        cancelledRef.current = true;
        if (!conversionSessionRef.current) {
            setIsConverting(false);
            setProgress(0);
            return;
        };

        try {
            /**
             * 优先尝试 session.cancel()
             */
            await conversionSessionRef.current.cancel();
        } catch (error) {
            /**
             * 调用cancel方法失败,尝试通过 sessionId 使用 FFmpegKit.cancel
             */
            try {
                const sessionId = conversionSessionRef.current.getSessionId();
                await FFmpegKit.cancel(sessionId)
                showNotification({ tip: '已取消转换', type: 'success' });
            } catch (err) {
                showNotification({ tip: '取消转换失败', type: 'error' });
            }
        } finally {
            // 清理状态
            conversionSessionRef.current = null;
            setIsConverting(false);
            setProgress(0);
            // 删除临时输出文件,避免残留
            if (cachedOutputUriRef.current) {
                try {
                    const file = new File(cachedOutputUriRef.current);
                    if (file.exists) {
                        file.delete();
                    }
                } catch (delErr) {
                    showNotification({ tip: '删除临时输出文件失败', type: 'error' });
                }
                cachedOutputUriRef.current = null;
            }
            if (lastOutputUriRef.current) {
                try {
                    const file = new File(lastOutputUriRef.current);
                    if (file.exists) {
                        file.delete();
                    }
                } catch (delErr) {
                    showNotification({ tip: '删除临时输出文件失败', type: 'error' });
                }
                lastOutputUriRef.current = null;
            }
        }
    };
    /**
     * 确定修改信息
     * @param info 
     * @returns Promise<void>
     */
    const handleConfirm = async (info?: UpdateSongInfoConfig | boolean) => {
        if (!convertVideoRef.current || typeof info === 'boolean') {
            return;
        }
        try {
            await saveConvertFile(info)
            setVisible(false);
        } catch (error) {
            showNotification({ tip: '更新文件信息失败,请检查文件权限', type: 'error' });
        }
    };
    /**
     * 取消修改 按默认名称保存
     * @returns Promise<void>
     */
    const handleCancelModify = async () => {
        await saveConvertFile();
    };
    return (
        <ThemedView style={[styles.container, { paddingTop: top, paddingBottom: bottom + 10 }]}>
            <TitleBar
                style={styles.top}
                leftComponent={
                    <Pressable
                        onPress={handleBack}
                        style={styles.topLeft}
                    >
                        <Ionicons name="chevron-back" size={22} color="#fff" />
                        <ThemedText style={styles.title}>视频转歌</ThemedText>
                    </Pressable>
                }
                rightComponent={
                    <ThemedText
                        style={styles.title}
                        onPress={handleConvertManage}
                    >
                        转换管理
                    </ThemedText>
                }
            />
            <VideoList
                onPress={handleConvertVideo}
            />
            <Modal
                visible={isConverting}
                transparent
                animationType="fade"
            >
                <View style={styles.modalContainer}>
                    < ProcessTip
                        progress={progress}
                        handleCancel={handleCancel}
                    />
                </View>
            </Modal>
            <UpdateOrDeleteModal
                visible={visible}
                needDeleteFile={false}
                setVisible={() => setVisible(false)}
                onConfirm={handleConfirm}
                onCancel={handleCancelModify}
                modalType='rename'
                audio={convertVideoRef.current as VideoAsset}
                fileName={outFileNameRef.current as string}
            />
        </ThemedView>
    );
};

const styles = StyleSheet.create({
    container: {
        flex: 1,
        alignItems: 'center',
    },
    top: {
        width: '100%',
    },
    title: {
        fontSize: 15,
        textAlign: 'center',
        fontWeight: 'bold',
    },
    topLeft: {
        flexDirection: 'row',
        alignItems: 'center',
    },
    modalContainer: {
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center',
    },
});

export default FfmpegKitPage;

小结

开始咱们说流程很清晰,关键在于获取要转换的文件转换文件和保存文件以及后续的修改,具体的细节其实还是比较多 的,但是代码没有什么高深的特技,就像这篇文件一样,平铺直叙。如果说有困难应该在FFmpegKit依赖库的文档方面,因为这个依赖库好像已经没人维护了,有些API我是看的node_modules里的TS类型定义和AI辅助编写的,刚开始即便借助AI也是囫囵吞枣,出现大量转换失败的情况,然后就是调试过程中产生大量缓存文件没有清理,后来打印输出缓存也是挺有美感

123.png

文笔非常一般,但是也算干货满满,文笔不行图片来凑,而且今天我打包了APK安装到手机上,目前测很流畅,比开发构建版本的运行流畅,目前该应用只开发了android版本,ios没有设没有针对性处理调试,接下来再放一些其他模块图片,但是其他有技术难度的值得宣讲的不多,因为该应用没有配套后台,纯本地,就涉及到了数据存取问题,就是使用了Sqlite数据库,后面有时间考虑写一篇文章讲解项目中歌单存取处理,多个单切换设计,播放队列管理等,再放点图吧,上面细节不明白的有问题的欢迎留言探讨

微信图片_20260114172112_167_23.jpg

微信图片_20260114172113_168_23.jpg

微信图片_20260114172114_169_23.jpg

微信图片_20260114172115_170_23.jpg

微信图片_20260114172116_171_23.jpg

antd6的table排序功能

作者 子玖
2026年1月14日 18:09

antd6的table排序功能

  • 技术栈:react 19 / ts / react-query
  • 效果:升序->降序->去掉排序->升序一直循环
  • 如果不需要接口,可跳到实现 父组件
  • 请求参数 :
    • sort_by :排序字段(created_at/updated_at)
    • order_by :排序方向(asc/desc,默认asc)

接口实现

// src\api\test.ts
import type { SortField } from "@/types/device";
import { apiClient } from "./http"; // 只是axios请求
import { useQuery } from "@tanstack/react-query";

export interface Response {
  list: []
  page: number
  size: number
  total: number
}

export interface Params {
  page?: number;
  pageSize?: number;
  sort_by?: SortField | '';
  order_by?: 'asc' | 'desc' | '';
}


export const useGetList = (params: Params) => {
  return useQuery<Response>({
    queryKey: ['useGetList', params],
    queryFn: async () => {
      const { data: response } = await apiClient.get('/test', { params: params })
      return response.data
    },
  })
}

定义类型

// src\types\test.ts
// 可排序字段
export const SORTABLE_FIELDS = [
  'created_at',
  'updated_at',
] as const;

export type SortField = typeof SORTABLE_FIELDS[number];

export type SortOrderBE = 'asc' | 'desc' | undefined;
export type SortOrderUI = 'ascend' | 'descend';

父组件

// src\views\test\index.tsx
import React, { useState } from 'react';
import { useGetList, type Device, type useGetDeviceListParams, } from '@/api/test';
import type { SortField } from '@/types/test';

import DeviceTable from './components/DeviceTable';

const INIT_VALUES = { page: 1, pageSize: 10 };

const DeviceManagement: React.FC = () => {

  const [params, setParams] = useState({ ...INIT_VALUES });
  
// ⭐这里是重点
  // 排序处理
  const handleSortChange = (sortBy?: SortField | '', orderBy?: 'asc' | 'desc' | '') => {
    setParams((prev) => ({
      ...prev,
      sort_by: sortBy ?? '',
      order_by: orderBy ?? '',
    }));
  };

  return (
    <>
      <DeviceTable
      // 多余的配置参数省略也就是一些配置page/data之类
        sortBy={params.sort_by}
        orderBy={params.order_by}
        onSortChange={handleSortChange}
      />
    </>
  );
};
  
export default DeviceManagement;

子组件


import React from 'react';
import { Table, type TableProps } from 'antd';
import type { Device } from '@/api/device';
import {  type SortField, type SortOrderBE, type SortOrderUI } from '@/types/test';

interface DeviceTableProps {
  sortBy?: SortField | '';
  orderBy?: SortOrderBE | ''; // 改为后端类型,允许空字符串
  onSortChange?: (sortBy: SortField | '', orderBy: SortOrderBE | '') => void;
}


// ⭐这里是重点
/* ---------- 工具 ---------- */
const toUIOrder = (be?: SortOrderBE | ''): SortOrderUI | undefined =>{
  return be === 'asc' ? 'ascend' : be === 'desc' ? 'descend' : undefined;
}
/**
 * 设备表格组件
 * 展示设备列表数据并提供分页和编辑功能
 */
const DeviceTable: React.FC<DeviceTableProps> = ({
  sortBy,
  orderBy,
  onSortChange,
}) => {

  // 表格列配置
  const columns = [
    {
      title: 'ID',
      dataIndex: 'id',
      key: 'id',
      width: 100,
    },
    {
      title: '创建时间',
      dataIndex: 'created_at',
      key: 'created_at',
      width: 170,
      sorter: true,
      sortOrder: sortBy === 'created_at' ? toUIOrder(orderBy) : null,
    },
    {
      title: '更新时间',
      dataIndex: 'updated_at',
      key: 'updated_at',
      width: 170,
      sorter: true,
      sortOrder: sortBy === 'updated_at' ? toUIOrder(orderBy) : null,
    },
  ];

// ⭐这里是重点
const handleTableChange: TableProps<Device>['onChange'] = (_, __, sorter) => {
  // 如果 sorter 是数组或者没有字段,直接返回
  const single = Array.isArray(sorter) ? sorter[0] : sorter;
  const field = single.field as SortField;
  // 三态:ascend → descend → null(取消)
  if (!single.order) {
    // 第三次点击:把 undefined 传出去
    onSortChange?.('', '');
    return;
  }
  const order = single.order === 'ascend' ? 'asc' : 'desc';
  onSortChange?.(field, order);
};

  return (
    <>
      <Table
        columns={columns}
        // 其他配置省略
        onChange={handleTableChange}
      />
    </>
  );
};

export default DeviceTable;

Three.js 开发快速入门

2026年1月14日 18:07

概述

本文档将介绍Three.js开发中的快速入门知识点,包括控制器使用、物体变换、材质纹理、雾效、模型加载等内容。通过这些实例,您将掌握Three.js的核心概念和实用技巧。

第一部分:控制器和辅助坐标系

1. 轨道控制器(OrbitControls)

轨道控制器允许用户通过鼠标拖拽、滚轮缩放等方式来改变相机视角,这对于3D场景的浏览非常有用。

// 导入轨道控制器
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";

// 添加轨道控制器
const controls = new OrbitControls(camera, document.body);
// 设置带阻尼的惯性
controls.enableDamping = true;
// 设置阻尼系数
controls.dampingFactor = 0.05;
// 设置自动旋转(可选)
controls.autoRotate = true;

2. 辅助坐标系(AxesHelper)

辅助坐标系可以帮助开发者可视化场景中的坐标轴方向:

// 添加世界坐标辅助器
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);

3. 动画循环中更新控制器

为了使控制器的阻尼效果生效,需要在动画循环中调用controls.update()

function animate() {
  controls.update();  // 必须在动画循环中调用
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}
animate();

第二部分:物体变换和层级关系

1. 物体位移

Three.js中每个对象都有position属性,用于控制物体的位置:

// 设置物体位置
cube.position.x = 2;
cube.position.set(3, 0, 0); // 同时设置xyz坐标

2. 父子层级关系

在Three.js中,可以建立物体间的父子关系,子物体的变换会受到父物体的影响:

// 创建父物体
let parentCube = new THREE.Mesh(geometry, parentMaterial);
// 创建子物体
const cube = new THREE.Mesh(geometry, material);
// 将子物体添加到父物体上
parentCube.add(cube);
// 设置父物体位置
parentCube.position.set(-3, 0, 0);
// 设置子物体位置(相对于父物体)
cube.position.set(3, 0, 0);

3. 旋转和缩放

除了位置变换,还可以对物体进行旋转和缩放:

// 旋转 - 使用弧度制
cube.rotation.x = Math.PI / 4;  // 绕X轴旋转45度
// 缩放
cube.scale.set(2, 2, 2);  // 在xyz轴上都放大2倍

第三部分:响应式画布与全屏控制

1. 响应式设计

为了让Three.js应用在窗口大小改变时自适应,需要监听resize事件:

// 监听窗口变化
window.addEventListener("resize", () => {
  // 重置渲染器宽高比
  renderer.setSize(window.innerWidth, window.innerHeight);
  // 重置相机宽高比
  camera.aspect = window.innerWidth / window.innerHeight;
  // 更新相机投影矩阵
  camera.updateProjectionMatrix();
});

2. 全屏功能

可以通过浏览器API实现全屏功能:

// 全屏
document.body.requestFullscreen();

// 退出全屏
document.exitFullscreen();

3. 自定义全屏按钮

var btn = document.createElement("button");
btn.innerHTML = "点击全屏";
btn.style.position = "absolute";
btn.style.top = "10px";
btn.style.left = "10px";
btn.style.zIndex = "999";
btn.onclick = function () {
  document.body.requestFullscreen();
  console.log("全屏");
};
document.body.appendChild(btn);

第四部分:使用lil-gui进行调试

lil-gui是一个轻量级的图形界面库,非常适合用于Three.js开发过程中的参数调试。

1. 基本使用

// 导入lil-gui
import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js";

// 创建GUI实例
const gui = new GUI();

2. 添加控件

// 添加按钮
gui.add(eventObj, "Fullscreen").name("全屏");

// 添加范围滑块
gui.add(cube.position, "x", -5, 5).name("立方体x轴位置");

// 创建文件夹组织控件
let folder = gui.addFolder("立方体位置");
folder
  .add(cube.position, "x")
  .min(-10)
  .max(10)
  .step(1)
  .name("立方体x轴位置");

// 添加颜色选择器
gui.addColor(colorParams, "cubeColor")
  .name("立方体颜色")
  .onChange((val) => {
    cube.material.color.set(val);
  });

第五部分:BufferGeometry详解

1. BufferGeometry vs Geometry

BufferGeometry是Three.js中更高效的几何体表示方式,它直接使用缓冲区存储顶点数据。

// 创建BufferGeometry
const geometry = new THREE.BufferGeometry();

// 创建顶点数据(每三个数值代表一个顶点的xyz坐标)
const vertices = new Float32Array([
  -1.0, -1.0, 0.0,  // 第一个顶点
  1.0, -1.0, 0.0,   // 第二个顶点
  1.0, 1.0, 0.0,    // 第三个顶点
  -1.0, 1.0, 0.0    // 第四个顶点
]);

// 设置顶点属性
geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));

2. 使用索引绘制

通过索引可以减少重复顶点数据:

// 创建索引
const indices = new Uint16Array([0, 1, 2, 2, 3, 0]);
// 设置索引属性
geometry.setIndex(new THREE.BufferAttribute(indices, 1));

第六部分:顶点组与材质

1. 多材质网格

一个网格可以使用多个材质:

// 创建多种材质
const cubematerial0 = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cubematerial1 = new THREE.MeshBasicMaterial({ color: 0xff0000 });
// ... 更多材质

// 将多个材质传递给网格
const cube = new THREE.Mesh(cubegeometry, [
  cubematerial0,
  cubematerial1,
  // ... 其他材质
]);

2. 顶点组

通过顶点组可以指定哪些面使用哪个材质:

// 设置2个顶点组,形成2个材质区域
geometry.addGroup(0, 3, 0);  // 从索引0开始的3个面使用第0个材质
geometry.addGroup(3, 3, 1);  // 从索引3开始的3个面使用第1个材质

第七部分:材质与纹理

1. 纹理加载

Three.js提供了多种纹理类型,可以为材质添加丰富的视觉效果:

// 创建纹理加载器
let textureLoader = new THREE.TextureLoader();
// 加载纹理
let texture = textureLoader.load("./texture/image.png");

2. 不同类型的纹理映射

// 基础颜色贴图
planeMaterial.map = texture;

// AO贴图(环境遮挡)
planeMaterial.aoMap = aoMap;
planeMaterial.aoMapIntensity = 1;

// 透明度贴图
planeMaterial.alphaMap = alphaMap;

// 高光贴图
planeMaterial.specularMap = specularMap;

3. 纹理颜色空间

不同的纹理应使用适当的颜色空间:

// 设置颜色空间
texture.colorSpace = THREE.SRGBColorSpace;      // 颜色贴图
// texture.colorSpace = THREE.LinearSRGBColorSpace;  // 法线贴图等

第八部分:雾效(Fog)

Three.js提供了两种雾效类型:线性雾和指数雾。

1. 线性雾

// 线性雾:在指定距离范围内逐渐显现雾效
scene.fog = new THREE.Fog(0x999999, 0.1, 50);  // 颜色, 近距离, 远距离

2. 指数雾

// 指数雾:随距离呈指数增长的雾效
scene.fog = new THREE.FogExp2(0x999999, 0.1);  // 颜色, 密度

第九部分:GLTF模型加载

GLTF是一种通用的3D模型格式,Three.js提供了专门的加载器。

1. 基本GLTF加载

import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

// 实例化加载器
const gltfLoader = new GLTFLoader();

// 加载模型
gltfLoader.load(
  "./model/model.glb",  // 模型路径
  (gltf) => {          // 加载完成回调
    console.log(gltf);
    scene.add(gltf.scene);
  }
);

2. 使用DRACO压缩

DRACO是一种3D几何压缩技术,可以显著减小模型文件大小:

import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";

// 实例化DRACO加载器
const dracoLoader = new DRACOLoader();
// 设置DRACO解码器路径
dracoLoader.setDecoderPath("./draco/");
// 设置GLTF加载器使用DRACO解码器
gltfLoader.setDRACOLoader(dracoLoader);

第十部分:光线投射与物体交互

光线投射(Raycasting)是检测鼠标与3D物体交互的重要技术。

1. 基本光线投射

// 创建射线
const raycaster = new THREE.Raycaster();
// 创建鼠标向量
const mouse = new THREE.Vector2();

// 监听鼠标点击事件
window.addEventListener("click", (event) => {
  // 设置鼠标向量的x,y值(转换为标准化设备坐标)
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -((event.clientY / window.innerHeight) * 2 - 1);

  // 通过相机和鼠标位置更新射线
  raycaster.setFromCamera(mouse, camera);

  // 计算物体和射线的交点
  const intersects = raycaster.intersectObjects([sphere1, sphere2, sphere3]);

  if (intersects.length > 0) {
    // 处理交点
    console.log(intersects[0].object);
  }
});

第十一部分:Tween补间动画

Tween.js提供了一种简单的方式来创建平滑的动画效果。

1. 基本补间动画

import * as TWEEN from "three/examples/jsm/libs/tween.module.js";

// 创建补间动画
const tween = new TWEEN.Tween(sphere1.position);
tween.to({ x: 4 }, 1000);  // 在1000毫秒内移动到x=4的位置

// 启动动画
tween.start();

2. 动画回调和链式调用

// 添加回调函数
tween
  .onStart(() => console.log("开始"))
  .onComplete(() => console.log("结束"))
  .onUpdate(() => console.log("更新"));

// 链式动画
let tween2 = new TWEEN.Tween(sphere1.position);
tween2.to({ x: -4 }, 1000);

tween.chain(tween2);   // tween完成后执行tween2
tween2.chain(tween);   // tween2完成后执行tween

3. 在动画循环中更新

function animate() {
  controls.update();
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
  // 更新tween动画
  TWEEN.update();
}
animate();

总结

通过本教程,我们学习了Three.js开发中的多个重要概念:

  1. 控制器和辅助工具:轨道控制器帮助我们更好地浏览3D场景,辅助坐标系帮助我们理解空间关系
  2. 物体变换:位置、旋转、缩放以及父子层级关系的处理
  3. 响应式设计:适配不同屏幕尺寸和全屏功能的实现
  4. 调试工具:使用lil-gui方便地调整参数
  5. 几何体:BufferGeometry的使用方法和优势
  6. 材质和纹理:如何为3D对象添加丰富的视觉效果
  7. 雾效:增强场景深度感的技术
  8. 模型加载:加载外部3D模型的方法
  9. 交互:光线投射实现用户与3D对象的交互
  10. 动画:使用Tween.js创建流畅的补间动画

这些知识为深入学习Three.js奠定了坚实的基础,接下来可以继续学习光照、阴影、后期处理等更高级的特性。

Three.js 环境搭建与开发初识

2026年1月14日 17:58

概述

本文档将详细介绍如何搭建ThreeJS开发环境以及创建您的第一个ThreeJS应用程序。通过本文档,您将学会:

  • 如何安装和配置ThreeJS开发环境
  • 如何创建一个简单的3D立方体并让它旋转
  • 如何将ThreeJS与Vue和React框架集成

第一部分:环境搭建与第一个应用

1. 初始化项目

要创建一个ThreeJS项目,首先需要初始化一个新的npm项目并安装必要的依赖:

npm create vite@latest my-threejs-app
cd my-threejs-app
npm install
npm install three

这将创建一个基于Vite的新项目,并安装ThreeJS作为依赖项。

2. 项目结构

典型的ThreeJS项目结构如下:

my-threejs-app/
├── public/
│   └── css/
│       └── style.css
├── src/
│   └── main.js
├── index.html
├── package.json
└── vite.config.js

3. HTML文件配置

index.html中,我们需要设置基本的HTML结构:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
    <link rel="stylesheet" href="css/style.css" />
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="src/main-mk.js"></script>
  </body>
</html>

注意:我们引入了CSS样式表来确保Canvas元素正确显示。

4. CSS样式配置

public/css/style.css中设置基本样式:

*{
  margin: 0;
  padding: 0;
}

canvas{
  display: block;
  position: fixed;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;
}

这些样式确保Canvas元素占据整个浏览器窗口且没有默认的边距。

5. 核心JavaScript代码

这是ThreeJS应用的核心代码(在src/main-mk.js中):

import * as THREE from 'three';

// 创建场景
const scene = new THREE.Scene();
// 修改场景背景色
scene.background = new THREE.Color(0xeeeeee);

// 创建透视相机
const camera = new THREE.PerspectiveCamera(
  45, // 视角
  window.innerWidth / window.innerHeight, // 宽高比
  0.1, // 近平面
)

// 创建WebGL渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 创建几何体 - 盒子几何体
const geometry = new THREE.BoxGeometry(1, 1, 1);
// 创建材质 - 基础网格材质
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
// 创建网格对象
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

// 设置相机位置
camera.position.z = 5;

// 动画循环函数
function animate() {
  requestAnimationFrame(animate);
  
  // 让立方体持续旋转
  cube.rotation.x += 0.01;
  cube.rotation.y += 0.01;
  
  // 渲染场景
  renderer.render(scene, camera);
}

// 启动动画循环
animate()

这段代码包含了ThreeJS应用的三个核心组件:

  1. 场景(Scene):所有3D对象的容器
  2. 相机(Camera):定义观察者视角
  3. 渲染器(Renderer):负责将场景和相机组合起来渲染为图像

6. 项目的package.json配置

{
  "name": "01-startapp",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "vite": "^4.3.9"
  },
  "dependencies": {
    "three": "^0.153.0"
  }
}

第二部分:与Vue框架集成

1. Vue项目配置

当将ThreeJS与Vue集成时,需要在Vue项目中安装额外的依赖:

{
  "name": "threejsvue",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "three": "^0.153.0",
    "vue": "^3.2.47"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.1.0",
    "vite": "^4.3.9"
  }
}

2. 在Vue组件中使用ThreeJS

在Vue的App.vue文件中可以直接集成ThreeJS代码:

<script setup>
// 导入threejs
import * as THREE from "three";

// 创建场景
const scene = new THREE.Scene();

// 创建相机
const camera = new THREE.PerspectiveCamera(
  45, // 视角
  window.innerWidth / window.innerHeight, // 宽高比
  0.1, // 近平面
  1000 // 远平面
);

// 创建渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 创建几何体
const geometry = new THREE.BoxGeometry(1, 1, 1);
// 创建材质
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
// 创建网格
const cube = new THREE.Mesh(geometry, material);

// 将网格添加到场景中
scene.add(cube);

// 设置相机位置
camera.position.z = 5;
camera.lookAt(0, 0, 0);

// 渲染函数
function animate() {
  requestAnimationFrame(animate);
  // 旋转
  cube.rotation.x += 0.01;
  cube.rotation.y += 0.01;
  // 渲染
  renderer.render(scene, camera);
}
animate();
</script>

<template>
  <div></div>
</template>

<style>
* {
  margin: 0;
  padding: 0;
}

canvas {
  display: block;
  position: fixed;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;
}
</style>

第三部分:与React框架集成

1. React项目配置

当将ThreeJS与React集成时,需要在React项目中安装相应的依赖:

{
  "name": "react3d",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "three": "^0.153.0"
  },
  "devDependencies": {
    "@types/react": "^18.0.37",
    "@types/react-dom": "^18.0.11",
    "@vitejs/plugin-react": "^4.0.0",
    "eslint": "^8.38.0",
    "eslint-plugin-react": "^7.32.2",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.3.4",
    "vite": "^4.3.9"
  }
}

2. 在React组件中使用ThreeJS

在React的App.jsx文件中可以这样集成ThreeJS:

import { useState,useEffect ,Component} from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
// 导入threejs
import * as THREE from "three";
import './App.css'

class App extends Component{
  render(){
    return <div></div>
  }
  componentDidMount(){
    // 创建场景
    const scene = new THREE.Scene();

    // 创建相机
    const camera = new THREE.PerspectiveCamera(
      45, // 视角
      window.innerWidth / window.innerHeight, // 宽高比
      0.1, // 近平面
      1000 // 远平面
    );

    // 创建渲染器
    const renderer = new THREE.WebGLRenderer();
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);

    // 创建几何体
    const geometry = new THREE.BoxGeometry(1, 1, 1);
    // 创建材质
    const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
    // 创建网格
    const cube = new THREE.Mesh(geometry, material);

    // 将网格添加到场景中
    scene.add(cube);

    // 设置相机位置
    camera.position.z = 5;
    camera.lookAt(0, 0, 0);

    // 渲染函数
    function animate() {
      requestAnimationFrame(animate);
      // 旋转
      cube.rotation.x += 0.01;
      cube.rotation.y += 0.01;
      // 渲染
      renderer.render(scene, camera);
    }
    animate();
  }
}

export default App

总结

通过以上步骤,我们已经成功搭建了ThreeJS开发环境,并创建了独立运行的ThreeJS应用以及与Vue和React框架集成的应用。ThreeJS的三个核心概念——场景、相机和渲染器是构建任何3D应用的基础,理解它们的作用和关系对于后续学习非常重要。

无论是在纯JavaScript项目还是在现代前端框架(Vue、React)中,ThreeJS的基本使用方法都是一致的,只是集成方式略有不同。掌握了这些基础知识后,您可以进一步探索ThreeJS的更多功能,如光照、材质、纹理等高级特性。

❌
❌