普通视图

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

Vue3 子传父全解析:从基础用法到实战避坑

2026年2月13日 14:17

在 Vue3 开发中,组件通信是绕不开的核心场景,而子传父作为最基础、最常用的通信方式之一,更是新手入门必掌握的知识点。不同于 Vue2 的 $emit 写法,Vue3 组合式 API(<script setup>)简化了子传父的实现逻辑,但也有不少细节和进阶技巧需要注意。

本文将抛开 TypeScript,用最通俗的语言 + 可直接复制的实战代码,从基础用法、进阶技巧、常见场景到避坑指南,全方位讲解 Vue3 子传父,新手看完就能上手,老手也能查漏补缺。

一、核心原理:子组件触发事件,父组件监听事件

Vue3 子传父的核心逻辑和 Vue2 一致:子组件通过触发自定义事件,将数据传递给父组件;父组件通过监听该自定义事件,接收子组件传递的数据

关键区别在于:Vue3 <script setup> 中,无需通过 this.$emit 触发事件,而是通过 defineEmits 声明事件后,直接调用 emit 函数即可,语法更简洁、更直观。

先记住核心流程,再看具体实现:

  1. 子组件:用 defineEmits 声明要触发的自定义事件(可选但推荐);
  2. 子组件:在需要传值的地方(如点击事件、接口回调),调用 emit('事件名', 要传递的数据)
  3. 父组件:在使用子组件的地方,通过 @事件名="处理函数" 监听事件;
  4. 父组件:在处理函数中,接收子组件传递的数据并使用。

二、基础用法:最简洁的子传父实现(必学)

我们用一个「子组件输入内容,父组件实时显示」的简单案例,讲解基础用法,代码可直接复制到项目中运行。

1. 子组件(Child.vue):声明事件 + 触发事件

<template>
  <div class="child">
    <h4>我是子组件</h4>
    <!-- 输入框输入内容,触发input事件,传递输入值 -->
    <input 
      type="text" 
      v-model="childInput" 
      @input="handleInput"
      placeholder="请输入要传递给父组件的内容"
    />
    <!-- 按钮点击,传递固定数据 -->
    <button @click="handleClick" style="margin-top: 10px;">
      点击向父组件传值
    </button>
  </div>
</template>

<script setup>
// 1. 声明要触发的自定义事件(数组形式,元素是事件名)
// 可选,但推荐声明:增强代码可读性,IDE会有语法提示,避免拼写错误
const emit = defineEmits(['inputChange', 'btnClick'])

// 子组件内部数据
const childInput = ref('')

// 输入框变化时,触发事件并传递输入值
const handleInput = () => {
  // 2. 触发事件:第一个参数是事件名,第二个参数是要传递的数据(可选,可多个)
  emit('inputChange', childInput.value)
}

// 按钮点击时,触发事件并传递固定对象
const handleClick = () => {
  emit('btnClick', {
    name: '子组件',
    msg: '这是子组件通过点击按钮传递的数据'
  })
}
</script>

2. 父组件(Parent.vue):监听事件 + 接收数据

<template>
  <div class="parent">
    <h3>我是父组件</h3>
    <p>子组件输入的内容:{{ parentMsg }}</p>
    <p>子组件点击传递的数据:{{ parentData }}</p>
    
    <!-- 3. 监听子组件声明的自定义事件,绑定处理函数 -->
    <Child 
      @inputChange="handleInputChange"
      @btnClick="handleBtnClick"
    />
  </div>
</template>

<script setup>
// 引入子组件
import Child from './Child.vue'
import { ref, reactive } from 'vue'

// 父组件接收数据的容器
const parentMsg = ref('')
const parentData = reactive({
  name: '',
  msg: ''
})

// 4. 处理子组件触发的inputChange事件,接收传递的数据
const handleInputChange = (val) => {
  // val 就是子组件emit传递过来的值(childInput.value)
  parentMsg.value = val
}

// 处理子组件触发的btnClick事件,接收传递的对象
const handleBtnClick = (data) => {
  // data 是子组件传递的对象,直接解构或赋值即可
  parentData.name = data.name
  parentData.msg = data.msg
}
</script>

3. 核心细节说明

  • defineEmits 是 Vue3 内置的宏,无需导入,可直接使用;
  • emit 函数的第一个参数必须和 defineEmits 中声明的事件名一致(大小写敏感),否则父组件无法监听到;
  • emit 可传递多个参数,比如 emit('event', val1, val2),父组件处理函数可对应接收 (val1, val2) => {}
  • 父组件监听事件时,可使用 @事件名(简写)或 v-on:事件名(完整写法),效果一致。

三、进阶用法:优化子传父的体验(实战常用)

基础用法能满足简单场景,但在实际开发中,我们还会遇到「事件校验」「双向绑定」「事件命名规范」等需求,这部分进阶技巧能让你的代码更规范、更健壮。

1. 事件校验:限制子组件传递的数据类型

通过 defineEmits 的对象形式,可对事件传递的数据进行类型校验,避免子组件传递错误类型的数据,提升代码可靠性(类似 props 校验)。

<script setup>
// 对象形式声明事件,key是事件名,value是校验函数(参数是子组件传递的数据,返回boolean)
const emit = defineEmits({
  // 校验inputChange事件传递的数据必须是字符串
  inputChange: (val) => {
    return typeof val === 'string'
  },
  // 校验btnClick事件传递的数据必须是对象,且包含name和msg属性
  btnClick: (data) => {
    return typeof data === 'object' && 'name' in data && 'msg' in data
  }
})

// 若传递的数据不符合校验,控制台会报警告(不影响代码运行,仅提示)
const handleInput = () => {
  emit('inputChange', 123) // 传递数字,不符合校验,控制台报警告
}
</script>

2. 双向绑定:v-model 简化子传父(高频场景)

很多时候,子传父是为了「修改父组件的数据」,比如表单组件、开关组件,这时可使用 v-model 简化代码,实现父子组件双向绑定,无需手动声明事件和处理函数。

Vue3 中,v-model 本质是「语法糖」,等价于 :modelValue="xxx" @update:modelValue="xxx = $event"

优化案例:子组件开关,父组件显示状态

<!-- 子组件(Child.vue) -->
<template>
  <div class="child">
    <h4>子组件开关</h4>
    <button @click="handleSwitch">
      {{ isOpen ? '关闭' : '打开' }}
    </button>
  </div>
</template>

<script setup>
// 1. 接收父组件通过v-model传递的modelValue
const props = defineProps(['modelValue'])
// 2. 声明update:modelValue事件(固定命名,不可修改)
const emit = defineEmits(['update:modelValue'])

// 子组件内部使用父组件传递的值
const isOpen = computed(() => props.modelValue)

// 开关切换,触发事件,修改父组件数据
const handleSwitch = () => {
  emit('update:modelValue', !isOpen.value)
}
</script>
<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <h3>父组件:{{ isSwitchOpen ? '开关已打开' : '开关已关闭' }}</h3>
    <!-- 直接使用v-model,无需手动监听事件 -->
    <Child v-model="isSwitchOpen" />
  </div>
</template>

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

const isSwitchOpen = ref(false)
</script>

扩展:多个 v-model 双向绑定

Vue3 支持给同一个子组件绑定多个 v-model,只需给 v-model 加后缀,对应子组件的propsemit 即可。

<!-- 父组件 -->
<Child 
  v-model:name="parentName" 
  v-model:age="parentAge" 
/>

<!-- 子组件 -->
<script setup>
// 接收多个v-model传递的props
const props = defineProps(['name', 'age'])
// 声明对应的update事件
const emit = defineEmits(['update:name', 'update:age'])

// 触发事件修改父组件数据
emit('update:name', '新名字')
emit('update:age', 25)
</script>

3. 事件命名规范:提升代码可读性

在实际开发中,遵循统一的事件命名规范,能让团队协作更高效,推荐以下规范:

  • 事件名采用「kebab-case 短横线命名」(和 HTML 事件命名一致),比如 input-change 而非 inputChange
  • 事件名要语义化,体现事件的用途,比如 form-submit(表单提交)、delete-click(删除点击);
  • 双向绑定的事件固定为 update:xxx,xxx 对应 props 名,比如 update:nameupdate:visible

四、实战场景:子传父的常见应用

结合实际开发中的高频场景,给大家补充 3 个常用案例,覆盖大部分子传父需求。

场景1:子组件表单提交,父组件接收表单数据

<!-- 子组件(FormChild.vue) -->
<template>
  <div class="form-child">
    <input v-model="form.name" placeholder="请输入姓名" />
    <input v-model="form.age" type="number" placeholder="请输入年龄" />
    <button @click="handleSubmit">提交表单</button>
  </div>
</template>

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

const emit = defineEmits(['form-submit'])

const form = reactive({
  name: '',
  age: ''
})

const handleSubmit = () => {
  // 表单校验(简化)
  if (!form.name || !form.age) return alert('请填写完整信息')
  // 提交表单数据给父组件
  emit('form-submit', form)
  // 提交后重置表单
  form.name = ''
  form.age = ''
}
</script>

场景2:子组件关闭弹窗,父组件控制弹窗显示/隐藏

<!-- 子组件(ModalChild.vue) -->
<template>
  <div class="modal" v-if="visible">
    <div class="modal-content">
      <h4>子组件弹窗</h4>
      <button @click="handleClose">关闭弹窗</button>
    </div>
  </div>
</template>

<script setup>
const props = defineProps(['visible'])
const emit = defineEmits(['close-modal'])

const handleClose = () => {
  // 触发关闭事件,通知父组件隐藏弹窗
  emit('close-modal')
}
</script>

场景3:子组件列表删除,父组件更新列表

<!-- 子组件(ListChild.vue) -->
<template>
  <div class="list-child">
    <div v-for="item in list" :key="item.id">
      {{ item.name }}
      <button @click="handleDelete(item.id)">删除</button>
    </div>
  </div>
</template>

<script setup>
const props = defineProps(['list'])
const emit = defineEmits(['delete-item'])

const handleDelete = (id) => {
  // 传递要删除的id给父组件,由父组件更新列表
  emit('delete-item', id)
}
</script>

五、常见坑点避坑指南(新手必看)

很多新手在写子传父时,会遇到「父组件监听不到事件」「数据传递失败」等问题,以下是最常见的 4 个坑点,帮你快速避坑。

坑点1:事件名大小写不一致

子组件 emit('inputChange'),父组件 @inputchange="handle"(小写),会导致父组件监听不到事件。

解决方案:统一采用 kebab-case 命名,子组件 emit('input-change'),父组件 @input-change="handle"

坑点2:忘记声明事件(defineEmits)

子组件直接调用 emit('event'),未用 defineEmits 声明事件,虽然开发环境可能不报错,但生产环境可能出现异常,且 IDE 无提示。

解决方案:无论事件是否需要校验,都用 defineEmits 声明(数组形式即可)。

坑点3:传递复杂数据(对象/数组)时,父组件修改后影响子组件

子组件传递对象/数组给父组件,父组件直接修改该数据,会影响子组件(因为引用类型传递的是地址)。

解决方案:父组件接收数据后,用 JSON.parse(JSON.stringify(data)) 深拷贝,或用 reactive + toRaw 处理,避免直接修改原始数据。

坑点4:v-model 双向绑定时报错,提示「modelValue 未定义」

原因:子组件未接收 modelValue props,或未声明 update:modelValue 事件。

解决方案:确保子组件 defineProps(['modelValue'])defineEmits(['update:modelValue']) 都声明。

六、总结:子传父核心要点回顾

Vue3 子传父的核心就是「事件触发 + 事件监听」,记住以下 3 个核心要点,就能应对所有场景:

  1. 基础写法:defineEmits 声明事件 → emit 触发事件 → 父组件 @事件名 监听;
  2. 进阶优化:事件校验提升可靠性,v-model 简化双向绑定,遵循 kebab-case 命名规范;
  3. 避坑关键:事件名大小写一致、必声明事件、复杂数据深拷贝、v-model 对应 props 和 emit 命名正确。

子传父是 Vue3 组件通信中最基础的方式,掌握它之后,再学习父传子(props)、跨层级通信(provide/inject)、全局通信(Pinia)会更轻松。

前端监控实践

作者 李元_霸
2026年2月13日 13:51

从零开发前端监控 SDK:异常、性能、访问量一网打尽

本文将带你从零开发一个完整的前端监控 SDK,涵盖异常监控、性能监控和访问量统计三大核心功能。

目录

  1. 为什么需要前端监控
  2. SDK 架构设计
  3. 核心功能实现
  4. 使用示例
  5. 总结与展望

为什么需要前端监控

在现代 Web 应用中,前端监控已经成为保障用户体验的重要手段:

  • 异常监控:及时发现并修复线上 Bug,减少用户流失
  • 性能监控:优化页面加载速度,提升用户体验
  • 访问统计:了解用户行为,指导产品决策

市面上已有 Sentry、Fundebug 等成熟的监控服务,但开发自己的 SDK 能让我们:

  1. 完全掌控数据,保障隐私安全
  2. 根据业务需求定制功能
  3. 深入理解监控原理,提升技术能力

SDK 架构设计

整体架构

┌─────────────────────────────────────────────────────────────┐
│                        Monitor SDK                          │
├─────────────────────────────────────────────────────────────┤
│  Core Layer  │  Reporter (上报中心)  │  Config (配置管理)    │
├─────────────────────────────────────────────────────────────┤
│  Module Layer│  ErrorMonitor │ PerformanceMonitor │ VisitMonitor│
├─────────────────────────────────────────────────────────────┤
│  Utils Layer │  Device │ Storage │ UUID │ Sampling           │
└─────────────────────────────────────────────────────────────┘

设计原则

  1. 模块化:每个监控功能独立模块,可单独启用/禁用
  2. 插件化:Reporter 统一管理上报,支持批量和即时发送
  3. 低侵入:自动捕获异常,业务代码零改动
  4. 高兼容:支持多种引入方式(ESM/CJS/UMD)

核心功能实现

1. 异常监控模块

异常监控是 SDK 的核心功能,我们需要捕获多种类型的错误:

1.1 JavaScript 运行时错误
// src/modules/error/globalError.ts
export function initGlobalError(reporter: Reporter): () => void {
  const handler = (event: ErrorEvent) => {
    const errorData: ErrorData = {
      type: 'js',
      message: event.message,
      filename: event.filename,
      lineno: event.lineno,
      colno: event.colno,
      stack: event.error?.stack
    };
    reporter.report('error', errorData);
  };

  window.addEventListener('error', handler);
  return () => window.removeEventListener('error', handler);
}

通过监听 window.onerror,我们可以捕获所有同步和异步的 JavaScript 错误。

1.2 Promise 未捕获异常
// src/modules/error/promiseError.ts
export function initPromiseError(reporter: Reporter): () => void {
  const handler = (event: PromiseRejectionEvent) => {
    const errorData: ErrorData = {
      type: 'promise',
      message: event.reason?.message || String(event.reason),
      stack: event.reason?.stack
    };
    reporter.report('error', errorData);
  };

  window.addEventListener('unhandledrejection', handler);
  return () => window.removeEventListener('unhandledrejection', handler);
}

现代前端大量使用 Promise,未捕获的 Promise 错误会导致应用崩溃。

1.3 资源加载错误
// src/modules/error/resourceError.ts
export function initResourceError(reporter: Reporter): () => void {
  const handler = (event: Event) => {
    const target = event.target as HTMLElement;
    const tagName = target.tagName?.toLowerCase();

    if (!['img', 'script', 'link'].includes(tagName)) return;

    const src = (target as any).src || (target as any).href || '';
    const errorData: ErrorData = {
      type: 'resource',
      message: `Failed to load ${tagName}: ${src}`,
      filename: src,
      extra: { tagName }
    };
    reporter.report('error', errorData);
  };

  window.addEventListener('error', handler, true); // 捕获阶段监听
  return () => window.removeEventListener('error', handler, true);
}

使用捕获阶段(true)可以监听到资源加载错误。

1.4 网络请求错误

通过劫持 XMLHttpRequest 和 fetch API,监控所有网络请求:

// src/modules/error/networkError.ts
const originalFetch = window.fetch;
window.fetch = function(input: RequestInfo | URL, init?: RequestInit) {
  const startTime = Date.now();
  const url = typeof input === 'string' ? input : input.toString();

  return originalFetch.apply(this, arguments as any)
    .then(response => {
      if (!response.ok) {
        reporter.report('error', {
          type: 'network',
          message: `Fetch ${response.status}: ${response.statusText}`,
          extra: { method: init?.method || 'GET', url, status: response.status }
        });
      }
      return response;
    })
    .catch(error => {
      reporter.report('error', {
        type: 'network',
        message: `Fetch failed: ${error.message}`,
        extra: { method: init?.method || 'GET', url }
      });
      throw error;
    });
};

2. 性能监控模块

2.1 Web Vitals 指标

Core Web Vitals 是 Google 提出的衡量用户体验的关键指标:

// src/modules/performance/webVitals.ts

// LCP - 最大内容绘制
export function observeLCP(reporter: Reporter): void {
  const observer = new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];
    const value = (lastEntry as any).renderTime || lastEntry.startTime;

    reporter.report('performance', {
      type: 'web-vitals',
      name: 'LCP',
      value: Math.round(value),
      rating: value <= 2500 ? 'good' : value <= 4000 ? 'needs-improvement' : 'poor'
    });
  });

  observer.observe({ entryTypes: ['largest-contentful-paint'] as any });
}

// CLS - 累积布局偏移
export function observeCLS(reporter: Reporter): void {
  let clsValue = 0;

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      const layoutEntry = entry as PerformanceEntry & { hadRecentInput: boolean; value: number };
      if (!layoutEntry.hadRecentInput) {
        clsValue += layoutEntry.value;
      }
    }
  });

  observer.observe({ entryTypes: ['layout-shift'] as any });

  // 页面隐藏时上报
  window.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
      reporter.report('performance', {
        type: 'web-vitals',
        name: 'CLS',
        value: Math.round(clsValue * 1000) / 1000,
        rating: clsValue <= 0.1 ? 'good' : clsValue <= 0.25 ? 'needs-improvement' : 'poor'
      });
    }
  });
}
2.2 导航性能

利用 Navigation Timing API 获取页面加载各阶段耗时:

export function observeNavigation(reporter: Reporter): void {
  window.addEventListener('load', () => {
    setTimeout(() => {
      const navigation = performance.getEntriesByType('navigation')[0]
        as PerformanceNavigationTiming;

      const metrics = [
        { name: 'DNS', value: navigation.domainLookupEnd - navigation.domainLookupStart },
        { name: 'TCP', value: navigation.connectEnd - navigation.connectStart },
        { name: 'TTFB', value: navigation.responseStart - navigation.startTime },
        { name: 'DOM解析', value: navigation.domInteractive - navigation.responseEnd },
        { name: 'Load', value: navigation.loadEventEnd - navigation.startTime }
      ];

      metrics.forEach(({ name, value }) => {
        if (value > 0) {
          reporter.report('performance', {
            type: 'navigation',
            name,
            value: Math.round(value)
          });
        }
      });
    }, 0);
  });
}
2.3 API 耗时监控

劫持 XMLHttpRequest 和 fetch,统计所有 API 请求耗时:

export function observeAPI(reporter: Reporter): () => void {
  // 劫持 XMLHttpRequest
  const originalXHRSend = XMLHttpRequest.prototype.send;

  XMLHttpRequest.prototype.send = function() {
    const startTime = Date.now();

    this.addEventListener('loadend', function() {
      const duration = Date.now() - startTime;
      reporter.report('performance', {
        type: 'api',
        name: `API: ${this._url}`,
        value: duration
      });
    });

    return originalXHRSend.apply(this, arguments);
  };

  // 劫持 fetch...
}

3. 访问监控模块

3.1 PV 统计
// src/modules/visit/pv.ts
export function observePV(reporter: Reporter, enableSPA: boolean): () => void {
  // 初始页面 PV
  reportPV(reporter);

  if (!enableSPA) return;

  // 劫持 history API 监听路由变化
  const originalPushState = history.pushState;
  history.pushState = function(...args) {
    originalPushState.apply(this, args);
    reportPV(reporter);
  };

  window.addEventListener('popstate', () => reportPV(reporter));
  window.addEventListener('hashchange', () => reportPV(reporter));
}
3.2 Session 管理
// src/modules/visit/session.ts
const SESSION_TIMEOUT = 30 * 60 * 1000; // 30分钟

export function initSession(reporter: Reporter): void {
  const startTime = Date.now();

  // 上报会话开始
  reporter.report('visit', { type: 'session-start' });

  // 页面可见性变化
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') {
      const lastActive = parseInt(storage.get('session_time') || '0');
      if (Date.now() - lastActive > SESSION_TIMEOUT) {
        // 新会话
        reporter.report('visit', { type: 'session-start' });
      }
    } else {
      storage.set('session_time', Date.now().toString());
    }
  });

  // 页面卸载时上报会话结束
  window.addEventListener('beforeunload', () => {
    reporter.report('visit', {
      type: 'session-end',
      duration: Date.now() - startTime
    });
  });
}

4. 数据上报中心

4.1 上报策略
// src/core/reporter.ts
export class Reporter {
  private queue: QueueItem[] = [];
  private readonly FLUSH_INTERVAL = 5000; // 5秒刷新
  private readonly MAX_QUEUE_SIZE = 10;   // 10条批量发送

  report(type: ReportData['type'], data: ReportData['data']): void {
    // 采样检查
    const sampleRate = this.config.sampleRate?.[type] || 1;
    if (!shouldSample(sampleRate)) return;

    const url = this.config.reportUrl[type];
    if (!url) return;

    // 异常数据立即上报
    if (type === 'error') {
      this.sendImmediately(data, url);
    } else {
      // 性能和访问数据批量上报
      this.addToQueue(data, url);
    }
  }

  private addToQueue(data: ReportData, url: string): void {
    this.queue.push({ data, url });

    if (this.queue.length >= this.MAX_QUEUE_SIZE) {
      this.flush();
    } else {
      this.scheduleFlush();
    }
  }
}
4.2 页面关闭补发

使用 sendBeacon API 在页面关闭前发送剩余数据:

private bindEvents(): void {
  const sendRemaining = () => {
    if (this.queue.length === 0) return;

    this.queue.forEach(({ data, url }) => {
      navigator.sendBeacon?.(url, JSON.stringify(data));
    });
  };

  window.addEventListener('beforeunload', sendRemaining);
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
      sendRemaining();
    }
  });
}

5. 设备信息解析

// src/utils/device.ts
export function getDeviceInfo(): DeviceInfo {
  const ua = navigator.userAgent;

  // 解析操作系统
  let os = 'unknown';
  let osVersion = 'unknown';

  if (ua.indexOf('Win') !== -1) {
    os = 'Windows';
    const match = ua.match(/Windows NT (\d+\.\d+)/);
    if (match) osVersion = match[1];
  } else if (ua.indexOf('Mac') !== -1) {
    os = 'macOS';
    // ...
  } else if (/iPad|iPhone|iPod/.test(ua)) {
    os = 'iOS';
    // ...
  } else if (ua.indexOf('Android') !== -1) {
    os = 'Android';
    // ...
  }

  // 解析浏览器
  let browser = 'unknown';
  let browserVersion = 'unknown';

  if (ua.indexOf('Chrome') !== -1 && ua.indexOf('Edg') === -1) {
    browser = 'Chrome';
    const match = ua.match(/Chrome\/(\d+\.\d+)/);
    if (match) browserVersion = match[1];
  }
  // ... Safari, Firefox, Edge

  return {
    ua,
    os,
    osVersion,
    browser,
    browserVersion,
    screen: `${window.screen.width}x${window.screen.height}`,
    language: navigator.language
  };
}

使用示例

基础使用

import Monitor from 'frontend-monitor-sdk';

Monitor.init({
  appId: 'my-app',
  appVersion: '1.0.0',
  env: 'production',
  reportUrl: {
    error: 'https://api.example.com/error',
    performance: 'https://api.example.com/perf',
    visit: 'https://api.example.com/visit'
  },
  sampleRate: {
    error: 1,         // 异常100%上报
    performance: 0.1, // 性能10%采样
    visit: 0.1        // 访问10%采样
  },
  enableSPA: true,
  beforeReport: (data) => {
    // 上报前钩子,可修改数据或返回 false 阻止上报
    if (data.type === 'error' && data.data.message?.includes('ignore')) {
      return false;
    }
    return data;
  }
});

Vue 集成

import { createApp } from 'vue';
import Monitor from 'frontend-monitor-sdk';

Monitor.init({ /* ... */ });

const app = createApp(App);
app.config.errorHandler = Monitor.vueErrorHandler;

React 集成

class ErrorBoundary extends React.Component {
  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    Monitor.reportError(error, {
      componentStack: errorInfo.componentStack
    });
  }

  render() {
    return this.props.children;
  }
}

总结与展望

已实现功能

异常监控:JS 错误、Promise 错误、资源错误、网络错误、控制台错误、框架错误 ✅ 性能监控:Web Vitals、导航计时、资源性能、API 耗时、长任务 ✅ 访问监控:PV/UV、Session、设备信息、SPA 路由监听 ✅ 数据上报:分类上报、采样控制、批量上报、页面关闭补发

技术亮点

  1. 类型安全:完整的 TypeScript 类型定义
  2. 模块化设计:各功能独立,可灵活组合
  3. 低侵入性:自动捕获,业务代码零改动
  4. 高兼容性:支持 ESM/CJS/UMD 多种格式

未来优化方向

🔲 SourceMap 解析:实现错误堆栈的源码还原 🔲 用户行为录屏:记录用户操作路径,辅助问题定位 🔲 性能面板可视化:开发 Chrome 插件查看性能数据 🔲 离线缓存:支持网络断开时的数据本地存储


参考资源


本文完,如有问题欢迎留言讨论!

想学 Electron?这份「能跑的示例集」一篇搞懂

2026年2月13日 12:03

想学 Electron?这份「能跑的示例集」一篇搞懂

VS Code、钉钉、Slack、Figma 桌面版……这些你熟悉的软件,背后都有同一个名字:Electron。用前端技术(HTML/CSS/JS)就能做桌面应用,是很多团队的选择。但官方文档知识点多、自己从零搭又容易踩坑,有没有一份「按主题拆好、每个都能直接跑」的示例? 有。本文就介绍这样一份示例仓库,并帮你把 Electron 的核心知识点串一遍,看完一篇,知道学什么、去哪看代码


一、Electron 是什么?这个仓库能帮你什么?

一句话: Electron 让你用写网页的技术(HTML/CSS/JavaScript)来开发桌面软件,一套代码可跑在 Windows、macOS、Linux 上。

这个仓库做什么: 把 Electron 常见能力拆成 23 个独立小项目,每个项目一个文件夹,里面是可运行代码 + 说明文档。你不需要从零搭环境,克隆下来进目录、装依赖、运行命令就能看到效果。适合:

  • 想学 Electron 的人:按主题边看边跑,比光看文档好懂;
  • 做桌面端开发的人:遇到「窗口、菜单、通知、IPC」等问题,可以对着对应示例改;
  • 面试前突击的人:仓库里还带 3 套 Electron 面试卷(含答案),可用来自查。

二、怎么跑起来?(3 步)

环境准备: 本机装好 Node.js(建议 18 及以上),示例在 Windows 11 下验证过,macOS / Linux 下大多也可直接运行。

运行任意一个示例:

  1. 克隆仓库;
  2. 进入想玩的案例目录(例如 ipcdarkmode);
  3. 在该目录下执行:npm installnpm run startnpm run start:1(具体看该目录的 readme)。

每个案例的详细命令和说明都在对应目录的 readme.md 里,进去就能看到。


三、Electron 知识点串讲(对应仓库怎么用)

下面按「从入门到进阶」的顺序,把主要概念过一遍,并标出仓库里哪一类示例可以对照着看。不展开代码细节,只帮你建立地图。

1. 入门:第一个应用长什么样?

Electron 应用至少有两个「角色」:主进程(负责创建窗口、系统 API)和渲染进程(你看到的页面)。两者不能直接互相调函数,要通过 preload 脚本安全地暴露接口,或用 IPC(进程间通信)传消息。

仓库对应: tutorial-first-app —— 第一个应用、主进程 / 渲染进程 / preload / IPC 入门。


2. 进程与通信:页面和「后台」怎么配合?

  • IPC:渲染进程和主进程互相发消息(单向、双向、主进程主动推给页面等)。
  • MessagePort:更灵活的通道,适合「渲染进程直连」「流式回复」等场景。
  • Utility Process(效率进程):跑 CPU 重活或容易崩的逻辑,和主进程隔离,用 MessagePort 通信。
  • 多线程:在渲染进程里用 Web Worker,避免大量计算卡住界面。
  • 沙盒:渲染进程默认沙盒、安全配置,减少安全风险。

仓库对应: ipcmessage-portsefficiency-processmultithreadingsandbox


3. 窗口与 UI:桌面应用「长什么样」?

  • 暗色模式:用系统主题或自己切换,和 CSS 的 prefers-color-scheme 配合。
  • 任务栏:Windows 上的 JumpList、缩略图工具栏、进度条、图标闪烁等。
  • 窗口定制:无边框、自定义标题栏、拖拽区域。
  • 进度条:在任务栏 / Dock 上显示进度(如下载、处理任务)。

仓库对应: darkmodewindows-taskbarwindow-customizationprogressbar


4. 系统与原生能力:和操作系统打交道

  • 菜单:应用菜单、右键菜单、托盘菜单。
  • 快捷键:绑定在菜单上的、全局的、窗口内自己监听的。
  • 系统通知:像微信/邮件那样在系统通知栏弹出,而不是只在页面里弹个提示。
  • 设备访问:例如蓝牙(Web Bluetooth API)。
  • 深度链接:自定义协议,从浏览器或别的应用点链接唤起你的应用。

仓库对应: menuskeyboardshortcutnotificationsDemobluetoothdeeplinks


5. 文件与文档、Web 与导航

  • 文件拖拽:把文件从应用拖到桌面或资源管理器。
  • 最近文档:系统「最近打开」列表的集成。
  • Web 嵌入:在窗口里嵌网页(iframe、webview、WebContentsView)。
  • 导航历史:窗口内前进/后退(goBack / goForward)。
  • 在线/离线:检测网络状态,做离线提示或缓存策略。

仓库对应: draganddroprecentdocumentsweb-embedsnavigationHistoryonlineofflineevents


6. 其他常用能力

  • 离屏渲染:在不可见的画布上渲染(例如生成图、PDF)。
  • 拼写检查:系统级拼写检查集成。

仓库对应: offscreenrenderingspellchecker


7. 学习与面试

仓库内带 paper 目录:3 套 Electron 面试卷,各 100 分,含答案与解析,适合考前自查。


四、案例目录一览(按需进目录看 readme 和代码)

下面表格里的目录名,在仓库里对应一个文件夹,点进去有 readme可运行代码。不同子项目可能有不同启动命令(如 npm run start:1start:2),以该目录下的 readme 为准。

分类 序号 目录 说明
入门 1 tutorial-first-app 第一个应用(主进程、渲染进程、preload、IPC 入门)
窗口与 UI 2 darkmode 黑暗模式 / 主题切换
3 windows-taskbar Windows 任务栏(JumpList、缩略图、叠加图标、闪烁)
4 window-customization 自定义窗口(无边框、自定义标题栏、拖拽区域)
5 progressbar 任务栏/Dock 进度条
进程与通信 6 ipc 进程间通信(单向/双向、主→渲染)
7 message-ports 消息端口(MessageChannel、流式回复)
8 efficiency-process 效率进程(Utility Process、MessagePort)
9 multithreading 多线程(Web Workers)
10 sandbox 进程沙盒与安全配置
系统与原生 11 menus 菜单(应用菜单、上下文菜单、托盘)
12 keyboardshortcut 键盘快捷键(局部、全局、窗口内)
13 notificationsDemo 系统通知(主进程/渲染进程)
14 bluetooth 设备访问(如蓝牙)
15 deeplinks 深度链接(自定义协议、从链接唤起应用)
文件与文档 16 draganddrop 文件拖拽(拖出到桌面/资源管理器)
17 recentdocuments 最近文件(系统最近文档列表)
Web 与导航 18 web-embeds Web 嵌入(iframe、webview、WebContentsView)
19 navigationHistory 导航历史(前进/后退)
20 onlineofflineevents 在线/离线事件
其他 21 offscreenrendering 离屏渲染
22 spellchecker 拼写检查器
学习与面试 23 paper Electron 面试卷(3 套,含答案与解析)

五、去哪看代码?仓库在这里

以上所有示例的完整代码、运行命令和说明都在下面这个仓库里,每个案例独立可运行,按目录即可找到对应 readme 和代码。

仓库地址: [gitee.com/sharetoyouc…]

克隆后,进入任意目录执行 npm install,再按该目录 readme 的脚本运行即可。MIT 许可,欢迎 Star、Fork,或提 Issue / PR。


本文基于 Electron 官方文档与示例整理,旨在帮助初学者和开发者快速建立知识地图并找到可运行示例。

告别 JSON.parse(JSON.stringify()) — 原生深拷贝 structuredClone

作者 陈广亮
2026年2月13日 11:40

深拷贝的老办法

在 JavaScript 中深拷贝一个对象,最常见的"hack"写法是:

const copy = JSON.parse(JSON.stringify(original));

这个方法简单粗暴,但有一堆坑:

const obj = {
  date: new Date(),
  regex: /test/gi,
  map: new Map([["key", "value"]]),
  set: new Set([1, 2, 3]),
  undef: undefined,
  fn: () => "hello",
  nan: NaN,
  infinity: Infinity,
};

const copy = JSON.parse(JSON.stringify(obj));
console.log(copy);
// {
//   date: "2026-02-11T06:00:00.000Z",  ← 变成了字符串
//   regex: {},                           ← 丢失了
//   map: {},                             ← 丢失了
//   set: {},                             ← 丢失了
//                                        ← undefined 直接消失
//                                        ← 函数直接消失
//   nan: null,                           ← 变成了 null
//   infinity: null                       ← 变成了 null
// }

还有一个致命问题 —— 循环引用直接报错:

const a = { name: "a" };
a.self = a;
JSON.parse(JSON.stringify(a)); // ❌ TypeError: Converting circular structure to JSON

structuredClone 登场

structuredClone() 是浏览器和 Node.js (v17+) 提供的原生深拷贝方法:

const original = {
  date: new Date(),
  regex: /test/gi,
  map: new Map([["key", "value"]]),
  set: new Set([1, 2, 3]),
  nested: { deep: { value: 42 } },
  arr: [1, [2, [3]]],
};

const copy = structuredClone(original);

copy.nested.deep.value = 0;
console.log(original.nested.deep.value); // 42 ✅ 互不影响

copy.date instanceof Date; // true ✅ 类型保留
copy.regex instanceof RegExp; // true ✅
copy.map instanceof Map; // true ✅
copy.set instanceof Set; // true ✅

循环引用也能正确处理:

const a = { name: "a" };
a.self = a;
const b = structuredClone(a); // ✅ 正常工作
b.self === b; // true(引用指向拷贝后的自身)

支持的类型

structuredClone 使用的是结构化克隆算法,支持绝大多数内置类型:

类型 JSON 方式 structuredClone
Date ❌ 变字符串
RegExp ❌ 变 {}
Map / Set ❌ 变 {}
ArrayBuffer
undefined ❌ 丢失
NaN / Infinity ❌ 变 null
循环引用 ❌ 报错

不支持什么

有几种东西是 structuredClone 无法克隆的:

// ❌ 函数
structuredClone({ fn: () => {} });
// DOMException: () => {} could not be cloned.

// ❌ DOM 节点
structuredClone(document.body);

// ❌ 原型链(拷贝后丢失)
class Dog {
  bark() { return "woof"; }
}
const dog = new Dog();
const cloned = structuredClone(dog);
cloned instanceof Dog; // false
cloned.bark; // undefined

所以如果你的对象包含函数或需要保留原型链,structuredClone 不适用。

一个实用技巧:transferable objects

structuredClone 支持第二个参数 transfer,可以"移交"而不是"复制"某些对象(如 ArrayBuffer),避免内存翻倍:

const buffer = new ArrayBuffer(1024 * 1024); // 1MB
const copy = structuredClone(buffer, { transfer: [buffer] });

console.log(buffer.byteLength); // 0 ← 原始的被清空了
console.log(copy.byteLength); // 1048576 ← 数据转移到了 copy

这在处理大型二进制数据时非常有用。

兼容性

  • Chrome 98+, Firefox 94+, Safari 15.4+, Node.js 17+
  • 2026 年的今天,基本可以放心使用

总结

场景 推荐方案
简单对象,无特殊类型 JSON.parse(JSON.stringify()) 仍然可用
包含 Date/Map/Set/循环引用 structuredClone()
需要保留原型链/函数 手写递归或 lodash _.cloneDeep()

以后深拷贝,先想想 structuredClone 吧。


原文链接:chenguangliang.com/posts/js-st…

前端侦探:我是如何挖掘出网站里 28 个"隐藏商品"的?

作者 小冰球
2026年2月13日 11:36

前端侦探:我是如何挖掘出网站里 28 个"隐藏商品"的?

免责声明:本文仅供技术交流与学习,请勿利用文中技术手段对他人的服务器造成压力或进行恶意爬取。所有测试数据均来自公开接口。

🕵️‍♂️ 从一个好奇心开始

前几天逛一个数字产品合租平台(nf.video)时,我发现它首页只孤零零地挂着 6 个商品:Netflix、Disney+、Spotify 等常见的全家桶。

作为一个前端开发者,我的直觉告诉我:事情没这么简单

通常这类平台为了 SEO 或者后台管理的统一性,数据库里往往躺着更多商品,只是因为库存、策略原因被前端"隐藏"了。今天就带大家通过浏览器控制台(Console),用几招前端调试技巧,扒出那些藏在代码背后的秘密。


🔍 第一层:摆在明面上的数据

首先,我们看看普通用户能看到什么。打开控制台,简单查一下 DOM:

// 获取首页所有商品卡片
const cards = document.querySelectorAll('.platFormItem');
console.log(`首页可见商品数: ${cards.length}`);
// 输出: 6

确实只有 6 个。这建立了我们的"基准线"。如果后面我们找到了多于 6 个的数据,那就说明有"隐藏款"。


🎣 第二层:Vue Router 拦截术

点击商品卡片会跳转到购买页。通常我们会看 Network 面板找链接,但这个网站是 SPA(单页应用),点击是路由跳转。

为了不真的跳走(跳走就得退回来,麻烦),我们可以利用 Vue Router 的全局前置守卫来做一个"钩子"。我们想知道点击卡片后,路由到底想带我们去哪?

我们可以直接在控制台注入这段代码:

// 假设挂载在 app 上的 router 实例(视具体项目而定,通常在 vueApp.config 或 __vue_app__ 中)
// 这里演示思路
const router = document.querySelector('#app').__vue_app__.config.globalProperties.$router;

// 👮‍♂️ 注册一个拦截守卫
router.beforeEach((to, from, next) => {
    console.log(`🎯 捕获到目标路由: ${to.fullPath}`);
    console.log(`📦 参数 ID: ${to.params.id}`);
    
    // ✋ next(false) 阻止实际跳转,我们就停在当前页
    next(false); 
});

然后在页面上点击一个"苹果商店"的卡片:

Console 输出: 🎯 捕获到目标路由: /buy/31 📦 参数 ID: 31

Bingo!我们摸清了路由规则:/buy/:id。这意味着商品是以 ID 为索引的。


🕵️ 第三层:Performance API 里的蛛丝马迹

页面加载完了,Network 面板里的请求都被冲掉了或者很难找。这时,浏览器原生的 Performance API 就像一个黑匣子,记录了所有发生过的资源请求。

我想看看前端到底请求了哪些 API 接口:

// 筛选所有 XMLHttpRequest 或 Fetch 请求
const apiRequests = performance.getEntriesByType('resource')
  .filter(e => e.initiatorType === 'xmlhttprequest' || e.initiatorType === 'fetch')
  .map(e => e.name);

console.table(apiRequests);

在一堆日志里,我发现了这几个有趣的接口:

  • /api/applets/goods/get/homeManage (首页数据,估计就那 6 个)
  • /api/applets/goods/get/categoryGoods (分类商品?这个听起来有戏!)

我尝试手动调用了一下这个 categoryGoods 接口:

fetch('/api/applets/goods/get/categoryGoods')
  .then(res => res.json())
  .then(data => console.log(`拿到所有商品数: ${data.data.length}`));
// 输出: 27

27 个! 远超首页的 6 个。

通过分析返回的 JSON,我看到了大量首页没展示的商品:

  • ID 20: MagSafe 三合一无线充
  • ID 96: 银河次时代智能 NAS (这啥黑科技?)
  • ID 111: Typora 正版授权

到这里,如果是普通用户可能就满足了。但作为程序员,我注意到 ID 并不连续。最大的 ID 是 113,但中间缺了很多数字。

那些消失的 ID 去哪了?


🚀 第四层:ID 暴力枚举与深度挖掘

既然知道了 API 模式是 /api/applets/goods/get/:id,且 ID 是数字。那我能不能写个脚本,把 1 到 200 的 ID 全扫一遍?

这就像是在玩"扫雷"。

// 简单的并发探测脚本
async function scanHiddenGoods(maxId) {
    const hiddenGoods = [];
    
    console.log(`🚀 开始扫描 ID 1 - ${maxId}...`);
    
    const promises = [];
    for (let id = 1; id <= maxId; id++) {
        const p = fetch(`/8081/api/applets/goods/get/${id}`)
            .then(res => res.json())
            .then(res => {
                // 如果接口返回成功且有数据
                if (res.code === 10000 && res.data) {
                    return { id, name: res.data.goodsName, price: res.data.price };
                }
                return null;
            })
            .catch(() => null);
        promises.push(p);
    }

    const results = await Promise.all(promises);
    return results.filter(Boolean); // 过滤掉 null
}

// 让我们跑一下
scanHiddenGoods(200).then(goods => {
    console.table(goods);
    console.log(`🎉 共发现商品: ${goods.length} 个`);
});

几秒钟后,控制台打出了一张长长的表格。

结果令人震惊:

除了刚才分类列表里的 27 个,我又挖出了 8 个"幽灵商品"。这些商品连分类 API 都不返回,完全是"隐形"的,只有通过 ID 直达才能看到:

ID 名称 这居然也有?
18 GPT Plus 可能因为合规问题隐藏
26 Midjourney 只能直接访问购买
50 Runway 那个文生视频的 AI
105 Codex 编程神器

这些商品很可能是测试下架的、或者是仅限内部/老客户通过链接购买的。


📝 总结

通过这次探索,我们发现了网站里共有 34 个有效商品,而首页只展示了 17%

回顾一下我们的"作案工具":

  1. DOM 解析:看清表象。
  2. Vue Router 守卫:拦截路由,探知路径规则。
  3. Performance API:回溯历史请求,定位关键后端接口。
  4. Promise.all 并发探测:暴力枚举,发现离散数据。

前端开发不仅仅是画页面,善用浏览器提供的调试工具,我们可以对正在运行的应用有更深层的理解(或者单纯是为了满足好奇心 😉)。


如果你觉得这个分析过程有趣,欢迎点赞收藏!

零 JavaScript 的性能优化视频嵌入

2026年2月13日 11:07

原文:Performance-Optimized Video Embeds with Zero JavaScript

翻译:TUARAN

欢迎关注 {{前端周刊}},每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

image.png

嵌入视频往往会显著拖慢页面:播放器会加载一堆额外资源,即使用户最终根本不点播放。

常见的优化是用 lite-youtube-embed 之类的轻量组件先占位、再按需加载。但如果视频就在首屏(above the fold),仍然可能因为占位与真实播放器尺寸/渲染时机问题带来 CLS(累计布局偏移)。

这篇文章给出一种“极简但很实用”的模式:只用原生 HTML 的 <details> / <summary> + 一点 CSS,实现交互时才加载 iframe,并且不写一行 JS。

解决方案:用 <details> / <summary> 作为交互边界

<summary> 的默认行为类似按钮:点击会展开对应 <details>,浏览器会给 <details> 加上 open 属性;再点一次就收起。

页面初始加载时,<details> 内除了 <summary> 以外的内容默认不显示——这使它天然适合“用户交互后才呈现”的内容(比如 iframe 视频)。

懒加载:要避免“首屏懒加载反伤”

现代浏览器支持 loading="lazy" 对图片与 iframe 做原生懒加载。

但需要注意:把所有东西都懒加载,可能反而让 LCP 变差。Chrome 团队的研究提到,过度懒加载可能让 LCP 下降约 20%,尤其是当你把内容懒加载到首屏视口里时。

这里的关键点在于:iframe 视频作为 <details> 的内容,在用户点击之前并不算“初始视口内容”,所以不会触发那种“首屏懒加载带来的反效果”。

结论:如果你本来就把视频放在一个可折叠区域里(accordion),那就非常适合把它延迟到“用户想看”的那一刻才加载。

样式:把 <summary> 做成视频缩略图

默认的 <details> 样式很朴素。我们可以把 <summary> 做成一个“视频缩略图占位”,上面叠一个自定义播放按钮。

<details class="video-embed">
  <summary class="video-summary" aria-label="播放视频:Big Buck Bunny">
    <img
      src="https://lab.n8d.studio/htwoo/htwoo-core/images/videos/big-bug-bunny.webp"
      class="video-thumbnail"
      alt=""
    />
    <svg class="video-playicon" viewBox="0 0 32 32" aria-hidden="true">
      <path d="m11.167 5.608 16.278 8.47a2.169 2.169 0 0 1 .011 3.838l-.012.006-16.278 8.47a2.167 2.167 0 0 1-3.167-1.922V7.529a2.167 2.167 0 0 1 3.047-1.981l-.014-.005.134.065z" />
    </svg>
  </summary>

  <div class="video-content">
    <!-- 原始 embed 代码尽量不改,直接放进来 -->
    <iframe
      src="https://www.youtube.com/embed/aqz-KE-bpKQ?autoplay=1"
      title="Big Buck Bunny"
      loading="lazy"
      allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
      allowfullscreen
    ></iframe>
  </div>
</details>

要点:

  • 缩略图与 iframe 维持同一宽高比(避免布局跳动)。
  • 播放按钮用自有 SVG,保证品牌一致性。
  • aria-label 给屏幕阅读器一个明确的动作提示(作者也强调需要做跨 VoiceOver/NVDA/JAWS 的实际测试)。

CSS 可以用 grid 把按钮叠在缩略图正中:

.video-summary {
  display: grid;
  place-items: center;
}

.video-thumbnail,
.video-playicon {
  grid-area1 / 1;
}

.video-playicon {
  width64px;
  height64px;
}

展开后隐藏缩略图,让 iframe 出现

<summary> 默认即使展开也会持续可见;但我们展开后希望看到的是 iframe,而不是缩略图。

思路很简单:当 <details> 具备 open 属性时,把 summary 隐藏。

.video-embed {
  position: relative;
}

.video-embed[open] .video-summary {
  visibility: hidden;
}

.video-content iframe {
  width100%;
  height100%;
}

用户点击缩略图时:

  • 浏览器把 open 加到 <details>
  • summary 被隐藏
  • iframe 进入视口并开始加载(而且只在用户真的想看时才加载)

小提示:对于 YouTube,可以在 iframe URL 上加 ?autoplay=1,让播放器尽快开始播放;但如果用户浏览器禁用了 autoplay,仍需要再次点击。

性能对比(与 lite-youtube-embed)

作者用同一张缩略图对比了本方案与 lite-youtube-embed

指标 <details> 模式 lite-youtube-embed 更优
Load Time 595ms 693ms <details>(约快 14%)
FCP 11ms 70ms <details>(约快 6.4×)
LCP 97ms 157ms <details>(约快 1.6×)
Transfer 34 KB 84 KB <details>(约少 2.5×)
CLS 0.0075 0.0000 都不错
TBT 0ms 0ms 持平
JavaScript 0 ~3KB <details>

(原文还提到资源请求数量也显著更少。)

收尾

  • <details> 自 2011 起就在浏览器中可用
  • iframe 原生 lazy loading 大约在 2019 落地

把两者结合起来,你就能获得“首屏更快、重内容延后、交互自然、键盘可用”的视频嵌入体验,而且完全不依赖 JavaScript。

它不是一个“产品”,而是一个“模式”:同样适用于 Vimeo、自托管视频、GIF、CodePen、地图等任何重量级嵌入内容。

gsap 配置解读 --1

作者 大时光
2026年2月13日 10:56

toggleActions: "play none none reverse" 是什么意思

gsap.to(panel, {
y: 0,
opacity: 1,
duration: 0.8,
ease: "power2.out", 
scrollTrigger: {
trigger: panel, 
start: "top 80%", // 当 panel 的顶部到达 viewport 的 80% 位置时,进入触发区
end: "top 40%", // 当 panel 的顶部到达 viewport 的 40% 位置时,离开触发区 
toggleActions: "play none none reverse"
} 
});
位置 触发时机 说明
1. onEnter 元素从上往下滚动进入触发区间(比如进入 startend 区域) 此处是 "play" → 播放动画
2. onLeave 元素继续向下滚动,离开触发区间(滚出 end 之后) 此处是 "none" → 什么都不做
3. onEnterBack 元素从下往上滚动,重新进入触发区间(反向滚动进入) 此处是 "none" → 什么都不做
4. onLeaveBack 元素继续向上滚动,离开触发区间(反向滚出 start 之前) 此处是 "reverse" → 反向播放动画(即倒放)

toggleActions

动作值 效果
"play" 播放动画(从当前进度开始)
"pause" 暂停动画
"resume" 恢复播放(如果已暂停)
"reverse" 反向播放(倒放)
"restart" 从头开始播放
"reset" 重置到初始状态
"none" 无操作(保持当前状态)

 典型使用场景对比:

需求 推荐 toggleActions
进入播放,离开重置 "play none none reset"
进入播放,反向离开时倒放 "play none none reverse" ← 你的情况
只播放一次,之后不再动 "play pause pause pause"
来回都播放 "play play play play"(不推荐,会闪烁)

paused: true是什么意思

{
x: 280, 
scale: 0.5,
opacity: 0,
duration: 1,
ease: "power2.out",
paused: true 
});

在 GSAP(GreenSock Animation Platform)中,paused: true 是一个动画配置选项,它的作用是:

创建动画时立即暂停(不自动播放),等待后续手动控制播放。

  • gsap.from(...) 表示:从指定的起始状态(x=280, scale=0.5, opacity=0)动画到元素当前的 CSS 状态
  • 但由于设置了 paused: true,这个动画不会立刻执行,而是被“冻结”在初始状态(即元素保持原样,不会动)。
  • 你需要手动调用 tween.play() 才会开始播放动画。

✅ 为什么需要 paused: true

通常用于以下场景:

1. 延迟触发动画

比如点击按钮、滚动到某位置、或满足某个条件后再播放:

document.querySelector('#btn').addEventListener('click', () => { 
tween.play(); // 点击时才播放
});
2. 配合 ScrollTrigger 或其他交互逻辑

你可能先定义好动画,等 ScrollTrigger 初始化完成后再关联:

ScrollTrigger.create({
trigger: ".section",
start: "top center",
onEnter: () => tween.play()
});
3. 复用动画

同一个 tween 可以多次 play()reverse()restart(),而不会重复创建。

方法 作用
tween.play() 播放动画
tween.pause() 暂停动画
tween.reverse() 反向播放(从当前进度倒放回起点)
tween.restart() 从头开始播放
tween.seek(0.5) 跳转到动画 50% 进度
tween.progress(1) 瞬间跳到结束状态
配置 含义
paused: true 创建动画但不自动播放,需手动调用 .play() 等方法控制
默认(不写) 动画创建后立即自动播放

gsap.set() set 是什么意思

在 GSAP(GreenSock Animation Platform)中,gsap.set() 是一个立即设置元素属性的方法,不会产生动画过渡,而是瞬间应用指定的样式或属性值

方法 是否动画 用途
gsap.set(target, vars) ❌ 否 立即设置属性(相当于“初始化状态”)
gsap.to(target, vars) ✅ 是 从当前状态 动画到 指定状态
gsap.from(target, vars) ✅ 是 从指定状态 动画到 当前状态
gsap.fromTo(target, fromVars, toVars) ✅ 是 自定义起始和结束状态

clearProps: "all"是什么意思

gsap.set([boxA, boxB], { clearProps: "all" }); 这行代码的作用是:

立即清除 boxA 和 boxB 元素上由 GSAP 设置的所有内联样式属性(比如 transformopacitybackgroundColor 等),让它们恢复到 GSAP 干预之前的状态(即仅受 CSS 类或原始 HTML 样式控制)。


✅ clearProps 的作用详解

  • GSAP 在执行动画(如 gsap.to()gsap.from())或 gsap.set() 时,会直接写入元素的 style 属性(例如:<div style="transform: translateX(100px); opacity: 0.5;">)。
  • 这些内联样式优先级很高,会覆盖你写的 CSS 类。
  • 使用 clearProps 可以清理这些“残留”的内联样式,避免干扰后续布局或样式。
说明
"all" 清除 所有 GSAP 设置过的内联样式(最常用)✅
"transform" 仅清除 transform 相关属性(如 x, y, scale, rotation 等)
"opacity,backgroundColor" 清除指定的多个属性(用逗号分隔)
"x,y" 仅清除 xy(即 transform: translateX/Y

💡 注意:clearProps 只清除 GSAP 显式设置过 的属性,不会影响其他 JavaScript 或 HTML 中原本就有的 style

🎯 使用场景举例

场景 1:重置动画状态

js

// 先执行一个动画
gsap.to(boxA, { x: 100, backgroundColor: "red", duration: 1 });

// 后来想让它完全回到原始 CSS 样式
gsap.set(boxA, { clearProps: "all" });
// 效果相当于:boxA.style.cssText = ""; (但更安全,只清 GSAP 设置的)
场景 2:避免 transform 冲突

css

.my-box {
  transform: rotate(10deg); /* 原始 CSS transform */
}

js

gsap.to(".my-box", { x: 50 }); // GSAP 会合并 transform,变成 rotate + translate
gsap.set(".my-box", { clearProps: "transform" }); // 清除后,只剩 rotate(10deg)
场景 3:组件销毁前清理

在 React/Vue 组件卸载时,清除 GSAP 添加的样式,防止内存泄漏或样式残留。


⚠️ 注意事项

  1. clearProps: "all" 不会删除非 GSAP 设置的内联样式
    比如你手动写了 <div style="color: blue">,GSAP 不会动它。
  2. transform 是一个整体
    即使你只设置了 x: 100clearProps: "transform" 也会清除整个 transform 字符串。
  3. autoAlpha 会同时影响 opacity 和 visibility
    如果你用了 autoAlpha,需要同时清除这两个属性。
代码 作用
gsap.set(el, { clearProps: "all" }) 彻底清除 GSAP 对该元素设置的所有内联样式,恢复“干净”状态

keyframes是什么意思

const tween = gsap.to(shape, {
        keyframes: [
          { x: -160, rotation: -15, duration: 0.4 },
          { x: 0, scale: 1.2, duration: 0.4 },
          { x: 160, rotation: 20, duration: 0.4 },
          { x: 0, scale: 1, rotation: 0, duration: 0.4 }
        ],
        ease: "power1.inOut",
        paused: true
      });

在 GSAP(GreenSock Animation Platform)中,keyframes 是一种将多个动画步骤串联起来的方式,类似于 CSS 的 @keyframes,但功能更强大、更灵活。

这段代码的意思是:

对 shape 元素执行一个由 4 个关键帧组成的复合动画,每个关键帧持续 0.4 秒,总共 1.6 秒。动画被暂停(paused: true),需手动调用 .play() 才会运行。


✅ keyframes 的工作原理

  • 每个对象代表一个动画阶段(关键帧)
  • GSAP 会按顺序依次播放这些关键帧。
  • 每一帧的属性是从上一帧的结束状态过渡到当前帧的目标值。
  • 每帧可以有自己的 durationease(如果未指定,则继承外层的 ease)。
动画流程分解:
阶段 起始状态 → 目标状态 效果
第1帧 当前状态 → {x: -160, rotation: -15} 向左飞 + 左转
第2帧 上一帧结束 → {x: 0, scale: 1.2} 回到中心 + 放大
第3帧 上一帧结束 → {x: 160, rotation: 20} 向右飞 + 右转
第4帧 上一帧结束 → {x: 0, scale: 1, rotation: 0} 回到原位 + 还原大小和角度

🔧 keyframes 的高级用法

1. 每帧可单独设置缓动(ease)

js

keyframes: [
  { x: 100, duration: 0.3, ease: "back.out" },
  { x: 0, duration: 0.3, ease: "elastic.out" }
]
2. 支持回调函数

js

keyframes: [
  { x: 100, duration: 0.5 },
  { 
    x: 0, 
    duration: 0.5,
    onComplete: () => console.log("第二帧完成") 
  }
]
3. 与 ScrollTrigger、Timeline 结合

js

gsap.timeline({
  scrollTrigger: { trigger: ".section", start: "top center" }
}).to(shape, {
  keyframes: [ /* ... */ ]
});

⚠️ 注意事项

  • keyframes 是 GSAP 3.0+  引入的功能,在旧版本中不可用。
  • 外层的 ease(如你的 "power1.inOut")会作为默认缓动应用到每一帧(除非某帧自己指定了 ease)。
  • 如果某帧没有指定 duration,它会继承前一帧的 duration 或使用默认值(通常为 0.3 秒)。

✅ 为什么用 keyframes 而不用多个 gsap.to()

表格

方式 优点
keyframes 代码更紧凑,自动串联,易于管理单个动画序列
多个 gsap.to() 更灵活(可插入延迟、回调等),适合复杂编排(推荐用 gsap.timeline()

对于简单的线性多步动画,keyframes 非常简洁;对于复杂时间轴,建议用 gsap.timeline()


keyframes = 把多个动画步骤写在一个数组里,GSAP 自动按顺序播放它们。

你的代码创建了一个“左右晃动 + 缩放”的弹性动画,常用于:

  • 按钮点击反馈
  • 错误提示抖动
  • 卡片翻转/弹跳效果

配合 paused: true,你可以在需要时(如点击、滚动)通过 tween.play() 触发动画,非常高效!

stagger 是什么意思

在 GSAP(GreenSock Animation Platform)中,stagger 是一个非常强大的功能,用于对多个目标元素(如数组、NodeList)依次错开播放动画,从而创建出“波浪式”、“逐个入场”等流畅的序列动画效果。

 const tween = gsap.from(cells, {
        opacity: 0,
        scale: 0.4,
        y: 20,
        duration: 0.6,
        ease: "power2.out",
       stagger: { 
           each: 0.04, // 每个元素之间的延迟时间(秒)
           from: "center" // 动画从中间的元素开始,向两边扩散
       },
        paused: true
      });

这段代码的作用是:

对 cells(一组 DOM 元素)执行“从透明、缩小、下移”状态淡入放大的动画,但不是同时播放,而是:

  • 从中间的元素开始
  • 相邻元素之间间隔 0.04 秒依次播放
  • 整体形成一种“由中心向外扩散”的入场效果 ✨

✅ stagger 的核心概念

当你对多个元素(如 document.querySelectorAll('.cell'))使用 GSAP 动画时:

  • 不加 stagger → 所有元素同时动画。
  • 加上 stagger → 元素依次错开动画,产生节奏感。

🔧 stagger 的常见写法

1. 最简形式:只指定间隔时间

js

stagger: 0.1  // 等价于 { each: 0.1 }

→ 从第一个元素开始,每个间隔 0.1 秒。

2. 对象形式(你用的方式):更精细控制

js

stagger: {
  each: 0.04,     // 每个元素间隔 0.04 秒
  from: "center", // 起始位置:可选 "start"(默认)、"center""end" 或具体索引(如 3)
  grid: "auto",   // 如果是网格布局,可设为 [rows, cols] 来按行/列交错
  axis: "x"       // 在网格中限制交错方向("x""y""xy")
}

🎯 from 的取值说明

效果
"start"(默认) 从第一个元素开始,依次到最后一个
"center" 从中间元素开始,向左右(或上下)同时扩散
"end" 从最后一个元素开始,倒序播放
数字(如 2) 从索引为 2 的元素开始

✅  from: "center" 非常适合居中对齐的列表、图标阵列、卡片网格等场景,视觉上更平衡。


💡 实际效果示例

假设 cells 有 5 个元素:[A, B, C, D, E]

  • from: "center" → 播放顺序:C → B & D → A & E
  • 每个间隔 0.04s,所以整个动画在约 0.04 × 2 = 0.08s 内完成扩散(因为两边并行)

这比线性播放(A→B→C→D→E)更生动!


⚠️ 注意事项

  • stagger 只在目标是多个元素时生效。如果 cells 只有一个元素,stagger 会被忽略。
  • stagger 的延迟是叠加在 duration 之上的,不影响单个动画的时长。
  • 可与 paused: true 完美配合,实现“按需触发动画序列”。

配置 含义
stagger: { each: 0.04, from: "center" } 从中间元素开始,以 0.04 秒的间隔向两侧依次播放动画

这是 GSAP 实现高级交互动效(如列表加载、菜单展开、数据可视化入场)的核心技巧之一。你的代码就是一个典型的“优雅批量入场”动画!

从 ES2015 到 ES2025:你还跟得上吗

2026年2月13日 10:52

ES6 是 2015 年发布的。
距离现在,已经过去整整十年。

这十年里,JavaScript 每一年都在进化。
新语法、新 API、新并发模型、新数据结构……

可大多数人,对 JavaScript 的认知,仍停留在:

  • 箭头函数
  • 解构赋值
  • Promise
  • let / const

从 ES2016 到 ES2025,你真的跟上了吗?

这篇文章,我会按时间线带你系统梳理 JavaScript 十年的演进轨迹。


ES2016 → ES2020

这5年新出的特性我想大多数人都已经熟练使用了,这里就简单列下,不详细介绍api细节了

ES2016

这是一个小版本,主要有以下3个特性:

  • Array.prototype.includes()
  • 指数运算符 (**)
    2 ** 3; // 8
    
  • 幂赋值运算符**=
    let num = 2; 
    num **= 3; // num = num ** 3 
    console.log(num); // 8
    

ES2017

ES2017的重点是异步编程,对象操作

  • async/await
  • Object.values()/Object.entries()
  • Object.getOwnPropertyDescriptors(): 返回对象所有自身属性的描述符对象
  • 字符串填充String Padding
console.log('5'.padStart(3, '0')); // '005' 
console.log('hello'.padEnd(10, '*')); // 'hello*****'
  • SharedArrayBuffer 和 Atomics

    这两个用在web worker中。主线程和worker使用postMessage通信往往要将数据拷贝一份,SharedArrayBuffer 则允许 Worker 线程与主线程共享同一块内存,通过 postMessage 将 SharedArrayBuffer 转移给 Worker,不会复制数据:

    // main.js
     const sab = new SharedArrayBuffer(1024);
     const worker = new Worker('worker.js');
     worker.postMessage(sab); //不复制数据
    
    // worker.js
     self.onmessage = (e) => {
       const sab = e.data; // 同一个内存块
     };
    

计算机中写操作可能被编译成多条指令,如果尚未写完就有其他线程读数据,便会产生错误。在多线程操作SharedArrayBuffer时就可能会出现这种问题。Atomics提供了原子级操作,其他线程读取到的数据,要么是没写入的,要么是已写完的。另外Atomics还提供了线程的阻塞和唤醒。

ES2018

ES2018新增了多个特性,算是一次中等规模升级,主要有异步编程的增强、对象和数组操作的改进、正则表达式的扩展,以及模板字面量的优化。

  • 异步生成器/异步迭代器
async function* fetchPages() {
  let page = 1;
  while (page <= 3) {
    const response = await fetch(`https://api.example.com/page/${page}`);
    yield await response.json();
    page++;
  }
}

(async () => {
  for await (const data of fetchPages()) {
    console.log(data);
  }
})();
  • Promise.prototype.finally()
  • rest/spreat操作符...
  • 正则表达式增强(后行断言,命名捕获等)
  • 模板字符串的标签模板提供raw访问原始字符串
function tag(strings) { 
    return strings.raw[0]; // 访问原始字符串,包括非法转义,比如LaTeX语法
} 
console.log(tag`\u{00000042}`); // strings[0]是'B' strings.raw[0]为\u{00000042}

ES2019

该版本内容不多但很实用

  • Array.prototype.flat() / flatMap()
  • Object.fromEntries()
  • String.trimStart() / trimEnd()
  • Optional catch binding: catch 可省略错误参数
try {
  JSON.parse('invalid json');
} catch {
  console.log('Parsing failed'); // 无需未使用的 error 变量
}
  • Symbol.description
  • Function.prototype.toString()能返回函数精确源码,包括注释和空格,方便调试
  • 稳定的 Array.prototype.sort()

ES2020

这个版本也是一个里程碑,更新了大量内容,而且都很实用

  • BigInt
  • Dynamic Import import()
  • 空值合并运算符??
  • 可选链运算符?.
  • Promise.allSettled()
  • String.prototype.matchAll()
  • 标准全局对象globalThis
  • 模块命名空间导出(export * as ns from 'mod')
  • for-in 枚举顺序与定义顺序一致

从ES2021开始新增的特性,在我日常code review中看到的越来越少了,但很多特性还是很实用的。

ES2021

String.prototype.replaceAll()

在此之前全局替换需要用正则/g标志

逻辑赋值运算符 (&&=, ||=, ??=)

let x = 0;
x ||= 10; // x = 10(因为 0 是 falsy)

let y = 5;
y &&= 20; // y = 20(因为 5 是 truthy)

let z;
z ??= 'default'; // z = 'default'(因为 undefined 是 nullish)

数字分隔符(1_000_000)

允许在数字字面量中使用下划线(_)作为分隔符,提高大数字的可读性。

Promise.any()

返回一个 Promise,在任意一个输入 Promise resolved 时解决;如果所有 rejected,则返回 AggregateError。

  • 语法:Promise.any(iterable)

    • iterable:Promise 数组或其他可迭代对象。
  • 示例

    const promises = [
      Promise.reject('Error 1'),
      Promise.resolve('Success'),
      Promise.reject('Error 2')
    ];
    Promise.any(promises)
      .then(value => console.log(value)) // 'Success'(第一个 resolved)
      .catch(error => console.error(error)); // 如果全 reject:AggregateError
    

WeakRefs和FinalizersRegistry:

  • WeakRefs 用于引用对象而不阻止垃圾回收
  • FinalizersRegistry 用于缓存或观察对象,而不干扰内存管理;FinalizationRegistry 提供清理回调,但不保证时序。
let obj = { name: 'Alice' };
const weak = new WeakRef(obj);

const registry = new FinalizationRegistry(heldValue => {
  console.log(`Object with ${heldValue} cleaned up`);
  console.log(weak.deref());//undefined
});
registry.register(obj, 'Alice');
obj = null; // GC 时调用 callback

ES2022

Top-level await:

可以直接在模块最外层使用 await

Class的私有/静态成员和方法

增加了#标识私有,static标识静态(现在都用ts了,这两个特性很少用到)

 class Counter {
  #value = 0;

  #increment() {
    this.#value++;
  }

  get #count() {
    return this.#value;
  }

  add() {
    this.#increment();
  }

  getValue() {
    return this.#count;
  }
}

const c = new Counter();
c.add();
console.log(c.getValue()); // 1
// c.#increment(); // SyntaxError,不过控制台访问不会报错
class MathUtils {
  static PI = 3.14159;
  static #secret = 42;

  static getPi() {
    return this.PI;
  }

  static getSecret() {
    return this.#secret;
  }
}

console.log(MathUtils.PI); // 3.14159
console.log(MathUtils.getSecret()); // 42

Error.cause:

在 Error 对象中添加 cause 属性,用于链式记录错误上下文,方便调试。

 try {
  throw new Error('Original issue');
} catch (err) {
  throw new Error('Failed operation', { cause: err });
}
// 结果错误:Failed operation (cause: Original issue)

at方法:

新增访问索引方法,可用于数组、字符串和 TypedArray

const arr = [1, 2, 3];
console.log(arr.at(1)); // 2
console.log(arr.at(-1)); // 3(最后一个元素,极力推荐这种写法)
console.log('hello'.at(-2)); // 'l'

Object.hasOwn():

代替Object.prototype.hasOwnProperty.call()

正则表达式的/d标志:

可用/d标志获取匹配范围

    const match = 'hello world'.matchAll(/(hello)/dg);
    for (const m of match) {
      console.log(m.indices[0]); // [0, 5] 对于 'hello'
    }

ES2023

Array 和 TypedArray的末尾查找

新增findLast() / findLastIndex()

通过拷贝修改数组(不改变原数组)

新增 toSorted() / toReversed() / toSpliced() / with()

  • 语法

    • array.toReversed():返回反转拷贝。
    • array.toSorted(compareFn):返回排序拷贝。
    • array.toSpliced(start, deleteCount, ...items):返回拼接拷贝。
    • array.with(index, value):返回替换指定索引值的拷贝。
  • 示例

    const arr = [1, 3, 2];
    console.log(arr.toSorted()); // [1, 2, 3](原 arr 不变)
    console.log(arr.toReversed()); // [2, 3, 1]
    
    console.log(arr.toSpliced(1, 1, 4)); // [1, 4, 2]
    console.log(arr.with(0, 5)); // [5, 3, 2]
    

Hashbang 语法

允许在 ECMAScript 文件开头使用 #!(shebang)注释,指示解释器执行脚本。

#!/usr/bin/env node
console.log('Hello from Node.js');

允许 Symbols 用作 WeakMap、WeakSet 的键

此前key仅支持对象


ES2024

Promise.withResolvers()

同时创建 Promise 及其 resolve 和 reject 函数,便于手动控制 Promise 的状态。

const { promise, resolve, reject } = Promise.withResolvers();

setTimeout(() => resolve('Success'), 1000);

promise.then(value => console.log(value)); // 'Success'

Object.groupBy() 和 Map.groupBy()

静态方法,用于根据回调函数返回的值对可迭代对象进行分组,返回一个对象(Object.groupBy)或 Map(Map.groupBy)。

  • 语法

    • Object.groupBy(iterable, callback)
    • Map.groupBy(iterable, callback)
  • 示例

    const items = [
      { name: 'apple', type: 'fruit' },
      { name: 'carrot', type: 'vegetable' },
      { name: 'banana', type: 'fruit' }
    ];
    
    const grouped = Object.groupBy(items, item => item.type);
    console.log(grouped); // { fruit: [{...}, {...}], vegetable: [{...}] }
    
    const groupedMap = Map.groupBy(items, item => item.type);
    console.log(groupedMap.get('fruit')); // [{...}, {...}]
    

正则表达式/v标志

新标志 /v 启用 Unicode 集模式,支持属性的组合、范围、否定、交集 / 并集运算

/v 标志出现前,JS 正则有 /u 标志支持基础 Unicode 属性转义(如 \p{Letter} 匹配字母),但只能匹配 “单一属性”,无法直接表达 “属性的组合 / 范围 / 否定”,而 /v 标志正是为了解决这个问题,提供扩展 Unicode 属性转义能力。

  • 语法:/pattern/v

  • 示例

    const regex = /[\p{Emoji}&&\p{Emoji_Presentation}]/v;
    console.log(regex.test('😀')); // true(Emoji)
    
    // 集操作示例
    const setDiff = /[a-z--[aeiou]]/v; // 辅音
    console.log(setDiff.test('b')); // true
    

Atomics.waitAsync()

共享内存的异步等待方法,返回一个 Promise,在共享值变化时解决。

  • 语法:Atomics.waitAsync(array, index, value, timeout)

  • 示例

    const sab = new SharedArrayBuffer(16);
    const int32 = new Int32Array(sab);
    
    const result = Atomics.waitAsync(int32, 0, 0);
    result.value.then(() => console.log('Woken up'));
    // 其他线程:Atomics.store(int32, 0, 1); Atomics.notify(int32, 0);
    

ArrayBuffer 和 SharedArrayBuffer 的resize和transfer

  • 语法

    • buffer.resize(newLength)
    • buffer.transfer(newLength):返回新缓冲区,旧的被分离。
    • 类似方法可用于 SharedArrayBuffer。
  • 示例

    const buffer = new ArrayBuffer(8, { maxByteLength: 16 });
    buffer.resize(16);
    console.log(buffer.byteLength); // 16
    
    const transferred = buffer.transfer();
    // 原 buffer 被分离,无法使用
    

String.prototype.isWellFormed() 和 toWellFormed()

这两个api用于"格式不良"字符串.

JavaScript 字符串基于 UTF-16 编码,其中单独的代理对字符(未配对的高 / 低代理项) 属于 “格式不良” 的字符串(也叫 “畸形 UTF-16 字符串”)。

高代理项范围:0xD800 - 0xDBFF

低代理项范围:0xDC00 - 0xDFFF只有高 + 低代理项配对才是合法的 UTF-16 字符,单独出现其中一个就是 “格式不良”。

   // 1. 格式良好的字符串(正常字符、合法代理对)
   const validStr1 = 'Hello 世界';
   console.log(validStr1.isWellFormed()); // true

   const validStr2 = '\uD83D\uDE00'; // 😀(合法的高+低代理对)
   console.log(validStr2.isWellFormed()); // true

   // 2. 格式不良的字符串(单独的高代理项/低代理项)
   const invalidStr1 = '\uD83D'; // 单独的高代理项(无对应低代理项)
   console.log(invalidStr1.isWellFormed()); // false

   const invalidStr2 = '测试\uDC00'; // 单独的低代理项(无对应高代理项)
   console.log(invalidStr2.isWellFormed()); // false

   // 3. 格式良好的字符串:返回原字符串副本
   const validStr = 'Hello 😀';
   console.log(validStr.toWellFormed()); // Hello 😀
   console.log(validStr.toWellFormed() === validStr); // true(内容相同,引用不同)

   // 4. 格式不良的字符串:替换未配对代理项为 �
   const invalidStr1 = '\uD83D'; // 单独高代理项
   console.log(invalidStr1.toWellFormed()); // �

ES2025

迭代器助手方法(Iterator Helpers)

引入 Iterator 全局对象及其原型方法,支持对任何可迭代对象(如 Array、Set、Map)进行函数式操作,如 map、filter 等。这些方法返回新的迭代器,支持惰性求值。

  • 语法:Iterator.from(iterable).method(callback)

    • 支持方法:map()、filter()、reduce()、take()、drop()、flatMap()、toArray()、forEach() 等。
  • 示例

    const arr = [1, 2, 3, 4];
    const evenSquares = Iterator.from(arr)
      .filter(x => x % 2 === 0)
      .map(x => x ** 2)
      .toArray();
    console.log(evenSquares); // [4, 16]
    

Set新增方法

为 Set.prototype 添加数学集合操作方法,支持集合的并集、交集、差集等。

  • 新增方法

    • set.union(other) 并集
    • set.intersection(other) 交集
    • set.difference(other) 差集
    • set.symmetricDifference(other) 对称差集(并集减交集)
    • set.isSubsetOf(other) 子集
    • set.isSupersetOf(other) 超集
    • set.isDisjointFrom(other) 不相交
  • 示例

    const setA = new Set([1, 2, 3]);
    const setB = new Set([2, 3, 4]);
    
    console.log(setA.union(setB)); // Set {1, 2, 3, 4}
    console.log(setA.intersection(setB)); // Set {2, 3}
    console.log(setA.isSubsetOf(setB)); // false
    

直接导入JSON 模块

引入导入属性(with 关键字),支持直接导入 JSON 文件作为模块。

  • 语法:import json from "./data.json" with { type: "json" };

  • 示例

    import data from "./config.json" with { type: "json" };
    console.log(data); // { key: "value" }
    

Promise.try()

一个新的静态方法,用于包装函数调用,确保返回 Promise,无论函数是否异步或抛出错误。

  • 语法:Promise.try(callback)

  • 示例

    Promise.try(() => {
      if (Math.random() > 0.5) throw new Error('Fail');
      return 'Success';
    })
      .then(result => console.log(result))
      .catch(error => console.error(error));
    

新增Float16Array

引入 Float16Array 类型数组,支持 16 位浮点数,以及 DataView 的 getFloat16/setFloat16 和 Math.f16round()。

RegExp.escape() 方法

一个静态方法,用于转义字符串,使其可安全用于正则表达式。

  • 语法:RegExp.escape(str)

  • 示例

    const userInput = 'a.b*c?';
    const regex = new RegExp(RegExp.escape(userInput), 'g');
    console.log('a.b*c?'.replace(regex, 'replaced')); // 'replaced'
    

正则表达式内联标志

  • 语法:/(?i:case-insensitive)/

  • 示例

    const regex = /(?i:hello)/;
    console.log(regex.test('HELLO')); // true(忽略大小写)
    

正则表达式重复命名捕获组

允许在正则表达式中重复使用相同的命名捕获组名称。

  • 语法:/(?<group>a)|(?<group>b)/

  • 示例

    const regex = /(?<year>\d{4})-(?<month>\d{2})|(?<year>\d{4})/(?<month>\d{2})/;
    const match = '2025-07'.match(regex);
    console.log(match.groups.year); // '2025'
    console.log(match.groups.month); // '07'
    

Vue 实战:从零实现“划词标注”与“高亮笔记”功能

2026年2月13日 10:42

在在线教育、文档阅读或博客系统中,划词标注(Highlight & Note) 是一个非常实用的功能。它允许用户像在纸质书上一样,用鼠标选中一段文字,进行高亮标记或添加读书笔记。

本文将拆解如何在 Vue 项目中实现这一功能,涵盖从底层的 Selection API 调用到 DOM 操作,再到数据状态管理的完整流程。


核心原理

实现划词标注的核心在于浏览器提供的 Selection APIRange API

  1. Selection: 代表用户当前选中的文本范围(可能跨越多个节点)。
  2. Range: 代表文档中一个连续的区域(Selection 通常包含一个 Range)。
  3. DOM 操作: 将选中的文本用一个特定样式的标签(如 <span>)包裹起来,从而实现高亮效果。

Step 1: 监听选区 (Capture Selection)

首先,我们需要在用户松开鼠标(mouseup)时捕获选区。

HTML 结构: 在内容容器上绑定 mouseup 事件。

<div class="content-container" @mouseup="handleTextSelection">
  <!-- 文章内容 -->
  <p>这是一段可以被选中的文本...</p>
</div>

JavaScript 实现

handleTextSelection() {
  // 延时保证选区状态已更新
  setTimeout(() => {
    const selection = window.getSelection();

    // 1. 基础校验:必须是 Range 类型且非空
    if (selection.toString().trim() === '' || selection.type !== 'Range' || selection.isCollapsed) {
      this.selectionMenuVisible = false; // 隐藏菜单
      return;
    }

    // 2. 获取核心 Range 对象
    const range = selection.getRangeAt(0);

    // 3. (可选) 进阶校验:禁止跨特定区域选择
    // 比如:不能同时选中 A 选项和 B 选项
    if (this.isCrossBlockSelection(range)) {
      selection.removeAllRanges();
      return;
    }

    // 4. 执行高亮包裹逻辑(见下文)
    this.createTempHighlight(range, selection);
  }, 0);
}

Step 2: 包裹文本 (Wrap Text)

获取到 Range 后,我们需要将选中的文本用一个临时标签(Temp Span)包裹起来。这个标签通常有两个作用:

  1. 视觉反馈:给用户一个“预选中”的状态(例如浅蓝色背景)。
  2. 定位锚点:用于计算后续“操作菜单”的显示位置。

核心代码

createTempHighlight(range, selection) {
  // 创建一个包裹标签
  const span = document.createElement('span');
  span.className = 'temp-selection-highlight'; // 自定义样式类

  try {
    // 核心操作:提取内容 -> 插入节点
    // range.extractContents() 会将选区内容从 DOM 树中移除并返回 DocumentFragment
    span.appendChild(range.extractContents());
    // 将包裹后的 span 插入回原处
    range.insertNode(span);

    // ⚠️重要:重置选区
    // 因为 DOM 结构改变了,原有的 selection 会失效或错位
    // 我们需要重新选中这个 span 的内容,让用户感觉“高亮还在”
    const newRange = document.createRange();
    newRange.selectNodeContents(span);
    selection.removeAllRanges();
    selection.addRange(newRange);

    // 保存当前 Range 引用,供后续操作使用
    this.currentRange = newRange;

    // 5. 显示操作菜单
    this.showActionMenu(span);

  } catch (e) {
    console.error('Wrapping failed:', e);
  }
}

Step 3: 菜单定位 (Positioning Menu)

操作菜单(“高亮”、“笔记”)通常悬浮在选区上方。我们可以利用 getBoundingClientRect()getClientRects() 来获取位置。

showActionMenu(spanElement) {
  // 获取元素的位置信息
  // getClientRects() 对于跨行文本更准确,取最后一行
  const rects = spanElement.getClientRects();
  const lastRect = rects.length > 0 ? rects[rects.length - 1] : spanElement.getBoundingClientRect();

  // 计算菜单坐标(相对于视口)
  this.selectionMenuPosition = {
    top: (lastRect.bottom + 5) + 'px', // 显示在下方 5px 处
    left: (lastRect.right + 5) + 'px'
  };

  this.selectionMenuVisible = true;
}

Step 4: 确认与状态管理 (Confirm & State)

用户点击菜单中的“高亮”或“笔记”按钮后,我们需要将临时的 span 转换为持久化的状态。

  1. 修改样式:将 temp-selection-highlight 类替换为 permanent-highlight(黄色)或 note-highlight(蓝色)。
  2. 生成 ID:给 span 添加一个唯一 ID(如 data-id="167...")。
  3. 保存数据:将笔记内容推入 Vue 的数据数组中。
applyHighlight(type) {
  const span = document.querySelector('.temp-selection-highlight');
  if (!span) return;

  // 1. 生成唯一 ID
  const id = Date.now().toString();

  // 2. 更新 DOM 类名和属性
  span.className = type === 'note' ? 'note-highlight' : 'highlight-text';
  span.setAttribute('data-id', id);

  // 3. 存入数据层
  const newNote = {
    id,
    text: span.innerText, // 选中的原文
    type, // 'highlight' or 'note'
    color: type === 'note' ? '#e6f7ff' : '#ffeb3b',
    createTime: new Date().toISOString()
  };

  this.notesList.push(newNote);

  // 4. 持久化(保存到后端或 LocalStorage)
  this.saveData();

  // 5.如果是笔记,打开侧边栏供用户输入
  if (type === 'note') {
    this.openNoteSidebar(id);
  }

  // 清除选中状态
  window.getSelection().removeAllRanges();
  this.selectionMenuVisible = false;
}

Step 5: 取消高亮 (Unwrap)

如果用户想删除高亮,我们需要执行“反向操作”:将 span 去掉,保留里面的文字。

removeHighlight(id) {
  const span = document.querySelector(`span[data-id="${id}"]`);
  if (span) {
    const parent = span.parentNode;
    // 将 span 的子节点(文本)移动到父节点中 span 的前面
    while (span.firstChild) {
      parent.insertBefore(span.firstChild, span);
    }
    // 移除空 span
    parent.removeChild(span);
    // 规范化节点,合并相邻的文本节点
    parent.normalize();
  }

  // 同步删除数据
  this.notesList = this.notesList.filter(n => n.id !== id);
}

进阶技巧:从数据还原 DOM

最大的难点在于:页面刷新后,如何重新渲染这些高亮?

如果你的内容是纯静态的,可以直接保存包含 span 标签的 HTML 字符串。但由于 Vue 的 v-html 或 React 的 dangerouslySetInnerHTML 会导致 DOM 重绘,简单的 HTML 替换可能会丢失事件绑定。

更稳健的做法是:

  1. 保存 选区路径(如:第 X 个段落,第 Y 个字符开始,长度 Z)。
  2. 页面加载时,遍历数据,利用 createRange() 重新定位并包裹 DOM。

由于这通常涉及到复杂的 DOM 遍历算法,生产环境中推荐结合成熟库(如 Rangy 或自行实现基于 XPath 的定位)来处理复杂场景。


总结

实现一个划词笔记功能,本质上是对 DOM Range 的灵活运用。通过 监听(Listen) -> 包裹(Wrap) -> 存储(Store) -> 还原(Restore) 这四个步骤,我们就能为用户提供流畅的沉浸式阅读体验。

深入解析 React 回到顶部(BackToTop)组件的实现与设计

作者 冻梨政哥
2026年2月13日 10:37

深入解析 React 回到顶部(BackToTop)组件的实现与设计

在现代网页开发中,长页面的场景十分常见,为了提升用户体验,“回到顶部” 功能几乎成为标配。本文将基于一段 React 实现的 BackToTop 组件代码,从结构、核心逻辑、性能优化等维度,全面解析该组件的设计与实现细节。

一、组件整体结构概览

首先来看 BackToTop 组件的完整代码结构,该组件基于 React 函数式组件实现,核心依赖 React 的 Hooks、UI 组件库、图标库以及自定义的节流工具函数,整体结构清晰且模块化。

import { useEffect, useState } from "react";
import { Button } from '@/components/ui/button'
import { ArrowUp } from "lucide-react";
import { throttle } from "@/utils";

// 定义组件Props类型
interface BackToTopProps {
    // 滚动超过多少像素后显示按钮
    threshold?: number
}

// 函数式组件,设置threshold默认值为400
const BackToTop: React.FC<BackToTopProps> = ({
    threshold = 400
}) => {
    // 状态管理:控制按钮是否可见
    const [isVisible, setIsVisible] = useState<boolean>(false);
    
    // 回到顶部核心逻辑
    const scrollTop = () => {
        window.scrollTo({
            top: 0,
            behavior:'smooth'
        })
    }
    
    // 监听滚动事件,控制按钮显示/隐藏
    useEffect(() => {
        const toggleVisibility = () => {
            setIsVisible(window.scrollY > threshold);
        }
        // 节流处理滚动监听函数
        const thtottled_func = throttle(toggleVisibility,200);
        window.addEventListener('scroll', thtottled_func);
        // 清理副作用:移除滚动监听
        return () => window.removeEventListener('scroll', thtottled_func);
    },[threshold])
    
    // 条件渲染:未达到阈值时不渲染组件
    if(!isVisible) return null;
    
    // 组件UI渲染
    return (
        <Button variant="outline" size="icon" onClick={scrollTop} className="fixed bottom-6 right-6 rounded-full shadow-lg hover:shadow-xl z-50">
            <ArrowUp className="h-4 w-4" />
        </Button>
    )
}

export default BackToTop

组件整体可分为 5 个核心部分:

  1. 依赖导入与类型定义;
  2. 状态管理(控制按钮可见性);
  3. 回到顶部核心逻辑;
  4. 滚动事件监听与性能优化;
  5. 条件渲染与 UI 展示。

二、核心功能逐行解析

1. 类型定义与 Props 设计

interface BackToTopProps {
    threshold?: number
}

const BackToTop: React.FC<BackToTopProps> = ({
    threshold = 400
}) => { ... }
  • 定义BackToTopProps接口,仅暴露threshold可选属性,用于配置 “滚动超过多少像素后显示按钮”,符合 “最小可用 API” 设计原则;
  • 通过解构赋值为threshold设置默认值 400,确保组件在未传入参数时仍能正常工作。

2. 状态管理:控制按钮可见性

const [isVisible, setIsVisible] = useState<boolean>(false);

使用useState Hook 创建布尔类型状态isVisible,初始值为false(页面加载时按钮默认隐藏),该状态用于控制组件的条件渲染。

3. 回到顶部逻辑:平滑滚动实现

const scrollTop = () => {
    window.scrollTo({
        top: 0,
        behavior:'smooth'
    })
}
  • 调用window.scrollTo方法实现滚动到页面顶部;
  • 通过配置behavior: 'smooth'实现平滑滚动,替代传统的瞬间跳转,提升用户体验;
  • 该函数作为按钮的点击事件回调,触发回到顶部操作。

4. 滚动监听与性能优化(核心)

useEffect(() => {
    const toggleVisibility = () => {
        setIsVisible(window.scrollY > threshold);
    }
    const thtottled_func = throttle(toggleVisibility,200);
    window.addEventListener('scroll', thtottled_func);
    return () => window.removeEventListener('scroll', thtottled_func);
},[threshold])

这是组件的核心逻辑,需重点解析:

(1)滚动监听函数toggleVisibility

toggleVisibility的作用是判断页面滚动距离(window.scrollY)是否超过阈值(threshold),并通过setIsVisible更新按钮可见状态。

(2)节流处理的必要性

scroll事件是高频触发事件(页面滚动时会连续触发),若直接将toggleVisibility绑定到scroll事件,会导致该函数被频繁调用,引发不必要的状态更新和重渲染,影响页面性能。

因此,组件通过throttle工具函数对toggleVisibility进行节流处理,设置 200ms 的节流间隔 —— 即滚动事件触发时,toggleVisibility最多每 200ms 执行一次,有效减少函数执行次数,优化性能。

(3)副作用的挂载与清理
  • useEffect在组件挂载时执行,为window添加scroll事件监听,绑定节流后的函数;
  • useEffect的返回值是一个清理函数,在组件卸载时执行,移除scroll事件监听 —— 避免内存泄漏,是 React 函数式组件处理事件监听的标准写法;
  • useEffect的依赖数组包含threshold,确保当阈值变化时,重新绑定监听函数。

5. 条件渲染与 UI 展示

if(!isVisible) return null;

return (
    <Button variant="outline" size="icon" onClick={scrollTop} className="fixed bottom-6 right-6 rounded-full shadow-lg hover:shadow-xl z-50">
        <ArrowUp className="h-4 w-4" />
    </Button>
)
  • 条件渲染:当isVisiblefalse时,组件返回null,不渲染任何内容;仅当滚动距离超过阈值时,才渲染回到顶部按钮;

  • UI 设计细节:

    • 使用 UI 组件库的Button组件,设置variant="outline"(轮廓样式)、size="icon"(图标尺寸);
    • 通过className设置固定定位(fixed)、位置(bottom-6 right-6,右下角)、圆角(rounded-full)、阴影(shadow-lg/xl)、层级(z-50),确保按钮悬浮在页面最上层且样式美观;
    • 嵌入lucide-reactArrowUp图标作为按钮内容,直观传达 “回到顶部” 的功能;
    • 按钮绑定onClick事件,触发scrollTop函数。

三、节流工具函数(throttle)解析

组件依赖的throttle函数位于index.ts中,其实现如下:

type ThrottleFunction = (...args: any[]) => void;

export function throttle(fun: ThrottleFunction, delay: number): ThrottleFunction {
  let last: number | undefined;
  let deferTimer: NodeJS.Timeout | undefined;

  return function (...args: any[]) {
    const now = +new Date();

    if (last && now < last + delay) {
      clearTimeout(deferTimer);
      deferTimer = setTimeout(function () {
        last = now;
        fun(args);
      }, delay);
    } else {
      last = now;
      fun(args);
    }
  };
}

节流函数的核心原理

节流(Throttle)的核心思想是:在指定时间间隔内,只允许函数执行一次,即使触发多次,也仅生效一次。该实现的关键逻辑:

  1. 定义last(上一次函数执行的时间戳)和deferTimer(延迟定时器)两个闭包变量,用于记录执行状态;

  2. 每次触发函数时,获取当前时间戳now

  3. 若距离上一次执行时间不足delay

    • 清除原有定时器,避免重复执行;
    • 重新设置定时器,延迟delay后执行函数,并更新last
  4. 若距离上一次执行时间超过delay:直接执行函数,并更新last

注意点

该实现中fun(args)的传参方式需注意 —— 原函数的参数通过数组形式传递,若原函数依赖参数解构,需确保传参逻辑匹配(本文中toggleVisibility无参数,因此无影响)。

四、组件的使用与扩展

1. 基础使用

import BackToTop from '@/components/BackToTop';

const App = () => {
  return (
    <div>
      {/* 其他页面内容 */}
      <BackToTop threshold={500} />
    </div>
  );
};

仅需引入组件,可通过threshold自定义显示阈值,开箱即用。

2. 扩展方向

  • 自定义样式:通过className覆盖默认样式,或新增className Props 支持自定义样式;
  • 自定义图标:将图标作为 Props 传入,支持替换为自定义图标;
  • 滚动目标:扩展 Props 支持滚动到指定元素(而非仅顶部);
  • 动画效果:添加按钮显示 / 隐藏的过渡动画(如 React Transition Group);
  • 移动端适配:针对移动端调整按钮尺寸和位置;
  • 无障碍访问(a11y) :添加aria-label等属性,提升无障碍体验。

五、总结

本文解析的 BackToTop 组件是一个典型的 “小而美” 的 React 组件,其设计具备以下优点:

  1. 类型安全:通过 TypeScript 定义 Props 接口,确保类型校验;
  2. 性能优化:使用节流处理高频滚动事件,避免性能损耗;
  3. 用户体验:平滑滚动、条件渲染、美观的 UI 设计;
  4. 可维护性:模块化结构、清晰的逻辑拆分、完善的副作用清理;
  5. 可扩展性:通过 Props 暴露核心配置,便于扩展。

该组件的实现思路不仅适用于 “回到顶部” 功能,也可迁移到其他需要监听滚动事件的场景(如导航栏吸顶、懒加载等),是 React 函数式组件开发的典型实践案例。

【节点】[BakedGI节点]原理解析与实际应用

作者 SmalBox
2026年2月13日 10:01

【Unity Shader Graph 使用与特效实现】专栏-直达

Baked GI 节点是 Unity URP Shader Graph 中一个重要的光照计算节点,它允许着色器访问预计算的光照信息,为场景中的静态物体提供高质量的间接光照效果。在实时渲染中,全局光照(Global Illumination)的计算通常非常耗费性能,因此 Unity 提供了烘焙光照的解决方案,将复杂的光照计算预先处理并存储在光照贴图或光照探针中,运行时直接采样这些预计算数据,既能保证视觉效果又能保持高性能。

该节点的核心功能是根据物体的位置和朝向,从预先烘焙的光照数据中获取相应的光照颜色值。这些数据可以来自两种主要来源:光照贴图用于静态几何体,以及光照探针用于动态物体或需要动态光照的静态物体。通过合理使用 Baked GI 节点,开发者可以创建出具有丰富间接光照和真实感光照交互的着色器,而无需承担实时全局光照计算的性能开销。

在 URP 管线中,Baked GI 节点的实现经过了优化,专门针对移动平台和性能敏感的场景。与内置渲染管线或 HDRP 相比,URP 中的 Baked GI 节点可能有一些特定的限制和行为差异,但这些差异主要是为了确保在目标平台上的最佳性能表现。理解这些差异对于创建跨管线兼容的着色器至关重要。

描述

Baked GI 节点为着色器提供了访问烘焙全局光照值的能力,这些值可以在顶点着色器或片元着色器阶段使用。节点需要几个关键的输入参数来确定如何采样光照数据,包括世界空间中的位置和法线向量,以及用于光照贴图采样的 UV 坐标。

烘焙全局光照基础

烘焙全局光照是 Unity 光照系统的重要组成部分,它通过预计算场景中光线如何在不同表面之间反射和传播,生成静态的光照信息。这个过程包括直接光照和间接光照的计算,但只针对标记为静态的物体进行。烘焙完成后,光照信息会被存储到光照贴图或光照探针中:

  • 光照贴图是应用于静态几何体的纹理,包含预先计算的光照信息
  • 光照探针是在场景空间中放置的采样点,存储了该位置的光照信息,可用于动态物体或需要动态光照的静态物体

Baked GI 节点的作用就是在着色器执行时,根据提供的输入参数,从这些预计算的光照数据中获取相应的颜色值。

位置和法线输入的重要性

位置和法线输入对于正确采样光照探针数据至关重要。光照探针数据是基于球谐函数编码的,这种编码方式能够高效地存储全方向的光照信息。当着色器需要获取某点的光照信息时,系统会根据该点的位置找到最近的光照探针组,然后使用法线方向来评估球谐函数,得到该方向上的光照颜色。

如果提供的位置或法线不正确,可能会导致光照采样错误,表现为不自然的光照过渡或错误的光照方向。因此,确保这些输入参数的准确性是使用 Baked GI 节点的关键。

光照贴图坐标的作用

Static UV 和 Dynamic UV 输入用于采样不同类型的光照贴图:

  • Static UV 通常对应网格的 UV1 通道,用于采样静态光照贴图
  • Dynamic UV 通常对应网格的 UV2 通道,用于采样动态全局光照的光照贴图

在 Unity 的光照设置中,开发者可以选择使用不同的光照模式,如 Baked、Mixed 或 Realtime。对于 Mixed 光照模式的静态物体,Unity 会生成两套光照贴图:一套用于完全烘焙的光照,另一套用于与实时光照结合的效果。Baked GI 节点通过不同的 UV 输入来访问这些不同的光照贴图。

节点行为的管线依赖性

一个重要的注意事项是,Baked GI 节点的具体行为并未在全局范围内统一定义。Shader Graph 本身并不定义这个节点的功能实现,而是由每个渲染管线决定为此节点生成什么样的 HLSL 代码。这意味着:

  • 在高清渲染管线中,Baked GI 节点可能有特定的优化和功能
  • 在通用渲染管线中,节点的实现可能更注重性能和跨平台兼容性
  • 在内置渲染管线中,节点的行为可能又有所不同

这种设计使得每个渲染管线可以根据自身的架构和需求,优化 Baked GI 节点的实现方式。对于着色器开发者来说,这意味着如果计划创建在多种渲染管线中使用的着色器,需要在每个目标管线中测试 Baked GI 节点的行为,确保它按预期工作。

无光照着色器中的限制

在 URP 和 HDRP 中,Baked GI 节点不能在无光照着色器中使用。无光照着色器通常用于不需要复杂光照计算的物体,如UI元素、粒子效果或特殊效果。这些着色器通常会绕过管线的标准光照流程,因此无法访问烘焙全局光照数据。

如果尝试在无光照着色器中使用 Baked GI 节点,可能会遇到编译错误或运行时错误。对于需要简单光照的无光照物体,考虑使用其他光照技术,如顶点光照或简单的漫反射计算。

端口

Baked GI 节点包含多个输入端口和一个输出端口,每个端口都有特定的功能和数据要求。理解这些端口的作用对于正确使用节点至关重要。

Position 输入端口

Position 输入端口接收世界空间中的位置坐标,用于确定光照采样的空间位置。这个位置信息主要用于:

  • 光照探针采样:确定使用哪些光照探针的数据
  • 光照贴图索引:在某些情况下,帮助确定使用哪张光照贴图

在大多数情况下,应该将物体的世界空间位置连接到这个端口。在顶点着色器阶段使用 Baked GI 节点时,可以使用 Position 节点获取顶点在世界空间中的位置;在片元着色器阶段使用时,可以使用屏幕位置或通过其他方式计算得到的世界位置。

当使用光照探针时,位置输入的准确性尤为重要。如果位置偏差过大,可能会导致物体采样到错误位置的光照探针数据,造成光照不匹配的现象。

Normal 输入端口

Normal 输入端口接收世界空间中的法线向量,用于确定表面朝向,从而影响光照采样的方向。法线输入的主要作用包括:

  • 光照探针评估:球谐光照基于法线方向评估光照颜色
  • 光照贴图采样:在某些高级用法中,法线可能影响光照贴图的采样方式

法线向量应当是世界空间中的单位向量。如果提供的法线没有归一化,可能会导致光照计算错误。通常情况下,可以使用 Transform 节点将物体空间法线转换到世界空间,并确保使用正确的变换矩阵(通常是转置逆矩阵)。

对于动态法线效果(如法线贴图),需要将修改后的法线向量连接到 Normal 端口,这样 Baked GI 节点就会基于修改后的表面朝向计算光照,创造出更加丰富的视觉效果。

Static UV 输入端口

Static UV 输入端口用于指定静态光照贴图的纹理坐标。这些坐标通常对应于网格的 UV1 通道,也就是在建模软件中为光照贴图准备的 UV 集。Static UV 的作用包括:

  • 采样完全烘焙的光照贴图
  • 访问静态物体的间接光照信息
  • 在 Mixed 光照模式下,采样烘焙的间接光照部分

当场景中使用 Baked 或 Mixed 光照模式时,Unity 会为静态物体生成光照贴图。这些光照贴图包含了预计算的直接和间接光照信息。Static UV 输入确保着色器能够正确访问这些光照数据。

如果网格没有正确设置光照贴图 UV,或者 Static UV 输入不正确,可能会导致光照贴图采样错误,表现为拉伸、扭曲或重复的光照图案。

Dynamic UV 输入端口

Dynamic UV 输入端口用于指定动态光照贴图的纹理坐标,通常对应于网格的 UV2 通道。Dynamic UV 的主要应用场景包括:

  • 在 Mixed 光照模式下,采样用于实时光照交互的光照贴图
  • 访问动态全局光照系统生成的光照信息
  • 处理需要与实时光源交互的静态物体的光照

在 Mixed 光照模式下,Unity 会为静态物体生成两套光照贴图:一套用于完全烘焙的光照(通过 Static UV 访问),另一套用于与实时光源结合的效果(通过 Dynamic UV 访问)。这种设计允许静态物体既受益于高质量的烘焙光照,又能与场景中的实时光源正确交互。

Out 输出端口

Out 输出端口提供从烘焙全局光照系统采样的颜色值。这个输出是三维向量,表示 RGB 颜色空间中的光照颜色。输出的光照值已经考虑了:

  • 直接光照和间接光照的贡献
  • 颜色反射和光能传递效果
  • 场景的环境光遮蔽

输出的颜色值通常需要与材质的反照率颜色相乘,以实现正确的光照着色。在基于物理的着色模型中,Baked GI 的输出代表入射光强度,应当与表面反照率相乘来计算出射光强度。

在某些高级用法中,Baked GI 的输出可以用于更复杂的光照计算,如与实时光照结合,或作为其他着色效果的输入。

控件

Baked GI 节点提供了一个重要的控件选项,用于调整光照贴图的处理方式。

Apply Lightmap Scaling 切换

Apply Lightmap Scaling 是一个布尔切换控件,决定是否对光照贴图坐标自动应用缩放和偏移。这个选项默认为启用状态,在大多数情况下应该保持启用。

当启用 Apply Lightmap Scaling 时,节点会自动应用 Unity 光照系统中定义的光照贴图缩放和偏移变换。这些变换确保光照贴图正确映射到网格表面,考虑到了光照贴图的分包、排列和压缩设置。

禁用 Apply Lightmap Scaling 的情况较为少见,通常只在以下特定场景中考虑:

  • 当手动处理光照贴图坐标时
  • 当使用自定义的光照贴图布局时
  • 在某些特殊效果着色器中,需要直接访问原始光照贴图坐标

在大多数标准用法中,建议保持此选项启用,以确保光照贴图正确映射。如果禁用此选项,需要手动确保光照贴图坐标的正确性,否则可能导致光照贴图采样错误。

生成代码示例

Baked GI 节点在生成着色器代码时,会根据所在的渲染管线产生相应的 HLSL 代码。以下示例展示了 URP 中 Baked GI 节点可能生成的代码结构。

基本函数定义

HLSL

void Unity_BakedGI_float(float3 Position, float3 Normal, float2 StaticUV, float2 DynamicUV, out float3 Out)
{
    Out = SHADERGRAPH_BAKED_GI(Position, Normal, StaticUV, DynamicUV, false);
}

这个函数定义展示了 Baked GI 节点的基本代码结构。函数接收位置、法线和光照贴图坐标作为输入,通过 SHADERGRAPH_BAKED_GI 宏计算烘焙全局光照值,并将结果输出到 Out 参数。

SHADERGRAPH_BAKED_GI 是一个由 Shader Graph 系统定义的宏,它的具体实现取决于目标渲染管线。在 URP 中,这个宏会展开为访问 URP 烘焙光照系统的代码。

实际应用示例

在实际的着色器中,Baked GI 节点通常与其他光照计算结合使用。以下是一个简单的表面着色器示例,展示如何将 Baked GI 与实时直接光照结合:

HLSL

void surf(Input IN, inout SurfaceOutputStandard o)
{
    // 采样反照率贴图
    fixed4 albedo = tex2D(_MainTex, IN.uv_MainTex) * _Color;

    // 获取烘焙全局光照
    float3 bakedGI;
    Unity_BakedGI_float(IN.worldPos, IN.worldNormal, IN.uv1, IN.uv2, bakedGI);

    // 计算实时直接光照(简化示例)
    float3 directLight = _LightColor0 * max(0, dot(IN.worldNormal, _WorldSpaceLightPos0.xyz));

    // 结合光照
    o.Albedo = albedo.rgb;
    o.Emission = bakedGI * albedo.rgb;
    // 直接光照已经在光照模型中处理
}

这个示例展示了烘焙间接光照与实时直接光照的基本结合方式。在实际的 URP 着色器中,光照计算可能更加复杂,涉及更多光照模型和渲染特性。

顶点与片元着色器中的使用

Baked GI 节点既可以在顶点着色器中使用,也可以在片元着色器中使用,取决于性能和质量的需求:

顶点着色器中使用:

HLSL

v2f vert (appdata v)
{
    v2f o;
    // ... 其他顶点变换

    // 在顶点着色器中计算烘焙GI
    Unity_BakedGI_float(mul(unity_ObjectToWorld, v.vertex).xyz,
                        normalize(mul(v.normal, (float3x3)unity_WorldToObject)),
                        v.uv1, v.uv2, o.bakedGI);

    return o;
}

片元着色器中使用:

HLSL

fixed4 frag (v2f i) : SV_Target
{
    // 在片元着色器中计算烘焙GI(更高质量)
    float3 bakedGI;
    Unity_BakedGI_float(i.worldPos, normalize(i.worldNormal), i.uv1, i.uv2, bakedGI);

    // ... 其他着色计算
}

在顶点着色器中使用 Baked GI 性能更好,但光照细节较少;在片元着色器中使用质量更高,但性能开销更大。根据目标平台和性能要求选择合适的阶段。

最佳实践和性能考虑

使用 Baked GI 节点时,遵循一些最佳实践可以确保最佳的性能和视觉效果。

光照贴图设置优化

确保场景的光照贴图设置正确优化:

  • 使用适当的光照贴图分辨率,平衡质量和内存使用
  • 合理设置光照贴图压缩,在移动平台上使用压缩格式
  • 对不需要高质量光照的物体使用较低的光照贴图分辨率

光照探针布局优化

光照探针的布局影响动态物体的光照质量:

  • 在光照变化明显的区域放置更多光照探针
  • 确保动态物体的移动路径上有足够的光照探针覆盖
  • 使用光照探针代理卷提高大范围区域的光照探针效率

着色器性能优化

在着色器中使用 Baked GI 节点时考虑性能:

  • 在移动平台上,考虑在顶点着色器中使用 Baked GI
  • 对于远处物体,使用简化的光照计算
  • 避免在透明物体的着色器中过度使用复杂的光照计算

跨管线兼容性

如果计划创建跨渲染管线使用的着色器:

  • 在目标管线中测试 Baked GI 节点的行为
  • 使用着色器变体或自定义函数处理管线特定的差异
  • 提供回退方案,当 Baked GI 节点不可用时使用替代光照计算

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

TypeScript 核心基础知识

2026年2月13日 09:11

TypeScript(简称 TS)作为 JavaScript 的超集,已成为前端工程化的标配。它通过静态类型检查,提前规避大量运行时错误,让代码更易维护、更具可读性。本文抛开复杂概念,从新手视角梳理 TS 核心基础知识,看完就能上手写 TS 代码。

一、为什么要学 TypeScript?

先明确学习的意义,避免盲目跟风:

  1. 静态类型检查:编码阶段发现错误(如类型不匹配、属性不存在),而非运行时崩溃;
  2. 更好的代码提示:VS Code 等编辑器能精准提示变量 / 函数的属性和方法,提升开发效率;
  3. 代码可读性提升:类型注解就是 “自文档”,一眼看懂变量 / 函数的用途;
  4. 工程化必备:Vue3、React、Node.js 主流框架 / 环境均推荐 / 支持 TS,大厂项目标配。

二、TS 环境搭建(快速上手)

1. 安装 TypeScript

# 全局安装 TS 编译器
npm install -g typescript
# 验证安装(查看版本)
tsc -v

2. 第一个 TS 程序

  • 创建 hello.ts 文件:

    // 类型注解:指定变量类型为字符串
    const message: string = "Hello TypeScript!";
    console.log(message);
    
  • 编译 TS 为 JS:

    # 将 hello.ts 编译为 hello.js
    tsc hello.ts
    
  • 运行 JS 文件:

    node hello.js
    

3. 简化开发:自动编译 + 热更新(可选)

# 安装 ts-node(直接运行 TS,无需手动编译)
npm install -g ts-node
# 直接运行 TS 文件
ts-node hello.ts

三、核心基础:类型注解与类型推断

1. 类型注解(手动指定类型)

语法:变量名: 类型 = 值,告诉 TS 变量的具体类型。

// 基本类型注解
let name: string = "张三"; // 字符串
let age: number = 25; // 数字(整数/浮点数/NaN/Infinity)
let isAdult: boolean = true; // 布尔值
let empty: null = null; // null
let undef: undefined = undefined; // undefined

// 数组注解(两种写法)
let arr1: string[] = ["苹果", "香蕉"]; // 推荐
let arr2: Array<number> = [1, 2, 3]; // 泛型写法

// 对象注解
let user: { name: string; age: number } = {
  name: "李四",
  age: 30,
};

// 函数注解(参数 + 返回值)
function add(a: number, b: number): number {
  return a + b;
}

2. 类型推断(TS 自动推导类型)

TS 会根据变量的初始值自动推断类型,无需手动注解(日常开发中优先用推断,减少冗余)。

typescript

运行

let str = "hello"; // TS 自动推断 str 为 string 类型
str = 123; // 报错:不能将类型“number”分配给类型“string”

let num = 100; // 推断为 number 类型
let bool = false; // 推断为 boolean 类型

核心原则:能靠推断的就不手动注解,需要明确约束时才加注解。

四、常用基础类型

1. 原始类型

表格

类型 说明 示例
string 字符串 let str: string = "TS"
number 数字 let num: number = 666
boolean 布尔值 let flag: boolean = false
null 空值 let n: null = null
undefined 未定义 let u: undefined = undefined
symbol 唯一值 let s: symbol = Symbol("id")
bigint 大整数 let b: bigint = 100n

2. 数组

两种写法,推荐第一种:

// 写法1:类型[]
let numbers: number[] = [1, 2, 3];
// 写法2:Array<类型>
let strings: Array<string> = ["a", "b"];
// 禁止混合类型(除非指定联合类型)
let mix: (string | number)[] = [1, "a"]; // 联合类型:字符串或数字

3. 元组(Tuple)

固定长度、固定类型的数组(强约束):

// 元组注解:第一个元素是string,第二个是number
let tuple: [string, number] = ["张三", 25];
tuple[0] = "李四"; // 合法
tuple[1] = 30; // 合法
tuple.push(3); // 注意:push 不会报错(TS 设计缺陷),但访问 tuple[2] 会报错

4. 任意类型(any)

关闭 TS 类型检查,慎用(失去 TS 核心价值):

let anyValue: any = "hello";
anyValue = 123; // 不报错
anyValue = true; // 不报错
anyValue.foo(); // 不报错(运行时可能崩溃)

5. 未知类型(unknown)

安全版 any,必须先类型校验才能使用:

let unknownValue: unknown = "hello";
// unknownValue.toUpperCase(); // 报错:不能直接调用方法

// 先校验类型,再使用
if (typeof unknownValue === "string") {
  unknownValue.toUpperCase(); // 合法
}

6. 空类型(void)

表示函数没有返回值(或返回 undefined):

function logMsg(): void {
  console.log("这是一个无返回值的函数");
  // 省略 return 或 return undefined 均合法
}

7. 永不类型(never)

表示永远不会发生的值(如抛出错误、无限循环):

// 抛出错误的函数,返回值为 never
function throwError(): never {
  throw new Error("出错了!");
}

// 无限循环的函数,返回值为 never
function infiniteLoop(): never {
  while (true) {}
}

五、进阶基础:接口与类型别名

1. 接口(interface)

用于约束对象的结构,可扩展、可实现,是 TS 中定义对象类型的核心方式:

// 定义接口
interface User {
  name: string; // 必选属性
  age: number; // 必选属性
  gender?: string; // 可选属性(加 ?)
  readonly id: number; // 只读属性(不可修改)
}

// 使用接口约束对象
let user: User = {
  name: "张三",
  age: 25,
  id: 1001,
  // gender 可选,可省略
};

user.id = 1002; // 报错:只读属性不能修改

2. 类型别名(type)

给类型起别名,适用范围更广(可约束任意类型,不止对象):

// 基本类型别名
type Str = string;
let str: Str = "hello";

// 对象类型别名
type User = {
  name: string;
  age: number;
};

// 联合类型别名
type NumberOrString = number | string;
let value: NumberOrString = 100;
value = "abc";

3. interface vs type 核心区别

表格

特性 interface type
扩展 可通过 extends 扩展 可通过 & 交叉扩展
重复定义 支持(自动合并) 不支持(会报错)
适用范围 主要约束对象 / 类 可约束任意类型(基本类型、联合类型等)

使用建议:定义对象 / 类的结构用 interface,其他场景用 type

六、函数相关类型

1. 函数参数与返回值注解

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

// 箭头函数
const multiply = (a: number, b: number): number => {
  return a * b;
};

// 无返回值
const log = (msg: string): void => {
  console.log(msg);
};

2. 可选参数与默认参数

// 可选参数(加 ?,必须放在必选参数后面)
function greet(name: string, age?: number): void {
  console.log(`姓名:${name},年龄:${age || "未知"}`);
}
greet("张三"); // 合法
greet("李四", 30); // 合法

// 默认参数(自动推断类型,无需加 ?)
function sayHi(name: string = "游客"): void {
  console.log(`你好,${name}`);
}
sayHi(); // 输出:你好,游客

3. 函数类型别名

定义函数的 “形状”(参数类型 + 返回值类型):

// 定义函数类型
type AddFn = (a: number, b: number) => number;

// 实现函数
const add: AddFn = (x, y) => {
  return x + y;
};

七、类型守卫

通过代码逻辑缩小类型范围,让 TS 更精准推断类型:

// typeof 类型守卫(适用于原始类型)
function printValue(value: string | number) {
  if (typeof value === "string") {
    console.log(value.toUpperCase()); // TS 知道这里 value 是 string
  } else {
    console.log(value.toFixed(2)); // TS 知道这里 value 是 number
  }
}

// instanceof 类型守卫(适用于类实例)
class Animal {}
class Dog extends Animal {
  bark() {
    console.log("汪汪汪");
  }
}

function judgeAnimal(animal: Animal) {
  if (animal instanceof Dog) {
    animal.bark(); // TS 知道这里 animal 是 Dog 实例
  }
}

八、TS 配置文件(tsconfig.json)

项目中通过 tsconfig.json 配置 TS 编译规则,执行 tsc --init 生成默认配置,核心配置说明:

{
  "compilerOptions": {
    "target": "ES6", // 编译目标 JS 版本(ES5/ES6/ESNext)
    "module": "ESNext", // 模块系统(CommonJS/ESModule)
    "outDir": "./dist", // 编译后的 JS 文件输出目录
    "rootDir": "./src", // 源文件目录
    "strict": true, // 开启严格模式(推荐,强制类型检查)
    "noImplicitAny": true, // 禁止隐式 any 类型
    "esModuleInterop": true // 兼容 CommonJS 和 ESModule
  },
  "include": ["./src/**/*"], // 要编译的文件
  "exclude": ["node_modules"] // 排除的文件
}

九、新手避坑指南

  1. 不要滥用 any:用 unknown 替代 any,保留类型检查;
  2. 可选参数放最后:TS 要求可选参数必须在必选参数之后;
  3. 元组 push 不报错:元组虽固定长度,但 push 不会触发 TS 报错,需手动规避;
  4. 严格模式必开strict: true 能暴露更多潜在问题,是 TS 核心价值所在;
  5. 类型断言要谨慎as 语法是 “告诉 TS 我比你更清楚类型”,滥用会导致类型不安全。

总结

  1. TS 核心是静态类型系统,通过类型注解 / 推断提前规避错误;
  2. 常用基础类型:原始类型、数组、元组、any/unknown、void/never,需掌握各自使用场景;
  3. 定义对象结构优先用 interface,其他类型约束用 type
  4. 函数注解要关注参数、返回值、可选参数,类型守卫能提升类型推断精度;
  5. 项目中务必开启严格模式(strict: true),发挥 TS 最大价值。

从 JS 过渡到 TS 无需一步到位,可先在项目中局部使用,逐步覆盖,重点是理解 “类型” 的核心思想,而非死记语法。掌握本文的基础知识,足以应对日常开发中 80% 的 TS 场景,后续可再深入泛型、装饰器、高级类型等内容。

JavaScript 手写 new 操作符:深入理解对象创建

作者 wuhen_n
2026年2月13日 09:10

当我们使用 new 关键字时,背后到底发生了什么?这个看似简单的操作,实际上完成了一系列复杂的步骤。理解 new 的工作原理,是掌握 JavaScript 面向对象编程的关键。

前言:从 new 的神秘面纱说起

function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.greet = function() {
    return `你好,我是${this.name},今年${this.age}岁`;
};

const person = new Person('张三', 25);

上述代码中 new 到底创建了什么?为什么 this 指向了新对象?原型链是怎么建立的?如果构造函数有返回值会怎样?我们将通过本篇文章揭开 new 的神秘面纱,从零实现一个自己的 new 操作符。

理解 new

new 的四个核心步骤:

  1. 创建一个空对象
  2. 将对象的原型设置为构造函数的 prototype 属性
  3. 将构造函数的 this 绑定到新对象,并执行构造函数
  4. 判断返回值类型:如果构造函数返回一个对象(包括函数),则返回该对象;否则返回新创建的对象

手写实现 new 操作符

基础版本实现

function myNew(constructor, ...args) {

  // 1. 创建一个空对象
  const obj = {};

  // 2. 将对象的原型设置为构造函数的 prototype 属性
  Object.setPrototypeOf(obj, constructor.prototype);

  // 3. 将构造函数的 this 绑定到新对象,并执行构造函数
  const result = constructor.apply(obj, args);

  // 4. 判断返回值类型
  // 如果构造函数返回一个对象(包括函数),则返回该对象
  // 否则返回新创建的对象
  const isObject = result !== null && (typeof result === 'object' || typeof result === 'function');

  return isObject ? result : obj;
}

处理边界情况

function myNewEnhanced(constructor, ...args) {

  // 边界情况1:constructor 不是函数
  if (typeof constructor !== 'function') {
    throw new TypeError(`${constructor} is not a constructor`);
  }

  // 边界情况2:箭头函数(没有 prototype)
  if (!constructor.prototype) {
    throw new TypeError(`${constructor.name || constructor} is not a constructor`);
  }

  // 1. 创建新对象(改进方法):使用 Object.create 更优雅地设置原型
  const obj = Object.create(constructor.prototype);

  // 2. 调用构造函数
  let result;
  try {
    result = constructor.apply(obj, args);
  } catch (error) {
    // 如果构造函数抛出异常,直接传播
    throw error;
  }

  // 3. 处理返回值
  // 注意:null 也是 object 类型,但需要特殊处理
  const resultType = typeof result;
  const isObject = result !== null && (resultType === 'object' || resultType === 'function');

  return isObject ? result : obj;
}

完整实现与原型链优化

function myNewComplete(constructor, ...args) {
  // 1. 参数验证
  if (typeof constructor !== 'function') {
    throw new TypeError(`Constructor ${constructor} is not a function`);
  }

  // 2. 检查是否为可构造的函数:箭头函数和部分内置方法没有 prototype
  if (!constructor.prototype && !isNativeConstructor(constructor)) {
    throw new TypeError(`${getFunctionName(constructor)} is not a constructor`);
  }

  // 3. 创建新对象并设置原型链
  const proto = constructor.prototype || Object.prototype;
  const obj = Object.create(proto);

  // 4. 绑定 constructor 属性
  obj.constructor = constructor; 

  // 5. 执行构造函数
  const result = Reflect.construct(constructor, args, constructor);

  // 6. 处理返回值
  // Reflect.construct 已经处理了返回值逻辑
  // 但我们还是实现自己的逻辑以保持一致
  return processConstructorResult(result, obj, constructor);
}

// 辅助函数:检查是否为原生构造函数
function isNativeConstructor(fn) {
  // 一些内置构造函数如 Symbol、BigInt 没有 prototype
  const nativeConstructors = [
    'Number', 'String', 'Boolean', 'Symbol', 'BigInt',
    'Date', 'RegExp', 'Error', 'Array', 'Object', 'Function'
  ];

  return nativeConstructors.some(name =>
    fn.name === name || fn === globalThis[name]
  );
}

// 辅助函数:获取函数名
function getFunctionName(fn) {
  if (fn.name) return fn.name;
  const match = fn.toString().match(/^function\s*([^\s(]+)/);
  return match ? match[1] : 'anonymous';
}

// 辅助函数:处理构造函数返回值
function processConstructorResult(result, defaultObj, constructor) {
  // 如果 result 是 undefined 或 null,返回 defaultObj
  if (result == null) {
    return defaultObj;
  }

  // 检查 result 的类型
  const type = typeof result;

  // 如果是对象或函数,返回 result
  if (type === 'object' || type === 'function') {
    // 额外检查:如果 result 是构造函数本身的实例,确保原型链正确
    if (result instanceof constructor) {
      return result;
    }
    return result;
  }

  // 原始值,返回 defaultObj
  return defaultObj;
}

深入原型链与继承

原型链的建立过程

// 父构造函数
function Animal(name) {
  this.name = name;
}
// 父类方法
Animal.prototype.speak = function () {
  return `${this.name} 叫了`;
};
// 子构造函数
function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}
// 建立原型链
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
// 子类方法
Dog.prototype.bark = function () {
  return `${this.name} 汪汪叫`;
};
// 创建实例
const myDog = new Dog('旺财', '金毛');
console.log(myDog.speak()); // 旺财 叫了
console.log(myDog.bark());  // 旺财 汪汪叫

ES6 类与 new 的关系

ES6 类的本质还是基于原型的语法糖:

ES6 基本写法

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  greet() {
    return `你好,我是${this.name}`;
  }
}
const person = new Person('张三', 30);

对应 ES5 的写法

function PersonES5(name, age) {
  // 类构造器中的代码
  if (!(this instanceof PersonES5)) {
    throw new TypeError("Class constructor Person cannot be invoked without 'new'");
  }

  this.name = name;
  this.age = age;
}

// 实例方法(添加到原型)
PersonES5.prototype.greet = function () {
  return `你好,我是${this.name}`;
};
const personES5 = new PersonES5('李四', 25);

类的重要特性

  1. 类必须用 new 调用
  2. 类方法不可枚举
  3. 类没有变量提升

ES6 实现继承的完整示例

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(this.name + ' 叫了');
  }
}
class Dog extends Animal {
  constructor(name) {
    super(name);
  }

  speak() {
    console.log(this.name + ' 汪汪叫');
  }
}

ES6 继承的本质

ES6 通过 extends 关键字实现继承,就等价于 ES5 的寄生组合继承:

function AnimalES5(name) {
  this.name = name;
}

AnimalES5.prototype.speak = function () {
  console.log(this.name + ' 叫了');
};

function DogES5(name) {
  AnimalES5.call(this, name);
}

// 设置原型链
DogES5.prototype = Object.create(AnimalES5.prototype);
DogES5.prototype.constructor = DogES5;

DogES5.prototype.speak = function () {
  console.log(this.name + ' 汪汪叫');
};

特殊场景与高级应用

单例模式与 new

方法1:使用静态属性

class SingletonV1 {
  static instance = null;

  constructor(name) {
    if (SingletonV1.instance) {
      return SingletonV1.instance;
    }

    this.name = name;
    SingletonV1.instance = this;
  }

  static getInstance(name) {
    if (!this.instance) {
      this.instance = new SingletonV1(name);
    }
    return this.instance;
  }
}

方法2:使用闭包

const SingletonV2 = (function () {
  let instance = null;

  return class Singleton {
    constructor(name) {
      if (instance) {
        return instance;
      }

      this.name = name;
      instance = this;
    }
  };
})();

方法3:代理模式

function createSingletonProxy(Class) {
  let instance = null;

  return new Proxy(Class, {
    construct(target, args) {
      if (!instance) {
        instance = Reflect.construct(target, args);
      }
      return instance;
    }
  });
}

实现 Object.create 的 polyfill

if (typeof Object.create !== 'function') {
  Object.create = function (proto, propertiesObject) {
    // 参数验证
    if (typeof proto !== 'object' && typeof proto !== 'function') {
      throw new TypeError('Object prototype may only be an Object or null');
    }

    // 核心实现:使用空函数作为中间构造函数
    function F() { }
    F.prototype = proto;

    // 创建新对象,原型指向proto
    const obj = new F();

    // 处理第二个参数(属性描述符)
    if (propertiesObject !== undefined) {
      Object.defineProperties(obj, propertiesObject);
    }

    // 处理 null 原型
    if (proto === null) {
      obj.__proto__ = null;
    }
    // 返回新对象
    return obj;
  };
}

常见面试问题与解答

问题1:new 操作符做了什么?

  1. 创建一个新的空对象',
  2. 将这个空对象的原型设置为构造函数的 prototype 属性',
  3. 将构造函数的 this 绑定到这个新对象,并执行构造函数',
  4. 如果构造函数返回一个对象(包括函数),则返回该对象;否则返回新创建的对象

问题2:如果构造函数有返回值会怎样?

  • 返回对象(包括函数):忽略 this 绑定的对象,返回该对象
  • 返回原始值(number, string, boolean等):忽略返回值,返回 this 绑定的对象
  • 没有 return 语句:隐式返回 undefined,返回 this 绑定的对象

问题3:如何判断函数是否被 new 调用?

  • ES5:检查 this instanceof Constructor'
  • ES6+:使用 new.target(更准确)
  • 箭头函数:不能作为构造函数,没有 new.target

结语

通过深入理解 new 操作符的工作原理,我们不仅能在面试中脱颖而出,还能在实际开发中做出更明智的设计决策。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

企业级 Prompt 工程实战指南(下):构建可复用 Prompt 架构平台

作者 乘风gg
2026年2月13日 08:47

一、前言:从“懂原理”到“能落地”

在上篇内容中企业级 Prompt 工程实战指南(上):别让模糊指令浪费你的AI算力,我们拆解了 Prompt 的底层逻辑、四大核心要素,以及四大典型避坑技巧,解决了“怎么写才不踩坑”的基础问题。

但对一线开发者和架构师而言,Prompt 工程的最终价值,不在于“懂原理”,而在于“能落地”——如何将 Prompt 设计融入实际业务,降低开发成本、提升效率,构建可复用、可迭代的 Prompt 体系?

本篇将聚焦实战,通过完整业务案例拆解落地流程,对比不同技术路径的优劣,分享工程化落地技巧,并展望未来发展趋势,真正把 Prompt 技术转化为业务竞争力。

二、实战案例:企业客服工单自动分类与摘要生成

为了更直观地展示 Prompt 工程在实际业务中的应用效果,我们以一家电商企业的售后客服场景为例,详细拆解如何通过精心设计的 Prompt 实现工单的自动分类与摘要生成,大幅提升客服工作效率。

2.1 场景角色

  • AI 应用产品经理(Prompt 设计者) :负责设计和优化 Prompt,确保大语言模型能够准确理解业务需求并生成高质量的输出。
  • 客服团队(需求方) :每天需要处理大量的售后工单,希望借助 AI 技术实现工单的自动分类和摘要生成,以减轻工作负担,提高服务效率。
  • 大模型(执行主体) :选用市面上成熟的大语言模型,如 ChatGPT、Gemini、通义千问等,作为执行任务的核心引擎,根据输入的 Prompt 和工单文本进行分析和处理。
  • 服务对象:日均产生 500 + 售后工单的电商售后部门,涵盖各类复杂的客户问题和诉求。

2.2 核心目标

通过优化 Prompt 设计,让大语言模型自动将杂乱无章的售后工单准确分类为 “物流问题”“产品故障”“退换货申请” 三类,并为每个工单生成 50 字以内的结构化处理摘要,清晰概括核心诉求与关键信息。目标是替代人工分类,将整体工作效率提升 30% 以上,同时保证分类准确率达到 95% 以上,摘要关键信息覆盖率达到 90% 以上。

2.3 输入

  • 原始输入:无结构化的售后工单文本,例如 “我买的衣服尺码不对,昨天收到的,想换大一码,请问需要寄回吗?” 这类文本通常表述随意,包含大量冗余信息,需要模型进行信息提取和分类。

  • 辅助输入(少样本学习) :为了引导模型更好地理解任务,提供 3 条分类示例,如:

    • 示例 1:“我买的手机三天了还没收到,单号查不到,啥情况?” - 分类:物流问题;摘要:用户反映手机未收到且单号查询无果。
    • 示例 2:“刚用的吹风机,突然冒烟了,不敢再用了。” - 分类:产品故障;摘要:用户反馈吹风机使用中冒烟。
    • 示例 3:“买的电脑配置和宣传不符,申请退货。” - 分类:退换货申请;摘要:用户因电脑配置不符申请退货。

2.4 处理流程(工具调用逻辑)

  • 第一步:编写系统 Prompt:“你是电商售后工单分类专家,需完成 2 个任务:1. 将工单分为物流问题 / 产品故障 / 退换货申请三类;2. 生成 50 字内处理摘要,包含核心诉求与关键信息。” 此系统 Prompt 明确了模型的角色和任务范围,为后续处理奠定基础。
  • 第二步:加入少样本示例:将上述 3 条分类示例加入 Prompt 中,让模型通过少样本学习掌握分类和摘要生成的模式与规则,增强模型对任务的理解和适应性。
  • 第三步:输入用户工单文本:将实际的售后工单文本输入给模型,与系统 Prompt 和少样本示例共同构成完整的输入信息,触发模型的处理流程。
  • 第四步:输出结构化结果:模型根据输入信息进行分析处理,输出结构化的结果,格式为 “分类:[具体类别];摘要:[处理摘要]”。整个过程无需对模型进行微调,仅通过精心设计的 Prompt 即可实现高效的任务处理。

2.5 输出与校验

  • 输出格式:“分类:退换货申请;摘要:用户购买衣服尺码不符,昨日收货,需求换货大一码,咨询寄回流程”。这种结构化的输出便于客服人员快速理解工单内容,提高处理效率。

  • 校验标准

    • 分类准确率:通过人工抽样复核 100 条工单,对比模型分类结果与人工标注结果,要求分类准确率达到 95% 以上。
    • 摘要关键信息覆盖率:同样抽样 100 条工单,检查摘要是否涵盖用户核心诉求和关键信息,如问题类型、涉及产品、关键时间等,覆盖率需达到 90% 以上。

三、技术路径对比:不同 Prompt 策略的适用场景与成本分析

3.1 三类主流 Prompt 技术路径对比表

在实际应用中,零样本、少样本和思维链(CoT)这三类 Prompt 技术路径各有优劣,适用于不同的业务场景。下面通过表格对比,我们可以更清晰地了解它们在设计思路、优势、劣势、适用场景以及技术成本等方面的差异。

技术路径 设计思路 优势 劣势 适用场景 技术成本 实现复杂度 落地可行性
零样本 Prompt 仅输入任务描述,无示例 成本最低、无需准备样本、迭代快 准确率低、复杂任务易失控 简单文本生成、基础问答 极低(仅需指令设计) 极高(即写即用)
少样本 Prompt 加入 3-5 个示例引导模型 准确率高于零样本、适配多数场景 需准备标注示例、指令长度受限 文本分类、摘要生成、格式标准化 低(样本标注成本低) 高(中小规模业务首选)
思维链(CoT)Prompt 引导模型分步推理,展示思考过程 适配复杂逻辑任务、推理准确率高 指令设计复杂、token 消耗大、速度慢 数学计算、故障排查、多步骤决策 中(需设计推理框架) 中(适合专业场景)

3.2 技术选型核心原则:成本与效果的平衡

从高层往下看视角看,技术选型需遵循 “低成本优先” 原则:优先用零样本 Prompt 解决简单任务;中等复杂度任务采用少样本 Prompt,以最低标注成本提升准确率;仅复杂推理任务考虑思维链 Prompt,同时需评估 token 消耗带来的算力成本,避免过度设计。在实际应用中,我们要根据任务的复杂度、数据资源、算力成本等多方面因素,综合评估选择最合适的 Prompt 技术路径,以实现最佳的性价比。例如,在一个简单的文本分类任务中,如果使用思维链 Prompt,虽然可能会提高准确率,但由于其指令设计复杂、token 消耗大,会增加不必要的成本,此时选择少样本 Prompt 可能更为合适。

四、Prompt 工程化落地:从 “一次性指令” 到 “可复用架构”

当我们在实际业务中大规模应用 Prompt 技术时,就不能仅仅满足于 “一次性” 的指令设计,而需要从工程化的角度构建一套可复用、可迭代、低成本的 Prompt 架构体系。这不仅关系到开发效率与成本控制,更是决定 AI 应用能否在复杂业务环境中持续稳定运行的关键。

4.1 模块化设计:Prompt 模板化与组件化

从工程实践看,将 Prompt 拆分为多个可复用组件是提高开发效率与灵活性的关键。一个典型的 Prompt 可以拆解为 “角色定义 + 任务指令 + 格式约束 + 示例” 四大组件。以电商客服场景为例,我们可以将 “你是专业电商客服” 这一角色定义固化为通用组件;任务指令部分则根据不同工单类型(如物流咨询、产品售后等)动态替换;格式约束(如 “输出为 JSON 格式”)和示例(如常见问题及解答示例)也可按需调整。通过这种组件化设计,我们可以快速搭建针对不同业务场景的 Prompt,实现跨工单类型的快速适配,大幅降低重复开发成本。这种方式就像是搭积木,每个组件都是一个独立的模块 ,我们可以根据不同的业务需求,灵活地组合这些模块,快速构建出满足需求的 Prompt。在这之后还会专门搭建 Prompt 平台,专门存储和编写 Prompt,一键更新到 AI 应用里面,方便 Prompt 各种环境使用和进行版本管理

4.2 迭代优化:基于输出反馈的指令调优

Prompt 并非一成不变,而是需要根据模型输出结果持续优化。建立 “指令 - 输出 - 反馈 - 优化” 的闭环迭代流程是实现这一目标的核心。例如,在工单分类任务中,如果模型将某个 “产品故障” 工单误分类为 “物流问题”,我们需要深入分析指令设计的漏洞,比如是否存在未覆盖的边缘场景、示例是否足够典型等。

针对这些问题,我们可以补充更多边缘场景的示例,细化分类规则,逐步提高模型的准确率。这种迭代优化的过程就像是对产品进行持续改进,通过不断收集用户反馈,优化产品功能,提升用户体验。

在这里,我想额外问一个问题 在进行 prompt 更新的时候,如何去评判 Prompt 前后两次修改的质量好坏呢? 我列出三个纬度供大家参考

  • 质量维度,能说到重点上吗?
  • 稳定性纬度,每次问都回答一样吗?
  • 正确性纬度,回答的数据正确吗?

4.3 成本控制:减少无效 token 消耗

在实际应用中,token 消耗不单单会影响大模型幻觉,还会直接关系到算力成本,因此从工程化角度优化 token 使用至关重要。首先,要精简指令内容,避免冗长复杂的表述,确保每一个 token 都传递有效信息;

其次,合理利用模型上下文窗口特性,优先保留系统 Prompt 中的核心规则与约束,对用户输入中的冗余信息进行预处理;对于超长文本任务,结合检索增强生成(RAG)技术,将长文本拆分为多个短文本分批次输入,避免一次性输入导致的 token 溢出。这就好比在装修房子时,合理规划空间,避免浪费,让每一寸空间都得到充分利用。通过这些策略,可以在保证模型性能的前提下,有效降低 token 成本,提高应用的性价比。

五、总结与展望:Prompt 工程的现在与趋势

5.1 核心观点总结

Prompt 工程的本质是 “用工程化思维替代感性经验”,核心在于明确角色、拆解任务、约束格式、补充示例,而非依赖模型参数提升。对于多数企业级应用,优质 Prompt 设计带来的效果提升,远高于盲目追求大模型升级的收益。在实际应用中,我们不应过分关注模型的参数规模和性能指标,而应将更多的精力放在如何设计有效的 Prompt 上。通过合理的 Prompt 设计,我们可以引导模型更好地理解任务需求,提高输出的质量和准确性,从而实现更高的性价比。

5.2 当前局限性

现有 Prompt 技术仍存在边界:无法突破模型预训练知识范围,易产生 “幻觉” ;复杂任务的指令设计依赖专业经验;多模态场景下的 Prompt 设计尚未形成标准化方案。例如,当我们询问模型关于未来的科技发展趋势时,由于模型的知识截止于训练时间,它无法提供最新的信息,可能会产生不准确或过时的回答。在多模态场景下,如结合图像和文本的应用中,如何设计有效的 Prompt 以实现多模态信息的融合和交互,仍然是一个待解决的问题。

5.3 目前趋势展望

目前 Prompt 工程将向 “自动化” 与 “融合化” 发展:自动化方面,AI 将自主生成并优化 Prompt,降低人工设计门槛;融合化方面,Prompt 将与 RAG 深度结合,形成 “Prompt+RAG 解决知识时效性的 SOP。随着技术的不断发展,我们可以期待 AI 能够根据用户的需求自动生成和优化 Prompt,进一步提高效率和准确性。Prompt 与其他技术的融合也将为 AI 应用带来更多的可能性,推动 AI 技术在各个领域的深入应用和发展。

感谢观看,欢迎大家点赞关注,下期更精彩!

JavaScript 手写 call、apply、bind:深入理解函数上下文绑定

作者 wuhen_n
2026年2月13日 06:57

当面试官让我们手写 call、apply、bind 时,他们真正考察的是什么?这三个方法看似简单,却隐藏着 JavaScript 函数执行上下文、原型链、参数处理等核心概念。本文将从零实现,并深入理解它们的差异和应用场景。

前言:为什么需要 call、apply、bind?

const obj = {
  name: '张三',
  sayHello() {
    console.log(`你好,我是${this.name}`);
  }
};

const sayHelloFunc = obj.sayHello;
obj.sayHello();     // "你好,我是张三" - 正确
sayHelloFunc();     // "你好,我是undefined" - this丢失了!

上述代码,出现问题根源是:函数的 this 在调用时才确定,取决于调用方式。那如何解决呢?使用call、apply、bind 显式绑定 this 。

call 方法的实现

call 的基本使用

call 方法用于调用一个函数,并显式指定函数的 this 值和参数列表。

function greet(message) {
  console.log(`${message}, ${this.name}!`);
}

const person = { name: 'zhangsan' };

// 原生 call 的使用
greet.call(person, '你好'); // "你好, zhangsan!"

call 的工作原理

  1. 将函数设为对象的属性
  2. 使用该对象调用函数
  3. 删除该属性

基础版本实现

Function.prototype.myCall = function (context, ...args) {
  // 如果context是null或undefined,则绑定到全局对象
  if (context == null) {
    context = globalThis;
  }
  // 给context对象添加一个临时属性,值为当前函数
  const fnKey = Symbol('fn'); // 使用Symbol避免属性名冲突
  context[fnKey] = this; // this指向调用myCall的函数
  // 使用context对象调用函数
  const result = context[fnKey](...args);
  // 删除临时属性
  delete context[fnKey];
  return result;
};

处理边界情况

Function.prototype.myCallEnhanced = function (context, ...args) {
  // 处理undefined和null
  if (context == null) {
    context = globalThis;
  }

  // 原始值需要转换为对象,否则不能添加属性
  const contextType = typeof context;
  if (contextType === 'string' ||
    contextType === 'number' ||
    contextType === 'boolean' ||
    contextType === 'symbol' ||
    contextType === 'bigint') {
    context = Object(context); // 转换为包装对象
  }

  // 使用更安全的Symbol作为key
  const fnKey = Symbol('fn');
  context[fnKey] = this;

  try {
    const result = context[fnKey](...args);
    return result;
  } finally {
    // 确保总是删除临时属性
    delete context[fnKey];
  }
};

完整实现与性能优化

Function.prototype.myCallFinal = function (context = globalThis, ...args) {
  // 1. 类型检查:确保调用者是函数
  if (typeof this !== 'function') {
    throw new TypeError('Function.prototype.myCallFinal called on non-function');
  }

  // 2. 处理Symbol和BigInt(ES6+)
  const contextType = typeof context;
  let finalContext = context;

  // 3. 处理原始值(非严格模式下的自动装箱)
  if (contextType === 'string') {
    finalContext = new String(context);
  } else if (contextType === 'number') {
    finalContext = new Number(context);
  } else if (contextType === 'boolean') {
    finalContext = new Boolean(context);
  } else if (contextType === 'symbol') {
    // Symbol不能通过new创建,使用Object
    finalContext = Object(context);
  } else if (contextType === 'bigint') {
    // BigInt不能通过new创建,使用Object
    finalContext = Object(context);
  }
  // null和undefined已经通过默认参数处理

  // 4. 使用Symbol创建唯一key,避免属性冲突
  const fnSymbol = Symbol('callFn');

  // 5. 将函数绑定到上下文对象
  // 使用Object.defineProperty确保属性可配置
  Object.defineProperty(finalContext, fnSymbol, {
    value: this,
    configurable: true,
    writable: true,
    enumerable: false
  });

  // 6. 执行函数并获取结果
  let result;
  try {
    result = finalContext[fnSymbol](...args);
  } finally {
    // 7. 清理临时属性
    try {
      delete finalContext[fnSymbol];
    } catch (error) {
      // 如果上下文不可配置,忽略错误
      console.warn('无法删除临时属性:', error.message);
    }
  }

  return result;
};

apply 方法的实现

apply 的基本使用

apply 和 call 的功能基本相同,唯一的区别在于参数的传递方式:

  • call 接受参数列表
  • apply 接受参数数组
function sum(a, b, c) {
  return a + b + c;
}
// apply:参数以数组形式传递
sum.apply(null, [1, 2, 3]);

基础版本实现

Function.prototype.myCall = function (context, args) {
  // 如果context是null或undefined,则绑定到全局对象
  if (context == null) {
    context = globalThis;
  }
  // 给context对象添加一个临时属性,值为当前函数
  const fnKey = Symbol('fn'); // 使用Symbol避免属性名冲突
  context[fnKey] = this; // this指向调用myCall的函数
  // 使用context对象调用函数
  const result = context[fnKey](...args);
  // 删除临时属性
  delete context[fnKey];
  return result;
};

完整实现与性能优化

Function.prototype.myApply = function (context = globalThis, argsArray) {
  // 1. 类型检查
  if (typeof this !== 'function') {
    throw new TypeError('Function.prototype.myApply called on non-function');
  }

  // 2. 参数处理:确保argsArray是数组或类数组对象
  let args = [];
  if (argsArray != null) {
    // 检查是否为数组或类数组
    if (typeof argsArray !== 'object' ||
      (typeof argsArray.length !== 'number' && argsArray.length !== undefined)) {
      throw new TypeError('第二个参数必须是数组或类数组对象');
    }

    // 将类数组转换为真实数组
    if (!Array.isArray(argsArray)) {
      args = Array.from(argsArray);
    } else {
      args = argsArray;
    }
  }

  // 3. 使用Symbol作为唯一key
  const fnSymbol = Symbol('applyFn');

  // 4. 处理原始值(与call相同)
  const contextType = typeof context;
  let finalContext = context;

  if (contextType === 'string') {
    finalContext = new String(context);
  } else if (contextType === 'number') {
    finalContext = new Number(context);
  } else if (contextType === 'boolean') {
    finalContext = new Boolean(context);
  } else if (contextType === 'symbol') {
    finalContext = Object(context);
  } else if (contextType === 'bigint') {
    finalContext = Object(context);
  }

  // 5. 绑定函数到上下文
  Object.defineProperty(finalContext, fnSymbol, {
    value: this,
    configurable: true,
    writable: true,
    enumerable: false
  });

  // 6. 执行函数
  let result;
  try {
    result = finalContext[fnSymbol](...args);
  } finally {
    // 7. 清理
    try {
      delete finalContext[fnSymbol];
    } catch (error) {
      // 忽略删除错误
    }
  }

  return result;
};

bind 方法的实现

bind 的基本使用

bind 方法创建一个新的函数,当这个新函数被调用时,它的 this 值会被绑定到指定的对象,并且可以预先传入部分参数。

function greet(greeting, name) {
  console.log(`${greeting}, ${name}! 我是${this.role}`);
}

const context = { role: '管理员' };

// bind:创建新函数,稍后执行
const boundGreet = greet.bind(context, '你好');
boundGreet('李四'); 

bind 的核心特性:

  1. 返回一个新函数
  2. 可以预设参数(柯里化)
  3. 绑定this值
  4. 支持new操作符(特殊情况)

基础版本实现

Function.prototype.myBind = function (context, ...bindArgs) {
  const fn = this;
    return function(...newArgs) {
        return fn.apply(context, [...args, ...newArgs]);
    };
};

处理 new 操作符的特殊情况

Function.prototype.myBindEnhanced = function (context = globalThis, ...bindArgs) {
  const originalFunc = this;

  if (typeof originalFunc !== 'function') {
    throw new TypeError('Function.prototype.myBindEnhanced called on non-function');
  }

  // 内部函数,用于判断是否被new调用
  const boundFunc = function (...callArgs) {
    // 关键判断:this instanceof boundFunc
    // 如果使用new调用,this会是boundFunc的实例
    const isConstructorCall = this instanceof boundFunc;

    // 确定最终的上下文
    // 如果是构造函数调用,使用新创建的对象作为this
    // 否则使用绑定的context
    const finalContext = isConstructorCall ? this : Object(context);

    // 合并参数
    const finalArgs = bindArgs.concat(callArgs);

    // 执行原函数
    // 如果原函数有返回值,需要特殊处理
    const result = originalFunc.apply(finalContext, finalArgs);

    // 构造函数调用的特殊处理
    // 如果原函数返回一个对象,则使用该对象
    // 否则返回新创建的对象(this)
    if (isConstructorCall) {
      if (result && (typeof result === 'object' || typeof result === 'function')) {
        return result;
      }
      return this;
    }

    return result;
  };

  // 维护原型链
  // 方法1:直接设置prototype(有缺陷)
  // boundFunc.prototype = originalFunc.prototype;

  // 方法2:使用空函数中转(推荐)
  const F = function () { };
  F.prototype = originalFunc.prototype;
  boundFunc.prototype = new F();
  boundFunc.prototype.constructor = boundFunc;

  // 添加一些元信息(可选)
  boundFunc.originalFunc = originalFunc;
  boundFunc.bindContext = context;
  boundFunc.bindArgs = bindArgs;

  return boundFunc;
};

完整实现与性能优化

Function.prototype.myBindFinal = (function () {
  // 使用闭包保存Slice方法,提高性能
  const ArraySlice = Array.prototype.slice;

  // 空函数,用于原型链维护
  function EmptyFunction() { }

  return function myBindFinal(context = globalThis, ...bindArgs) {
    const originalFunc = this;

    // 严格的类型检查
    if (typeof originalFunc !== 'function') {
      throw new TypeError('Function.prototype.bind called on non-function');
    }

    // 处理原始值的上下文(非严格模式)
    let boundContext = context;
    const contextType = typeof boundContext;

    // 原始值包装(与call/apply保持一致)
    if (contextType === 'string') {
      boundContext = new String(boundContext);
    } else if (contextType === 'number') {
      boundContext = new Number(boundContext);
    } else if (contextType === 'boolean') {
      boundContext = new Boolean(boundContext);
    } else if (contextType === 'symbol') {
      boundContext = Object(boundContext);
    } else if (contextType === 'bigint') {
      boundContext = Object(boundContext);
    }

    // 创建绑定函数
    const boundFunction = function (...callArgs) {
      // 判断是否被new调用
      const isConstructorCall = this instanceof boundFunction;

      // 确定最终上下文
      let finalContext;
      if (isConstructorCall) {
        // new调用:忽略绑定的context,使用新实例
        finalContext = this;
      } else if (boundContext == null) {
        // 非严格模式:使用全局对象
        finalContext = globalThis;
      } else {
        // 普通调用:使用绑定的context
        finalContext = boundContext;
      }

      // 合并参数
      const allArgs = bindArgs.concat(callArgs);

      // 调用原函数
      const result = originalFunc.apply(finalContext, allArgs);

      // 处理构造函数调用的返回值
      if (isConstructorCall) {
        // 如果原函数返回对象,则使用该对象
        if (result && (typeof result === 'object' || typeof result === 'function')) {
          return result;
        }
        // 否则返回新创建的实例
        return this;
      }

      return result;
    };

    // 维护原型链 - 高性能版本
    // 避免直接修改boundFunction.prototype,使用中间函数
    if (originalFunc.prototype) {
      EmptyFunction.prototype = originalFunc.prototype;
      boundFunction.prototype = new EmptyFunction();
      // 恢复constructor属性
      boundFunction.prototype.constructor = boundFunction;
    } else {
      // 处理没有prototype的情况(如箭头函数)
      boundFunction.prototype = undefined;
    }

    // 添加不可枚举的原始函数引用(用于调试)
    Object.defineProperty(boundFunction, '__originalFunction__', {
      value: originalFunc,
      enumerable: false,
      configurable: true,
      writable: true
    });

    // 添加不可枚举的绑定信息
    Object.defineProperty(boundFunction, '__bindContext__', {
      value: boundContext,
      enumerable: false,
      configurable: true,
      writable: true
    });

    Object.defineProperty(boundFunction, '__bindArgs__', {
      value: bindArgs,
      enumerable: false,
      configurable: true,
      writable: true
    });

    // 设置适当的函数属性
    Object.defineProperty(boundFunction, 'length', {
      value: Math.max(0, originalFunc.length - bindArgs.length),
      enumerable: false,
      configurable: true,
      writable: false
    });

    Object.defineProperty(boundFunction, 'name', {
      value: `bound ${originalFunc.name || ''}`.trim(),
      enumerable: false,
      configurable: true,
      writable: false
    });

    return boundFunction;
  };
})();

面试常见问题与解答

问题1:手写call的核心步骤是什么?

  1. 步骤1: 将函数设为上下文对象的属性
  2. 步骤2: 执行该函数
  3. 步骤3: 删除该属性
  4. 步骤4: 返回函数执行结果
  5. 关键点:
    • 使用Symbol避免属性名冲突
    • 处理null/undefined上下文
    • 处理原始值上下文
    • 使用展开运算符处理参数

问题2:bind如何处理new操作符?

  1. 通过 this instanceof boundFunction 判断是否被new调用
  2. 如果是new调用,忽略绑定的上下文,使用新创建的对象作为this
  3. 需要正确设置boundFunction的原型链,以支持instanceof
  4. 如果原构造函数返回对象,则使用该对象,否则返回新实例

问题3:call、apply、bind的性能差异?

  1. call通常比apply快,因为apply需要处理数组参数
  2. bind创建新函数有开销,但多次调用时比重复call/apply高效

结语

通过深入理解call、apply、bind的实现原理,我们不仅能更好地回答面试问题,还能在实际开发中编写出更优雅、更高效的JavaScript代码。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

Next.js 16 + Supabase + Vercel:SmartChat 全栈 AI 应用架构实战

作者 梦里寻码
2026年2月13日 01:19

前言

全栈 AI 应用怎么选技术栈?这个问题没有标准答案,但 SmartChat 的选择——Next.js 16 + Supabase + Vercel——是一套经过验证的高效组合。本文从架构角度拆解这套技术栈的设计思路。

🔗 项目地址:smartchat.nofx.asia/

微信图片_20260212194724_46_236.png

一、为什么是 Next.js 16?

SmartChat 使用 Next.js 16 的 App Router,充分利用了以下特性:

Server Components: 仪表盘页面使用 Server Components 直接在服务端查询数据库,减少客户端 JS 体积。

API Routes(Serverless): 所有后端逻辑通过 API Routes 实现,无需维护独立的后端服务。

src/app/api/
├── chat/          # 聊天接口(SSE 流式)
├── bots/          # 机器人 CRUD
├── upload/        # 文档上传与向量化
└── conversations/ # 对话管理

Turbopack: 开发环境使用 Turbopack,热更新速度显著提升。

关键优势:前后端同仓库、同语言(TypeScript)、同部署,极大降低了开发和运维复杂度。

二、Supabase:不只是数据库

SmartChat 用 Supabase 承担了多个角色:

2.1 PostgreSQL 数据库 + pgvector

-- 业务数据和向量数据在同一个库
CREATE TABLE document_chunks (
  id uuid PRIMARY KEY,
  bot_id uuid REFERENCES bots(id) ON DELETE CASCADE,
  content text,
  embedding vector(512),  -- pgvector 向量列
  metadata jsonb
);

-- IVFFlat 索引加速向量检索
CREATE INDEX ON document_chunks
  USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

2.2 用户认证(Supabase Auth)

内置邮箱/密码、OAuth 登录,无需自建认证系统。

2.3 行级安全(RLS)

-- 用户只能访问自己的机器人
CREATE POLICY "Users can only access own bots" ON bots
  FOR ALL USING (auth.uid() = user_id);

-- 匿名访客可以查看公开的机器人配置
CREATE POLICY "Public bot access" ON bots
  FOR SELECT USING (is_public = true);

RLS 确保了多租户数据隔离,同时通过 Service Role 客户端允许匿名访客与机器人对话。

2.4 文件存储

文档上传使用 Supabase Storage,统一管理。

SmartChat Dashboard转存失败,建议直接上传图片文件

三、可嵌入组件设计

SmartChat 的一大亮点是一行代码嵌入任何网站

<script src="https://your-domain.com/embed.js" data-bot-id="xxx"></script>

实现原理:

// embed.js 核心逻辑
(function() {
  const botId = document.currentScript.getAttribute('data-bot-id');

  // 创建 iframe 容器
  const iframe = document.createElement('iframe');
  iframe.src = `https://your-domain.com/chat/${botId}?embed=true`;
  iframe.style.cssText = 'position:fixed;bottom:20px;right:20px;...';

  // 创建触发按钮
  const button = document.createElement('div');
  button.onclick = () => iframe.classList.toggle('visible');

  document.body.appendChild(button);
  document.body.appendChild(iframe);
})();

通过 iframe 隔离样式和脚本,避免与宿主网站冲突。聊天界面的颜色、头像、欢迎语都可以在后台自定义。

微信图片_20260212194756_49_236.png

微信图片_20260212194810_51_236.png

四、SSE 流式响应架构

SmartChat 使用 Server-Sent Events 实现实时流式输出:

// API Route: /api/chat
export async function POST(req: Request) {
  const { message, botId, conversationId } = await req.json();

  // 1. 向量检索相关文档
  const relevantDocs = await searchDocuments(message, botId);

  // 2. 构建带上下文的 Prompt
  const messages = await buildMessages(conversationId, relevantDocs);

  // 3. 流式调用 LLM
  const stream = await streamChat({ provider, model, messages });

  // 4. 返回 SSE 流
  return new Response(
    new ReadableStream({
      async start(controller) {
        let fullResponse = '';
        for await (const chunk of stream) {
          const text = extractText(chunk);
          fullResponse += text;
          controller.enqueue(`data: ${JSON.stringify({ text })}\n\n`);
        }
        // 5. 流结束后,附加来源信息
        controller.enqueue(`data: ${JSON.stringify({
          sources: relevantDocs
        })}\n\n`);
        controller.close();

        // 6. 异步保存完整回复到数据库
        await saveMessage(conversationId, fullResponse, relevantDocs);
      }
    }),
    { headers: { 'Content-Type': 'text/event-stream' } }
  );
}

流程:向量检索 → 构建 Prompt → 流式生成 → 实时推送 → 保存记录。

五、Vercel 一键部署

SmartChat 的 Serverless 架构天然适合 Vercel 部署:

  • 零服务器管理:API Routes 自动变成 Serverless Functions
  • 全球 CDN:静态资源自动分发
  • 自动扩缩容:流量高峰自动扩容,空闲时零成本
  • 环境变量管理:在 Vercel Dashboard 配置 API Keys 等敏感信息

部署流程:Fork 仓库 → 连接 Vercel → 配置环境变量 → 部署完成。

六、性能优化要点

  • React 19 + Turbopack:开发体验和构建速度大幅提升
  • Server Components:减少客户端 JS 体积
  • 流式渲染:用户无需等待完整回复
  • IVFFlat 索引:向量检索毫秒级响应
  • 批量写入:文档分块后每 10 条一批插入,避免超时

总结

Next.js + Supabase + Vercel 这套组合的核心优势是简单:一个仓库、一种语言、一键部署。对于中小团队做 AI 应用,这可能是目前投入产出比最高的技术栈选择。SmartChat 是这套架构的一个完整实践案例。

🔗 项目地址:smartchat.nofx.asia/,MIT 开源协议,支持一键部署到 Vercel。

文件16进制查看器核心JS实现

作者 滕青山
2026年2月13日 00:45

文件16进制查看器核心JS实现

本文将介绍基于 Vue 3 和 Nuxt 3 实现的“文件16进制查看器”的核心技术方案。该工具主要用于在浏览器端直接查看任意文件(包括二进制文件)的十六进制编码,所有文件处理均在前端完成,不涉及后端上传。

在线工具网址:see-tool.com/file-hex-vi…
工具截图:
在这里插入图片描述

1. 核心工具函数 (utils/file-hex-viewer.js)

我们将核心的文件处理和格式化逻辑封装在 utils/file-hex-viewer.js 中,主要包括文件大小格式化、二进制转换十六进制字符串以及文件名生成。

1.1 文件大小格式化 (formatFileSize)

用于将字节数转换为人类可读的格式(如 KB, MB)。

export function formatFileSize(bytes, units = ['Bytes', 'KB', 'MB', 'GB', 'TB']) {
  if (!Number.isFinite(bytes) || bytes < 0) return `0 ${units[0] || 'Bytes'}`
  if (bytes === 0) return `0 ${units[0] || 'Bytes'}`

  const k = 1024
  const index = Math.floor(Math.log(bytes) / Math.log(k))
  const value = Math.round((bytes / Math.pow(k, index)) * 100) / 100
  const unit = units[index] || units[units.length - 1] || 'Bytes'
  return `${value} ${unit}`
}

1.2 二进制转十六进制 (bytesToHex)

这是本工具的核心转换函数。它接收一个 Uint8Array,并根据传入的 format 参数(支持 spacenospaceuppercase)生成对应的十六进制字符串。对于 space 格式,每16个字节会自动换行,方便阅读。

export function bytesToHex(uint8Array, format = 'space') {
  if (!uint8Array || !uint8Array.length) return ''
  const useUppercase = format === 'uppercase'
  const useSpace = format === 'space'
  let hexString = ''

  for (let i = 0; i < uint8Array.length; i++) {
    // 将每个字节转换为2位十六进制字符串
    let hex = uint8Array[i].toString(16).padStart(2, '0')
    
    if (useUppercase) {
      hex = hex.toUpperCase()
    }
    
    if (useSpace) {
      hexString += `${hex} `
      // 每16个字节插入一个换行符
      if ((i + 1) % 16 === 0) {
        hexString += '\n'
      }
    } else {
      hexString += hex
    }
  }

  return hexString.trim()
}

1.3 导出文件名生成 (buildHexFileName)

根据原文件名和当前的格式设置,生成导出文件的名称(后缀为 .hex.HEX)。

export function buildHexFileName(originalName, format = 'space') {
  if (!originalName) return `file${format === 'uppercase' ? '.HEX' : '.hex'}`
  const lastDot = originalName.lastIndexOf('.')
  const baseName = lastDot > 0 ? originalName.slice(0, lastDot) : originalName
  const extension = format === 'uppercase' ? '.HEX' : '.hex'
  return `${baseName}${extension}`
}

2. 文件读取与处理逻辑

在前端实现十六进制查看器的核心是利用 HTML5 的 FileReader API 读取文件内容为 ArrayBuffer,然后转换为 Uint8Array 进行处理。

const processFile = (file) => {
  const reader = new FileReader()
  
  reader.onload = (event) => {
    try {
      const buffer = event.target.result
      const bytes = new Uint8Array(buffer)
      // 调用工具函数生成 Hex 字符串
      const hex = bytesToHex(bytes, 'space') 
      // 更新视图...
    } catch (error) {
      console.error('Process failed:', error)
    }
  }
  
  reader.onerror = () => {
    console.error('Read error')
  }
  
  // 读取文件为 ArrayBuffer
  reader.readAsArrayBuffer(file)
}

3. 导出与下载功能

为了让用户可以将十六进制编码保存到本地,我们利用 Blob 对象和 URL.createObjectURL 创建临时的下载链接,实现纯前端下载。

const downloadHexFile = (hexContent, originalName, format) => {
  if (!hexContent) return

  const fileName = buildHexFileName(originalName, format)
  // 创建包含 Hex 内容的 Blob
  const blob = new Blob([hexContent], { type: 'text/plain' })
  const url = URL.createObjectURL(blob)
  
  // 创建临时链接并触发下载
  const link = document.createElement('a')
  link.href = url
  link.download = fileName
  document.body.appendChild(link)
  link.click()
  
  // 清理
  document.body.removeChild(link)
  URL.revokeObjectURL(url)
}

总结

该方案的核心在于通过 utils/file-hex-viewer.js 封装纯粹的格式化和转换逻辑,并结合浏览器原生的 FileReaderBlob API 完成文件的读取与导出,实现了一个轻量级且高效的纯前端文件十六进制查看工具。

Vben Admin管理系统集成微前端wujie-(三)终

作者 go_caipu
2026年2月12日 22:56
  1. # Vben Admin管理系统集成qiankun微服务(一)
  2. # Vben Admin管理系统集成qiankun微服务(二)

一、前言

本篇是vben前端框架集成微服务的第3篇,前段时间写了vue-vben-admin集成qiankun的两篇文章,收到了大家不少建议,文章还遗留了一个问题就是多tab标签不支持状态保持,借助AI虽然也实现的相应方案,但是对vben的package包修改内容较多(后续同步主框架较为繁琐),并且修改代码健状性不好评估。抱歉暂停了进一步完善实现方案,目前先保持基本功能是ok。

近期也尝试wujie微前端框架发现能满足我当前的所有诉求,所以有了本篇的文章内容,前两篇文章的功能和问题在本文中都已支持,选择wujie原因是支持以下两个功能:

  • 天然支持保活模式alive=true,与vben中route中Keeplive参数绑定,能支持状态保持的配置。
  • wujie实现逻辑是iframe框架模式,对子应改造较小,如果不要支持主应用传参子应用可以不用改造或少量改造。

下面分步实施集成功能:

二、主应用调整

1.安装wujie和wujie-vue3

# 安装wujie
pnpm i wujie
# 安装wujie-vue3
pnpm i wujie-vue3

2. 清除沙箱数据实现

主应用src下添加wujie文件夹并添加index.ts文件,两个函数实现功能是清理沙箱缓存数据,保证在”退出登录重新打开“样式不会异常,refreshApp函数为后续单个页签关闭提供备用支持。 index.ts,文件内容如下:

interface HTMLIframeElementWithContentWindow extends HTMLIFrameElement {
  contentWindow: Window;
}

// refreshApp 主应用可以通过下述方法,主动清除指定子应用的沙箱缓存
const refreshApp = (name = '') => {
  if (!name) {
    console.error('refreshApp方法必须传入子应用的name属性');
    return;
  }

  // 这里的window应该是顶级窗口,也就是主应用的window
  const SUB_FRAME = window.document.querySelector(
    `iframe[name=${name}]`,
  ) as HTMLIframeElementWithContentWindow;

  if (!SUB_FRAME) {
    console.warn(`未找到${name}子应用,跳过刷新`);
    return;
  }

  const SUB_WINDOW = SUB_FRAME.contentWindow;
  const SUB_IDMAP = SUB_WINDOW.__WUJIE?.inject?.idToSandboxMap; // 沙箱Map对象
  SUB_IDMAP.clear();
};

// 主应用中清除所有已激活的子应用沙箱缓存
const refreshAllApp = () => {
  // 找到所有无界子应用的iframe
  const ALL_SUB_IFRAME = window.document.querySelectorAll(
    'iframe[data-wujie-flag]',
  );

  if (ALL_SUB_IFRAME.length === 0) {
    console.warn('未找到任何子应用,跳过刷新');
    return;
  }

  // 拿到这些iframe里面的contentWindow
  const ALL_SUB_WINDOW = [...ALL_SUB_IFRAME].map(
    (v) => (v as HTMLIframeElementWithContentWindow).contentWindow,
  );

  // 依次执行清除
  ALL_SUB_WINDOW.forEach((v) => v.__WUJIE?.inject?.idToSandboxMap?.clear());
};

export { refreshAllApp, refreshApp };

主应用/src/layouts/basic.vue 程序主界面,在头部引入上述文件并在相应位置调用清除沙箱方法

# 引用
import { refreshAllApp } from '#/wujie/index';

# 退出时清理
// logout
async function handleLogout() {
  await authStore.logout(false);
  refreshAllApp();
}

3. 添加微服务通用页面wujie.vue

在主应用 /apps/web-caipu/src/views/_core下添加wujie.vue页面,页面内容如:

<script setup lang="ts">
import { ref } from 'vue';
import { useRoute } from 'vue-router';

import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';

import WujieVue from 'wujie-vue3';

const useStore = useUserStore();
const accessStore = useAccessStore();
const route = useRoute();

// props通信
const props = ref({
  userinfo: useStore.userInfo,
  token: accessStore.accessToken,
  preferences,
});
// 加时缀是强制刷新
const appUrl = ref(`http://localhost:5667/app${route.path}?t=${Date.now()}`);
const keepLive = route.meta?.keepAlive;
</script>
<template>
  <div class="sub-app-container">
    <WujieVue
      width="100%"
      height="100%"
      :name="appUrl"
      :url="appUrl"
      :alive="keepLive"
      :props="props"
    />
  </div>
</template>
<style scoped>
.sub-app-container {
  width: 100%;
  height: 100%;
  background: white;
}
</style>

<style scoped>
.sub-app-container {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: white;
  border-radius: 8px;
}
</style>

聪明的你,一定知道实现的逻辑。其中子应用的地址测试写localhost:5667,后面会集成配置文件中,至此主应用改造完成。

三、子应用改造

子应用基本不用改,只要改/Users/wgh/code/caipu-vben-admin/apps-micro/web-antd/src/bootstrap.ts文件即可

image.png 在49行添加如下代码,代码不用解释,之前一样的实现逻辑。


 // 初使化存储之后赋值,避免路由判断跳转到登录页
  if (window.__POWERED_BY_WUJIE__) {
    // props 接收
    const props = window.$wujie?.props; // {data: xxx, methods: xxx}
    const useStore = useUserStore();
    const accessStore = useAccessStore();
    useStore.setUserInfo(props.userInfo);
    accessStore.setAccessToken(props.token);
    updatePreferences(props.preferences);
    // window.$wujie?.bus.$on('wujie-theme-update', (theme: any) => {
    //   alert('wujie-theme-update');
    //   updatePreferences(theme);
    // });
    window.addEventListener('wujie-theme-update', (theme: any) => {
      updatePreferences(theme.detail);
    });
  }

四。新增路由配置

在主应用路由中配置子应用一个测试路由 /app/basic/test,

image.png

为测试在子应用状态保持,我在页面中添加一个测试文本框 ,测试内容不会随着切tab页签而重新加载,浏览器的前进后退也不会出错。

image.png

上述功能已集成在前端程序里,如果我的文章对你有帮助,感谢给我点个🌟

Anthony Fu 的 Vue3 开发规范完整解读

作者 扉川川
2026年2月12日 21:59

Anthony Fu 的 Vue3 开发规范完整解读

本文基于 antfu/skills 仓库整理翻译,全面解析 Anthony Fu 在 Vue 3 生态中的编码规范、最佳实践和工具链推荐。作为 Vue 核心团队成员、Vite 团队成员以及众多开源项目的作者(VueUse、UnoCSS、Vitest、Slidev 等),Anthony 的开发理念深刻影响了现代 Vue 开发生态。

第一部分:编码实践与工具链

代码组织原则

单一职责原则

保持文件和函数专注于单一职责。当文件超过 200-300 行时,考虑拆分:

// ❌ 避免:一个文件包含所有逻辑
// UserManager.ts (800 lines)
export class UserManager {
  validateUser() { /* 50 lines */ }
  fetchUserData() { /* 100 lines */ }
  updateUserProfile() { /* 150 lines */ }
  // ...
}

// ✅ 推荐:按职责拆分
// validation.ts
export function validateUser(user: User) { /* ... */ }

// api.ts
export function fetchUserData(id: string) { /* ... */ }

// profile.ts
export function updateUserProfile(data: ProfileData) { /* ... */ }

类型与常量分离

// types.ts
export interface User {
  id: string
  name: string
  role: UserRole
}

export type UserRole = 'admin' | 'user' | 'guest'

// constants.ts
export const DEFAULT_PAGE_SIZE = 20
export const MAX_RETRIES = 3
export const API_ENDPOINTS = {
  users: '/api/users',
  posts: '/api/posts',
} as const

// user-service.ts
import type { User, UserRole } from './types'
import { API_ENDPOINTS } from './constants'

export async function fetchUsers(): Promise<User[]> {
  const response = await fetch(API_ENDPOINTS.users)
  return response.json()
}

运行时环境标注

编写同构代码时,为环境特定的代码添加明确的注释:

// ✅ 明确标注环境依赖
// @env browser
export function getWindowSize() {
  return {
    width: window.innerWidth,
    height: window.innerHeight,
  }
}

// @env node
export function readConfigFile() {
  return fs.readFileSync('./config.json', 'utf-8')
}

// ✅ 同构代码无需标注
export function formatDate(date: Date): string {
  return date.toISOString()
}

TypeScript 最佳实践

显式返回类型

// ❌ 避免:隐式返回类型
export function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price, 0)
}

// ✅ 推荐:显式返回类型
export function calculateTotal(items: Item[]): number {
  return items.reduce((sum, item) => sum + item.price, 0)
}

// ✅ 复杂类型提取为类型别名
export type AsyncResult<T> = Promise<{ data: T; error: null } | { data: null; error: Error }>

export function fetchData<T>(url: string): AsyncResult<T> {
  // ...
}

避免复杂内联类型

// ❌ 避免:复杂内联类型
function processUsers(
  users: Array<{
    id: string
    profile: {
      name: string
      email: string
      settings: {
        theme: 'light' | 'dark'
        notifications: boolean
      }
    }
  }>
) {
  // ...
}

// ✅ 推荐:提取类型定义
interface UserSettings {
  theme: 'light' | 'dark'
  notifications: boolean
}

interface UserProfile {
  name: string
  email: string
  settings: UserSettings
}

interface User {
  id: string
  profile: UserProfile
}

function processUsers(users: User[]) {
  // ...
}

注释哲学

解释"为什么"而非"怎么做"

// ❌ 避免:无意义的注释
// 循环遍历用户数组
users.forEach(user => {
  // 打印用户名
  console.log(user.name)
})

// ✅ 推荐:解释为什么这样做
// 使用 setTimeout 0 延迟执行,确保 DOM 更新完成后再计算高度
setTimeout(() => {
  const height = element.offsetHeight
}, 0)

// ✅ 解释非直观的业务逻辑
// 价格计算需要先扣除折扣,再加税费,顺序不能颠倒
// 因为税费基于折后价计算(符合当地税法要求)
const finalPrice = (price - discount) * (1 + taxRate)

测试规范(Vitest)

文件组织

src/
  utils/
    format.ts          # 源代码
    format.test.ts     # 测试文件
  components/
    Button.vue
    Button.test.ts

测试结构

// format.test.ts
import { describe, it, expect } from 'vitest'
import { formatCurrency, formatDate } from './format'

describe('formatCurrency', () => {
  it('should format USD correctly', () => {
    expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56')
  })

  it('should handle zero', () => {
    expect(formatCurrency(0, 'USD')).toBe('$0.00')
  })

  it('should round to 2 decimal places', () => {
    expect(formatCurrency(1.234, 'USD')).toBe('$1.23')
  })
})

describe('formatDate', () => {
  it('should match snapshot', () => {
    const date = new Date('2024-01-15T10:30:00Z')
    expect(formatDate(date)).toMatchSnapshot()
  })
})

工具链速查

@antfu/ni - 通用包管理器命令
命令 npm yarn pnpm bun
ni npm install yarn install pnpm install bun install
nr dev npm run dev yarn run dev pnpm run dev bun run dev
nu npm update yarn upgrade pnpm update bun update
nun lodash npm uninstall lodash yarn remove lodash pnpm remove lodash bun remove lodash
nci npm ci yarn install --frozen-lockfile pnpm install --frozen-lockfile bun install --frozen-lockfile
nlx vitest npx vitest yarn dlx vitest pnpm dlx vitest bunx vitest
TypeScript 配置标准
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
    "jsx": "preserve",
    "strict": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "noUncheckedIndexedAccess": true,
    "paths": {
      "~/*": ["./src/*"]
    }
  }
}

关键配置说明:

  • moduleResolution: "bundler" - 适配 Vite/Rollup 等现代打包工具
  • noUncheckedIndexedAccess: true - 索引访问返回 T | undefined,更安全
  • strict: true - 启用所有严格类型检查
ESLint 配置
# 安装
pnpm add -D @antfu/eslint-config eslint

# 运行
pnpm run lint --fix
// eslint.config.js
import antfu from '@antfu/eslint-config'

export default antfu({
  vue: true,
  typescript: true,
  formatters: {
    css: true,
    html: true,
    markdown: true,
  },
})
Git Hooks 配置
# 安装
pnpm add -D simple-git-hooks lint-staged
// package.json
{
  "simple-git-hooks": {
    "pre-commit": "pnpm lint-staged"
  },
  "lint-staged": {
    "*.{js,ts,vue}": "eslint --fix"
  }
}
pnpm Catalogs 最佳实践
# pnpm-workspace.yaml
catalogs:
  # 生产依赖
  prod:
    vue: ^3.5.0
    pinia: ^2.2.0
  
  # 内联依赖(会被打包)
  inlined:
    lodash-es: ^4.17.21
  
  # 开发依赖
  dev:
    vitest: ^2.0.0
    typescript: ^5.6.0
  
  # 前端特定依赖
  frontend:
    unocss: ^0.63.0
// package.json
{
  "dependencies": {
    "vue": "catalog:prod",
    "lodash-es": "catalog:inlined"
  },
  "devDependencies": {
    "vitest": "catalog:dev",
    "unocss": "catalog:frontend"
  }
}

第二部分:Vue 3 核心规范

基于 Vue 3.5,优先使用 TypeScript 和 <script setup>

偏好设定

场景 推荐方案 原因
语言选择 TypeScript 类型安全、更好的 IDE 支持
脚本格式 <script setup lang="ts"> 更简洁的语法、更好的性能
响应式选择 shallowRef > ref 大多数场景足够用,性能更好
API 风格 Composition API 更好的逻辑复用和类型推导
Props 解构 ❌ 不推荐 会丢失响应式

标准组件模板

<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import type { ComponentPublicInstance } from 'vue'

// Props 定义
interface Props {
  title: string
  count?: number
  disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  disabled: false,
})

// Emits 定义
interface Emits {
  update: [value: number]
  submit: [data: { name: string }]
}

const emit = defineEmits<Emits>()

// Model 双向绑定
const modelValue = defineModel<string>({ required: true })

// 响应式状态
const isLoading = ref(false)
const items = ref<Item[]>([])

// 计算属性
const displayTitle = computed(() => {
  return props.disabled ? `${props.title} (已禁用)` : props.title
})

// 侦听器
watch(() => props.count, (newVal, oldVal) => {
  console.log(`Count changed from ${oldVal} to ${newVal}`)
})

// 生命周期
onMounted(() => {
  console.log('Component mounted')
})

// 方法
function handleClick() {
  emit('update', props.count + 1)
}

// 暴露给父组件(defineExpose)
defineExpose({
  focus: () => {
    // 暴露的方法
  },
})
</script>

<template>
  <div>
    <h1>{{ displayTitle }}</h1>
    <button @click="handleClick" :disabled="disabled">
      Count: {{ count }}
    </button>
  </div>
</template>

<style scoped>
/* scoped 样式 */
</style>

关键导入速查

// 核心响应式 API
import {
  ref,           // 深层响应式
  shallowRef,    // 浅层响应式(推荐)
  reactive,      // 深层响应式对象
  shallowReactive, // 浅层响应式对象
  readonly,      // 只读代理
  computed,      // 计算属性
  watch,         // 侦听器
  watchEffect,   // 副作用侦听器
} from 'vue'

// 生命周期钩子
import {
  onMounted,
  onUpdated,
  onUnmounted,
  onBeforeMount,
  onBeforeUpdate,
  onBeforeUnmount,
} from 'vue'

// 组件通信
import {
  defineProps,
  defineEmits,
  defineModel,
  defineExpose,
  defineSlots,
  provide,
  inject,
} from 'vue'

// 工具函数
import {
  nextTick,      // 等待 DOM 更新
  toRef,         // 转换为 ref
  toRefs,        // 解构保持响应式
  unref,         // 解包 ref
  isRef,         // 判断是否 ref
  markRaw,       // 标记为非响应式
} from 'vue'

// 类型工具
import type {
  Ref,
  ComputedRef,
  ComponentPublicInstance,
  PropType,
} from 'vue'

ref vs shallowRef 性能对比

// ref - 深层响应式(递归代理所有层级)
const deepState = ref({
  user: {
    profile: {
      name: 'John',
      settings: {
        theme: 'dark',
      },
    },
  },
})

// 任何层级的修改都会触发响应
deepState.value.user.profile.settings.theme = 'light' // ✅ 响应式

// shallowRef - 浅层响应式(只代理第一层)
const shallowState = shallowRef({
  user: {
    profile: {
      name: 'John',
      settings: {
        theme: 'dark',
      },
    },
  },
})

// 只有整体替换才会触发响应
shallowState.value.user.profile.settings.theme = 'light' // ❌ 不会触发
shallowState.value = { ...shallowState.value } // ✅ 触发响应

// 性能建议:大部分场景使用 shallowRef 足够

第三部分:Vue 3 最佳实践与常见陷阱

响应式系统

问题 建议
ref vs reactive 如何选择? 优先使用 refref 可以存储任何类型,而 reactive 只能用于对象。ref 在重新赋值时保持响应性,reactive 不行。
什么时候用 shallowRef? 存储大型数据结构(如长列表、复杂嵌套对象)时,用 shallowRef 避免深层代理的性能开销。更新时需要整体替换对象。
如何阻止对象变成响应式? 使用 markRaw()。例如存储第三方库实例(Chart.js、Monaco Editor)时,避免不必要的代理。
多次修改 ref 会触发多次渲染吗? 不会。Vue 会将同一 tick 内的更新批量处理。如需立即看到 DOM 变化,使用 await nextTick()
ref 解包规则是什么? 在模板中自动解包({{ count }})。在 reactive 对象中自动解包(state.count)。在数组和 Map/Set 中不解包(需要 .value)。
解构 props 会丢失响应性吗? 是的。使用 toRefs(props)toRef(props, 'key') 保持响应性,或在 computed/watch 中访问 props.xxx

计算属性

问题 建议
计算属性可以有副作用吗? 不应该。计算属性应该是纯函数,只做计算和返回值。副作用应该放在 watchwatchEffect 中。
为什么计算属性是只读的? 默认只读,但可以提供 setter。推荐只读设计,修改应通过源数据。
计算属性什么时候重新计算? 只有当依赖的响应式数据变化时才重新计算(懒执行 + 缓存)。这是相比 method 的主要优势。
计算属性的条件依赖如何工作? 只追踪当前执行分支的依赖。if (flag) return a 时只追踪 flaga,不追踪 else 分支。
计算属性内使用 array.map 有性能问题吗? 有。每次重新计算都会创建新数组。考虑使用 shallowRef 存储映射结果,或在 watch 中手动更新。

侦听器

问题 建议
watch 的 getter 函数是什么? watch(() => obj.count, ...) 中的箭头函数。推荐用 getter 而非直接传对象,可以精确控制依赖。
deep: true 有性能问题吗? 有。深度侦听需要遍历对象的所有属性。只在必要时使用,或用 getter 函数精确指定依赖。
immediate: true 的执行时机是什么? 立即执行一次,此时 DOM 可能未挂载。需要访问 DOM 时注意判断。
flush 选项有什么区别? pre(默认):DOM 更新前执行。post:DOM 更新后执行。sync:同步执行(避免使用)。
如何在侦听器中访问旧值? watch(source, (newVal, oldVal) => {})。注意对象类型的 oldValnewVal 可能指向同一个引用。
watch vs watchEffect 如何选择? watchEffect:自动追踪依赖,简洁。watch:显式指定侦听源,可访问旧值,更精确。

组件通信

问题 建议
可以修改 props 吗? 不可以。Props 是单向数据流,只读。需要修改时,emit 事件或使用 defineModel
自定义事件会冒泡吗? 不会。Vue 的自定义事件不像原生 DOM 事件那样冒泡,只触发直接父组件的监听器。
组件名应该用什么格式? PascalCase(MyComponent.vue)。在模板中可以用 <MyComponent><my-component>,推荐前者。
defineExpose 何时使用? 当需要父组件通过 ref 调用子组件方法时。默认 <script setup> 不暴露任何内容。
如何获取组件实例的类型? InstanceType<typeof MyComponent>,配合 ref<InstanceType<typeof MyComponent>>()

Props 与 Emits

问题 建议
Boolean props 的转换规则? <MyComp disabled> 等价于 :disabled="true"<MyComp> 则是 undefined(除非有默认值)。
解构 props 会丢失响应性吗? 是的。const { title } = defineProps() 会丢失响应性。使用 toRefs 或直接访问 props.title
props 命名约定是什么? JS 中用 camelCase,HTML 中用 kebab-case。defineProps<{ userName: string }>()<Comp user-name="John">
emit 事件命名约定? JS 中用 camelCase,HTML 中用 kebab-case。emit('updateValue')@update-value="handler"
defineModel 的优势是什么? 简化 v-model 实现,自动生成 prop 和 emit。支持修饰符(.trim.number 等)。

模板语法

问题 建议
v-html 安全吗? 不安全。可能导致 XSS 攻击。只用于可信内容,或使用 DOMPurify 等库清理。
v-if 和 v-for 能同时用吗? Vue 3 中 v-if 优先级高于 v-for,但不推荐同时使用。应该用 computed 过滤或嵌套 template。
v-if vs v-show 如何选择? v-if:条件渲染,切换开销高。v-show:CSS 切换,初始渲染开销高。频繁切换用 v-show
key 的作用是什么? 帮助 Vue 识别节点,优化 diff 算法。列表渲染必须提供唯一 key,避免用 index。
如何绑定多个属性? v-bind="attrs" 可以一次性绑定对象的所有属性。例如 v-bind="{ id: 'foo', class: 'bar' }"

表单与 v-model

问题 建议
defineModel 的修饰符如何使用? 内置 .trim.number.lazy。自定义修饰符通过 defineModel 的第二个参数处理。
v-model 在组件上的原理? 语法糖::modelValue="value" @update:modelValue="value = $event"。多个 v-model:v-model:title
如何在 v-model 更新后访问 DOM? 使用 await nextTick(),因为 Vue 异步更新 DOM。
textarea 的 v-model 和插值的区别? <textarea v-model="text"> 正确。<textarea>{{ text }}</textarea> 不生效,textarea 不支持插值。

事件处理与修饰符

问题 建议
.once 修饰符如何工作? 事件只触发一次后自动移除监听器。@click.once="handler"
.exact 修饰符的作用? 精确匹配修饰键。@click.ctrl.exact 只在按下 Ctrl(无其他键)时触发。
.passive 和 .prevent 冲突吗? 冲突。.passive 告诉浏览器不调用 preventDefault(),两者不能同时使用。
自定义事件可以用修饰符吗? 可以,但需要在子组件中通过 defineEmits 的第二个参数手动实现验证逻辑。

生命周期

问题 建议
生命周期钩子必须同步注册吗? 是的。必须在 setup<script setup> 的同步代码中调用,不能在 setTimeoutasync 函数中。
onUpdated 钩子性能如何? 会在任何响应式数据变化导致的重新渲染后调用,可能频繁触发。谨慎使用,考虑用 watch 替代。
如何在组件外部注册生命周期? 使用 effectScope 创建作用域,在其中注册钩子。

插槽

问题 建议
插槽的作用域是什么? 默认插槽只能访问父组件的数据。作用域插槽通过 v-slot="slotProps" 接收子组件传递的数据。
defineSlots 的作用? 仅用于类型定义,帮助 TypeScript 推导插槽的 props 类型。不影响运行时。
插槽的 fallback content 是什么? <slot>默认内容</slot> 中的默认内容,当父组件不提供插槽内容时显示。
动态插槽名如何使用? v-slot:[dynamicSlotName]#[dynamicSlotName]

Provide / Inject

问题 建议
应该用什么作为 injection key? 使用 Symbol 而非字符串,避免命名冲突。export const userKey = Symbol('user')
注入的数据可以修改吗? 可以,但建议 mutations 集中在 provider 组件,通过提供修改方法而非直接暴露响应式状态。
如何为 inject 提供类型? const user = inject<User>(userKey) 或在定义 key 时指定 InjectionKey<User>

组合式函数

问题 建议
命名约定是什么? use 开头,camelCase。例如 useMouseuseFetch
返回值应该是什么? 返回包含响应式状态和方法的对象。使用 readonly() 保护内部状态。
何时使用 options 对象模式? 参数超过 2 个时推荐。useFetch(url, { method, headers, onSuccess })
组合式函数可以嵌套调用吗? 可以。一个组合式函数可以调用其他组合式函数。
// 示例:标准组合式函数
export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(event: MouseEvent) {
    x.value = event.clientX
    y.value = event.clientY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return {
    x: readonly(x),
    y: readonly(y),
  }
}

Composition API

问题 建议
为什么用 Composition API 替代 mixin? Mixin 有命名冲突、来源不清晰、难以重用等问题。Composition API 通过函数组合解决这些问题。
Composition API 和 React Hooks 有什么区别? Vue 的 setup 只执行一次,不受闭包陷阱影响。React Hooks 每次渲染都执行,需要依赖数组。
何时仍然使用 Options API? 简单组件、团队不熟悉 Composition API、维护老代码时可以使用 Options API。

自定义指令

问题 建议
必须清理副作用吗? 是的。在 unmounted 钩子中清理事件监听器、定时器等,避免内存泄漏。
指令命名约定? v 开头。注册时用 camelCase(vFocus),使用时用 kebab-case(v-focus)。
可以在组件上使用指令吗? 可以,但不推荐。指令会应用到组件的根元素,多根元素组件会报警告。

过渡与动画

问题 建议
Transition 只能包含单个子元素吗? 是的。多个元素需要用 v-if / v-elseTransitionGroup
为什么列表项需要 key? TransitionGroup 使用 key 追踪元素移动,实现平滑的移动动画。
mode 属性的作用? out-in:旧元素先离开,新元素再进入。in-out:新元素先进入,旧元素再离开。
如何自定义动画时长? 通过 duration prop:<Transition :duration="500">{ enter: 500, leave: 800 }

KeepAlive

问题 建议
max 属性的作用? 限制缓存组件数量,超出时移除最久未访问的。<KeepAlive :max="10">
组件必须有 name 属性吗? 使用 include / exclude 时需要。<script setup> 组件名默认是文件名。
特殊生命周期钩子? onActivated(激活时)、onDeactivated(停用时)。用于处理缓存组件的状态恢复。

异步组件

问题 建议
delay 选项的作用? 延迟显示加载状态,避免加载很快时出现闪烁。默认 200ms。
hydration 策略是什么? Vue 3.5+ 支持延迟 hydration:defineAsyncComponent({ loader, hydrate: 'visible' })

TypeScript 集成

问题 建议
如何为 defineProps 提供类型? 基于类型:defineProps<{ title: string }>()。基于运行时:defineProps({ title: String })。推荐前者。
withDefaults 如何使用? withDefaults(defineProps<Props>(), { count: 0 }),为类型定义的 props 提供默认值。
如何获取组件实例类型? InstanceType<typeof MyComponent>,用于 ref 的类型标注。
// 完整示例
import MyComponent from './MyComponent.vue'

const compRef = ref<InstanceType<typeof MyComponent>>()

onMounted(() => {
  compRef.value?.focus() // 类型安全的方法调用
})

SSR 注意事项

问题 建议
如何避免跨请求状态污染? 每个请求创建新的应用实例。避免在模块顶层创建响应式状态。
服务端可以使用哪些 API? 不能用 windowdocument 等浏览器 API。生命周期只有 setuponServerPrefetch
getSSRProps 的作用? 在 SSR 时修改组件 props,常用于注入服务端数据。

性能优化

问题 建议
props 稳定性为什么重要? 子组件使用 shallowRef 时,props 引用变化会触发重新渲染。尽量保持 props 引用稳定。
何时使用虚拟滚动? 渲染超过 1000 项的列表时。使用 vue-virtual-scroller 等库。
v-once 和 v-memo 的区别? v-once:只渲染一次,永不更新。v-memo:条件性跳过更新,依赖数组未变时复用。

SFC 特性

问题 建议
如何在 scoped 样式中修改子组件? 使用 :deep() 伪类:.parent :deep(.child) { }
scoped CSS 的限制? 不影响子组件的根元素(会自动添加 scoped 属性)。深层元素需要 :deep()

插件开发

问题 建议
插件应该用 provide/inject 吗? 是的。插件通过 provide 提供功能,组件通过 inject 使用,比全局属性更灵活。
注入 key 命名约定? 使用 Symbol 避免冲突:export const myPluginKey = Symbol()
如何为插件添加类型支持? 通过模块扩展:declare module 'vue' { interface ComponentCustomProperties { $myPlugin: MyPlugin } }

第四部分:为什么选择 UnoCSS 而不是 Tailwind CSS?

核心论点:UnoCSS 是 Tailwind 的超集

UnoCSS 不是 Tailwind 的竞争者,而是增强版。通过预设系统,UnoCSS 可以 100% 兼容 Tailwind 语法

// uno.config.ts
import { defineConfig, presetWind } from 'unocss'

export default defineConfig({
  presets: [
    presetWind(), // Tailwind CSS v3 兼容
    // 或 presetWind({ version: 4 }) // Tailwind CSS v4 兼容
  ],
})

使用 presetWind 后,所有 Tailwind 类名都能正常工作:

<!-- Tailwind 语法完全兼容 -->
<div class="flex items-center justify-between p-4 bg-blue-500 text-white rounded-lg shadow-md hover:bg-blue-600 transition">
  <span class="text-xl font-bold">完全兼容</span>
</div>

UnoCSS 的独家能力

1. 纯 CSS 图标(零 JS 运行时)

UnoCSS 通过 presetIcons 支持 10 万+ Iconify 图标,编译为纯 CSS,零 JavaScript 运行时开销:

pnpm add -D @iconify-json/carbon @iconify-json/mdi
<!-- 直接用 class 引用图标,无需导入 -->
<div class="i-carbon-logo-github text-2xl" />
<div class="i-mdi-home text-red-500" />
<button class="i-carbon-arrow-right hover:i-carbon-arrow-right-filled" />

编译结果(纯 CSS):

.i-carbon-logo-github {
  display: inline-block;
  width: 1em;
  height: 1em;
  background: url("data:image/svg+xml;utf8,...") no-repeat;
  background-size: 100% 100%;
}

对比 Tailwind + React Icons:

  • Tailwind:需要导入 React/Vue 组件,增加 bundle 体积
  • UnoCSS:纯 CSS,零 JS,图标按需编译
2. 属性化模式(Attributify)

避免 class 字符串爆炸:

<!-- Tailwind:class 字符串过长 -->
<button class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg shadow-md transition duration-300 flex items-center gap-2">
  提交
</button>

<!-- UnoCSS:属性化模式 -->
<button 
  bg="blue-500 hover:blue-600"
  text="white"
  font="bold"
  p="y-2 x-4"
  rounded="lg"
  shadow="md"
  transition
  duration="300"
  flex
  items="center"
  gap="2"
>
  提交
</button>
3. Variant Group(变体组简写)
<!-- Tailwind:重复写 hover -->
<div class="hover:bg-red-500 hover:text-white hover:scale-105">

<!-- UnoCSS:Variant Group -->
<div class="hover:(bg-red-500 text-white scale-105)">
4. 自定义规则引擎

Tailwind 需要配置复杂的插件系统,UnoCSS 支持正则和函数定义原子类:

// uno.config.ts
import { defineConfig } from 'unocss'

export default defineConfig({
  rules: [
    // 正则匹配:自定义间距
    [/^m-(\d+)$/, ([, d]) => ({ margin: `${d}px` })],
    
    // 函数定义:自定义颜色
    ['text-brand', { color: '#3b82f6' }],
    
    // 动态值:任意单位
    [/^gap-(\d+)(px|rem|em)$/, ([, num, unit]) => ({ gap: `${num}${unit}` })],
  ],
  shortcuts: {
    // 快捷组合类
    'btn-primary': 'bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600',
  },
})
5. 编译模式(Compile Class)

将多个原子类编译为一个哈希类,减少 HTML 体积:

<!-- 开发模式:原子类 -->
<div class="flex items-center gap-4 bg-blue-500 p-4">

<!-- 生产模式:编译为单个类 -->
<div class="uno-abc123">

<style>
.uno-abc123 {
  display: flex;
  align-items: center;
  gap: 1rem;
  background: #3b82f6;
  padding: 1rem;
}
</style>

对比总结

特性 Tailwind CSS UnoCSS
基础原子类 ✅ 完整支持 ✅ 完全兼容(presetWind)
图标方案 需要额外库(React Icons 等) ✅ 内置 10 万+ 图标(纯 CSS)
属性化模式 ❌ 不支持 ✅ presetAttributify
Variant Group ❌ 不支持 hover:(bg-red text-white)
自定义规则 复杂插件系统 ✅ 正则/函数直接定义
编译模式 ❌ 不支持 ✅ 编译为哈希类
性能 JIT 编译快 ✅ 更快(Vite 原生)
生态整合 Standalone ✅ Vite/Nuxt 深度集成

与 Anthony Fu 技术栈的协同

  1. Vite 原生设计:UnoCSS 为 Vite 设计,HMR 极快
  2. Nuxt 一等公民@nuxt/unocss 开箱即用
  3. 作者生态:Anthony Fu 同时是 UnoCSS 和 Iconify 的作者,工具链深度整合

完整配置示例

// uno.config.ts
import {
  defineConfig,
  presetAttributify,
  presetIcons,
  presetTypography,
  presetUno,
  presetWebFonts,
  transformerDirectives,
  transformerVariantGroup,
} from 'unocss'

export default defineConfig({
  // 预设
  presets: [
    presetUno(), // 默认预设(类似 Tailwind)
    presetAttributify(), // 属性化模式
    presetIcons({
      scale: 1.2,
      cdn: 'https://esm.sh/',
    }),
    presetTypography(), // 排版预设
    presetWebFonts({
      fonts: {
        sans: 'Inter',
        mono: 'Fira Code',
      },
    }),
  ],

  // 转换器
  transformers: [
    transformerDirectives(), // @apply 指令
    transformerVariantGroup(), // Variant Group
  ],

  // 自定义规则
  rules: [
    ['text-brand', { color: '#3b82f6' }],
  ],

  // 快捷方式
  shortcuts: {
    'btn': 'px-4 py-2 rounded inline-block cursor-pointer',
    'btn-primary': 'btn bg-blue-500 text-white hover:bg-blue-600',
  },

  // 主题扩展
  theme: {
    colors: {
      brand: {
        primary: '#3b82f6',
        secondary: '#8b5cf6',
      },
    },
  },
})

安装命令:

pnpm add -D unocss
pnpm add -D @iconify-json/carbon @iconify-json/mdi

第五部分:配套工具链一览

工具 用途 推荐理由
Vue 3.5+ 渐进式 JavaScript 框架 Composition API、性能优化、TypeScript 支持
Nuxt 3 Vue 元框架 SSR/SSG、文件路由、服务端 API、SEO 优化
Pinia 状态管理 直观的 API、完整的 TypeScript 支持、Vue DevTools 集成
Vite 构建工具 极速 HMR、原生 ESM、Rollup 生产构建
VitePress 静态站点生成器 Vue 驱动、Markdown 扩展、主题定制
Vitest 单元测试 Vite 原生、与 Jest 兼容的 API、快速执行
UnoCSS 原子化 CSS 引擎 Tailwind 超集、纯 CSS 图标、Vite 深度集成
pnpm 包管理器 磁盘高效、严格依赖管理、monorepo 支持
VueUse 组合式函数集合 200+ 实用工具、SSR 友好、Tree-shakable
Slidev 开发者幻灯片 Markdown 编写、Vue 组件、录制功能
tsdown TypeScript 打包工具 零配置、类型声明生成、ESM/CJS 双输出
Vue Router 官方路由 嵌套路由、导航守卫、动态路由匹配

快速开始命令

# 创建 Vue 3 项目(Vite)
pnpm create vite my-vue-app --template vue-ts

# 创建 Nuxt 3 项目
pnpm dlx nuxi@latest init my-nuxt-app

# 添加 UnoCSS
pnpm add -D unocss

# 添加 VueUse
pnpm add @vueuse/core

# 添加 Pinia
pnpm add pinia

# 添加 Vitest
pnpm add -D vitest

总结

Anthony Fu 的开发规范强调:

  1. 类型安全优先:TypeScript + 显式类型定义
  2. 性能意识shallowRef > ref、避免深度侦听、虚拟滚动
  3. 工具链协同:Vite + UnoCSS + Vitest 深度整合
  4. 代码质量:单一职责、ESLint 自动化、Git Hooks
  5. 现代化实践:Composition API、<script setup>、组合式函数

通过遵循这些规范,可以构建更快、更可维护、更具扩展性的 Vue 3 应用。


参考资料:

译者注: 本文基于 antfu/skills 仓库于 2026 年 2 月的内容整理翻译,随着生态演进,部分实践可能更新,请以官方文档为准。

❌
❌