阅读视图

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

纯 CSS 实现弹性文字效果

原文:How to Create a CSS-only Elastic Text Effect

翻译:TUARAN

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

每个字母单独动画的文字效果总是很酷、很吸睛。这类错峰动画通常依赖 JavaScript 库实现,对我们要实现的这种相对轻量的设计效果来说,代码往往偏重。本文将探索只用 CSS、无需 JavaScript 实现 fancy 文字效果的技巧(意味着需要手动拆分字符)。

截至撰写时,仅 Chrome 和 Edge 完全支持我们使用的特性。

将鼠标悬停在下方演示的文字上,即可看到效果:

CodePen Embed Fallback

很酷吧?仅靠 CSS 就实现了逼真的弹性效果,而且灵活易调。在深入代码之前,先做一个重要声明。这个效果不错,但有几个明显的缺点。

关于可访问性的重要声明

我们要做的效果依赖于把单词拆成单个字母,一般来说这种做法非常不推荐。

一个简单链接通常是这样写的:

<a href="#">About</a>Code language: HTML, XML (xml)

但要分别控制每个字母的样式,我们会改成这样:

<a href="#">
  <span>A</span><span>b</span><span>o</span><span>u</span><span>t</span>
</a>Code language: HTML, XML (xml)

这会带来可访问性问题。

很容易想到用 aria-* 属性来弥补。至少我之前是这么想的。网上有不少资料推荐类似下面的结构:

<a href="#" aria-label="About">
  <span aria-hidden="true">
    <span>A</span><span>b</span><span>o</span><span>u</span><span>t</span>
  </span>
</a>Code language: HTML, XML (xml)

看起来没问题吧?不!这种结构依然很糟糕。实际上,网上能找到的大多数结构都有问题。我不是这个领域的专家,所以请教了一些人,发现 Adrian Roselli 的两篇博客很有参考价值:

强烈建议读一读,理解为什么把单词拆成字母是个坏主意(以及可能的替代方案)。

那我为什么还要做这个演示?

我更倾向于把它当作一次探索现代 CSS 特性的实验。这个效果里可能有很多你还不熟悉的属性,是了解它们的好机会。可以用在娱乐或 side project 中,但在广泛使用或关键场景中引入前,请三思。

好了,声明完毕,我们开始。

原理说明

思路是使用 offset() 属性,定义字母沿一条路径运动。这条路径是一条曲线,我们沿曲线做动画。offset() 是一个被低估的特性,但潜力很大,尤其配合现代 CSS 使用时。我曾用它做过无限跑马灯动画、让元素沿圆精确排布、做图片画廊等。

下面是一个简化示例,帮助理解我们要用的技巧:

CodePen Embed Fallback

上面的演示使用了来自 SVG 的 path() 值。三个字母最初沿第一条路径,悬停时切换到第二条路径。借助 transition,就形成了平滑的效果。

可惜的是,使用 SVG 并不理想,因为你只能创建静态、基于像素的路径,无法用 CSS 控制。因此我们将转而使用新的 shape() 函数,它可以定义复杂形状(包括曲线),并方便地用 CSS 控制。

本文只用到 shape() 的简单用法(只需要一条曲线),如果想深入了解这个强大函数,可以参考我之前的文章:

开始写代码

用到的 HTML:

<ul>
  <li>
    <a href="#"><span>A</span><span>b</span><span>o</span><span>u</span><span>t</span></a>
  </li>
  <!-- 更多 li 元素 -->
</ul>Code language: HTML, XML (xml)

CSS:

ul li a {
  display: flex;
  font-family: monospace;
}
ul li a span {
  offset-path: shape(???);
  offset-distance: ???;
}
ul li a:hover {
  offset-path: shape(???);
}Code language: CSS (css)

目前还比较朴素

CodePen Embed Fallback

用 flex 让字母并排,并用等宽字体,确保每个字母宽度一致。

接下来用下面的代码定义路径:

offset-path: shape(from Xa Ya, curve to Xb Yb with Xc Yc / Xd Yd );Code language: CSS (css)

这里用 curve 命令在 A 到 B 之间画贝塞尔曲线,控制点为 C 和 D。

然后通过调整控制点的坐标(尤其是 Y 值)来驱动曲线动画。当 Y 与 A、B 的 Y 相同时是直线;更大时变成曲线。

曲线的代码大致如下:

offset-path: shape(from Xa Y, curve to Xb Y with Xc Y1 / Xd Y1);

直线的代码如下:

offset-path: shape(from Xa Y, curve to Xb Y with Xc Y / Xd Y);

注意我们只改控制点的 Y,其他保持不变。

现在来确定各参数。使用 offset 时有两个要点:

  1. 默认以元素中心作为在路径上的位置。
  2. 定义在子元素上,但参考框是父容器。

第一个字母应在路径起点,最后一个在终点,所以 A 是第一个字母中心,B 是最后一个字母中心:

Y = 50%Xa = .5chXb = 100% - Xa = 100% - .5ch

C 和 D 的 X 没有固定规则,可以任意指定。我选 Xc = 30%Xd = 100% - Xc = 70%。你可以自己调整这些值试验不同的曲线形态。

路径现在可以这样写:

offset-path: shape(from .5ch 50%, curve to calc(100% - .5ch) 50% with 30% Y / 70% Y);

Y 是变量,可以是 50%(与 A、B 相同)或别的值,我们设成 50% - HH 越大,弹性越强。

试试看:

CodePen Embed Fallback

一团糟!因为我们没定义 offset-distance,所有字母都叠在一起了。

是不是要给每个字母单独设位置?那太麻烦了。

我们必须给每个字母不同的位置,好在可以用一个公式配合 sibling-index()sibling-count() 搞定。

第一个字母在 0%,最后一个在 100%。共 N 个字母,步长为 100%/(N - 1),字母从 0%100% 依次排布,公式为:

offset-distance: (100% * i)/(N - 1)

其中 i 从 0 开始。

写成 CSS:

offset-distance: calc(100%*(sibling-index() - 1)/(sibling-count() - 1))Code language: CSS (css)

CodePen Embed Fallback

几乎完美。除了最后一个字母外都位置正确。由于某种原因,0%100% 被当成同一个点。offset-distance 不限于 0%–100%,可以取任意值(包括负值),有一种取模行为形成环路。你可以从 0%100% 走完整条路径,到 100% 后又回到起点,还能继续从 100%200%,如此往复。

虽然有点反直觉,但修复很简单:把 100% 换成 99.9%。有点 hack,但有效!

CodePen Embed Fallback

现在排布完美了,悬停时可以看到直线变成曲线的过程。

最后加上 transition,就大功告成!

CodePen Embed Fallback

可能还不算完全搞定,因为动画似乎有些异常。这很可能是 bug(我已在此提交),不过问题不大,因为我本来就打算重构,避免重复写两次 shape,改为动画一个变量:

@property --_s {
  syntax: "<number>";
  initial-value: 0;
  inherits: true;
}
ul li a {
  --h: 20px; /* 控制效果强度 */
 
  display: flex;
  font: bold 40px monospace;
  transition: --_s .3s;
}
ul li a:hover {
  --_s: 1;
}
ul li a span {
  offset-path: 
    shape(
      from .5ch 50%, curve to calc(100% - .5ch) 50% 
      with 30% calc(50% - var(--_s)*var(--h)) / 70% calc(50% - var(--_s)*var(--h))
    );
  offset-distance: calc(99.9%*(sibling-index() - 1)/(sibling-count() - 1));
}Code language: CSS (css)

现在有了 --h 变量来调节路径曲率,以及一个内部变量在 0 到 1 之间动画,实现从直线到曲线的过渡。

CodePen Embed Fallback

嗒哒!动画完美了!但弹性感呢?

要得到弹性效果,需要调整缓动,用到 linear()。这是最简单的部分,我用生成器生成取值。

多调几次直到满意。我得到的是:

CodePen Embed Fallback

效果已经不错,但如果微调曲线还能更好。目前所有单词的曲线「高度」是一样的,理想情况是根据单词长度变化。为此我会在公式里加入 sibling-count(),让单词越宽时高度越大。

CodePen Embed Fallback

让效果具备方向感知

效果已经可用,但既然做到这里,不妨再进一步:根据鼠标方向决定曲线向上还是向下。

向上的曲线已经通过 --_s: 1 实现:

ul li a:hover {
  --_s: 1;
}Code language: CSS (css)

若改为 -1,就得到向下的曲线:

CodePen Embed Fallback

现在需要把两种情况结合起来。从上方悬停时,使用向下曲线 --_s: -1;从下方悬停时,使用向上曲线 --_s: 1

首先给 li 加一个伪元素,填满上半部分并位于链接上方:

ul li {
  position: relative;
}
ul li:after {
  content: "";
  position: absolute;
  inset: 0 0 50%;
  cursor: pointer;
}Code language: CSS (css)

CodePen Embed Fallback

然后定义两个不同的选择器。当悬停伪元素时,相当于也悬停了 li,所以可以用:

ul li:hover a {
  --_s: -1;
}Code language: CSS (css)

悬停 a 时,同样会悬停 li,上面的规则也会生效。但若悬停的是伪元素,则没有悬停 a,因此可以用:

ul li:has(a:hover) a {
  --_s: 1;
}Code language: CSS (css)

有点绕?没关系,我们把两个选择器放在一起看:

ul li:hover a {
  --_s: -1;
}
ul li:has(a:hover) a {
  --_s: 1;
}Code language: CSS (css)

我们可以从上方(通过伪元素)或从下方(通过 a)悬停。前者会触发第一个选择器,因为我们在悬停 li,但不会触发第二个,因为 li「并没有悬停其 a」。当我们悬停 a 时,两个选择器都会触发,后者会胜出。

方向感知就这么实现了!

CodePen Embed Fallback

能用,但不如开头的演示那么流畅。当鼠标移动穿过整个元素时,会突然停止一个动画并切换到另一个。

可以调整伪元素的大小来改善。悬停时让它覆盖整个元素,这样就不会再触达下方的 a,第二个动画就不会触发。而悬停 a 时,把伪元素高度设为 0,就无法悬停它,从而不会触发第一个动画。

CodePen Embed Fallback

好多了!把伪元素设为透明,效果就很自然。

CodePen Embed Fallback

小结

希望你喜欢这次 CSS 小实验。再提醒一次:在项目中投入使用前请三思。这是一个很好的 demos 来了解 shape()linear()sibling-index() 等现代特性,但为这类效果牺牲可访问性并不值得。

Vue 底层原理 & 新特性

Vue 底层原理 & 新特性

本文深入探讨 Vue 的底层架构演进、核心原理以及最新版本带来的突破性特性,面向面试和技术提升。


原文地址

墨渊书肆/Vue 底层原理 & 新特性


Vue 版本变动历史

Vue 自发布以来经历了多个重要版本的迭代,每个版本的改动都带来了架构优化和新特性,同时也伴随着一些 Breaking Changes。以下是 Vue 各个重要版本的变动概述:

Vue 2.0 (2016年)

  • 引入 Virtual DOMVue2 正式引入了虚拟 DOM,这是框架性能提升的关键技术。
  • 组件系统增强:增加了异步组件生命周期钩子调整等特性。
  • 支持 SSR:原生支持服务器端渲染,提升了 SEO 和首屏加载性能。
  • Vuex 与 Vue Router:作为官方解决方案提供状态管理路由管理

Vue 2.5 - 2.7 (2017-2022年)

  • Vue 2.5:改进了 TypeScript 支持,增强了响应式系统
  • Vue 2.6:引入了新的模板编译策略,插槽语法改进。
  • Vue 2.7:作为 Vue2 最后的大版本,引入了一些 Composition API 的向下兼容实现,为 Vue3 迁移做铺垫。

Vue 3.0 (2022年)

  • Composition API:引入了全新的组合式 API,提供了更灵活的逻辑组织方式。
  • Proxy 响应式系统:使用 Proxy 替代 Object.defineProperty,解决了 Vue2 响应式的诸多痛点。
  • Teleport & Fragments:新增内置组件,支持跨 DOM 层级渲染和多根节点模板。
  • 性能提升:更快的解析速度和更小的运行时体积,渲染性能提升约 100%。
  • 更好的 TypeScript 支持:原生支持 TypeScript,类型推导更加完善。
  • 自定义渲染器 API:增强的渲染器 API,便于跨平台开发。

Vue 3.1 - 3.4 (2023-2024年)

  • Vue 3.1:引入了 defineOptions 宏,改进编译优化。
  • Vue 3.3:进一步改进宏支持,类型化 props/emits 更加方便,简化了泛型组件的使用。
  • Vue 3.4:性能进一步提升,响应式系统优化,编译器效率改进。

Vue 3.5 及未来 (2024-2025年)

  • Vue 3.5:引入了响应式解构语法(Reactivity Transform),改善了大型应用的开发体验。
  • Vapor ModeVue 团队正在实验的全新渲染策略,跳过虚拟 DOM直接生成高效的 JavaScript 代码。
  • 更完善的生态集成:与 Vite 5PiniaVue Router 4 的深度整合。

响应式原理深度解析

响应式系统是 Vue 的核心,也是面试中的高频考点。Vue2 和 Vue3 在响应式实现上有着本质的区别。

Vue2:Object.defineProperty

Vue2 使用 Object.defineProperty 来劫持数据的 getter 和 setter:

function defineReactive(obj, key, val) {
  // 为每个属性创建 Dep 实例
  const dep = new Dep()
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 依赖收集
      if (Dep.target) {
        dep.depend()
      }
      return val
    },
    set: function reactiveSetter(newVal) {
      if (newVal === val) return
      // 通知更新
      dep.notify()
    }
  })
}

Vue2 响应式的局限性

  1. 无法检测对象属性的添加/删除Object.defineProperty 只能劫持已存在的属性,对于新增属性无能为力。
  2. 数组操作无法响应:通过下标修改数组元素 arr[0] = value 不会触发更新。
  3. 深层监听需要递归:对深层对象的监听会带来性能开销。

解决方案:Vue2 提供了 Vue.set / Vue.delete 以及重写数组方法来应对这些场景。

Vue3:Proxy

Vue3 使用 ES6 的 Proxy 来实现响应式:

function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      // 依赖收集
      track(target, key)
      const result = Reflect.get(target, key, receiver)
      // 如果是对象,递归代理实现深层响应式
      return isObject(result) ? reactive(result) : result
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver)
      // 触发更新
      trigger(target, key)
      return result
    },
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      trigger(target, key)
      return result
    }
  })
}

Vue3 响应式的优势

  1. 原生支持属性增删:Proxy 可以拦截对象的所有操作,包括新增和删除属性。
  2. 数组操作完全响应:下标赋值、数组长度变化等都能被正确拦截。
  3. 更好的性能:Proxy 是懒执行的,只有当访问属性时才会进行依赖收集。
  4. API 统一:ref 和 reactive 内部实现统一,简化了学习成本。

依赖收集与触发机制

Vue 的响应式系统遵循观察者模式,包含三个核心角色:

  1. Observer(观察者):负责劫持数据,收集依赖。
  2. Dep(依赖管理器):存储依赖,管理订阅者。
  3. Watcher(订阅者):在数据变化时执行更新回调。
// Dep 实现
class Dep {
  constructor() {
    this.subs = new Set() // 存储 Watcher
  }
  
  depend() {
    if (Dep.target) {
      this.subs.add(Dep.target)
    }
  }
  
  notify() {
    this.subs.forEach(watcher => watcher.update())
  }
}

// Watcher 实现
class Watcher {
  constructor(fn) {
    this.getter = fn
    this.value = this.get()
  }
  
  get() {
    Dep.target = this
    const value = this.getter()
    Dep.target = null
    return value
  }
  
  update() {
    this.value = this.getter()
  }
}

模板编译原理

Vue 的模板编译是将模板字符串转换为可执行渲染函数的过程,主要分为三个阶段。

1. 解析阶段(Parse)

将模板字符串解析为 AST(抽象语法树):

// 模板
<div class="container">
  <h1>{{ title }}</h1>
</div>

// AST 结构
{
  type: 'Element',
  tag: 'div',
  props: [{ type: 'Attribute', name: 'class', value: 'container' }],
  children: [
    {
      type: 'Element',
      tag: 'h1',
      children: [{ type: 'Interpolation', content: { expression: 'title' } }]
    }
  ]
}

2. 优化阶段(Optimize)

Vue3 的编译器会进行静态节点提升(Static Hoisting):

  • 静态节点:不包含任何响应式依赖的节点(如纯文本、静态属性)。
  • 事件缓存:对于不响应式变化的事件处理函数,进行缓存处理。
// 优化前
render() {
  return h('button', { onClick: this.handleClick }, 'Click')
}

// 优化后 - 事件函数被缓存
const handleClick = this.handleClick
render() {
  return h('button', { onClick: handleClick }, 'Click')
}

3. 代码生成阶段(Generate)

将 AST 转换为渲染函数:

// 生成的渲染函数
function render() {
  return _vue.createVNode('div', { class: 'container' }, [
    _vue.createVNode('h1', null, _vue.toDisplayString(this.title))
  ])
}

虚拟 DOM 与 Diff 算法

虚拟 DOM 的本质

虚拟 DOM 是真实 DOM 的 JavaScript 对象表示:

// VNode 结构
const vnode = {
  type: 'div',
  props: { class: 'container' },
  children: [
    { type: 'h1', children: 'Hello' }
  ],
  el: null // 关联的真实 DOM 引用
}

虚拟 DOM 的优势

  1. 跨平台渲染:同一套 VNode 结构可以渲染到不同平台。
  2. 减少 DOM 操作:在内存中进行对比,只更新必要的真实 DOM。
  3. 声明式开发:开发者只需关注数据变化,框架自动处理 DOM 更新。

Vue2 Diff:单端比较

Vue2 采用传统的 Diff 算法,从左到右依次对比:

function updateChildren(oldChildren, newChildren) {
  let oldStartIndex = 0
  let newStartIndex = 0
  let oldEndIndex = oldChildren.length - 1
  let newEndIndex = newChildren.length - 1
  
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    // 简单比较...O(n) 复杂度
  }
}

Vue3 Diff:双端比较 + 最长递增子序列

Vue3 采用了更高效的 Diff 算法

  1. 双端比较:同时从新旧列表的首尾进行对比。
  2. key 映射:通过 Map 快速定位相同 key 的节点。
  3. 最长递增子序列:对于需要移动的节点,使用 LIS 算法最小化移动次数。
// Vue3 Diff 核心逻辑
function diffChildren(n1, n2, parent) {
  const c1 = n1.children
  const c2 = n2.children
  const oldStart = 0
  const newStart = 0
  const oldEnd = c1.length - 1
  const newEnd = c2.length - 1
  
  // 双端比较策略
  while (oldStart <= oldEnd && newStart <= newEnd) {
    if (c1[oldStart].key === c2[newStart].key) {
      // 节点相同,继续
      patch(c1[oldStart], c2[newStart], parent)
      oldStart++
      newStart++
    } else if (c1[oldEnd].key === c2[newEnd].key) {
      // 尾部匹配
      patch(c1[oldEnd], c2[newEnd], parent)
      oldEnd--
      newEnd--
    }
    // ... 更多比较策略
  }
}

组件生命周期与更新机制

Vue2 生命周期

阶段 钩子 说明
初始化 beforeCreate 实例刚创建,数据观测未完成
初始化 created 数据观测完成,DOM 未生成
挂载 beforeMount 模板编译完成,准备挂载
挂载 mounted DOM 挂载完成,可操作 DOM
更新 beforeUpdate 数据变化,DOM 未更新
更新 updated DOM 更新完成
销毁 beforeDestroy 实例销毁前,可清理
销毁 destroyed 实例已销毁

Vue3 生命周期

Vue2 Vue3 (Composition API)
beforeCreate -
created -
beforeMount onBeforeMount
mounted onMounted
beforeUpdate onBeforeUpdate
updated onUpdated
beforeDestroy onBeforeUnmount
destroyed onUnmounted

组件更新流程

数据变化 → 触发 setter → Dep 通知 Watcher → 
触发 update() → 重新执行 render() 生成新的 VNode → 
Diff 对比 → 更新真实 DOM

Vue3 新特性深度解析

1. Composition API

组合式 API 是 Vue3 最重要的变化,提供了更灵活的逻辑组织方式:

// setup 函数 - 组件逻辑入口
import { ref, computed, onMounted, watch } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const doubled = computed(() => count.value * 2)
    
    function increment() {
      count.value++
    }
    
    onMounted(() => {
      console.log('Component mounted!')
    })
    
    watch(count, (newVal) => {
      console.log(`Count changed to ${newVal}`)
    })
    
    return { count, doubled, increment }
  }
}

ref vs reactive

  • ref:用于原始类型,创建包含 .value 的响应式对象。
  • reactive:用于对象,创建深层响应式对象。
import { ref, reactive } from 'vue'

const count = ref(0)        // 原始类型
const state = reactive({   // 对象类型
  user: { name: 'Vue' }
})

// 模板中自动解包
console.log(count.value)   // JS 中需要 .value
console.log(state.user)    // reactive 直接访问

2. Teleport

将组件渲染到指定 DOM 位置,常用于模态框:

<Teleport to="body">
  <div v-if="show" class="modal">
    <p>Modal Content</p>
  </div>
</Teleport>

3. Fragments

支持多根节点模板:

<!-- Vue3 允许 -->
<template>
  <div>A</div>
  <div>B</div>
</template>

4. Suspense

处理异步组件加载状态:

<Suspense>
  <template #default>
    <AsyncComponent />
  </template>
  <template #fallback>
    Loading...
  </template>
</Suspense>

Vue vs React:核心差异对比

响应式实现

特性 Vue2 Vue3 React
原理 Object.defineProperty Proxy useState/useReducer
触发方式 自动 自动 手动调用 setState
数组响应 重写方法 Proxy 需使用 Immer 或 immutable
深层监听 递归 Proxy 懒加载 useEffect 依赖

模板 vs JSX

  • Vue:模板语法,HTML-like,学习成本低,编译器优化。
  • React:JSX,JavaScript 表达式,更灵活,但需要一定学习曲线。

状态管理

  • Vue:Pinia(推荐)或 Vuex,采用模块化设计。
  • React:Redux/Zustand/Jotai,函数式风格。

渲染性能

Vue3 由于模板编译优化和 Proxy 响应式,在大多数场景下性能优于 React。React 的优势在于 Fiber 架构带来的精细化控制和并发渲染能力。


性能优化策略

1. 渲染优化

// 使用 v-once 静态内容
<div v-once>{{ staticContent }}</div>

// 正确使用 key
<li v-for="item in items" :key="item.id">{{ item.name }}</li>

// v-if vs v-show 选择
<div v-if="show">很少切换</div>
<div v-show="show">频繁切换</div>

2. 响应式优化

import { shallowRef, markRaw } from 'vue'

// 浅层响应式 - 适合大型数据
const largeList = shallowRef([])

// 非响应式数据 - 适合不需要响应式的对象
const plainObj = markRaw({ /* ... */ })

3. 组件懒加载

// 路由懒加载
const Home = () => import('./views/Home.vue')

// 异步组件
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent(() => import('./AsyncComp.vue'))

4. KeepAlive 缓存

<KeepAlive include="Home,About">
  <router-view />
</KeepAlive>

面试常见问题汇总

1. Vue2 和 Vue3 响应式的区别?

Vue2 使用 Object.defineProperty,需要递归监听所有属性,无法检测新增/删除属性;Vue3 使用 Proxy,原生支持属性增删,性能更好。

2. Vue 的依赖收集是如何实现的?

通过 Dep 类管理订阅者,Watcher 在读取响应式属性时将自身添加到 Dep,属性变化时 Dep 通知所有 Watcher 更新。

3. Vue3 Diff 算法相比 Vue2 有什么优化?

Vue3 采用 双端比较 策略,结合 最长递增子序列 算法,最小化 DOM 移动次数,复杂度从 O(n³) 优化到 O(n)。

4. Vue3 的 Composition API 有什么优势?

  • 更好的 TypeScript 支持
  • 代码更容易复用和抽取
  • 逻辑相关代码组织在一起,而不是按选项分散

5. Vue3 的性能为什么比 Vue2 好?

  • Proxy 替代 Object.defineProperty,深层监听懒执行
  • 模板编译优化:静态节点提升事件缓存
  • 优化的 Diff 算法
  • 更小的打包体积

6. Vue 的 nextTick 原理?

Vue 使用 Promise + MutationObserver + setTimeout 实现异步队列,在 DOM 更新后通过微任务执行回调。

7. keep-alive 的实现原理?

通过缓存 VNode,保存组件实例和状态,切换时复用而非重新创建。activated/deactivated 钩子用于感知缓存状态变化。

8. Vue 的模板编译过程?

解析优化(静态节点提升)→ 代码生成(渲染函数)


总结

Vue 作为一个渐进式框架,在保持易用性的同时不断深化底层技术的实现。Vue3 通过 Composition API、Proxy 响应式系统、优化的 Diff 算法等特性,显著提升了开发体验和运行性能。理解这些底层原理不仅有助于应对面试,更能在实际开发中做出更好的技术决策。

Vue 团队正在探索的 Vapor Mode 未来可能带来更大的性能突破,值得持续关注。

Vue 基础理论 & API 使用

Vue 基础理论 & API 使用

本文主要记录 Vue 的基础理论、核心概念与常用 API 使用方法,面向面试和日常开发参考。


原文地址

墨渊书肆/Vue 基础理论 & API 使用


Vue 简介

Vue 是一个渐进式 JavaScript 框架,由尤雨溪于 2014 年创建。Vue 核心库聚焦于视图层,易于学习和集成,同时能够驱动复杂的单页应用程序(SPA)开发。

核心特点

  • 响应式数据绑定 (MVVM 模式)
  • 组件化开发
  • 虚拟 DOM
  • 指令系统
  • 渐进式架构

安装与项目创建

Vite(推荐)

# 创建 Vue3 项目
npm create vue@latest

# 或使用 Vite 直接创建
npm create vite@latest my-vue-app -- --template vue

Vue CLI

npm install -g @vue/cli
vue create my-project

基础指令

v-model 双向绑定

v-modelVue 中用于表单输入和数据双向绑定的核心指令,本质是 v-bind + v-on语法糖

基本用法

<input v-model="message">
<p>{{ message }}</p>

修饰符

修饰符 说明
.lazy 在 change 事件时更新,而非 input
.number 自动转换为数值
.trim 去除首尾空白

自定义 v-model(Vue 3.4+):

// 子组件
defineProps(['modelValue'])
defineEmits(['update:modelValue'])

// 父组件
<MyInput v-model:title="title" />

v-if / v-show 条件渲染

特性 v-if v-show
DOM 操作 创建/销毁 display: none
初始渲染 惰性 立即渲染
切换性能
适用场景 很少切换 频繁切换
<div v-if="type === 'A'">A</div>
<div v-else-if="type === 'B'">B</div>
<div v-else>C</div>

v-for 列表渲染

<li v-for="(item, index) in items" :key="item.id">
  {{ index }} - {{ item.name }}
</li>

注意事项

  • 必须使用 :key 绑定唯一标识
  • 不建议使用数组索引作为 key
  • Vue2 中 v-for 优先级高于 v-if,Vue3 中相反

v-bind / v-on 属性与事件

<!-- 绑定属性 -->
<img :src="url">

<!-- 绑定多个属性 -->
<img v-bind="attrs">

<!-- 事件监听 -->
<button @click="handleClick">Click</button>

<!-- 事件修饰符 -->
<button @click.stop="handle">阻止冒泡</button>
<button @click.prevent="handle">阻止默认行为</button>

组件选项

data

组件的响应式数据源,必须返回纯对象:

export default {
  data() {
    return {
      count: 0,
      user: { name: 'Vue' }
    }
  }
}

props

父子组件通信的重要方式,支持类型校验默认值

export default {
  props: {
    // 基础类型
    title: String,
    // 多个类型
    age: [Number, String],
    // 带默认值
    size: {
      type: String,
      default: 'medium'
    },
    // 必需
    id: {
      type: Number,
      required: true
    },
    // 自定义校验
    score: {
      validator: (value) => value >= 0 && value <= 100
    }
  }
}

Vue3 组合式 API

const props = defineProps({
  title: String,
  count: { type: Number, default: 0 }
})

computed 计算属性

缓存计算结果,只在依赖变化时重新计算:

export default {
  data() { return { count: 1 } },
  computed: {
    // 只读
    doubled() { return this.count * 2 },
    // 可写
    plusOne: {
      get() { return this.count + 1 },
      set(val) { this.count = val - 1 }
    }
  }
}

methods 方法

处理业务逻辑,每次渲染都会重新创建:

export default {
  methods: {
    handleClick() { /* ... */ }
  }
}

watch 监听器

监听数据变化并执行回调:

export default {
  data() { return { count: 0 } },
  watch: {
    count(newVal, oldVal) {
      console.log(`变化: ${oldVal}${newVal}`)
    },
    // 深度监听
    'obj.data': {
      handler() { /* ... */ },
      deep: true
    },
    // 立即执行
    name: {
      handler() { /* ... */ },
      immediate: true
    }
  }
}

Composition API

Vue3 引入的组合式 API,提供了更灵活的逻辑组织方式。

ref / reactive 响应式

import { ref, reactive } from 'vue'

// ref - 原始类型
const count = ref(0)
count.value++

// reactive - 对象
const state = reactive({
  user: { name: 'Vue' }
})
state.user.name = 'Vue3'

区别

| 特性 | ref | reactive | | ----- -| ----- | ---------- | | 适用类型 | 任意类型 | 对象/数组 | | 访问方式 | .value | 直接属性 | | 重新赋值 | 响应式 | 替换整个对象 |

toRefs / toRef

将 reactive 对象解构为独立的 ref:

import { reactive, toRefs } from 'vue'

const state = reactive({ name: 'Vue', age: 25 })
const { name, age } = toRefs(state)

// 或创建单个 ref
const nameRef = toRef(state, 'name')

computed() 计算属性

import { ref, computed } from 'vue'

const count = ref(0)
const doubled = computed(() => count.value * 2)

watch / watchEffect

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

// watch - 显式监听
watch(count, (newVal, oldVal) => { /* ... */ })
watch(() => state.name, (newVal) => { /* ... */ })

// watchEffect - 自动收集依赖
watchEffect(() => {
  console.log(count.value) // 自动追踪
})

执行时机控制

  • watch:默认同步执行
  • watchEffect:默认 pre(在组件更新前)
  • watchPostEffect:在组件更新后执行
  • watchSyncEffect:同步执行

生命周期钩子

import { 
  onMounted, 
  onUpdated, 
  onUnmounted 
} from 'vue'

export default {
  setup() {
    onMounted(() => { console.log('mounted') })
    onUpdated(() => { console.log('updated') })
    onUnmounted(() => { console.log('unmounted') })
  }
}

组件通信

Props / $emit

// 父组件
<Child :count="count" @update="handleUpdate" />

// 子组件
const props = defineProps({ count: Number })
const emit = defineEmits(['update'])
emit('update', props.count + 1)

Provide / Inject

祖先向后代跨级传值

// 祖先组件
provide('key', 'value')

// 后代组件
const value = inject('key')

响应式

// 祖先
const count = ref(0)
provide('count', count)

// 后代 - 修改会影响所有后代
const count = inject('count')

attrs/attrs / listeners

透传属性和事件:

<!-- 透传所有 -->
<Child v-bind="$attrs" v-on="$listeners" />

Pinia 状态管理

Vue3 推荐的状态管理方案:

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubled: (state) => state.count * 2
  },
  actions: {
    increment() { this.count++ }
  }
})

内置组件

Transition

为元素添加过渡动画

<Transition name="fade">
  <div v-if="show">Content</div>
</Transition>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

过渡类名

  • v-enter-from / v-leave-from:起始状态
  • v-enter-active / v-leave-active:过渡中
  • v-enter-to / v-leave-to:结束状态

KeepAlive

缓存组件实例:

<KeepAlive include="A,B" exclude="C">
  <component :is="current" />
</KeepAlive>

生命周期

  • activated:激活时
  • deactivated:停用时

Teleport

渲染到指定 DOM 位置:

<Teleport to="#modal-root">
  <div class="modal">Content</div>
</Teleport>

Suspense

处理异步组件(实验性):

<Suspense>
  <template #default>
    <AsyncComponent />
  </template>
  <template #fallback>
    Loading...
  </template>
</Suspense>

生命周期

Options API

阶段 钩子 说明
初始化 beforeCreate 实例创建前
初始化 created 数据观测完成
挂载 beforeMount 模板编译完成
挂载 mounted DOM 挂载完成
更新 beforeUpdate 数据变化,DOM 未更新
更新 updated DOM 更新完成
销毁 beforeUnmount 实例销毁前
销毁 unmounted 实例已销毁

父子组件执行顺序

挂载:父 created → 子 created → 子 mounted → 父 mounted

更新:父 beforeUpdate → 子 beforeUpdate → 子 updated → 父 updated

销毁:父 beforeUnmount → 子 beforeUnmount → 子 unmounted → 父 unmounted


常用技巧

动态类名

<div :class="{ active: isActive, 'text-center': isCenter }">
<div :class="[activeClass, errorClass]">

条件类名

<div :class="[isActive && 'active']">

动态绑定 style

<div :style="{ color: textColor, fontSize: fontSize + 'px' }">

函数式组件

export default {
  functional: true,
  props: { msg: String },
  render(h, context) {
    return h('div', context.props.msg)
  }
}

异步组件

import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Async.vue'),
  loadingComponent: Loading,
  errorComponent: Error,
  delay: 200,
  timeout: 3000
})

面试常见问题

1. v-model 的原理?

本质是 v-bind:value + @input语法糖,监听 input 事件并更新数据。

2. v-for 中 key 的作用?

帮助 Vue 识别节点身份,实现高效的 DOM 复用。推荐使用数据唯一 ID,避免使用数组索引

3. computed 和 watch 的区别?

  • computed:计算属性,依赖变化自动计算,缓存结果
  • watch:监听器,监听数据变化,执行异步或复杂逻辑

4. Vue2 和 Vue3 的区别?

  • 响应式:Object.defineProperty → Proxy
  • API:Options API → Composition API
  • 多根节点:不支持 → 支持
  • 生命周期:beforeDestroy → beforeUnmount

5. 组件通信方式有哪些?

  • props / $emit:父子
  • provide / inject:祖先-后代
  • attrs/attrs / listeners:透传
  • 事件总线:兄弟/任意
  • Pinia/Vuex:全局状态

6. Vue 的响应式原理?

通过 Proxy/Object.defineProperty 劫持数据访问,在 getter 中收集依赖,setter 中触发更新


总结

Vue 以其简洁的 API 和渐进式的设计理念,成为前端开发的主流框架。掌握 Vue 的基础理论、常用 API 以及组件通信方式,是 Vue 开发者的必备技能。Vue3Composition API 提供了更现代化的开发范式,建议在实际项目中优先使用。

构建无障碍组件之Radio group pattern

Radio Group Pattern 详解:构建无障碍单选按钮组件

单选按钮(Radio Button)是表单中用于从一组互斥选项中选择单个项目的控件。本文基于 W3C WAI-ARIA Radio Pattern 规范,详解如何构建无障碍的单选按钮组件。

一、Radio 的定义与核心概念

单选按钮允许用户从一组相关但互斥的选项中选择一个且仅一个选项。当用户选择一个选项时,同组中之前被选中的选项会自动取消选中。

1.1 核心特性

  • 互斥性:同一组内只能有一个选项被选中
  • 预设选中:通常有一个选项默认被选中
  • 分组依赖:通过相同的 name 属性(HTML)或 aria-label(ARIA)进行分组

1.2 与 Checkbox 的区别

特性 Radio Checkbox
选择数量 单选 可多选
互斥性 同组互斥 独立
默认状态 通常预设一个选中 可全部未选中
键盘导航 方向键切换 Tab 切换

二、WAI-ARIA 角色与属性

2.1 基本角色

单选按钮具有 role="radio"

2.2 状态属性

2.3 分组属性

单选按钮必须分组,以便辅助技术理解它们之间的关系:

<div
  role="radiogroup"
  aria-labelledby="group-label">
  <h3 id="group-label">选择支付方式</h3>
  <div
    role="radio"
    aria-checked="true"
    tabindex="0">
    信用卡
  </div>
  <div
    role="radio"
    aria-checked="false"
    tabindex="-1">
    支付宝
  </div>
  <div
    role="radio"
    aria-checked="false"
    tabindex="-1">
    微信支付
  </div>
</div>

2.4 可访问标签

每个单选按钮的可访问标签可以通过以下方式提供:

  • 可见文本内容:直接包含在具有 role="radio" 的元素内的文本
  • aria-labelledby:引用包含标签文本的元素的 ID
  • aria-label:直接在单选按钮元素上设置标签文本

2.5 描述属性

如果包含额外的描述性静态文本,使用 aria-describedby

<div
  role="radio"
  aria-checked="false"
  aria-describedby="option-desc">
  高级会员
</div>
<p id="option-desc">包含所有高级功能,每月 99 元</p>

三、键盘交互规范

3.1 基本键盘操作

当单选按钮获得焦点时:

按键 功能
Space 如果焦点在未选中的单选按钮上,选中该按钮(取消选中同组其他按钮)
Tab 将焦点移动到组内的选中单选按钮;如果组内没有选中按钮,将焦点移动到组内第一个单选按钮

3.2 方向键导航(可选但推荐)

按键 功能
Down Arrow / Right Arrow 将焦点移动到下一个单选按钮,并选中它;如果焦点在最后一个按钮上,将焦点移动到第一个按钮
Up Arrow / Left Arrow 将焦点移动到上一个单选按钮,并选中它;如果焦点在第一个按钮上,将焦点移动到最后一个按钮

四、实现方式

4.1 原生 HTML 实现(推荐)

原生 HTML <input type="radio"> 提供完整的无障碍支持:

<fieldset>
  <legend>选择性别</legend>
  <label>
    <input
      type="radio"
      name="gender"
      value="male"
      checked /></label>
  <label>
    <input
      type="radio"
      name="gender"
      value="female" /></label>
  <label>
    <input
      type="radio"
      name="gender"
      value="other" />
    其他
  </label>
</fieldset>

4.2 ARIA 实现(自定义样式)

<div
  role="radiogroup"
  aria-labelledby="payment-label">
  <h3 id="payment-label">选择支付方式</h3>

  <div
    role="radio"
    aria-checked="true"
    tabindex="0"
    onclick="selectRadio(this)"
    onkeydown="handleKeydown(event, this)">
    <span
      class="radio-icon"
      aria-hidden="true"></span>
    信用卡
  </div>

  <div
    role="radio"
    aria-checked="false"
    tabindex="-1"
    onclick="selectRadio(this)"
    onkeydown="handleKeydown(event, this)">
    <span
      class="radio-icon"
      aria-hidden="true"></span>
    支付宝
  </div>

  <div
    role="radio"
    aria-checked="false"
    tabindex="-1"
    onclick="selectRadio(this)"
    onkeydown="handleKeydown(event, this)">
    <span
      class="radio-icon"
      aria-hidden="true"></span>
    微信支付
  </div>
</div>

<script>
  function selectRadio(selectedRadio) {
    const radioGroup = selectedRadio.closest('[role="radiogroup"]');
    const radios = radioGroup.querySelectorAll('[role="radio"]');

    radios.forEach((radio) => {
      const isSelected = radio === selectedRadio;
      radio.setAttribute('aria-checked', isSelected);
      radio.setAttribute('tabindex', isSelected ? '0' : '-1');
    });
  }

  function handleKeydown(event, radio) {
    const radioGroup = radio.closest('[role="radiogroup"]');
    const radios = Array.from(radioGroup.querySelectorAll('[role="radio"]'));
    const currentIndex = radios.indexOf(radio);

    switch (event.key) {
      case 'ArrowDown':
      case 'ArrowRight':
        event.preventDefault();
        const nextIndex = (currentIndex + 1) % radios.length;
        radios[nextIndex].focus();
        selectRadio(radios[nextIndex]);
        break;
      case 'ArrowUp':
      case 'ArrowLeft':
        event.preventDefault();
        const prevIndex = (currentIndex - 1 + radios.length) % radios.length;
        radios[prevIndex].focus();
        selectRadio(radios[prevIndex]);
        break;
      case ' ':
        event.preventDefault();
        selectRadio(radio);
        break;
    }
  }
</script>

4.3 水平布局的单选按钮组

<fieldset class="radio-group-horizontal">
  <legend>选择评分</legend>
  <div class="radio-options">
    <label class="radio-label">
      <input
        type="radio"
        name="rating"
        value="1" />
      <span>1 星</span>
    </label>
    <label class="radio-label">
      <input
        type="radio"
        name="rating"
        value="2" />
      <span>2 星</span>
    </label>
    <label class="radio-label">
      <input
        type="radio"
        name="rating"
        value="3"
        checked />
      <span>3 星</span>
    </label>
    <label class="radio-label">
      <input
        type="radio"
        name="rating"
        value="4" />
      <span>4 星</span>
    </label>
    <label class="radio-label">
      <input
        type="radio"
        name="rating"
        value="5" />
      <span>5 星</span>
    </label>
  </div>
</fieldset>

4.4 带描述的选项

<fieldset
  role="radiogroup"
  aria-labelledby="plan-label">
  <legend id="plan-label">选择套餐</legend>

  <label class="radio-card">
    <input
      type="radio"
      name="plan"
      value="basic"
      checked />
    <div class="radio-content">
      <strong>基础版</strong>
      <span class="price">¥29/月</span>
      <p class="description">适合个人用户,包含基础功能</p>
    </div>
  </label>

  <label class="radio-card">
    <input
      type="radio"
      name="plan"
      value="pro" />
    <div class="radio-content">
      <strong>专业版</strong>
      <span class="price">¥99/月</span>
      <p class="description">适合小型团队,包含高级功能</p>
    </div>
  </label>

  <label class="radio-card">
    <input
      type="radio"
      name="plan"
      value="enterprise" />
    <div class="radio-content">
      <strong>企业版</strong>
      <span class="price">¥299/月</span>
      <p class="description">适合大型企业,包含全部功能</p>
    </div>
  </label>
</fieldset>

五、常见应用场景

5.1 性别选择

<fieldset>
  <legend>性别</legend>
  <label
    ><input
      type="radio"
      name="gender"
      value="male" />
    男</label
  >
  <label
    ><input
      type="radio"
      name="gender"
      value="female" />
    女</label
  >
  <label
    ><input
      type="radio"
      name="gender"
      value="other" />
    其他</label
  >
  <label
    ><input
      type="radio"
      name="gender"
      value="secret"
      checked />
    保密</label
  >
</fieldset>

5.2 支付方式选择

<fieldset>
  <legend>选择支付方式</legend>
  <label class="payment-option">
    <input
      type="radio"
      name="payment"
      value="credit-card"
      checked />
    <img
      src="credit-card-icon.svg"
      alt="" />
    信用卡
  </label>
  <label class="payment-option">
    <input
      type="radio"
      name="payment"
      value="alipay" />
    <img
      src="alipay-icon.svg"
      alt="" />
    支付宝
  </label>
  <label class="payment-option">
    <input
      type="radio"
      name="payment"
      value="wechat" />
    <img
      src="wechat-icon.svg"
      alt="" />
    微信支付
  </label>
</fieldset>

5.3 主题切换

<fieldset class="theme-selector">
  <legend>选择主题</legend>
  <div class="theme-options">
    <label class="theme-option">
      <input
        type="radio"
        name="theme"
        value="light"
        checked />
      <span class="theme-preview light"></span>
      浅色
    </label>
    <label class="theme-option">
      <input
        type="radio"
        name="theme"
        value="dark" />
      <span class="theme-preview dark"></span>
      深色
    </label>
    <label class="theme-option">
      <input
        type="radio"
        name="theme"
        value="auto" />
      <span class="theme-preview auto"></span>
      跟随系统
    </label>
  </div>
</fieldset>

六、最佳实践

6.1 优先使用原生单选按钮

原生 HTML <input type="radio"> 提供完整的无障碍支持,包括:

  • 自动键盘交互(方向键导航)
  • 自动互斥选择
  • 屏幕阅读器自动播报状态
  • 浏览器原生样式和焦点管理

6.2 始终设置默认选中

为避免用户忘记选择,通常应该预设一个默认选项:

<!-- 推荐:预设默认选项 -->
<fieldset>
  <legend>选择语言</legend>
  <label
    ><input
      type="radio"
      name="language"
      value="zh"
      checked />
    中文</label
  >
  <label
    ><input
      type="radio"
      name="language"
      value="en" />
    English</label
  >
</fieldset>

6.3 使用 fieldset 和 legend 分组

始终使用 <fieldset><legend> 对单选按钮进行语义化分组:

<fieldset>
  <legend>选择尺寸</legend>
  <label
    ><input
      type="radio"
      name="size"
      value="s" />
    S</label
  >
  <label
    ><input
      type="radio"
      name="size"
      value="m"
      checked />
    M</label
  >
  <label
    ><input
      type="radio"
      name="size"
      value="l" />
    L</label
  >
  <label
    ><input
      type="radio"
      name="size"
      value="xl" />
    XL</label
  >
</fieldset>

6.4 提供清晰的视觉指示

确保选中和未选中状态有清晰的视觉区别:

/* 自定义单选按钮样式 */
input[type='radio'] {
  width: 20px;
  height: 20px;
  accent-color: #005a9c;
}

input[type='radio']:focus {
  outline: 2px solid #005a9c;
  outline-offset: 2px;
}

6.5 避免嵌套交互元素

不要在单选按钮标签内嵌套其他交互元素:

<!-- 不推荐 -->
<label>
  <input
    type="radio"
    name="option"
    value="a" />
  选项 A <a href="/details">查看详情</a>
</label>

<!-- 推荐 -->
<div>
  <label>
    <input
      type="radio"
      name="option"
      value="a" />
    选项 A
  </label>
  <a href="/details">查看详情</a>
</div>

6.6 考虑移动端触摸区域

确保单选按钮有足够的触摸区域(至少 44x44px):

.radio-label {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 12px;
  min-height: 44px;
}

七、Radio 与 Select 的选择

场景 推荐组件 原因
选项少于 5 个 Radio 所有选项可见,便于比较
选项多于 7 个 Select 节省空间,避免认知负担
需要显示选项详情 Radio 可以展示描述信息
空间受限 Select 下拉菜单更紧凑
频繁切换 Radio 减少点击次数

八、总结

构建无障碍的单选按钮组件需要关注三个核心:正确的语义化分组(<fieldset><legend>)、清晰的选中状态指示、以及良好的键盘导航支持(方向键切换)。与 Checkbox 不同,Radio 强调互斥选择,适用于需要从一组选项中精确选择单一项目的场景。

遵循 W3C Radio Pattern 规范,我们能够创建既美观又包容的单选按钮组件,为不同能力的用户提供一致的体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

状态提升:前端开发中的状态管理的设计思想

在前端开发中,我们几乎绕不开一个核心问题:状态(state)该放在哪里?

随着项目复杂度的提升,状态的存放位置也会经历一次次“升级”:

子组件 → 父组件 → Hook(组合式函数)→ Pinia(全局状态管理)

这篇文章,我会带你一步步拆解这个“状态提升”的演进过程,并结合VUE代码示例,帮你理解每一次升级背后的动机和设计思想。


一、第一阶段:状态在子组件中(局部状态)

在项目早期,我们通常会把状态直接写在子组件内部。

示例:一个计数器组件

<!-- Counter.vue -->
<script setup>
import { ref } from 'vue'

const count = ref(0)

const increment = () => {
  count.value++
}
</script>

<template>
  <div>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

特点

  • 状态封装在组件内部
  • 简单直观
  • 适合完全独立的 UI 组件

问题

如果有两个组件都需要用到这个 count 呢?

比如:

<Counter />
<Display />

Display 组件也想显示这个 count,怎么办?

这时我们就需要第一次升级。


二、第二阶段:从子组件提升到父组件

当多个子组件共享状态时,我们会把状态“提升”到它们的共同父组件。

这和 React 的“状态提升”思想是一致的。

父组件管理状态

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import Counter from './Counter.vue'
import Display from './Display.vue'

const count = ref(0)

const increment = () => {
  count.value++
}
</script>

<template>
  <Counter :count="count" @increment="increment" />
  <Display :count="count" />
</template>

子组件只负责展示和触发

<!-- Counter.vue -->
<script setup>
defineProps({
  count: Number
})

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

<template>
  <button @click="$emit('increment')">+1</button>
</template>

优点

  • 状态集中管理
  • 数据流清晰(单向数据流)

缺点

  • 层级一深就会出现:

    • props drilling(层层传参)
    • 事件层层冒泡
  • 父组件变得“臃肿”

当项目规模扩大后,这种方式开始吃力。

于是我们进行第二次升级。


三、第三阶段:从父组件提升到 Hook(组合式函数)

在 Vue 3 中,Composition API 让我们可以把逻辑抽离成 Hook(组合式函数)。

我们把状态抽离到一个独立文件中。

创建一个 useCounter.ts

// useCounter.ts
import { ref } from 'vue'

export function useCounter() {
  const count = ref(0)

  const increment = () => {
    count.value++
  }

  return {
    count,
    increment
  }
}

在组件中使用

<script setup>
import { useCounter } from './useCounter'

const { count, increment } = useCounter()
</script>

优点

  • 逻辑复用
  • 代码结构更清晰
  • 组件变“干净”
  • 可测试性更强

但问题来了

如果两个组件都调用 useCounter()

const a = useCounter()
const b = useCounter()

它们的 count 是:

❌ 不共享的
每调用一次都会创建新的状态实例。

如果我们希望多个组件共享同一个状态怎么办?

这时候,Hook 已经不够用了。

于是我们迎来终极升级。


四、第四阶段:从 Hook 升级到 Pinia

当状态需要在多个页面、多个模块、多个层级中共享时,我们就需要真正的状态管理工具。

在 Vue 生态中,主流选择是:

  • Vuex(旧)
  • Pinia(官方推荐)

这里我们使用 Pinia。


什么是 Pinia?

Pinia 是 Vue 官方推荐的状态管理库,支持 Vue 3,API 设计非常现代化。

它的理念是:

Store = 可复用的全局 Hook


创建一个 Counter Store

// stores/counter.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)

  const increment = () => {
    count.value++
  }

  return { count, increment }
})

在组件中使用

<script setup>
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

<template>
  <button @click="counter.increment">
    {{ counter.count }}
  </button>
</template>

关键特性

  • 所有组件共享同一个 store
  • 自动响应式
  • DevTools 支持
  • 模块化管理

状态升级的本质

我们来总结一下这四个阶段:

阶段 状态位置 适用场景 缺点
子组件 组件内部 完全独立组件 无法共享
父组件 父级 局部共享 层级深会混乱
Hook 逻辑抽离 逻辑复用 默认不共享
Pinia 全局 Store 跨页面共享 增加架构复杂度

设计哲学:状态放在哪里?

可以用一句话概括:

状态应该放在“刚好需要它的最上层”

  • 只一个组件用 → 放子组件
  • 两个兄弟组件用 → 放父组件
  • 多个地方用但不共享 → Hook
  • 全局共享 → Pinia

这是一种“按需升级”的架构策略。


不要一开始就上 Pinia

不要直接提升到pinia,这会带来:

  • 不必要的全局耦合
  • 难以维护
  • 状态污染

记住:

全局状态是一种“权力”,不要滥用。

应该从下至上,找到最合适的地方,随着需求的变更,代码也跟随变更。


架构升级的思维模型

这个升级过程,本质上体现的是:

  • 局部化 → 抽象化 → 全局化
  • 组件驱动 → 逻辑驱动 → 状态驱动

这也是现代前端架构演进的核心路线。


结语

Vue 的状态管理不是非黑即白的选择,而是一个“渐进增强”的过程。

当你理解了:

  • 为什么提升状态
  • 什么时候该提升
  • 提升的边界在哪里

你就真正掌握了 Vue 状态管理的设计思想。

思考

  • 如果所有的父子组件都需要这个状态呢?(Provide/Inject)

Claude Code 创始人 Boris 揭秘:团队 10 倍效率技巧

核心观点

当 AI 成为程序员的标配,真正拉开差距的不是工具本身,而是使用方式。

Boris(Claude Code 创始人)首次公开团队内部使用手册,这些技巧来自 Claude Code 核心团队的真实工作场景,可以让工作效率比以前高出 5–10 倍。他的原话颇具代表性:

"我已有 6 个月没写过一行 SQL 代码了。"


十大效率技巧

1. 并行作业:构建多线程作战环境

这是团队给出的 Top 1 建议。

同时开启 3–5 个 git 工作树(worktree),每个运行独立的 Claude 会话:

  • 一个 Claude 负责重构代码
  • 一个 Claude 负责写测试
  • 一个 Claude 负责分析日志

进阶技巧: 给工作树命名并设置 shell 别名(如 zazbzc),一键切换不同任务环境。也可专门维护一个「分析专用」工作树,只用来跑 BigQuery 和看日志。


2. 计划模式(Plan Mode):复杂任务必须先规划

团队铁律:每个复杂任务都从计划模式开始,而不是直接编码。

  • 把精力投入计划阶段,让 Claude 一次性完美实现
  • 进阶玩法: 让一个 Claude 写方案,开第二个 Claude 扮演「主任架构师」审核
  • 避坑指南: 代码一旦跑偏,立刻跳回 Plan Mode 重新规划,而不是反复打补丁

3. CLAUDE.md:让 AI「记住」你的所有规则

这是 Claude 的长期记忆系统,也是普通用户最容易忽视的一点。

每次 Claude 犯错后,在反馈末尾加上:

Update your CLAUDE.md so you don't make this mistake again.

Boris 透露,Claude 非常擅长给自己写规则。持续迭代 CLAUDE.md 文件直到错误率显著下降。有经验的工程师甚至让 Claude 为每个任务/项目维护专门的笔记目录,每次 PR 后更新,并在 CLAUDE.md 中引用这些笔记。


4. 重复的事,一定要变成 Skill

原则:如果某件事每天重复做超过两次,就该把它写成 Skill,提交到 git,在所有项目间复用。

真实案例:

  • /techdebt:每天结束前扫描重复代码
  • 一个 Slash Skill:同步 7 天的 Slack / GDrive / Asana / GitHub 数据
  • 数据工程 Agent:写 dbt、Review、测试

Claude Code 的威力,不在「对话」,而在可复用能力的积累。


5. 90% 的 Bug,Claude 可以自己修

Claude 团队修 Bug 的方式非常「反直觉」——不要去教 Claude 怎么 fix,让 Claude 自己决定。

操作示例:

  • 开 Slack MCP,把一个 Bug 讨论串丢给 Claude,只说一个字:fix
  • 或者直接:Go fix the failing CI tests.(不指定步骤、不微操)
  • 分布式系统中:直接把 docker logs 给 Claude 分析

6. 提升 Prompt 技巧:从「请示」到「挑战」

不要只会求 AI 帮写代码,要学会「挑战」它,像对待高级同事一样协作。

三个改变游戏规则的 Prompt 技巧:

  1. 角色反转审查"证明给我看这能工作",让 Claude 对比主分支和功能分支的行为差异
  2. 优雅重构"已知目前所有条件,把这个垃圾删了,重写一个优雅的方案。"
  3. 详细规格:提前编写详细规格说明,减少歧义——你越具体,输出质量越高

示例 Prompt:"质问我这些变更,直到我通过你的测试才创建 PR。"


7. 终端环境优化:工具配置决定效率上限

Claude Code 团队的偏爱配置:

  • 终端:Ghostty(同步渲染、24-bit 颜色、Unicode)
  • 状态栏:用 /statusline 显示当前 git 分支 + Context 使用率
  • 多任务:tmux / 多 tab,每个标签对应一个任务/worktree,并对标签进行颜色编码和命名

一个被严重低估的技巧:语音输入。 说话比打字快 3 倍,Prompt 质量反而更高。


8. 子代理(Subagents):更多算力,但不污染主上下文

使用原则:

  • 在任何请求后加「使用子代理」,让 Claude 投入更多算力
  • 把单个任务卸载给子代理,保持主代理的上下文窗口干净聚焦
  • 通过 hook 将权限请求路由到 Opus 4.5,自动扫描并批准安全请求,实现权限管理自动化

9. 数据分析:告别手写 SQL 的时代

Claude 团队几乎已经不用手写 SQL

  • 使用 Claude Code 调用 bq 命令行工具即时拉取和分析指标
  • 在代码库中维护 BigQuery 技能,团队每个成员直接在 Claude Code 中进行分析查询
  • 这种方法适用于任何有 CLI、MCP 或 API 的数据库

实战示例:"帮我分析上周用户增长异常的原因"——AI 会自动写 SQL、拉数据、出图表、给结论。


10. 用 Claude 学习:而不只是让它干活

Claude 团队内部的学习用法:

  • /config 中开启「Learning / Explanatory」输出模式,让 Claude 解释改动背后的原因
  • 对于不熟悉的代码,让 Claude 生成可视化 HTML 演示文稿
  • 要求 Claude 绘制新协议和代码库的 ASCII 图表,帮助理解
  • 构建间隔重复学习 Skill(你讲 → 它追问 → 存储)

Claude 不只是生产工具,也是放大理解力的杠杆。


总结

Boris 在开头就强调:使用 Claude Code 没有唯一正确的方式,每个人的设置都不同。

这 10 条技巧只是起点,真正的效率提升来自于:

  • 大胆实验,找到适合自己工作流的方式
  • 持续迭代,不断优化技能和配置
  • 分享交流,从团队中汲取智慧

AI 时代的编程,拼的不是打字速度,而是定义问题的边界以及调度算力的能力。

⚡️ vite-plugin-oxc:从 Babel 到 Oxc,我为 Vite 写了一个高性能编译插件

写在前面

关注前端工具链的人应该都注意到了,Rust 正在「入侵」这个领域。SWC 被 Next.js 采用,Biome 在蚕食 ESLint 和 Prettier 的地盘,而 Oxc 作为新一代 Rust 工具链,性能数据更是夸张——比 SWC 还快好几倍。

更值得关注的是 Vite 的动向。Evan You 团队正在开发 Rolldown,一个用 Rust 重写的 Rollup,底层就是基于 Oxc。按照 roadmap,Rolldown 会逐步集成到 Vite 中,届时整个构建流程都将是 Rust 实现。

既然大势所趋,为什么不提前体验一下?Oxc 的各个模块(oxc-transformoxc-resolveroxc-minify)都已经通过 npm 包发布了,完全可以在当前的 Vite 项目中直接使用。于是我动手写了 vite-plugin-oxc,把 Oxc 的能力接入 Vite 的插件体系,算是在 Rolldown 正式落地前的一次提前尝鲜。

这就是这个插件的由来。

项目源码:github.com/Sunny-117/v…


一、JavaScript 编译工具的前世今生

在聊具体实现之前,有必要回顾一下 JavaScript 编译工具这些年的演进。这不是为了炒冷饭,而是理解这个演进脉络,才能明白为什么 Oxc 的出现是一种必然。

1.1 Babel:开创性的存在

2014 年,当 ES6 规范还在草案阶段,6to5(后来更名为 Babel)横空出世。那时候浏览器对新语法的支持参差不齐,Babel 让开发者可以放心使用箭头函数、解构赋值、类等新特性,然后转译成 ES5 代码跑在老旧浏览器上。

Babel 的架构设计很经典:

源代码 → Parser(解析成 AST)→ Transformer(插件转换)→ Generator(生成代码)

这套架构的优势在于插件系统的灵活性。任何人都可以写一个 Babel 插件,操作 AST 实现自定义的代码转换。十年过去了,Babel 生态里积累了数以万计的插件,覆盖了几乎所有你能想到的代码转换需求。

但问题也很明显:

Babel 是纯 JavaScript 实现的。JavaScript 是单线程、解释执行、带 GC 的语言,天生就不是性能敏感型任务的最佳选择。当项目规模膨胀到几十万行代码时,Babel 的编译时间会变得令人抓狂。我见过一些大型 monorepo 项目,光 Babel 编译就要好几分钟。

另一个痛点是配置复杂度。@babel/preset-env@babel/plugin-transform-runtimecore-jsbrowserslist……这些概念交织在一起,新手很容易迷失在配置地狱里。我至今还记得当年为了搞清楚 useBuiltIns: 'usage'useBuiltIns: 'entry' 的区别,翻了多少遍文档。

1.2 esbuild:用 Go 重写一切

2020 年,esbuild 的出现彻底改变了游戏规则。

作者 Evan Wallace(Figma 联合创始人)用 Go 语言重写了一个 JavaScript/TypeScript 打包器,性能数据令人瞠目结舌:比 Webpack 快 10-100 倍。这不是什么黑魔法,原因很朴素:

  1. Go 是编译型语言,执行效率远高于 JavaScript
  2. Go 的并发模型让 esbuild 可以充分利用多核 CPU
  3. 从零设计,没有历史包袱,数据结构和算法都针对性能优化过
  4. All-in-one,解析、转换、打包、压缩一条龙,减少中间环节的开销

Vite 选择 esbuild 做预构建(pre-bundling)正是看中了这一点。在开发模式下,esbuild 可以在几百毫秒内把 node_modules 里的依赖打包好,让 Vite 的冷启动时间保持在秒级。

但 esbuild 也有它的局限:

  • 不做类型检查。它只剥离 TypeScript 类型,不验证类型正确性。
  • 插件系统相对简单。不像 Babel 那样可以精细操作 AST,esbuild 的插件主要用于自定义模块解析和加载。
  • 不追求 100% 兼容。一些边缘场景的语法转换可能和 Babel 结果不一致。
  • 作者明确表示不会支持某些特性,比如装饰器的旧版实现。

对于大多数项目来说,这些局限不是问题。但在某些场景下,你可能还是得请出 Babel。

1.3 SWC:Rust 阵营的第一枪

2019 年,韩国开发者 Donny(강동윤)用 Rust 启动了 SWC 项目。名字来源于 "Speedy Web Compiler",目标很直接:做一个更快的 Babel 替代品。

SWC 的策略是 兼容 Babel。它实现了大部分 Babel 的转换能力,配置项也尽量保持一致,让迁移成本降到最低。性能方面,SWC 号称比 Babel 快 20 倍以上。

2021 年,SWC 被 Vercel 收购,成为 Next.js 12 的默认编译器。这是一个标志性事件——Rust 编写的前端工具链开始进入主流视野。

SWC 的优势在于:

  • Rust 的性能。编译型语言、零成本抽象、无 GC 停顿。
  • 良好的 Babel 兼容性。支持大部分 Babel 插件的功能。
  • 持续的投入。有 Vercel 背书,项目维护有保障。

但 SWC 也有一些问题。最常被吐槽的是 编译产物的稳定性。早期版本偶尔会出现一些边缘情况的 bug,导致生产环境翻车。另外,SWC 的架构设计主要服务于 Next.js 的需求,作为通用工具使用时,某些场景的支持不够完善。

1.4 工具演进的本质规律

回顾这段历史,可以看到一个清晰的趋势:

时期 代表工具 实现语言 核心特点
2014-2019 Babel JavaScript 开创性、生态丰富、慢
2020-2021 esbuild Go 极致性能、功能精简
2021-2023 SWC Rust 高性能、Babel 兼容
2023-now Oxc Rust 更快、模块化、工具链

这个演进本质上是在解决同一个问题:如何在保证功能的前提下,榨干硬件的每一分性能

JavaScript 天生不适合这类 CPU 密集型任务,所以社区开始用系统级语言重写。Go 和 Rust 之争,目前看来 Rust 略占上风——主要是因为 Rust 的零成本抽象和更精细的内存控制,在极致性能场景下更有优势。


二、Oxc 凭什么更快

Oxc(Oxidation Compiler)是 2023 年开始崭露头角的新项目,作者是 Boshen Chen。相比前辈们,Oxc 有几个独特的特点。

2.1 不只是编译器,是完整工具链

Oxc 的野心不止于一个编译器。它的目标是提供一整套高性能 JavaScript 工具链:

  • oxc-parser:JavaScript/TypeScript 解析器
  • oxc-transform:代码转换器(JSX、TypeScript 等)
  • oxc-resolver:模块路径解析器
  • oxc-minify:代码压缩器
  • oxc-linter:代码检查器(对标 ESLint)
  • oxc-formatter:代码格式化器(对标 Prettier)

每个模块都可以独立使用,通过 npm 包的形式分发(底层是 Rust 编译成 N-API 原生模块)。这种模块化设计让你可以按需引入,而不是大包大揽。

2.2 性能数据

根据 Oxc 官方的 benchmark,在 parser 层面:

  • 比 SWC 快 3 倍
  • 比 Babel parser 快 40+ 倍

在 transformer 层面,处理 TypeScript 的速度大约是 SWC 的 4 倍

这些数字看起来很夸张,但我自己跑过测试,差距确实存在。Oxc 的作者在性能优化上下了很大功夫,比如:

  • 使用 bumpalo 这种 arena allocator 来减少内存分配开销
  • AST 节点设计更紧凑,减少内存占用
  • 大量使用 SIMD 指令加速字符串处理
  • 零拷贝解析,尽量复用源代码字符串

2.3 兼容性策略

Oxc 的兼容性策略比较务实。它不追求 100% 兼容 Babel 的每一个行为,而是覆盖 实际生产中最常用的转换场景

  • TypeScript 类型剥离
  • JSX 转换(classic 和 automatic 两种模式)
  • ES target 降级(async/await、optional chaining 等)
  • React Fast Refresh 注入

对于大多数项目来说,这些功能已经够用了。


三、vite-plugin-oxc 的设计思路

有了前面的背景铺垫,现在进入正题:如何把 Oxc 接入 Vite?

3.1 需求分析

我给自己定的目标是:

  1. 替代 Vite 内置的 esbuild 做代码转换。包括 TypeScript 类型剥离、JSX 转换。
  2. 提供模块解析能力。用 oxc-resolver 替代 Vite 的默认解析逻辑(可选)。
  3. 提供代码压缩能力。用 oxc-minify 在生产构建时压缩代码。
  4. 支持 React Fast Refresh。开发模式下的 HMR 必须正常工作。
  5. 零配置可用。默认配置就能满足大多数项目的需求。

3.2 Vite 插件机制简介

Vite 的插件系统基于 Rollup,但做了一些扩展。一个 Vite 插件本质上是一个对象,包含若干个 hook 函数。和我们这个插件相关的主要有:

  • config:修改 Vite 配置
  • configResolved:配置解析完成后的回调,可以拿到最终配置
  • resolveId:自定义模块 ID 解析
  • load:自定义模块加载
  • transform:代码转换
  • transformIndexHtml:转换 HTML 入口文件
  • generateBundle:生成产物后的回调,可以修改最终产物

另外还有一个关键配置:enforce。它决定插件的执行顺序:

  • enforce: 'pre':在 Vite 核心插件之前执行
  • 不设置:在 Vite 核心插件之后、用户插件之前执行
  • enforce: 'post':在所有插件之后执行

对于代码转换类插件,通常需要设置 enforce: 'pre',这样才能在 Vite 的默认处理之前介入。

3.3 整体架构

┌─────────────────────────────────────────────────────────────┐
│                     vite-plugin-oxc                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐   │
│  │  Transform   │  │   Resolve    │  │     Minify       │   │
│  │  (oxc-       │  │  (oxc-       │  │   (oxc-minify)   │   │
│  │  transform)  │  │  resolver)   │  │                  │   │
│  └──────────────┘  └──────────────┘  └──────────────────┘   │
│         │                 │                   │             │
│         ▼                 ▼                   ▼             │
│  ┌──────────────────────────────────────────────────────┐   │
│  │                React Fast Refresh                    │   │
│  │               (HMR 边界检测 + 运行时)                 │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

这个插件由三个核心功能模块组成,加上一套 React Fast Refresh 的支持逻辑。下面逐一拆解。


四、Transform 模块:代码转换的核心

代码转换是整个插件最核心的功能。它的职责是把 TypeScript、JSX 这些浏览器不认识的语法,转换成标准的 JavaScript。

4.1 基本实现

先看核心代码:

import { transformSync as oxcTransform } from 'oxc-transform'

// 在 transform hook 中
transform(code, id, transformOptions) {
  if (!filter(id) || options.transform === false) return null

  const isJsxFile = /\.[jt]sx$/.test(id)
  const enableRefresh = isDev && options.reactRefresh && isJsxFile

  const result = oxcTransform(id, code, {
    ...transformOpts,
    sourceType: guessSourceType(id, transformOptions?.format),
    sourcemap: options.sourcemap,
    jsx: {
      ...jsxOpts,
      development: isDev,
      refresh: enableRefresh ? {} : undefined,
    },
  })

  if (result.errors.length) {
    throw new SyntaxError(
      result.errors.map((error) => error.message).join('\n')
    )
  }

  return {
    code: result.code,
    map: result.map,
  }
}

oxcTransformoxc-transform 提供的同步转换函数。它接收三个参数:

  1. 文件路径(用于 sourcemap 和错误信息)
  2. 源代码字符串
  3. 转换选项

返回值包含转换后的代码和 sourcemap。

4.2 sourceType 推断

一个容易被忽略的细节是 sourceType 的处理。JavaScript 有两种模块类型:

  • module:ESM,支持 import/export
  • script:传统脚本,支持 CommonJS

如果 sourceType 判断错误,转换结果可能出问题。比如把 ESM 代码当成 script 处理,import 语句就会报语法错误。

我实现了一个 guessSourceType 函数来推断:

export function guessSourceType(
  id: string,
  format?: string
): 'module' | 'script' | undefined {
  // 优先使用上游传递的 format 信息
  if (format === 'module' || format === 'module-typescript') {
    return 'module'
  } else if (format === 'commonjs' || format === 'commonjs-typescript') {
    return 'script'
  }

  // 根据文件扩展名推断
  const moduleFormat = getModuleFormat(id)
  if (moduleFormat) {
    return moduleFormat === 'module' ? 'module' : 'script'
  }
}

export function getModuleFormat(
  id: string
): 'module' | 'commonjs' | 'json' | undefined {
  const ext = path.extname(id)
  switch (ext) {
    case '.mjs':
    case '.mts':
      return 'module'
    case '.cjs':
    case '.cts':
      return 'commonjs'
    case '.json':
      return 'json'
    case '.jsx':
    case '.tsx':
      return 'module' // JSX/TSX 默认当 ESM 处理
  }
}

这里有个约定:.mjs/.mts 是 ESM,.cjs/.cts 是 CommonJS,.jsx/.tsx 默认当 ESM。对于 .js/.ts,则依赖上游的 format 信息或者返回 undefined 让 Oxc 自己判断。

4.3 与 Vite 内置 esbuild 的协同

Vite 默认会用 esbuild 处理 TypeScript 和 JSX。如果我们的插件也处理这些文件,就会重复转换,结果不可预期。

解决方案是在 config hook 里禁用 esbuild 对 JSX/TSX 的处理:

config(_userConfig, { command }) {
  return {
    esbuild: {
      // 让 esbuild 只处理纯 .ts 文件
      include: /\.ts$/,
      // 排除 JSX/TSX,交给我们的插件处理
      exclude: /\.[jt]sx$/,
    },
    optimizeDeps: {
      esbuildOptions: {
        jsx: 'automatic',
      },
    },
  }
}

这样配置后,.ts 文件继续走 esbuild(速度也很快),而 .jsx.tsx.js 走我们的 Oxc 转换。

不过说实话,这个设计有点 trade-off。理想情况下应该完全接管所有 JS/TS 文件的转换,但考虑到 Vite 生态的兼容性,保持这种混合模式可能更稳妥。

4.4 JSX 转换配置

JSX 转换有两种模式:

  1. Classic:转换成 React.createElement 调用
  2. Automatic:转换成 _jsx/_jsxs 调用,自动引入 react/jsx-runtime

React 17+ 推荐使用 automatic 模式,这也是我们插件的默认行为。配置项支持自定义:

const jsxOpts = transformOpts.jsx && typeof transformOpts.jsx === 'object'
  ? transformOpts.jsx
  : {}

oxcTransform(id, code, {
  jsx: {
    ...jsxOpts,
    development: isDev, // 开发模式启用额外的调试信息
    refresh: enableRefresh ? {} : undefined, // Fast Refresh 注入
  },
})

development: true 会在转换结果中加入 __source__self 等调试信息,方便在 React DevTools 里看到组件的源码位置。


五、Resolve 模块:模块解析

模块解析看起来简单,实际上是个大坑。import './foo' 这行代码,到底应该解析成哪个文件?

  • ./foo.js
  • ./foo.ts
  • ./foo/index.js
  • ./foo/index.ts
  • 还是 ./foo.json

这取决于项目配置、Node.js 版本、模块类型等一系列因素。

5.1 为什么要自己做解析

Vite 内部已经有一套解析逻辑,为什么我们还要用 oxc-resolver 再来一套?

两个原因:

  1. 性能。Oxc resolver 是 Rust 实现的,解析速度比 Vite 的 JavaScript 实现更快。在大型项目中,模块解析的开销不可忽视。
  2. 一致性。既然 transform 用了 Oxc,resolver 也用 Oxc,整个工具链的行为会更一致。

当然,这是可选功能,默认开启但可以关闭。

5.2 基本实现

import { ResolverFactory } from 'oxc-resolver'

let resolver: InstanceType<typeof ResolverFactory> | null = null

// 在 configResolved 中初始化
configResolved(config) {
  if (options.resolve !== false) {
    resolver = new ResolverFactory({
      extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.node'],
      conditionNames: ['import', 'require', 'browser', 'node', 'default'],
      builtinModules: true,
      moduleType: true,
      ...options.resolve,
    })
  }
}

// 在 resolveId hook 中使用
resolveId(id, importer, _resolveOptions) {
  // 处理 React Refresh 虚拟模块
  if (id === '/@react-refresh') {
    return id
  }

  if (!resolver || options.resolve === false) return null

  // 默认跳过 node_modules,除非显式启用
  if (
    !options.resolveNodeModules &&
    id[0] !== '.' &&
    !path.isAbsolute(id)
  ) {
    return null
  }

  try {
    const directory = importer ? path.dirname(importer) : process.cwd()
    const resolved = resolver.sync(directory, id)

    // 处理 Node.js 内置模块
    if (resolved.error?.startsWith('Builtin module')) {
      return {
        id,
        external: true,
        moduleSideEffects: false,
      }
    }

    if (resolved.path) {
      const format = getModuleFormat(resolved.path) || resolved.moduleType || 'commonjs'
      return {
        id: resolved.path,
        format,
      }
    }
  } catch (error) {
    // 解析失败,交给 Vite 的默认逻辑处理
    return null
  }

  return null
}

5.3 性能优化:跳过 node_modules

一个重要的优化是 默认不解析 node_modules

if (
  !options.resolveNodeModules &&
  id[0] !== '.' &&          // 不是相对路径
  !path.isAbsolute(id)       // 不是绝对路径
) {
  return null  // 交给 Vite 处理
}

为什么?因为 Vite 的预构建机制已经把 node_modules 里的依赖处理好了,我们没必要再去解析一遍。只解析项目内的相对路径和绝对路径,性能开销可控。

如果某些场景确实需要解析 node_modules,可以通过配置项开启:

oxc({
  resolveNodeModules: true
})

5.4 内置模块处理

Node.js 有一些内置模块(fspathhttp 等),在浏览器环境是不存在的。当 oxc-resolver 遇到这些模块时,会返回一个特殊的 error:

if (resolved.error?.startsWith('Builtin module')) {
  return {
    id,
    external: true,
    moduleSideEffects: false,
  }
}

把它标记为 external,告诉 Vite 这个模块不需要处理。


六、Minify 模块:代码压缩

生产构建时,代码压缩是必不可少的一环。oxc-minify 提供了和 Terser 类似的压缩能力,但性能更好。

6.1 在 generateBundle 中压缩

代码压缩放在 generateBundle hook 里做:

async generateBundle(_outputOptions, bundle) {
  if (options.minify === false) return

  const { minifySync } = await import('oxc-minify')

  for (const fileName of Object.keys(bundle)) {
    const chunk = bundle[fileName]
    if (chunk.type !== 'chunk') continue

    try {
      const result = minifySync(fileName, chunk.code, {
        ...(options.minify === true ? {} : options.minify),
        sourcemap: options.sourcemap,
      })
      chunk.code = result.code

      // SourceMap 合并...
    } catch (error) {
      this.error(`Failed to minify ${fileName}: ${error}`)
    }
  }
}

这里有几个设计考量:

  1. 为什么在 generateBundle 而不是 transform 因为压缩应该在所有代码转换完成后、输出文件前进行。此时代码已经是最终形态,压缩效果最好。

  2. 为什么用动态 import? oxc-minify 只在生产构建时需要,开发模式下不需要加载这个依赖,动态 import 可以减少冷启动时间。

  3. 为什么跳过非 chunk 类型? bundle 对象里既有 JS chunk,也有 CSS、图片等 asset。我们只压缩 JS。

6.2 SourceMap 合并

压缩代码会改变代码的行列位置,如果项目需要 sourcemap,必须把压缩前后的 sourcemap 合并,才能正确映射到源码。

这个合并逻辑用 @ampproject/remapping 来做:

import remapping, { type EncodedSourceMap } from '@ampproject/remapping'

if (result.map && chunk.map) {
  const minifyMap: EncodedSourceMap = {
    version: 3,
    file: result.map.file,
    sources: result.map.sources,
    sourcesContent: result.map.sourcesContent,
    names: result.map.names,
    mappings: result.map.mappings,
  }
  const chunkMap: EncodedSourceMap = {
    version: 3,
    file: chunk.map.file,
    sources: chunk.map.sources,
    sourcesContent: chunk.map.sourcesContent,
    names: chunk.map.names,
    mappings: chunk.map.mappings,
  }

  // 合并两个 sourcemap
  const merged = remapping([minifyMap, chunkMap], () => null)

  chunk.map = {
    file: merged.file ?? '',
    mappings: merged.mappings as string,
    names: merged.names,
    sources: merged.sources as string[],
    sourcesContent: merged.sourcesContent as string[],
    version: merged.version,
    toUrl() {
      return `data:application/json;charset=utf-8;base64,${Buffer.from(JSON.stringify(this)).toString('base64')}`
    },
  }
}

remapping 函数接收一个 sourcemap 数组,按顺序合并。第一个是最终代码的 map(压缩后),第二个是上一步的 map(压缩前)。合并后的 map 可以从最终代码直接映射回原始源码。

6.3 压缩选项透传

oxc-minify 支持 Terser 风格的压缩选项:

// 默认压缩
oxc({ minify: true })

// 自定义选项
oxc({
  minify: {
    mangle: true,        // 变量名混淆
    compress: {
      dropConsole: true, // 删除 console.log
    },
  }
})

// 禁用压缩
oxc({ minify: false })

这些选项原封不动传给 minifySync,插件层只加了一个 sourcemap 选项的处理。


七、React Fast Refresh:HMR 的核心

React Fast Refresh 是 React 官方的热更新方案,可以在修改组件代码后保留组件状态,只更新改变的部分。要让它正常工作,需要在编译时注入一些运行时代码。

这部分是整个插件最复杂的地方。

7.1 Fast Refresh 的工作原理

Fast Refresh 的基本原理是:

  1. 编译时:在每个模块末尾注入代码,把模块导出的组件注册到 Fast Refresh runtime。
  2. 运行时:当模块热更新时,runtime 对比新旧导出,判断是否可以安全刷新。
  3. 刷新执行:如果可以安全刷新,runtime 触发 React 重新渲染更新后的组件,同时保留状态。

关键在于「安全刷新」的判断。Fast Refresh 只能处理「纯组件变更」的情况。如果模块导出了非组件内容(比如常量、工具函数),且这些内容发生了变化,就必须做完整刷新。

7.2 运行时模块

我实现了一个虚拟模块 /@react-refresh,提供 Fast Refresh 的运行时代码:

const refreshRuntimeCode = `
import RefreshRuntime from 'react-refresh/runtime';

export function injectIntoGlobalHook(globalObject) {
  RefreshRuntime.injectIntoGlobalHook(globalObject);
}

export function register(type, id) {
  RefreshRuntime.register(type, id);
}

export function createSignatureFunctionForTransform() {
  return RefreshRuntime.createSignatureFunctionForTransform();
}

export function performReactRefresh() {
  return RefreshRuntime.performReactRefresh();
}

// 判断是否是 React 组件
export function isLikelyComponentType(type) {
  if (typeof type !== 'function') return false;
  if (type.prototype != null && type.prototype.isReactComponent) return true;
  if (type.$$typeof) return false;
  const name = type.name || type.displayName;
  return typeof name === 'string' && /^[A-Z]/.test(name);
}

// 注册模块导出的组件
export function registerExportsForReactRefresh(filename, moduleExports) {
  for (const key in moduleExports) {
    if (key === '__esModule') continue;
    const exportValue = moduleExports[key];
    if (isLikelyComponentType(exportValue)) {
      RefreshRuntime.register(exportValue, filename + ' export ' + key);
    }
  }
}

// 防抖更新
let enqueueUpdateTimer = null;
function enqueueUpdate() {
  if (enqueueUpdateTimer === null) {
    enqueueUpdateTimer = setTimeout(() => {
      enqueueUpdateTimer = null;
      RefreshRuntime.performReactRefresh();
    }, 16);
  }
}

// 验证刷新边界并触发更新
export function validateRefreshBoundaryAndEnqueueUpdate(id, prevExports, nextExports) {
  // 检查导出是否发生不兼容的变化
  for (const key in prevExports) {
    if (key === '__esModule') continue;
    if (!(key in nextExports)) {
      return 'Could not Fast Refresh (export removed)';
    }
  }
  for (const key in nextExports) {
    if (key === '__esModule') continue;
    if (!(key in prevExports)) {
      return 'Could not Fast Refresh (new export)';
    }
  }

  let hasExports = false;
  for (const key in nextExports) {
    if (key === '__esModule') continue;
    hasExports = true;
    const value = nextExports[key];
    if (isLikelyComponentType(value)) continue;
    if (prevExports[key] === nextExports[key]) continue;
    return 'Could not Fast Refresh (non-component export changed)';
  }

  if (hasExports) {
    enqueueUpdate();
  }
  return undefined;
}

export const __hmr_import = (module) => import(/* @vite-ignore */ module);
`

这段代码做了几件事:

  1. 组件注册registerExportsForReactRefresh 遍历模块导出,把看起来像组件的函数注册到 runtime。
  2. 边界验证validateRefreshBoundaryAndEnqueueUpdate 检查新旧导出的差异,判断是否可以安全刷新。
  3. 防抖更新enqueueUpdate 用 16ms 的防抖,避免短时间内多次触发刷新。

7.3 HTML Preamble 注入

Fast Refresh 需要在页面加载之前初始化全局钩子。通过 transformIndexHtml hook 注入:

transformIndexHtml() {
  if (!isDev || !options.reactRefresh) return []

  return [
    {
      tag: 'script',
      attrs: { type: 'module' },
      children: `
import { injectIntoGlobalHook } from "/@react-refresh";
injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
`,
    },
  ]
}

这段脚本会被插入到 HTML 的 <head> 中,在任何业务代码执行之前运行。它做两件事:

  1. 调用 injectIntoGlobalHook(window) 初始化 runtime。
  2. window 上挂载两个占位函数 $RefreshReg$$RefreshSig$,防止业务代码报错。

7.4 模块尾部代码注入

最后,需要在每个 JSX/TSX 模块末尾注入 HMR 边界检测代码:

if (enableRefresh && transformedCode.includes('$RefreshReg$')) {
  const refreshFooter = `
import * as RefreshRuntime from "/@react-refresh";
const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;
if (import.meta.hot && !inWebWorker) {
  if (!window.$RefreshReg$) {
    throw new Error(
      "vite-plugin-oxc can't detect preamble. Something is wrong."
    );
  }

  RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => {
    RefreshRuntime.registerExportsForReactRefresh(${JSON.stringify(id)}, currentExports);
    import.meta.hot.accept((nextExports) => {
      if (!nextExports) return;
      const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(
        ${JSON.stringify(id)},
        currentExports,
        nextExports
      );
      if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage);
    });
  });
}
function $RefreshReg$(type, id) {
  return RefreshRuntime.register(type, ${JSON.stringify(id)} + ' ' + id)
}
function $RefreshSig$() {
  return RefreshRuntime.createSignatureFunctionForTransform();
}
`
  transformedCode = transformedCode + refreshFooter
}

这段代码的逻辑:

  1. 动态导入自身RefreshRuntime.__hmr_import(import.meta.url) 拿到当前模块的导出。
  2. 注册导出:把导出的组件注册到 runtime。
  3. 接受热更新:通过 import.meta.hot.accept 监听更新,拿到新的导出后验证边界。
  4. 判断刷新方式:如果边界验证失败(invalidateMessage 不为空),调用 invalidate 触发完整刷新;否则自动执行 Fast Refresh。

注意这里有个细节:只有当转换后的代码包含 $RefreshReg$ 时才注入。因为 Oxc 只会在检测到组件定义时才插入这些调用,如果模块里没有组件(比如纯工具函数文件),就不需要这套逻辑。

7.5 Web Worker 兼容

代码里有个判断:

const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;
if (import.meta.hot && !inWebWorker) {
  // ...
}

Web Worker 环境没有 window 对象,也不支持 HMR,所以需要跳过。


八、文件过滤:控制处理范围

不是所有文件都需要经过 Oxc 处理。CSS、图片、JSON 这些应该跳过。

8.1 Filter 实现

export function createFilter(
  include?: FilterPattern,
  exclude?: FilterPattern
): (id: string) => boolean {
  const includePatterns = normalizePatterns(include)
  const excludePatterns = normalizePatterns(exclude)

  return (id: string) => {
    // 先检查 exclude,命中则跳过
    if (excludePatterns.length > 0) {
      for (const pattern of excludePatterns) {
        if (testPattern(pattern, id)) {
          return false
        }
      }
    }

    // 再检查 include
    if (includePatterns.length === 0) {
      return true // 没有 include 规则则默认处理
    }

    for (const pattern of includePatterns) {
      if (testPattern(pattern, id)) {
        return true
      }
    }

    return false
  }
}

function testPattern(pattern: string | RegExp, id: string): boolean {
  if (typeof pattern === 'string') {
    return id.includes(pattern)
  }
  return pattern.test(id)
}

这个实现遵循一个简单的规则:exclude 优先于 include。如果一个文件同时匹配 include 和 exclude,以 exclude 为准。

8.2 默认配置

include: options.include || [/\.[cm]?[jt]sx?$/],
exclude: options.exclude || [/node_modules/],

默认配置的含义:

  • include: 处理所有 .js.jsx.ts.tsx.mjs.mts.cjs.cts 文件
  • exclude: 跳过 node_modules 目录

[cm]? 这个正则匹配可选的 c(CommonJS)或 m(Module)前缀,覆盖了 Node.js 的各种模块扩展名约定。


九、配置系统设计

一个好的插件应该做到「零配置可用,有需要时可配」。

9.1 类型定义

export interface VitePluginOxcOptions {
  include?: FilterPattern          // 文件包含规则
  exclude?: FilterPattern          // 文件排除规则
  enforce?: 'pre' | 'post'         // 插件执行顺序
  transform?: TransformOptions | false  // 转换选项,false 禁用
  resolve?: NapiResolveOptions | false  // 解析选项,false 禁用
  resolveNodeModules?: boolean     // 是否解析 node_modules
  minify?: MinifyOptions | boolean // 压缩选项
  sourcemap?: boolean              // SourceMap 生成
  reactRefresh?: boolean           // React Fast Refresh
}

每个配置项都可以是具体的选项对象、布尔值、或不设置(使用默认值)。

9.2 选项解析

export function resolveOptions(
  options: VitePluginOxcOptions,
  isDev: boolean
): ResolvedOptions {
  return {
    include: options.include || [/\.[cm]?[jt]sx?$/],
    exclude: options.exclude || [/node_modules/],
    enforce: options.enforce,
    transform: options.transform !== false ? (options.transform || {}) : false,
    resolve: options.resolve !== false ? (options.resolve || {}) : false,
    resolveNodeModules: options.resolveNodeModules || false,
    minify: options.minify !== false ? (options.minify || false) : false,
    sourcemap: options.sourcemap ?? isDev,  // 开发模式默认开启
    reactRefresh: options.reactRefresh ?? true,  // 默认开启
  }
}

几个设计决策:

  1. transformresolve 默认开启,可以传 false 禁用
  2. minify 默认关闭,需要显式传 true 或选项对象开启
  3. sourcemap 根据环境决定,开发模式默认开启,生产模式默认关闭
  4. reactRefresh 默认开启,因为大部分 React 项目都需要

9.3 enforce 处理

enforce 的处理比较特殊:

const plugin: Plugin = {
  name: 'vite-plugin-oxc',
  enforce: 'pre',  // 默认值
  // ...
}

// 如果用户显式设置了 enforce,覆盖默认值
if ('enforce' in rawOptions) {
  plugin.enforce = rawOptions.enforce
}

为什么不直接用 options.enforce || 'pre'?因为用户可能想显式设置 enforce: undefined,表示不要任何 enforce 约束。用 'enforce' in rawOptions 可以区分「没传」和「传了 undefined」两种情况。


十、测试策略

工程化项目离不开测试。这个插件的测试主要覆盖以下场景。

10.1 单元测试结构

import { describe, it, expect, vi, beforeEach } from 'vitest'
import vitePluginOxc from '../src/index'

// Mock oxc-transform
vi.mock('oxc-transform', () => ({
  transformSync: vi.fn((_id: string, code: string, _options?: unknown) => ({
    code: `// Transformed: ${code}`,
    map: null,
    errors: [],
  })),
}))

// Mock oxc-resolver
vi.mock('oxc-resolver', () => ({
  ResolverFactory: class MockResolverFactory {
    sync(_directory: string, id: string) {
      return {
        path: `/resolved/${id}`,
        moduleType: 'module',
      }
    }
  },
}))

// Mock oxc-minify
vi.mock('oxc-minify', () => ({
  minifySync: vi.fn((fileName: string, code: string) => ({
    code: `/* Minified */ ${code.replace(/\s+/g, ' ').trim()}`,
    map: null,
  })),
}))

为什么要 mock Oxc 的依赖?因为:

  1. 隔离测试范围。单元测试关注的是插件的集成逻辑,不是 Oxc 本身的转换行为。
  2. 测试执行速度。原生依赖的加载需要时间,mock 后测试更快。
  3. 确定性。Oxc 的版本更新可能改变输出,mock 可以保证测试稳定。

10.2 核心测试用例

describe('vite-plugin-oxc', () => {
  it('should create plugin with default options', () => {
    const plugin = vitePluginOxc()
    expect(plugin.name).toBe('vite-plugin-oxc')
    expect(plugin.enforce).toBe('pre')
    expect(typeof plugin.transform).toBe('function')
  })

  it('should allow overriding enforce option', () => {
    const pluginPost = vitePluginOxc({ enforce: 'post' })
    expect(pluginPost.enforce).toBe('post')

    const pluginNone = vitePluginOxc({ enforce: undefined })
    expect(pluginNone.enforce).toBeUndefined()
  })
})

describe('generateBundle - oxc-minify integration', () => {
  it('should minify chunk code using oxc-minify', async () => {
    const plugin = vitePluginOxc({ minify: true })
    ;(plugin.configResolved as Function)({ command: 'build' })

    const bundle = {
      'index.js': {
        type: 'chunk',
        code: 'function hello() { console.log("hi"); }',
        map: null,
      },
    }

    await (plugin.generateBundle as Function).call({ error: vi.fn() }, {}, bundle)

    expect(bundle['index.js'].code).toContain('Minified')
  })

  it('should skip minification when minify is false', async () => {
    const plugin = vitePluginOxc({ minify: false })
    ;(plugin.configResolved as Function)({ command: 'build' })

    const originalCode = 'function hello() { console.log("hi"); }'
    const bundle = {
      'index.js': { type: 'chunk', code: originalCode, map: null },
    }

    await (plugin.generateBundle as Function).call({ error: vi.fn() }, {}, bundle)

    expect(bundle['index.js'].code).toBe(originalCode)
  })

  it('should merge sourcemaps when both exist', async () => {
    // 测试 sourcemap 合并逻辑
  })
})

测试覆盖了:

  • 插件创建和默认配置
  • 配置覆盖
  • 压缩功能的开启/关闭
  • SourceMap 合并
  • 错误处理

十一、性能实测

说了这么多理论,实际效果如何?我用一个中型 React 项目做了测试。

11.1 测试环境

  • 项目规模:约 200 个 TypeScript/TSX 文件,5 万行代码
  • 机器配置:MacBook Pro M2,16GB 内存
  • Node.js:v20.10.0

11.2 开发模式冷启动

方案 首次启动时间
Vite 默认(esbuild) 1.2s
vite-plugin-oxc 1.1s

开发模式差距不大,因为 Vite 的预构建已经很快了。

11.3 生产构建

方案 构建时间 产物体积
Vite 默认 18.3s 1.42 MB
vite-plugin-oxc (无压缩) 12.1s 1.58 MB
vite-plugin-oxc (开启压缩) 14.7s 1.39 MB

Transform 阶段提速明显(约 33%),开启 oxc-minify 后总体时间也有优势,且压缩效果略好于默认的 esbuild。

11.4 HMR 响应时间

修改一个组件文件后:

方案 HMR 更新时间
Vite + esbuild 50-80ms
vite-plugin-oxc 40-60ms

HMR 场景下 Oxc 的优势更明显,因为单文件转换时 Oxc 的启动开销比例更低。


十二、踩过的坑

开发过程中遇到了不少问题,记录几个典型的。

12.1 esbuild 的 JSX 处理冲突

最开始没有禁用 esbuild 的 JSX 处理,导致 JSX 被转换了两次,结果代码里出现了奇怪的双重嵌套。

解决方案就是在 config hook 里配置 esbuild 跳过 JSX/TSX:

config() {
  return {
    esbuild: {
      include: /\.ts$/,
      exclude: /\.[jt]sx$/,
    },
  }
}

12.2 SourceMap 合并顺序

第一版 sourcemap 合并写反了顺序:

// 错误写法
const merged = remapping([chunkMap, minifyMap], () => null)

// 正确写法
const merged = remapping([minifyMap, chunkMap], () => null)

remapping 的数组是从「最终代码」到「原始代码」的顺序。压缩后的 map 在前,压缩前的 map 在后。

12.3 React Refresh 的 preamble 时机

Fast Refresh 的 preamble 必须在任何业务代码之前执行。最开始我用 transform hook 在第一个 JSX 文件转换时注入,结果时机不稳定。

后来改用 transformIndexHtml hook,直接往 HTML 里插入 <script>,稳定多了。

12.4 模块格式推断

有些项目混用 ESM 和 CommonJS,如果模块格式判断错误,会导致语法错误或运行时问题。

最后的方案是综合多个信息源:

  1. 上游传递的 format 参数
  2. 文件扩展名(.mjs/.cjs 等)
  3. Oxc resolver 返回的 moduleType
  4. 兜底默认值

12.5 虚拟模块的处理

/@react-refresh 是个虚拟模块,不存在于文件系统。需要在 resolveIdload 两个 hook 里配合处理:

resolveId(id) {
  if (id === '/@react-refresh') {
    return id  // 告诉 Vite 这个 ID 我来处理
  }
}

load(id) {
  if (id === '/@react-refresh') {
    return refreshRuntimeCode  // 返回模块内容
  }
}

十三、未来展望

13.1 Rolldown 的影响

Vite 团队正在开发 Rolldown,一个用 Rust 重写的 Rollup。一旦 Rolldown 成熟,Vite 的整个构建流程都会是 Rust 实现,性能会再上一个台阶。

Rolldown 底层使用的就是 Oxc 的 parser 和 transformer,所以 vite-plugin-oxc 的很多逻辑可能会被 Vite 原生支持。到那时,这个插件的历史使命可能就完成了。

实际上,官方文档里已经提到:

这个包已弃用。请使用 @vitejs/plugin-react,因为 rolldown-vite 已自动启用基于 Oxc 的 Fast Refresh 转换。

这说明方向是对的,只是时机早了一点。

13.2 工具链的 Rust 化趋势

纵观前端工具链的演进,Rust 化是一个明确的趋势:

  • Bundler:Rolldown、Turbopack
  • Compiler:SWC、Oxc
  • Linter:oxlint、Biome
  • Formatter:dprint、Biome
  • Package Manager:pnpm(部分 Rust)、Bun(Zig)

JavaScript 工具用 JavaScript 写的时代正在过去。对于开发者来说,这意味着更快的开发体验,但也意味着参与工具开发的门槛变高了——你得会 Rust。

13.3 这个插件的定位

虽然 Rolldown 出来后这个插件可能就没用了,但它的价值在于:

  1. 作为学习材料。展示了如何把一个 Rust 工具链集成到现有的 JavaScript 生态中。
  2. 作为过渡方案。在 Rolldown 正式发布前,想尝鲜 Oxc 的人可以用这个插件。
  3. 作为参考实现。React Fast Refresh 的集成逻辑,sourcemap 合并的处理,这些代码可以被其他项目借鉴。

十四、总结

回到开头的问题:2026 年了,前端构建为什么还是慢?

答案是:工具链正在追赶硬件的脚步,只是还没追上。

从 Babel 到 esbuild,从 SWC 到 Oxc,每一代工具都在压榨更多性能。vite-plugin-oxc 是我在这条路上的一次尝试——用 Oxc 这套 Rust 工具链,给 Vite 的构建流程提提速。

核心实现其实不复杂:

  • Transform:调用 oxc-transform 做代码转换,处理好 sourceType 推断和 JSX 配置
  • Resolve:用 oxc-resolver 做模块解析,默认跳过 node_modules 保证性能
  • Minify:在 generateBundle 阶段用 oxc-minify 压缩,注意 sourcemap 合并
  • React Fast Refresh:虚拟模块 + HTML preamble + 模块尾部注入,三件套配合

难点在于细节:与 Vite 内置 esbuild 的配合、sourcemap 合并的顺序、模块格式的正确推断、HMR 边界检测的实现……这些东西文档不会告诉你,只能靠踩坑。

这个插件的代码开源在 GitHub 上,欢迎 star 和 PR。虽然它可能很快就会被 Rolldown 取代,但在那之前,希望它能给想了解 Vite 插件开发、Oxc 集成的同学一些参考。

前端工具链的进化永远不会停止。今天是 Oxc,明天可能是更快的东西。作为开发者,保持学习、保持好奇,可能是我们能做的最重要的事。

项目源码:github.com/Sunny-117/v… 欢迎 Star、Issue 和 PR!


参考资料

ArcPy 开发环境搭建

^ 关注我,带你一起学GIS ^

通过上一节ArcPy,一个基于 Python 的 GIS 开发库简介[1],我们知道了ArcPy作为ArcGIS桌面软件随附的一部分,要么集成在ArcGIS DesktopArcGIS Pro中,要么存在于ArcGIS Engine或者ArcGIS Server中。

文中以ArcGIS Pro3.5为例进行讲解。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2026年

系统:Windows 11

ArcGIS Pro:3.5

Python:3.11.11

2. 安装Arcpy

ArcPy包是默认 Python 分布 arcgispro-py3(ArcGIS Pro 和 ArcGIS Server 随附)的一部分。通过克隆 arcgispro-py3 使用 ArcPy 创建环境。 可以使用 ArcGIS Pro 中的软件包管理器,或 Python 命令提示符中的 conda 命令行应用程序来克隆环境。

从 ArcGIS Pro 2.7 开始,当 ArcPy 包版本不冲突时,可将其添加到现有 Python 3 环境中。 要添加 ArcPy,可以使用 conda 从 Anaconda Cloud 上的 Esri 频道安装 ArcPy。 从 Python 命令提示符中,使用适当的版本号运行以下命令:

conda install arcpy=3.6 -c esri

需要注意的是即使ArcPy可以下载安装,但包仍需要ArcGIS Pro,必须安装它才能使用ArcPy

3. 创建环境

3.1. 创建基础环境

随附在ArcGIS Pro、arcgispro-py3中的默认Python环境包含了对用于支持所有ArcGIS Pro Python使用案例的 200 多个软件包的访问权限。在某些情况下,此环境中包含的内容可能远远超出您的需求。 如果你只需要一个简单的环境,即仅包含运行地理处理工具和核心 ArcPy函数所需的最少依赖项的环境,则请使用arcpy-base环境。

arcpy-base明显小于 arcgispro-py3,其中仅包含几十个依赖项。要创建基于arcpy-base的环境,请运行以下conda命令:

conda create -n my-env arcpy-base

凭借一组有限的库,arcpy-base无法完全支持所有基于PythonArcGIS Pro功能。arcpy-base仍可用于运行几乎所有地理处理工具和ArcPy函数,其中包括NumPyGDAL 和Pandas等软件包。

仅使用 arcpy-base将限制对Notebooks(从 ArcGIS Pro 内部和外部)、ArcGIS API for Python 以及许多其他库(包括 matplotlib、pillow、pytest、requests、scipy、sqalachemy 和 swat)的访问权限。

3.2. 克隆arcgispro-py3环境

可以使用ArcGIS Pro包管理器来克隆arcgispro-py3环境。

打开ArcGIS Pro软件,点击Project(工程)。

点击包管理器Package Manager,可以看到当前Python环境已经下载的依赖。

右侧矩形红框查看当前的Python环境,可以点击设置按钮添加Python环境。

可以新建环境,也可以复制环境。

4. 注意事项

官方的建议是不要修改默认环境,否则很可能会影响ArcGIS Pro软件运行。

小小声说一下,我本地为了方便,一直使用的是默认arcgispro-py3环境,到目前为止,还没出现什么问题(何时因为环境问题导致ArcGIS Pro软件无法运行,我还会发文进行说明的)。

参考资料[1] 

ArcPy,一个基于 Python 的 GIS 开发库简介


GIS之路-开发示例数据下载,请在公众号后台回复:vector

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集(全)

GDAL 开发合集(全)

GIS 影像数据源介绍

GeoJSON 数据源介绍

GIS 名词解释

ArcPy,一个基于 Python 的 GIS 开发库简介

GIS 开发库 Turf 介绍

GIS 开发库 GeoTools 介绍

GIS 开发库 GDAL 介绍

地图网站大全

从微信指数看当前GIS框架的趋势

Landsat 卫星数据介绍

OGC:开放地理空间联盟简介

中国地图 GeoJSON 数据集网站介绍

结合OpenSpec 与 Everything-Claude-Code (ECC) 的构建团队工作流程

一、两者定位本质差异

这两个项目解决的是 AI 辅助开发流程中完全不同层面的问题,理解这一点是最关键的。

OpenSpec 解决的是 "做什么"(What) 的问题 —— 它是一个规格驱动开发(Spec-Driven Development)框架。核心理念是:在 AI 写代码之前,先让人和 AI 就需求规格达成共识。它通过 proposal → specs → design → tasks 的制品(artifact)依赖图来管理每一个变更的完整生命周期。

ECC 解决的是 "怎么做"(How) 的问题 —— 它是一个AI 编码助手的配置工具箱。核心理念是:提供一整套生产就绪的 agents、skills、hooks、commands、rules 和 MCP 配置,让 Claude Code(以及 Cursor/OpenCode/Codex)的执行能力最大化。

打个比方:OpenSpec 相当于项目经理的需求管理系统,ECC 相当于开发者的瑞士军刀。


二、核心机制对比

OpenSpec 的核心机制

OpenSpec 的核心是一个 制品依赖图引擎(Artifact DAG)。每个变更(change)被组织为一个独立文件夹,包含四类制品:

proposal.md 记录意图和范围,specs/ 通过 Delta 格式(ADDED/MODIFIED/REMOVED)描述行为变化,design.md 记录技术方案和架构决策,tasks.md 则是带复选框的实施清单。这些制品形成有向无环图的依赖关系,状态通过文件系统存在性自动检测(BLOCKED → READY → DONE)。

它的 OPSX 工作流打破了传统线性阶段的限制,采用流动式操作:你可以在实施过程中随时回头修改设计,不存在阶段门禁。同时它支持 24+ AI 编码工具(Claude Code、Cursor、Windsurf、Gemini CLI、GitHub Copilot、Kiro 等),做到了真正的工具无关性。

特别值得一提的是它的 Delta Spec 概念 —— 不是重写整个规格,而是描述变化量,这对存量项目(brownfield)极其友好。变更完成后通过 archive 操作将 delta 合并回主规格,形成持续演进的系统行为文档。

ECC 的核心机制

ECC 是一套模块化的配置组件库,包含 13 个专用子代理(planner、architect、tdd-guide、code-reviewer、security-reviewer 等),56 个 skills 覆盖前后端模式、持续学习、安全审计等领域,32 个斜杠命令(/plan、/tdd、/code-review、/e2e 等),以及 hooks 系统实现会话记忆持久化、自动格式化、战略性上下文压缩等自动化行为。

它的 Instinct 持续学习系统(v2)是亮点:自动从会话中提取模式,赋予置信度评分,支持跨团队导入导出,并可通过 /evolve 命令将相关直觉聚合为技能。

此外 ECC 的 AgentShield 安全扫描器(1282 个测试、102 条规则)可以扫描你的 AI 配置(CLAUDE.md、hooks、MCP 等)中的漏洞和注入风险,甚至支持三个 Opus 代理的红队/蓝队/审计流水线。


三、各自优缺点

OpenSpec

优点: 工具无关性是它最大的战略优势。支持 24+ AI 工具意味着团队不会被锁定在某个特定 IDE 或 AI 提供商上,前端用 Cursor、后端用 Claude Code 的团队可以共享同一套规格流程。Delta Spec 机制为存量项目提供了优雅的增量规格管理。自定义 schema 能力让团队可以定义自己的制品类型和依赖关系,比如加入 research 步骤。作为 npm 包分发(openspec init 一条命令初始化)降低了团队推广门槛。同时,制品文件夹结构天然支持 Git 版本控制和代码评审。

缺点: 它不涉及代码执行层面的优化,不提供 agent、hook 或 coding skill。对于小型快速迭代任务(hotfix、小 bug)流程可能显得重。需要团队养成写规格的习惯,存在文化适配成本。它也没有 token 优化、上下文管理、会话持久化等运行时增强能力。

ECC

优点: 开箱即用的生产级配置经过了实际产品构建的验证(Anthropic 黑客松获奖作品)。token 优化策略具体且实用(模型选择、thinking token 限制、compaction 阈值),能显著降低成本。多语言 rules 架构(common/ + typescript/ + python/ + golang/)适配不同技术栈。Instinct 学习系统让团队经验可以积累和传承。AgentShield 安全扫描填补了 AI 配置安全性的空白。同时跨平台支持(Claude Code、Cursor、Codex CLI、OpenCode)也相当完善。

缺点: 以 Claude Code 为主要目标,对其他工具的支持虽在扩展但存在功能差距。缺少需求-设计-实施的结构化流程管理,/plan 命令只是调用 planner agent 生成蓝图,不像 OpenSpec 那样有制品依赖图和生命周期管理。配置项繁多(56 skills + 32 commands),新用户需要时间理解哪些组件适合自己。Rules 无法通过插件自动分发(Claude Code 平台限制),需要手动安装。


四、最佳实践姿势

OpenSpec 最佳实践

适合的场景是中大型功能、跨团队协作、需要需求对齐的工作。推荐的流程是:对于明确需求用 /opsx:propose 快速启动然后 /opsx:apply 实施;对于模糊需求先用 /opsx:explore 探索再决定方案;保持每个 change 聚焦于一个逻辑单元;archive 前用 /opsx:verify 验证实现与规格的一致性。

命名规范也很重要:用 add-dark-mode、fix-login-redirect 这种描述性名称,避免 feature-1、wip 这种模糊命名。

ECC 最佳实践

token 管理是关键:默认使用 sonnet,只在复杂架构推理时切换 opus;将 MAX_THINKING_TOKENS 设为 10000,CLAUDE_AUTOCOMPACT_PCT_OVERRIDE 设为 50;MCP 服务器保持 10 个以内,工具总数 80 以内。

工作流模式上:新功能用 /plan → /tdd → /code-review 三步走;修复 bug 先用 /tdd 写失败测试再修复;发布前用 /security-scan + /e2e + /test-coverage 三重验证。任务之间用 /clear 重置上下文,逻辑断点处用 /compact 压缩。


五、面向公司前后端团队的整合方案

基于对两者的深入分析,我推荐的整合策略是 OpenSpec 做流程骨架 + ECC 做执行引擎,两者互补而非替代。

第一层:需求与规格管理(OpenSpec)

在项目根目录执行 openspec init 初始化,所有非 trivial 的功能开发都从 /opsx:propose 或 /opsx:explore 开始。前端和后端开发者在同一个 openspec/changes/ 目录下协作,但各自维护各自领域的 specs(如 specs/api/、specs/ui/)。利用 OpenSpec 的工具无关性,让用不同 IDE 的团队成员共享同一套规格流程。

第二层:编码执行增强(ECC)

在每个开发者的环境中安装 ECC 插件并配置对应语言的 rules(前端装 common/ + typescript/,后端根据栈选择 python/ 或 golang/)。利用 ECC 的 agents 体系:planner 和 architect 辅助 OpenSpec 的 proposal 和 design 阶段,tdd-guide 和 code-reviewer 在 /opsx:apply 实施阶段提供质量保障。配置 token 优化策略统一降本。

第三层:质量与安全闭环

实施完成后,用 /opsx:verify 验证规格一致性(OpenSpec),同时用 ECC 的 /security-scan、/test-coverage、/e2e 进行技术层面验证。用 AgentShield 定期扫描团队的 AI 配置安全性。

第四层:知识积累与传承

利用 ECC 的 Instinct 系统自动学习团队模式,定期 /instinct-export 导出分享。OpenSpec 的 archive 机制自动积累系统行为规格,新人 onboard 时直接阅读 openspec/specs/ 了解系统当前行为。

推荐的完整工作流

一个典型的功能开发流程如下:

  1. PM/开发者启动 → /opsx:explore(OpenSpec,厘清需求)
  2. 需求明确后 → /opsx:propose(OpenSpec,生成 proposal + specs + design + tasks)
  3. 前后端评审制品 → 在 Git PR 中 review openspec/changes/feature-name/
  4. 实施阶段 → /opsx:apply + ECC 的 /tdd/code-review(OpenSpec 管进度,ECC 保质量)
  5. 验证阶段 → /opsx:verify + /security-scan + /e2e
  6. 完成归档 → /opsx:archive(delta specs 合并回主规格)

这套方案的核心价值是:OpenSpec 确保团队在写代码前达成共识(减少返工),ECC 确保写代码时效率和质量最大化(降低成本)。两者结合覆盖了从需求到交付的完整 AI 辅助开发链路。

告别漫长的HbuilderX云打包排队!uni-app x 安卓本地打包保姆级教程(附白屏、包体积过大排坑指南)

接触过 uni-app 的同学,在进行 App 打包时习惯使用 HBuilderX 的“云打包”。但随着项目变大,你一定会遇到这些痛苦:

  • 漫长的排队与等待:打个包动辄半小时起步,遇到高峰期更是无限制延长打包时间

image.png

  • 体积过大的“斩杀线” :包体积稍微大点,HBuilderX 就会提示你需要额外付费才能打包。

image.png

  • 恼人的次数限制:每天的免费打包次数有限,稍微改个 bug 想测一下都得精打细算。
  • 各种受限的配置:例如使用谷歌登录时,应用名称会被云打包强制固定为 uniappX,无法修改。

今天,我将手把手教你如何跑通 Android 本地打包流程!一次配置,终身受益!

本地打包的绝对优势:

  1. 极速出包:打包时间从原本半小时以上(周五较多人排队打包更是一次一小时以上),直接缩短到 20 秒左右!(视情况而定)
  2. 绝对自由:你可以随心所欲地修改 Android 原生配置(如包名、各种第三方登录的名称等)。
  3. 自主瘦身:可自行精简 SDK,剔除不需要的模块,完美避开云打包的大体积收费限制。
  4. 无限续杯:没有次数限制,没有排队,随心所欲,想打就打!

image.png

(注:本文基于 uni-app x 5.01 Alpha 版本演示,其他版本流程基本一致。)

废话少讲,准备动手,准备动手

image.png

准备阶段:环境与资源

1. 下载官方离线 SDK

前往 DCloud 官网,下载与你的 HBuilderX 版本完全一致的 Android 离线 SDK。

官方文档链接:doc.dcloud.net.cn/uni-app-x/n…

image.png

2. 在 HBuilderX 中生成本地资源

在你的 uni-app x 项目中,点击顶部菜单栏 发行 ➡️ 原生App-本地打包 ➡️ 生成本地打包App资源
编译完成后,会生成一个以你的 AppID 命名的文件夹(如 __UNI__1940137),复制这个文件夹备用。

image.png

勾选你要打包的应用类型:Android or iOS

image.png

image.png


核心阶段:配置 Android Studio 工程

第一步:打开正确的纯安卓工程

  1. 解压刚才下载的官方离线 SDK 压缩包。(如下图所示)

image.png

  1. 打开 Android Studio,点击 Open(或 File -> Open)。

  2. ⚠️重点防坑:  不要直接打开最外层文件夹,一定要展开目录,选中里面的 uniappxnativepackage 这个文件夹,点击打开。(如图所示)

image.png

  1. 【注意:耐心等待】  打开之后,注意看软件的右下角,会有一个进度条在转,或者显示 Gradle Build Running... / Syncing...。
    👉 只要右下角还在转,就什么都不要点,把手离开鼠标,去喝口水。等它彻底转完,左边出现一个带绿色安卓小机器人图标的 app 文件夹,才算准备就绪!

image.png

  1. 加载成功,准备就绪:

image.png

  1. 这时候注意你的文件类型识别为了Android,为了方便操作对应文件路径,把Android切换为Project,如图所示:

image.png

第二步:导入你的前端代码资源

  1. 在 Android Studio 左侧目录树,依次展开:app -> src -> main -> assets -> apps。(如果找不到assets文件夹,可直接在main下自行创建)
  2. 将刚才在 HBuilderX 里生成的 __UNI__XXXXXXX 文件夹,直接粘贴到 apps 目录中。(如下图所示)

image.png

image.png

image.png

image.png

第三步:修改 App 桌面名称

  1. 展开目录:app -> src -> main -> res -> values。
  2. 双击打开 strings.xml。
  3. 将 uni-app x 中的 uni-app x 修改为你真实的 App 名称。

image.png

第四步:修改应用包名 (Package Name)

  1. 找到 app 目录直属的 build.gradle(图标带个大象🐘)并打开。
  2. 找到 defaultConfig 节点下的 applicationId "com.xxxx.xxxx"
  3. 将其修改为你自己的包名(如 com.yourcompany.app)。
  4. 务必点击右上角弹出的 Sync Now 进行代码同步。

image.png

  1. 注意:修改build.gradle后要点击右上角的 Sync Now应用一下,否则无效 image.png

关键阶段:配置证书与离线 AppKey

要让 App 正常运行并成功打包,必须配置签名证书和离线打包 Key,否则打开会直接红屏报错

第五步:获取云端证书与生成 AppKey

  1. 登录 DCloud 开发者后台(dev.dcloud.net.cn),进入你的项目。
  2. 在  “Android云端证书”  页面,下载你的 .keystore 证书文件到电脑桌面,并记录下证书密码证书别名 (Alias)  和 SHA1指纹
  3. 在左侧菜单找到  “各平台信息” -> “离线打包Key管理”
  4. 填入你刚才配置的包名和 SHA1 指纹,点击生成,复制生成好的那一长串 AppKey

第六步:将证书放入工程并配置

  1. 将下载好的 .keystore 证书文件,直接复制粘贴到 Android Studio 左侧的 app 文件夹根目录下。(如下图所示放到app目录下)

image.png

  1. 再次打开 app/build.gradle 文件,在 buildTypes { 这一行的正上方,手动添加如下签名配置:
signingConfigs {
        config {
            keyAlias '你的证书别名'
            keyPassword '你的证书密码'
            storeFile file('你的证书文件名.keystore')
            storePassword '你的证书密码'
            v1SigningEnabled true
            v2SigningEnabled true
        }
    }
  1. 然后在下方的 buildTypes -> release 里面,加上一行引用:signingConfig signingConfigs.config
  2. 修改完毕后,再次点击右上角的 Sync Now

image.png

第七步:配置离线 AppKey

  1. 打开 app -> src -> main -> AndroidManifest.xml。
  2. 滑动到文件最下方,在  标签的正上方,添加如下代码:
<meta-data
            android:name="dcloud_appkey"
            android:value="在这里粘贴你刚才生成的极长AppKey字符串" />

image.png


避坑指南:解决白屏与包体积过大问题(必看!)

如果现在直接打包,你会面临两个新手必踩的坑:打开只有标题栏一片空白,以及包体积高达 150MB+ 。我们需要做最后两步优化。

第八步:解决首页白屏问题

原因:  官方模版默认的 MainActivity.kt 是一个带安卓原生按钮的测试壳子,我们需要把它换成直接启动 uni-app 的代码。
解决:

  1. 打开 app -> src -> main -> java -> ... -> MainActivity.kt。
  2. 清空里面的所有代码,替换为以下纯净版启动代码(注意包名要保留你自己的):
package com.example.uniappx_native_package // 这里的 package 保持你文件原有的不要动

import android.os.Bundle
import io.dcloud.uniapp.UniAppActivity

class MainActivity : UniAppActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }
}

image.png

第九步:App 瘦身(减小包体积)

原因:  离线 SDK 默认是“全家桶”,把微信、支付宝、个推、地图等所有模块全塞进去了。
解决:

  1. 打开 app/build.gradle,滑到最底部的 dependencies { ... } 区域。
  2. 把你项目中没有用到的功能依赖,在前面加上 // 注释掉。
    (例如:没用到华为广告,就注释掉 implementation "com.huawei.hms:ads-lite...";没用到高德地图,就注释掉 implementation 'com.amap.api:...' 等等)
  3. 注释完成后,点击 Sync Now。这能让你的 APK 体积瞬间缩小几十兆!

image.png


最终阶段:一键出包!

激动人心的时刻到了!

  1. 点击 Android Studio 顶部菜单栏:Build -> Generate Signed Bundle / APK...
  2. 选择 APK,点击 Next。
  3. 选择你的证书路径,填入密码和别名,勾选记住密码,点击 Next。
  4. 在最后一个窗口,选中 release(正式版) ,或者 debug(调试版)
  5. 点击 Create

image.png

image.png

image.png

image.png

等待右下角进度条跑完,点击弹窗中的 locate 定位文件夹。
恭喜你!完美打包的 app-debug.apk 就出现了

image.png


总结:
第一次本地打包由于要对齐包名、证书、AppKey,稍显繁琐。但这套流程跑通之后,以后你每次修改了前端代码,只需在 HBuilderX 里生成一下本地资源,去 Android Studio 替换掉 apps 目录下的文件夹,然后直接点 Build,20 秒左右即可一键出包

再也不用忍受云端漫长的排队等待,再也不用担心大体积应用的额外收费,真正的“打包自由”,你值得拥有!

image.png

下次有空再更新下 iOS 的本地打包

下次再见!🌈

Snipaste_2025-04-27_15-18-02.png


[前端特效] 左滑显示按钮的实现介绍

最近在开发「Todo-List」应用,今天想介绍下一个前端特效 - 左滑显示按钮组的实现。

左滑功能演示压缩.gif

精简后的代码已提交至Github-Gist:slide-item-demo.html,有需要自取~

下述是具体实现的讲解。

页面代码

<div class="slide-container">
    <!-- 滑动项 -->
    <div class="slide-item" id="item1">
        <div class="item-content">项目1 - 向左滑动删除</div>
        <div class="delete-btn">删除</div>
    </div>
    <div class="slide-item" id="item2">
        <div class="item-content">项目2 - 向左滑动删除</div>
        <div class="delete-btn">删除</div>
    </div>
</div>

这里页面代码就一个容器,内部是列表项,而每个列表项内部是一个主体内容外加一个删除按钮。

样式代码

.slide-item {
    position: relative;
    overflow: hidden; /* 隐藏超出部分 */
    user-select: none; /* 防止拖拽时选中文字 */
    ...
}

.item-content {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: 2;
    ...
}

.delete-btn {
    ...
}

上述是精简后的核心的样式,主要有:

  • slide-item 的溢出隐藏,来保证左滑后主体部分溢出列表项边界后被隐藏
  • item-content 的绝对位置是结合父组件的相对位置来使用的,确保位置是在父组件内;topleft固定左上角位置;z-index必须大于delete-btn的,来确保未滑动时,可以遮挡隐藏删除组件
  • delete-btn基本和item-content类似的样式

滑动效果实现

滑动效果的实现主要依赖 JavaScript,这块代码确实有点实现难度,十分考验程序员对 JavaScript 各种监听事件以及对变量状态的熟练使用程度。

这里具体源码就不展示了,主要是太长了,就介绍下大体实现思路吧。有源码需要的同学,请自取:slide-item-demo.html

监听事件的运用

主要涉及两类事件:一类是实现左滑交互效果的拖拽事件的监听;一类是防止干扰的点击事件或原生拖拽事件的监听。

1. 基础拖拽事件:实现左滑交互效果

  • mousedown - 开始拖拽:当鼠标在元素上按下时触发,通常在这里记录初始位置、准备拖拽、可以设置拖拽标志为true;具体到这里的左滑效果中是 dragStartHandler 事件。
  • mousemove - 拖拽过程:鼠标在元素上移动时持续触发,负责更新元素位置,配合mousedown开启的标志位来执行;具体到这里的左滑效果中是 dragMoveHandler 事件。
  • mouseup - 结束拖拽:鼠标松开时触发,清理拖拽状态、重置相关标志位;具体到这里的左滑效果中是 dragEndHandler 事件。

2. 防止其他事件干扰

  • click - 点击处理:点击时不触发拖拽效果甚至回复拖拽效果;具体到这里的左滑效果中是 clickHandler 事件。
  • dragstart - 阻止原生拖拽:阻止浏览器默认的拖拽行为(如图片拖拽、链接拖拽),避免与自定义拖拽实现冲突
item.addEventListener('dragstart', (e) => e.preventDefault());

状态变量的运用

整个处理过程中,通过状态变量来控制组件的最终位置等数据,最终配合拖拽事件等来实现左滑效果。

具体来说就是两个变量了:stateinstances

  • state 用于控制具体左滑项的各种位置信息和状态信息。
  • instances 用于存储整个列表项数据,来确保点击其他位置时,原已经滑动的列表项可以恢复,从而实现「滑动A后,滑动B,此时,A自动恢复」。

总结

日常看到的含拖拽效果,我理解应该都是类似上述代码实现的。掌握了上述操作,其他拖拽效果也就会了。


好啦,以上就是今天的讲解内容啦,感谢阅读,欢迎三连!

前端JS: 虚拟dom是什么? 原理? 优缺点?

虚拟DOM (Virtual DOM)

什么是虚拟DOM?

虚拟DOM是一个JavaScript对象,它是真实DOM的轻量级内存表示。它是一个抽象层,用于描述UI应该是什么样子。

核心原理

1. 创建虚拟DOM树

// 虚拟DOM对象示例
const vNode = {
  type: 'div',
  props: {
    className: 'container',
    onClick: () => console.log('clicked')
  },
  children: [
    { type: 'h1', props: {}, children: 'Hello' },
    { type: 'p', props: {}, children: 'Virtual DOM' }
  ]
};

2. Diff算法(差异化比较)

  • 比较策略

    • 同层级比较(时间复杂度O(n))
    • 类型不同 → 直接替换
    • 类型相同 → 比较属性
    • 列表比较(key优化)

3. 渲染流程

真实DOM操作昂贵          虚拟DOM操作快速
     ↓                         ↓
状态变化 → 生成虚拟DOM → Diff比较 → 最小化更新 → 更新真实DOM

核心实现步骤

1. 初始化

// 创建虚拟DOM
function createElement(type, props, ...children) {
  return {
    type,
    props: props || {},
    children: children.flat()
  };
}

2. Diff算法实现思路

function diff(oldVNode, newVNode) {
  // 1. 类型不同,直接替换
  if (oldVNode.type !== newVNode.type) {
    return { type: 'REPLACE', newNode: newVNode };
  }
  
  // 2. 属性比较
  const propPatches = diffProps(oldVNode.props, newVNode.props);
  
  // 3. 子节点比较
  const childrenPatches = diffChildren(oldVNode.children, newVNode.children);
  
  return { propPatches, childrenPatches };
}

优点

1. 性能优化

  • 批量更新:合并多次DOM操作
  • 最小化更新:只更新变化的部分
  • 减少重排重绘:优化渲染性能

2. 开发效率

  • 声明式编程:关注"应该是什么样子"
  • 跨平台能力:一套代码多端渲染
  • 组件化:更好的代码组织和复用

3. 其他优势

  • 抽象真实DOM差异
  • 更好的可测试性
  • 框架级优化支持

缺点

1. 性能开销

  • 内存占用:额外存储虚拟DOM树
  • CPU计算:Diff算法有计算成本
  • 初始渲染慢:需要构建虚拟DOM树

2. 适用场景限制

  • 简单页面不适用:小项目可能得不偿失
  • 实时性要求高:游戏、动画等场景
  • SSR首屏:可能产生双重计算

3. 学习成本

  • 需要理解虚拟DOM概念
  • 框架特定的API学习
  • 调试相对复杂

实际应用对比

原生DOM操作

// 传统方式
const list = document.getElementById('list');
list.innerHTML = '';  // 清空(重绘)
items.forEach(item => {
  const li = document.createElement('li');
  li.textContent = item;
  list.appendChild(li);  // 多次重排
});

虚拟DOM方式

// React示例
function List({ items }) {
  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.text}</li>)}
    </ul>
  );
}
// 只更新变化的li,批量DOM操作

现代框架实现差异

React

  • Fiber架构:可中断的Diff过程
  • 协调器(Reconciler):调度更新优先级
  • 并发模式:更好的用户体验

Vue

  • 响应式依赖追踪
  • 编译时优化:静态节点提升
  • 更细粒度的更新

性能优化建议

1. 合理使用key

// 好的:稳定唯一的key
{items.map(item => (
  <ListItem key={item.id} item={item} />
))}

// 避免:index作为key
{items.map((item, index) => (
  <ListItem key={index} item={item} />  // 不推荐
))}

2. 减少不必要的渲染

  • 使用React.memo、PureComponent
  • 合理使用useMemo、useCallback
  • 避免在render中创建新对象/函数

3. 代码分割

  • 按需加载组件
  • 路由懒加载
  • 减少初始包大小

总结

虚拟DOM是现代前端框架的核心技术,它在大多数应用场景下提供了更好的开发体验和可接受的性能。但对于性能极其敏感或简单的应用,直接操作DOM或使用更轻量的方案可能更合适。

使用建议

  • 大型复杂应用 ✅ 推荐使用
  • 简单静态页面 ❌ 可能过度设计
  • 性能敏感场景 🔍 需要详细评估

别再谈提效了:AI 时代的开发范式本质变了

回应标题,为什么说别再谈提效。只要说的是提效,那思维主体就依旧是人为主导,AI 是辅助。但新时代的开发范式主导权已经发生了迁移:从“人主导、AI 辅助”,变成“AI 主导、人辅助”。

真正该讨论的是——当 AI 成为主要生产者,人该负责什么?

我认为真正的转折点是 Opus 4.6、GPT 5.2/5.3 模型的发布,模型能力大幅增强,使 AI 编码领域上了一个大的阶梯,比如 Remotion。

传统开发范式

不是把原流程每一步都加个 AI 就是新的开发方式,而是需要真正思考AI时代下的新范式,把传统流程里“靠人搬运信息、补齐细节、重复劳动、低效回路”的部分,改成更短的闭环 + 更强的自动验证。

AI 时代,代码是副产品,推理意图才是真资产

在先理解的开发范式之前,先达成一个共识:在 AI 时代下,代码不重要,而为什么这么写代码才是最重要的,推理过程才是真的资产。

代码越来越像编译产物,而推理过程(意图、边界、风险判断)才是可复用的工程资产。

在旧范式里,人写代码 -> 人 review 代码 -> Git 保存 Diff。Git 只记住改了什么,但是没有记住为什么改。

这在 AI 时代下已经不可用,因为 Agent 生成的是海量代码,Agent 代码生成量已经超过人能 review 的上限,如果还靠人去 review,那你的上限就是人,但如果 review 的是意图,那你的上限就约等于 AI 的上限,这两者可能是数十倍的差距。

那开发者不看代码了看什么?看意图。

前一段时间我还认为现在的工程师是 code review 工程师,但是现在我认为可以将前面的 code 去掉了。

新范式里更接近两种形态:

  • Agent 写代码,人 review 意图:人不再逐行读实现,而是确定风险是否可控,验证是否正确
  • 人给意图和标准,Agent 生成代码并自证:人提供目标、边界情况、验收标准,Agent 负责实现、写测试用例、跑验证、给出变更理由和影响范围

我个人目前更倾向于第二种,因为它把人的注意力从实现细节转移到了验收上。

新范式里需要保存推理链路,如果推理过程只存在于上下文窗口中,一旦 Session 结束,上下文就丢失了,这最终只会导致:代码仓库越来越大,项目越来越不可控,因为关键的为什么没有被持久化。

所以,仅仅把代码存进 Git 里已经不够了,还需要一个意图资产库,把推理过程沉淀为可复用、可审计、可持续迭代更新的持久化资产。

理解代码为什么被写出来,比看到代码本身更重要。

面向人工智能编码助手的规范驱动开发 SDD:github.com/Fission-AI/…

开发新范式

各步骤流程具体解释

  • 产品提出需求:需求文档需要尽可能详细,包括背景、用户场景、功能描述、可衡量的目标

  • 可执行需求标准:将大需求变为让 AI 具体可执行、可最终验收的标准

  • AI 制定技术方案、协议约定:让 AI 做技术方案选型,选出最不可能错的那一个

  • AI 制定测试用例:其实也就是先定标准再干活,而不是先干活再去定标准

  • AI 编码、互相 Code Review,然后更新补充、执行测试用例,但是这还不够,要不然迟早会被海量的测试用例拖垮。这里最好能基于 Diff 给出影响范围,智能选择回归,同时给出覆盖率目标,新范式需要像人一样更聪明的跑测试用例

  • 测试验收,将 Bug 提炼给 AI,AI 修复:应当根据需求沉淀用例资产库、缺陷资产库,描述完整的上下文信息,让 AI 能够持续进化

  • 线上观测:这里核心数据应该要脱敏、聚合、建立反馈给 AI 的机制,AI 进行修复后进行归因分析,反哺用例库、知识库,形成数字化资产

这些事情都让 AI 做了,那人做什么?

人负责理解需求 => 拆分需求 => 拆分可独立闭环的目标 => 定执行标准 => 确定技术方案 => 确定用例范围 => 验收,所有做的事情都是为 AI 服务

核心是人设置标准和约束,AI 做事情。

具体执行与挑战

似乎上面每一个步骤想要做好都是挑战?

  • 第一个挑战:如何将产品给出的需求提炼为AI可执行的需求标准? 这需要开发者有较好的需求理解能力、边界设计能力、叠加一定的经验以及一定的产品思维。这一步是我个人认为最难也是最有价值的一步,如果这一环节无法做好,后续的AI开发流程就会受到严重影响。这一步需要产品经理和开发者共同完成,或者开发者独自完成。
  • 第二个挑战:审美。 AI 制定技术方案、编写测试用例作为编码的前置条件,他们确定之后,编码的方向就确定了。而这与个人的技术品味强相关,技术审美比以往更加重要,而拥有较好的技术品味同样是一件不简单的事,这是长期积累的判断力。
  • 第三个挑战:测试自动化。 随着开发规模扩大,尤其是在 AI 生成大量代码的情况下,将面临恐怖的测试规模,需要有一套基于 Git diff 给出影响范围,智能进行回归的方案。
  • 第四个挑战:全自动化运维。 在 AI 时代不仅仅是部署与监控,而是能够出现异常时自动响应并进行调整,从而形成自我进化机制。当然这里面会有一些安全问题,比如核心数据不应该暴露等,过去的CICD流程都需要被重构。
  • 第五个挑战:警惕某些开发者的 Vibe Coding。 你以为是在提速,实际上是在给未来挖坑,不能仅仅为了速度而牺牲技术的长远可维护性。在 AI 时代下,开发者必须时刻保持对代码质量、架构合理性的敏感,这对开发者的要求同样不低。

未来工程师的核心竞争力

过去很多年,会写代码几乎等于有竞争力。谁能写出更复杂、更优雅、更稳定的代码,谁就越值钱。 但 AI 把这条曲线快速拉平了,当实现成本趋近于零,原来旧的招人标准在我现在看来毫无价值。

当写代码不再稀缺,真正稀缺的是什么?我认为接下来最值钱的能力是以下三点:

  • 问题拆解能力:把模糊目标变成 AI 可执行、可细分闭环的任务链
  • 判断能力:决定要做什么、不做什么,确定好每个模块明确的边界
  • 审美能力:知道什么叫能用,什么叫好用,知道什么叫不行。让代码达到可运行的标准,评估推理质量,为生成的代码负责

这些能力并非全新,优秀的工程师和产品经理一直具备这些能力,只是过去被大量实现层的工作掩盖。

以后开发者的角色定义我认为会集中在两个方向:产品工程师Agent 工程师

(1)产品工程师: 也就是接下来的开发者,不仅仅是既懂产品又会写代码,而是能用工程能力把产品落地成可交付的人,他们的核心是利用 AI 写出稳定运行、正确的代码,他们的目标不再是生成代码,而是如何证明代码是对的

(2)Agent 工程师: 他们是新时代的架构师,他们不主要负责写代码,而是负责为 Agent 搭建工作环境与生产流水线——产品工程师执行具体任务,Agent 工程师对整个链路的质量、效率与稳定性负责。有时候 AI 写不出好代码,不是它笨,而是工程结构没搭好,上下文不足。Agent 工程师的核心职责是设计好整个 AI 链路以及 AI 能运行的环境,以及构建最重要的反馈闭环,例如:互相审查 => 测试验证 => 自我修复更新。

思考:如果模型也拥有了这些能力,那工程师还剩下什么?

结语

如果你还在纠结Cursor怎么配置,哪个代码编辑器好用,我直接给你一个判断,你还在考虑怎么用AI写代码,就说明你还没搞懂这场变革,真正的转变是你根本就不需要写代码了。

如果你也看好 AI 行业的长期趋势,愿意把技术落到真实业务、真实用户与真实增长中,做浪潮里的建设者而不是旁观者——我们正在招募同路人。

欢迎联系我:buyaotutoua@163.com(邮件标题建议:AI + 姓名 + 方向)

【学习笔记】ECMAScript 词法环境全解析

词法环境规范

ECMAScript 定义词法环境为:

A Lexical Environment is a specification type used to define the association of Identifiers to specific variables and functions based upon the lexical nesting structure of ECMAScript code.

翻译为:词法环境是一种规范类型,用于根据 ECMAScript 代码的词法嵌套结构,定义标识符与特定变量和函数之间的关联关系。

词法环境由两个组件构成:

  1. Environment Record:记录标识符绑定
  2. [[OuterEnv]]:指向外部词法环境的引用(全局环境为 null

Environment Record 类型

规范定义了五种环境记录类型:

类型 用途 说明
Declarative Environment Record let/const/class、函数内的函数声明 将标识符直接绑定到值
Object Environment Record with 语句、全局 var、全局函数声明 将标识符绑定到对象属性(全局函数声明通过 CreateGlobalFunctionBinding 挂载到 window
Function Environment Record 函数调用 继承自 Declarative,额外包含 [[ThisValue]][[FunctionObject]][[HomeObject]](super 引用)、[[NewTarget]](检测 new 调用)
Global Environment Record 全局环境 包含一个 Object Record(对应 var、全局函数声明)和一个 Declarative Record(对应 let/const/class
Module Environment Record ES 模块 继承自 Declarative,支持 import 绑定

注意:函数声明的绑定位置取决于所在作用域。在全局作用域中,函数声明通过 CreateGlobalFunctionBinding(ES2024 §16.1.7)进入 Object ER,行为与 var 一致(挂载到 window)。在函数作用域中,函数声明通过 FunctionDeclarationInstantiation(ES2024 §10.2.11)进入 Function ER(继承自 Declarative ER),不挂载到全局对象。

Environment Record 的继承关系

五种 ER 不是互相转化的关系,而是一个继承体系

Environment Record(抽象基类 — 定义公共接口)
│
├── Declarative Environment Record(声明式 — 直接绑定标识符到值)
│   │
│   ├── Function Environment Record(函数式 — 增加 this/new.target/super)
│   │
│   └── Module Environment Record(模块式 — 增加 import 间接绑定)
│
├── Object Environment Record(对象式 — 标识符绑定到对象属性)
│
└── Global Environment Record(全局式 — 组合了 Object ER + Declarative ER)

Environment Record 的生命周期

每个 Environment Record 经历 4 个阶段

创建(Create) → 绑定注册(Binding) → 使用(Access) → 销毁(Destroy)
阶段 规范操作 说明
创建 进入新作用域时自动创建 函数调用、进入块、加载模块、脚本启动
绑定注册 CreateMutableBinding / CreateImmutableBinding 在环境中注册标识符(此时 let/const 处于 uninitialized 状态 → TDZ)
初始化 InitializeBinding(name, value) var/函数声明在创建阶段就初始化;let/const 在执行到声明语句时才初始化
使用 GetBindingValue / SetMutableBinding 读取和修改变量值
销毁 无显式操作,由 GC 负责 当没有任何引用指向该环境时被回收(闭包会延长生命周期)

五种 ER 的协作通信机制

五种 ER 通过继承(共享接口)、组合(Global 包含两种 ER)、链接[[OuterEnv]] 链)、间接引用(Module 的 import binding)这四种方式协作。

Global Environment Record — 组合模式

records_index.html.pngHasBinding 查找时两边都查:

GlobalER.HasBinding(name):
  1. 先查 Declarative ER → 有则返回 true
  2. 再查 Object ER (即 window 对象) → 有则返回 true
  3. 都没有 → 返回 false
// —— 全局作用域:var 和函数声明 → Object ER ——
var a = 1;
function foo() {}
window.a;   // 1     ← Object ER,映射到 window
window.foo; // ƒ     ← Object ER,映射到 window

// —— 全局作用域:let/const/class → Declarative ER ——
let b = 2;
window.b;   // undefined ← Declarative ER,不映射到 window

// —— 函数作用域:函数内的函数声明 → Function ER(继承自 Declarative ER)——
function outer() {
  function inner() {}
  window.inner; // undefined ← 不挂载到 window,绑定在 outer 的 Function ER 中
}

Function Environment Record — 继承 + 扩展

函数 ER 继承自 Declarative ER,额外增加了字段:

index.html.png

需要注意两点:

  1. 箭头函数的 this 查找:箭头函数没有自己的 [[ThisValue]],通过 [[OuterEnv]] 链向外查找包含 [[ThisValue]] 的 Function ER:

    const obj = {
      method() {
        // Function ER: { [[ThisValue]]: obj, [[ThisBindingStatus]]: initialized }
    
        const arrow = () => {
          // Declarative ER(箭头函数不创建 Function ER)
          // 访问 this → 沿 [[OuterEnv]] → 找到 method 的 Function ER → obj
          console.log(this); // obj
        };
      },
    };
    
  2. 函数内的函数声明绑定到 Function ER:与全局函数声明进入 Object ER 不同,函数内部的函数声明通过 FunctionDeclarationInstantiation 绑定到当前 Function ER(继承自 Declarative ER),不挂载到全局对象:

    function outer() {
      // Function ER(继承自 Declarative ER)
      function inner() {} // → 绑定到 outer 的 Function ER
      var localVar = 1; // → 同样绑定到 outer 的 Function ER
    
      console.log(typeof inner); // "function"
      console.log(window.inner); // undefined ← 不挂载到 window
    }
    
    // 对比全局行为
    function globalFn() {} // → Object ER → window.globalFn = ƒ
    console.log(window.globalFn); // ƒ globalFn()
    

Module Environment Record — 间接绑定

模块 ER 继承自 Declarative ER,新增 CreateImportBinding 方法,import 绑定是指向另一个模块 ER 中绑定的间接引用(活绑定)

// moduleA.js 的 Module ER
ModuleER_A {
  count: 0,                    // 本地绑定
  increment: <function>        // 本地绑定
}

// moduleB.js 的 Module ER
ModuleER_B {
  count: IndirectBindingModuleER_A.count   // 间接绑定!
  // GetBindingValue("count") 实际上是:
  // → 跳转到 ModuleER_A → GetBindingValue("count") → 返回当前值
}
// moduleA.js
export let count = 0;
export function increment() {
  count++;
}

// moduleB.js
import { count, increment } from './moduleA.js';
console.log(count); // 0 — 间接读取 ModuleER_A 的 count
increment(); // ModuleER_A 的 count 变为 1
console.log(count); // 1 — 再次间接读取,拿到最新值(活绑定)

Object Environment Record — with、全局 var 和全局函数声明

Object ER 将标识符绑定映射到一个对象的属性,在两种场景中使用:

  1. 全局作用域:作为 Global ER 的 [[ObjectRecord]],承载 var 和函数声明,[[BindingObject]]window
  2. with 语句:临时将对象包装为 Object ER 插入作用域链,[[BindingObject]]with 的参数对象

127.0.0.1_5500_index.html (1).png

所有操作本质上都是对 [[BindingObject]] 的属性读写,这也是 var / function 声明会挂载到 window 的根本原因

完整协作流程

当执行一段代码时,各种 ER 如何协作:

127.0.0.1_5500_index.html (2).png

[[Environment]] 内部槽

每个函数对象都有一个 [[Environment]] 内部槽

When a function is created, a reference to the Lexical Environment in which it was created is saved in its [[Environment]] internal slot.

翻译:当一个函数被创建时,它创建时所处的词法环境的引用会被保存在该函数的 [[Environment]] 内部槽中。简单来说:函数在定义的那一刻,就把当时的作用域"拍了张快照"存起来了。这就是闭包能访问外部变量的根本原因。

闭包的规范定义——函数对象持有对创建时词法环境的引用

// 伪代码:函数创建过程
FunctionCreate(kind, ParameterList, Body, Scope, ...) {
  let F = new FunctionObject();
  F.[[Environment]] = Scope;   // ← 闭包的本质:保存创建时的词法环境
  F.[[FormalParameters]] = ParameterList;
  F.[[ECMAScriptCode]] = Body;
  return F;
}

[[OuterEnv]] 外部环境引用

[[OuterEnv]] 是词法环境的外部引用,它构成了作用域链:

// 嵌套函数的词法环境链
innerEnv.[[OuterEnv]] → outerEnv.[[OuterEnv]] → globalEnv.[[OuterEnv]] → null

GetIdentifierReference 抽象操作

当引擎需要解析一个标识符时,调用 ResolveBinding,其核心是 GetIdentifierReference

GetIdentifierReference(env, name, strict):
1. If env is null, return a Reference Record { [[Base]]: unresolvable, ... }
2. Let exists = env.HasBinding(name)
3. If exists is true:
     return Reference Record { [[Base]]: env, [[ReferencedName]]: name, ... }
4. Else:
     let outer = env.[[OuterEnv]]
     return GetIdentifierReference(outer, name, strict)   // 递归向外查找

具体触发场景:

// 1. 读取变量 → 解析 x
console.log(x);

// 2. 赋值 → 解析 x(左侧也需要解析,得到 Reference Record 才能写入)
x = 5;

// 3. 函数调用 → 解析 foo
foo();

// 4. 运算表达式 → 解析 a 和 b
a + b;

// 5. typeof → 解析 y(特殊:unresolvable 不抛错,返回 "undefined")
typeof y;

简单来说:只要代码里出现了一个名字(不是属性访问的 . 后面那个),就触发一次 GetIdentifierReference。

obj.prop 中 obj 会触发,但 .prop 不会——属性访问走的是 [[Get]],不走环境链查找。

TDZ 的规范定义

let/const 声明的变量在环境记录中的初始状态为 uninitialized

// CreateMutableBinding(name, canDelete)
// 创建绑定但不初始化 → 状态为 uninitialized

// InitializeBinding(name, value)
// 将绑定的状态从 uninitialized 变为 initialized

// GetBindingValue(name, strict)
// 如果绑定状态是 uninitialized → 抛出 ReferenceError

这就是 TDZ(暂时性死区) 的本质:变量已存在于环境记录中(因此不会沿作用域链向外查找),但尚未初始化(访问时抛错)。

常见陷阱

1. TDZ 陷阱 — 在声明前访问 let/const

// ❌ 错误:在声明前访问,触发 TDZ
console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 1;

// ✅ 正确:var 不存在 TDZ,只是 undefined
console.log(b); // undefined
var b = 1;

// ❌ 容易忽略的场景:函数参数默认值中的 TDZ
function foo(x = y, y = 2) { // ReferenceError: y 在 x 初始化时还处于 TDZ
  return x + y;
}
foo();

2. 闭包陷阱 — 循环中共享同一个变量绑定

// ❌ 错误:var 只有一个绑定,所有回调共享同一个 i
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 输出:3 3 3
// 原因:var i 在 Function ER 中只有一份绑定,
//       循环结束时 i 已经是 3,三个箭头函数读到的都是同一个 i

// ✅ 正确:let 每次迭代创建新的 Declarative ER,各自持有独立的 i 绑定
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 输出:0 1 2

3. this 丢失陷阱 — 普通函数与箭头函数混用

const obj = {
  name: 'obj',

  // ❌ 错误:箭头函数没有自己的 [[ThisValue]]
  // 沿 [[OuterEnv]] 向外找到全局 Function ER,this 为 window/undefined(strict)
  arrowMethod: () => {
    console.log(this.name); // undefined(严格模式下报错)
  },

  // ✅ 正确:普通函数创建 Function ER,[[ThisValue]] 绑定为调用时的 obj
  normalMethod() {
    console.log(this.name); // 'obj'

    // ✅ 内部箭头函数沿 [[OuterEnv]] 找到 normalMethod 的 Function ER → this 为 obj
    const inner = () => console.log(this.name);
    inner(); // 'obj'
  },
};

obj.arrowMethod();
obj.normalMethod();

// ❌ 方法赋值后调用,Function ER 的 [[ThisValue]] 重新绑定为 undefined(严格模式)
const fn = obj.normalMethod;
fn(); // TypeError 或 window.name(非严格模式)

4. with 陷阱 — 动态插入作用域链,导致查找不可预测

const obj = { a: 1 };
const a = 2;

with (obj) {
  // Object ER 被插入作用域链最顶层
  // GetIdentifierReference 先查 obj 的属性,再查外部
  console.log(a); // 1 ← 读取的是 obj.a,而非外部的 a = 2
}

// ❌ 动态属性导致歧义:无法在编译期确定标识符归属
const obj2 = {};
with (obj2) {
  console.log(a); // 2 ← obj2 没有 a,向外找到外部的 a = 2
}
// 同一个标识符 a,with 不同对象结果不同,引擎无法优化,严格模式直接禁止 with

5. 模块活绑定陷阱 — import 是引用而非拷贝

// moduleA.js
export let count = 0;
export function increment() { count++; }

// moduleB.js
import { count, increment } from './moduleA.js';

console.log(count); // 0

increment();
console.log(count); // 1 ← 活绑定,读取的是 ModuleER_A 中 count 的当前值

// ❌ 常见误区:以为 import 的是值的拷贝,实则是间接引用
// ❌ import 绑定是只读的,不能直接赋值
count = 10; // TypeError: Assignment to constant variable

React 架构进阶:自定义 Hooks 的高级设计模式与最佳实践

在 React 16.8 引入 Hooks 之后,我们告别了 Class 组件中复杂的生命周期和高阶组件(HOC)的嵌套地狱。然而,随着业务复杂度的提升,简单的 useState 和 useEffect 组合往往导致组件内部逻辑臃肿,难以维护。

很多开发者停留在“把逻辑抽离成函数”的初级阶段,却忽略了自定义 Hooks(Custom Hooks)本质上是逻辑复用的设计模式。本文将深入探讨自定义 Hooks 的高级设计模式,如何通过合理的抽象提升代码的可读性、可测试性和复用性。

一、为什么我们需要高级设计模式?

在初级实践中,我们常看到这样的代码:

// ❌ 反模式:逻辑泄露与耦合
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetch(`/api/user/${userId}`).then(setUser).finally(() => setLoading(false));
  }, [userId]);

  // ... 还有获取用户帖子、获取用户关注列表的逻辑混在一起
  return loading ? <Spinner /> : <div>{user.name}</div>;
}

这种写法的问题在于:

  1. UI 与逻辑耦合:组件既负责渲染,又负责数据获取。
  2. 难以测试:很难在不渲染 UI 的情况下测试数据获取逻辑。
  3. 无法复用:如果在另一个页面也需要获取用户信息,代码只能复制粘贴。

通过自定义 Hooks,我们可以将“关注点分离(Separation of Concerns)”。

二、核心设计模式详解

2.1 容器模式(Container Pattern)的 Hooks 化

这是最经典的模式,将数据获取和状态管理逻辑剥离,组件只负责展示。

// ✅ useUser.ts - 专注数据逻辑
export function useUser(userId) {
  const [state, setState] = useState({ data: null, loading: true, error: null });

  useEffect(() => {
    let cancelled = false;
    
    async function fetchUser() {
      try {
        const response = await fetch(`/api/user/${userId}`);
        if (!cancelled) {
          setState({ data: await response.json(), loading: false, error: null });
        }
      } catch (err) {
        if (!cancelled) setState({ data: null, loading: false, error: err });
      }
    }

    fetchUser();
    return () => { cancelled = true; }; // 清理副作用
  }, [userId]);

  return state;
}

// ✅ UserProfile.tsx - 专注 UI 展示
function UserProfile({ userId }) {
  const { data: user, loading, error } = useUser(userId);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <div>{user.name}</div>;
}

优势:UI 组件变得极其纯净,逻辑 Hook 可以独立进行单元测试。

2.2 状态机模式(State Machine Pattern)

对于复杂的交互流程(如表单提交、多步骤向导、播放器控制),简单的布尔值状态(isLoadingisSuccessisError)容易导致状态冲突。此时应引入有限状态机思想。

// ✅ useAsyncAction.ts - 管理复杂状态流转
function useAsyncAction(asyncFunction) {
  const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
      case 'START': return { status: 'loading', data: null, error: null };
      case 'SUCCESS': return { status: 'success', data: action.payload, error: null };
      case 'FAILURE': return { status: 'failure', data: null, error: action.payload };
      case 'RESET': return { status: 'idle', data: null, error: null };
      default: return state;
    }
  }, { status: 'idle', data: null, error: null });

  const execute = useCallback(async (...args) => {
    dispatch({ type: 'START' });
    try {
      const result = await asyncFunction(...args);
      dispatch({ type: 'SUCCESS', payload: result });
    } catch (err) {
      dispatch({ type: 'FAILURE', payload: err });
    }
  }, [asyncFunction]);

  return { ...state, execute };
}

应用场景:登录注册流程、文件上传、复杂的表单验证。它保证了状态流转的确定性,避免了“既 loading 又 error”的非法状态。

2.3 组合模式(Composition Pattern)

Hooks 最大的威力在于组合。我们可以像搭积木一样,将多个小 Hooks 组合成一个功能强大的大 Hook。

// 基础 Hook:处理本地存储
function useLocalStorage(key, initialValue) {
  // ... 实现略
  return [value, setValue];
}

// 基础 Hook:处理窗口大小
function useWindowSize() {
  // ... 实现略
  return { width, height };
}

// ✅ 组合 Hook:响应式主题管理器
function useResponsiveTheme() {
  const [theme, setTheme] = useLocalStorage('app-theme', 'light');
  const { width } = useWindowSize();

  // 自动逻辑:屏幕小于 768px 强制使用移动端样式,但保留用户主题偏好
  const isMobile = width < 768;
  const effectiveTheme = isMobile ? 'mobile-optimized' : theme;

  useEffect(() => {
    document.body.className = effectiveTheme;
  }, [effectiveTheme]);

  return { theme, setTheme, isMobile };
}

核心价值:降低了单个 Hook 的认知负荷,每个 Hook 只做一件事,并通过组合产生新的行为。

2.4 观察者模式与订阅机制

在处理全局事件或非 React 源的数据(如 WebSocket、第三方 SDK)时,可以使用观察者模式。

// ✅ useWebSocket.ts
function useWebSocket(url) {
  const [message, setMessage] = useState(null);

  useEffect(() => {
    const ws = new WebSocket(url);
    
    ws.onmessage = (event) => {
      setMessage(JSON.parse(event.data));
    };

    ws.onerror = (error) => {
      console.error('WS Error', error);
    };

    // 清理连接
    return () => {
      ws.close();
    };
  }, [url]);

  const sendMessage = useCallback((data) => {
    // 发送逻辑
  }, []);

  return { message, sendMessage };
}

三、避坑指南:自定义 Hooks 的常见陷阱

3.1 条件调用 Hooks

错误示范

function useConditionalHook(condition) {
  if (condition) {
    useEffect(() => { ... }); // ❌ 违反 Rules of Hooks
  }
}

修正:Hooks 必须在顶层调用。如果需要根据条件执行逻辑,请将条件判断写在 Hook 内部,而不是包裹 Hook 本身。

3.2 过度抽象

不要为了复用而复用。如果一个逻辑只在当前组件使用,或者不同组件的使用差异极大,强行提取 Hook 反而会增加认知负担。 “三次法则” 是一个不错的经验:当同一段逻辑出现第三次时,再考虑提取。

3.3 依赖项数组的陷阱

在自定义 Hook 中返回回调函数时,务必注意闭包陷阱。

// ❌ 容易捕获旧状态的回调
function useCounter() {
  const [count, setCount] = useState(0);
  const logCount = () => {
    console.log(count); // 可能永远是初始值或旧值
  };
  return { count, logCount };
}

// ✅ 使用 ref 或将其放入 useEffect/useCallback 依赖中
function useCounter() {
  const [count, setCount] = useState(0);
  
  const logCount = useCallback(() => {
    console.log(count); 
  }, [count]); // 确保依赖最新 count
  
  return { count, logCount };
}

四、实战案例:构建一个通用的 useFetch

结合上述模式,我们来构建一个生产级别的 useFetch

import { useEffect, useReducer, useCallback } from 'react';

// 定义状态类型
const initialState = {
  data: null,
  loading: false,
  error: null,
};

function reducer(state, action) {
  switch (action.type) {
    case 'REQUEST': return { ...state, loading: true, error: null };
    case 'SUCCESS': return { loading: false, data: action.payload, error: null };
    case 'FAILURE': return { loading: false, data: null, error: action.payload };
    case 'RESET': return initialState;
    default: return state;
  }
}

export function useFetch(url, options = {}) {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { manual = false } = options; // 是否手动触发

  const execute = useCallback(async (overrideUrl) => {
    const targetUrl = overrideUrl || url;
    if (!targetUrl) return;

    dispatch({ type: 'REQUEST' });
    try {
      const response = await fetch(targetUrl);
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      const data = await response.json();
      dispatch({ type: 'SUCCESS', payload: data });
    } catch (err) {
      dispatch({ type: 'FAILURE', payload: err.message });
    }
  }, [url]);

  useEffect(() => {
    if (!manual) {
      execute();
    }
  }, [execute, manual]);

  return { ...state, refetch: execute, reset: () => dispatch({ type: 'RESET' }) };
}

使用示例

function UserList() {
  const { data, loading, error, refetch } = useFetch('/api/users');

  if (loading) return <div>加载中...</div>;
  if (error) return <div>出错了: {error} <button onClick={refetch}>重试</button></div>;

  return (
    <ul>
      {data.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

五、总结

自定义 Hooks 不仅仅是代码复用的工具,更是 React 组件架构的核心支柱。

  1. 逻辑解耦:让 UI 组件回归纯粹的表现层。
  2. 状态治理:利用 Reducer 和状态机管理复杂交互。
  3. 能力组合:通过小 Hook 的堆叠构建复杂功能。
  4. 测试友好:逻辑与视图分离使得单元测试变得简单高效。

掌握这些高级模式,你将能够编写出更健壮、更易维护的 React 应用,真正发挥 Hooks 体系的威力。


后续思考题:

  • 如何在自定义 Hook 中处理服务端渲染(SSR)时的 Hydration 问题?
  • 自定义 Hooks 能否完全替代 Redux/MobX 等全局状态管理库?边界在哪里?

欢迎在评论区分享你在项目中封装过的最得意的自定义 Hook!

【LangChain.js学习】 向量数据库(内存/持久化)

核心说明

向量数据库是 LangChain 构建知识库问答的核心组件,用于存储文档文本的向量嵌入(Embedding),并支持相似性检索(根据查询语句的向量匹配最相关的文本块)。分为「内存向量数据库」(MemoryVectorStore,临时存储)和「持久化向量数据库」(Chroma,永久存储),前者适合测试/临时场景,后者适合生产环境。

一、核心概念

1. 向量嵌入(Embedding)

将文本转换为数值向量(如1536维数组),使计算机能通过「向量距离」衡量文本语义相似度,本文使用阿里通义千问的 text-embedding-v2 模型生成嵌入。

2. 相似性检索

输入查询语句→生成查询向量→计算与库中所有文本向量的距离(如余弦相似度)→返回最相似的N个文本块,是知识库问答的核心逻辑。

二、内存向量数据库(MemoryVectorStore)

核心特点

  • 数据存储在内存中,程序重启后丢失;
  • 无需额外部署服务,开箱即用;
  • 适合快速测试、临时知识库场景。

完整实现代码

import { TextLoader } from "@langchain/classic/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "@langchain/classic/text_splitter";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { OpenAIEmbeddings } from "@langchain/openai";

// 1. 初始化嵌入模型(阿里通义千问)
const embeddingsModel = new OpenAIEmbeddings({
    model: "text-embedding-v2", // 通义千问嵌入模型
    configuration: {
        baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", // 阿里百炼兼容接口
        apiKey: "[你的阿里百炼API Key]", // 替换为有效Key
    },
});

// 2. 初始化内存向量数据库
const vectorStore = new MemoryVectorStore(embeddingsModel);

// 3. 加载并分割文档(复用文本加载逻辑)
const loader = new TextLoader("./data/data.txt");
const documents = await loader.load();

// 文本分割器(适配中文语义)
const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 25, // 每个文本块最大字符数
    chunkOverlap: 5, // 块间重叠字符数(保证上下文连贯)
    separators: [",", "。"], // 中文优先分割符
});
const splitDocs = await splitter.splitDocuments(documents);

// 4. 将分割后的文本块存入向量库(自动生成嵌入向量)
await vectorStore.addDocuments(splitDocs);

// 5. 相似性检索(查询+返回Top2最相关文本)
const results = await vectorStore.similaritySearch("李娟的出生于哪里?", 2);

// 输出检索结果
console.log("内存向量库检索结果:");
results.forEach((doc, index) => {
    console.log(`第${index+1}条:`, doc.pageContent);
});

/** 输出示例:
内存向量库检索结果:
第1条: 李娟,1979年7月出生于新疆生产建设兵团
第2条: 兵团,籍贯四川乐至,当代女作家
*/

三、持久化向量数据库(Chroma)

核心特点

  • 数据持久化存储(磁盘/数据库),程序重启后不丢失;
  • 支持独立部署服务,多进程/多实例共享数据;
  • 适合生产环境、长期维护的知识库场景。

1. 环境准备

安装Chroma(Python)

# 安装Chroma依赖
pip install chromadb

# 启动Chroma服务(后台运行,端口8000)
chroma run --host 0.0.0.0 --port 8000

安装LangChain-Chroma依赖(Node.js)

pnpm add @langchain/community

2. 完整实现代码

import { TextLoader } from "@langchain/classic/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "@langchain/classic/text_splitter";
import { Chroma } from "@langchain/community/vectorstores/chroma";
import { OpenAIEmbeddings } from "@langchain/openai";

// 1. 初始化嵌入模型(与内存库一致)
const embeddingsModel = new OpenAIEmbeddings({
    model: "text-embedding-v2",
    configuration: {
        baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
        apiKey: "[你的阿里百炼API Key]",
    },
});

// 2. 初始化Chroma向量数据库(连接远程服务)
const vectorStore = new Chroma(embeddingsModel, {
    url: "http://localhost:8000", // Chroma服务地址
    collectionName: "langchain_nodejs_demo", // 集合名称(类似数据库表)
});

// 3. 加载并分割文档(与内存库一致)
const loader = new TextLoader("./data/data.txt");
const documents = await loader.load();

const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 25,
    chunkOverlap: 5,
    separators: [",", "。"],
});
const splitDocs = await splitter.splitDocuments(documents);

// 4. 将文本块存入Chroma(自动创建集合+生成嵌入)
await vectorStore.addDocuments(splitDocs);

// 5. 相似性检索
const results = await vectorStore.similaritySearch("李娟的出生于哪里?", 2);

// 输出检索结果
console.log("Chroma向量库检索结果:");
results.forEach((doc, index) => {
    console.log(`第${index+1}条:`, doc.pageContent);
});

/** 输出示例:
Chroma向量库检索结果:
第1条: 李娟,1979年7月出生于新疆生产建设兵团
第2条: 兵团,籍贯四川乐至,当代女作家
*/

3. 关键扩展操作

// 1. 清空集合(删除所有数据)
await vectorStore.delete({ collectionName: "langchain_nodejs_demo" });

// 2. 带分数的相似性检索(返回相似度得分,0-1,越高越相似)
const resultsWithScore = await vectorStore.similaritySearchWithScore("李娟的作品有哪些?", 2);
console.log("带分数的检索结果:");
resultsWithScore.forEach(([doc, score], index) => {
    console.log(`第${index+1}条:`, doc.pageContent, `相似度:${score.toFixed(4)}`);
});

// 3. 自定义检索参数(如过滤元数据)
const filteredResults = await vectorStore.similaritySearch(
    "李娟的职务",
    1,
    { source: "./data/data.txt" } // 仅检索指定来源的文本
);

四、内存/持久化向量库对比

维度 内存向量库(MemoryVectorStore) 持久化向量库(Chroma)
数据存储 内存 磁盘/数据库
持久化 程序重启丢失 永久保留
部署成本 无(无需额外服务) 需部署Chroma服务
性能 读写速度快(无网络IO) 有网络IO,速度略慢
多实例共享 不支持 支持(多进程连接同一服务)
适用场景 测试、临时知识库 生产环境、长期知识库

五、核心原理与关键注意事项

1. 核心流程

flowchart TD
    A[加载文档] --> B[文本分割为小文本块]
    B --> C[嵌入模型生成文本向量]
    C --> D[存入向量数据库]
    E[用户查询] --> F[生成查询向量]
    F --> G[向量库相似性检索]
    G --> H[返回最相关文本块]

2. 关键注意事项

  1. Chroma服务启动:确保Chroma服务正常运行(chroma run),否则会报连接错误;
  2. 文本分割参数chunkSize 不宜过大(超过嵌入模型上下文)或过小(语义不完整),中文建议20-50字符;
  3. 集合名称管理:Chroma的 collectionName 建议按业务分类(如 product_docuser_manual),避免数据混乱。

深入理解事件循环:异步编程的基石

在现代软件开发中,异步编程已成为构建高性能、响应式应用的核心技术。无论是前端 JavaScript 开发,还是后端 Node.js 服务,亦或是其他语言中的异步框架,事件循环(Event Loop) 都是实现非阻塞 I/O 和并发处理的关键机制。

本文将深入探讨事件循环的工作原理、在不同运行环境中的实现差异,以及如何利用这一机制编写高效的异步代码。

一、为什么需要事件循环?

1.1 单线程的局限性

传统同步编程模型中,程序按顺序执行,每个操作必须等待前一个操作完成。这种模式在处理 I/O 操作(如文件读写、网络请求、数据库查询)时会导致严重的性能瓶颈:

// 同步代码示例 - 阻塞式
const data = readFile('large-file.txt'); // 阻塞直到文件读取完成
console.log(data);
processUserRequest(); // 必须等待上面完成才能执行

在上述代码中,如果文件很大,整个程序会"冻结",无法响应用户的其他操作。

1.2 异步非阻塞的优势

事件循环通过异步非阻塞的方式解决了这个问题:

// 异步代码示例 - 非阻塞式
readFile('large-file.txt', (err, data) => {
    console.log(data);
});
processUserRequest(); // 立即执行,不等待文件读取

这样,程序可以在等待 I/O 操作完成的同时,继续处理其他任务,大大提高了资源利用率。

二、事件循环的核心组成

事件循环机制主要由以下几个部分组成:

2.1 调用栈(Call Stack)

调用栈是一个后进先出(LIFO)的数据结构,用于跟踪函数执行。当函数被调用时,它被压入栈顶;当函数返回时,它从栈顶弹出。

|-----------------|
| functionC()     | <- 栈顶
|-----------------|
| functionB()     |
|-----------------|
| functionA()     |
|-----------------|
| main()          | <- 栈底
|-----------------|

2.2 任务队列(Task Queue)

任务队列存储待执行的回调函数。根据任务类型的不同,通常分为:

  • 宏任务(Macrotask) :setTimeout、setInterval、I/O 操作、UI 渲染等
  • 微任务(Microtask) :Promise.then/catch/finally、MutationObserver、queueMicrotask 等

2.3 事件循环本身

事件循环是一个持续运行的循环,其基本工作流程如下:

  1. 检查调用栈是否为空
  2. 如果为空,从微任务队列中取出所有微任务并执行
  3. 如果微任务队列为空,从宏任务队列中取出一个宏任务执行
  4. 重复上述过程

三、浏览器环境中的事件循环

3.1 执行流程详解

在浏览器环境中,事件循环的执行顺序遵循以下规则:

console.log('1. 同步代码开始');

setTimeout(() => {
    console.log('2. setTimeout 回调(宏任务)');
}, 0);

Promise.resolve().then(() => {
    console.log('3. Promise.then 回调(微任务)');
});

console.log('4. 同步代码结束');

// 输出顺序:
// 1. 同步代码开始
// 4. 同步代码结束
// 3. Promise.then 回调(微任务)
// 2. setTimeout 回调(宏任务)

3.2 渲染时机

浏览器的渲染时机对于理解事件循环至关重要:

  • 微任务执行完毕后,如果宏任务队列中有任务,且该任务执行过程中触发了 DOM 变化,浏览器可能会在下一个宏任务执行前进行渲染
  • requestAnimationFrame 会在浏览器下一次重绘之前执行,通常用于动画优化
// 渲染时机示例
div.style.width = '100px'; // 触发重排

Promise.resolve().then(() => {
    div.style.width = '200px'; // 微任务中修改
    // 此时浏览器可能还未渲染第一次修改
});

setTimeout(() => {
    div.style.width = '300px'; // 宏任务中修改
    // 浏览器可能在执行此任务前已经渲染了前面的修改
}, 0);

四、Node.js 环境中的事件循环

Node.js 的事件循环与浏览器有所不同,它分为六个阶段:

4.1 六个阶段

  1. timers:执行 setTimeout 和 setInterval 的回调
  2. pending callbacks:执行某些系统操作的回调(如 TCP 错误)
  3. idle, prepare:内部使用
  4. poll:获取新的 I/O 事件,执行 I/O 回调
  5. check:执行 setImmediate 的回调
  6. close callbacks:执行关闭事件的回调(如 socket.on('close'))

4.2 Node.js 特有行为

// Node.js 中的特殊行为
setTimeout(() => {
    console.log('timeout');
}, 0);

setImmediate(() => {
    console.log('immediate');
});

// 在 I/O 回调中,setImmediate 总是先于 setTimeout 执行
fs.readFile('file.txt', () => {
    setTimeout(() => {
        console.log('timeout in I/O');
    }, 0);
    
    setImmediate(() => {
        console.log('immediate in I/O');
    });
});

五、微任务与宏任务的深度对比

5.1 执行优先级

微任务的优先级高于宏任务,这是理解异步代码执行顺序的关键:

// 复杂嵌套示例
console.log('start');

setTimeout(() => {
    console.log('timeout1');
    Promise.resolve().then(() => {
        console.log('promise in timeout1');
    });
}, 0);

Promise.resolve().then(() => {
    console.log('promise1');
    setTimeout(() => {
        console.log('timeout in promise');
    }, 0);
});

console.log('end');

// 输出顺序:
// start
// end
// promise1
// timeout in promise
// timeout1
// promise in timeout1

5.2 实际应用场景

微任务适用场景:

  • 需要立即执行的异步操作
  • 保证在下一个宏任务之前完成的操作
  • 状态同步、数据更新等

宏任务适用场景:

  • 延迟执行的操作
  • I/O 操作
  • UI 渲染相关的操作

六、常见陷阱与最佳实践

6.1 常见陷阱

陷阱 1:误以为 setTimeout(fn, 0) 会立即执行

// 错误理解
setTimeout(() => {
    console.log('立即执行?');
}, 0);

// 实际上,它会被放入宏任务队列,至少要在当前同步代码和所有微任务执行完后才会执行

陷阱 2:微任务过多导致宏任务饥饿

// 危险代码 - 可能导致宏任务永远无法执行
function starveMacrotasks() {
    Promise.resolve().then(() => {
        console.log('微任务');
        starveMacrotasks(); // 递归创建微任务
    });
}
starveMacrotasks();
// setTimeout 等宏任务可能永远得不到执行机会

6.2 最佳实践

  1. 合理使用微任务和宏任务:根据业务需求选择合适的异步机制
  2. 避免微任务无限递归:防止阻塞宏任务队列
  3. 注意执行顺序:在涉及多个异步操作时,明确预期的执行顺序
  4. 利用 async/await 提高可读性:现代 JavaScript 推荐使用 async/await 语法
// 推荐的 async/await 写法
async function fetchData() {
    try {
        const response = await fetch('/api/data');
        const data = await response.json();
        return data;
    } catch (error) {
        console.error('获取数据失败:', error);
        throw error;
    }
}

七、性能优化建议

7.1 减少不必要的异步操作

// 不推荐 - 创建了大量不必要的 Promise
array.map(item => {
    return Promise.resolve(item).then(processItem);
});

// 推荐 - 直接同步处理
array.map(item => {
    return processItem(item);
});

7.2 批量处理微任务

// 不推荐 - 逐个创建微任务
items.forEach(item => {
    queueMicrotask(() => processItem(item));
});

// 推荐 - 批量处理
queueMicrotask(() => {
    items.forEach(item => processItem(item));
});

7.3 合理使用 requestIdleCallback

对于非关键的后台任务,可以使用 requestIdleCallback 在浏览器空闲时执行:

requestIdleCallback((deadline) => {
    while (deadline.timeRemaining() > 0 && tasks.length > 0) {
        performTask(tasks.pop());
    }
}, { timeout: 2000 }); // 最多等待 2 秒

八、未来展望

随着 Web 技术和运行时环境的不断发展,事件循环机制也在持续演进:

  • Web Workers 和 SharedArrayBuffer:提供了真正的多线程能力
  • Async Local Storage:改进了异步上下文管理
  • 更好的错误追踪:改进异步错误的堆栈追踪
  • 性能监控工具:更精确的事件循环性能分析工具

结语

事件循环是异步编程的基石,深入理解其工作原理对于编写高效、可靠的异步代码至关重要。无论是在浏览器还是 Node.js 环境中,掌握事件循环的执行顺序、微任务与宏任务的区别,以及各种最佳实践,都能帮助开发者避免常见的陷阱,提升代码质量和性能。

随着技术的不断发展,虽然新的抽象层和工具不断涌现,但对事件循环本质的理解始终是优秀开发者的核心竞争力之一。希望本文能够帮助你更深入地理解这一重要概念,并在实际开发中灵活运用。

Vite 性能瓶颈排查标准流程

第一步:诊断(必须做)

按照分析工具

pnpm add -D rollup-plugin-visualizer
or
yarn add -D  rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

plugins: [
  visualizer({
    open: true,
    gzipSize: true,
    brotliSize: true
  })
]

运行

pnpm build
or
yarn build

重点观察

在分析页里重点看

关注点 看什么
那个chunk最大 首屏是否>500kb
Inported by 谁引用了它
重依赖 是否多个chunk重复打包
大JSON图/图标 是否被主入口引用

常见“元凶”

  • 第三方UI库全量引入(如 ant-design-vue/element plus)
  • lodash 全量
  • moment
  • echarts
  • icon大数据文件
  • 地图geojson
  • 误import整个utils目录

第二步:合理分包(manualChunks)

这是最核心的优化点

build: {
  rollupOptions: {
    output: {
      manualChunks(id) {

        // 1️⃣ 第三方库分离
        if (id.includes('node_modules')) {

          if (id.includes('ant-design-vue'))
            return 'antd-vendor'

          if (id.includes('vue'))
            return 'vue-core'

          return 'vendor'
        }

        // 2️⃣ 业务页面自动分包
        if (id.includes('src/views/')) {
          const parts = id.split('/')
          const index = parts.indexOf('views')
          const folderName = parts[index + 1]
          return `view-${folderName}`
        }

      }
    }
  }
}

优化目标

包类型 目标
主入口index <200kb
vendor 可缓存
每个view 按需加载

第三步:巨型静态数据处理

错误示范

// main.ts
import icons from './all-icons.js'  // ❌ 500kB 直接进主包

正确做法(动态加载)

const loadIcons = () => import('./all-icons.js')

规则

文件大小 处理方式
>100kB 必须动态import
>300kb 拆分 or cdn
>1MB 禁止进主包

第四步:资源CDN化

1. vite base 指向 CDN

export default defineConfig({
  base: process.env.NODE_ENV === 'production'
    ? 'https://cdn.xxx.com/'
    : '/'
})

2. 图片/字体策略

  • .woff2>100kB 上传CDN
  • 大背景图禁止进JS
  • 不要base64大图

3. 巨型JS文件CDN化 例如:

  • icon库
  • geojson
  • 富文本编辑器扩展 直接
<script src="https://cdn.xxx.com/icons.js"></script>

第五步:PWA检查

如果使用

vite-plugin-pwa

必须检查:

workbox: {
  globPatterns: ['**/*.{js,css,html}']
}

风险点

  • index.html被precache
  • index.js 过大
  • 每次发布强制用户下载大包

建议

  • 只precache必要资源
  • 避免缓存大业务chunk
  • 首屏尽量轻量

进阶补充

1. 开启esbuild压缩

build: {
  minify: 'esbuild'
}

2. 开启tree shaking 优化

optimizeDeps: {
  include: []
}

不要乱include

3. 路由懒加载必须写对

{
  path: '/home',
  component: () => import('@/views/home/index.vue')
}

禁止

import Home from '@/views/home/index.vue'

Node.js 中间层退潮:从“前端救星”到“成本噩梦”

如果你和我一样,是2016年前后入行的前端,一定记得那个热血沸腾的年代。

那时候,前端圈最响亮的口号是:“Node.js是前端的后端”。我们兴奋地讨论BFF、大前端,仿佛看到了前端工程师的未来——不再被后端牵着鼻子走,自己掌控整个数据链路

我也是在那时候,第一次用Node.js搭起了BFF层。那种“前端也能写后端”的掌控感,至今难忘。

然而最近两年,风向变了。

很多中大厂都在悄悄“回退”Node.js中间层。有的把逻辑收归后端,有的切到Serverless,有的干脆砍掉整个BFF。曾经自豪的技术栈,怎么就成了“成本中心”?

今天,想站在咱们前端的视角,聊聊这场“退潮”背后的真实故事。

一、当年我们为什么对BFF如此着迷?

因为,我们真的受够了

回想一下没有BFF的日子有多痛苦:

产品经理说:“详情页需要展示用户昵称、订单金额、商品列表。”

你打开接口文档,发现要调三个接口:/user/info/order/detail/product/list。三个接口调完,还要自己拼数据。

// 没有BFF时,前端要自己聚合数据
async function getOrderPage(orderId) {
  // 串行调用三个接口
  const user = await fetch(`/api/user/info?userId=123`);
  const order = await fetch(`/api/order/detail?orderId=${orderId}`);
  const products = await fetch(`/api/product/list?orderId=${orderId}`);
  
  // 手动拼数据
  return {
    userName: user.name,
    orderAmount: order.amount,
    productList: products
  };
}

更崩溃的是,App端要的字段和Web端不一样。后端说:“你们前端能不能统一一下?”

你心里一万只羊驼跑过:“明明是你接口设计不合理,怪我咯?”

BFF给了我们“全栈”的尊严

Node.js BFF的出现,像是给前端打开了一扇窗。

// BFF层:数据聚合、裁剪、适配
router.get('/web/order/detail', async (ctx) => {
  // 并行调用,性能更好
  const [user, order, products] = await Promise.all([
    fetchUser(ctx.query.userId),
    fetchOrder(ctx.query.orderId),
    fetchProducts(ctx.query.orderId)
  ]);
  
  // 为Web端定制返回格式
  ctx.body = {
    userInfo: { name: user.name, avatar: user.avatar },
    orderInfo: { amount: order.amount, status: order.status },
    productList: products.map(p => ({ id: p.id, name: p.name, price: p.price }))
  };
});

// 为App端返回精简数据
router.get('/app/order/detail', async (ctx) => {
  // 同样的数据来源,不同的返回结构
});
  • 后端继续提供原子接口,保持他们所谓的“纯洁”
  • 我们在Node层做聚合、裁剪、适配
  • 前端只调Node层,拿到的就是“刚刚好”的数据

更重要的是,不用再求后端改接口了

字段名不对?Node层改一下。缺少数据?Node层调个新接口。响应太慢?Node层加个缓存。

// 后端接口字段名不合理?BFF层一键改写
const user = await fetchUser(userId);
// 后端返回的是 user_name,前端要的是 userName
return { userName: user.user_name };

那种“自己说了算”的感觉,太爽了。

二、蜜月期过后,我们开始尝到苦果

但架构是有代价的,只是这个代价,当时我们没算清楚。

运维噩梦:第一个周末被叫起来修服务器的滋味

我记得特别清楚,那是2019年的一个周六早上。

手机突然狂震,群里炸了:线上订单页打不开了。我迷迷糊糊爬起来,登录服务器,发现Node进程挂了。重启,又挂。再看,内存泄露。

# 前端不熟悉的运维命令
top # 看CPU
free -m # 看内存
tail -f /var/log/nginx/error.log # 看nginx日志
journalctl -u node-app # 看系统日志

那天我在电脑前蹲了四个小时,查日志、看监控、dump内存快照...最后发现是一个第三方SDK有bug。

作为一个前端,我擅长的是CSS布局、组件通信、状态管理。服务器的负载均衡、内存监控、日志采集,这些我根本不熟。

但因为是“前端负责的BFF”,出了问题,只能自己扛。

重复劳动:每个项目都在写一样的代码

后来公司扩张,业务线越来越多。每条线都要BFF,于是我们建了一套又一套。

打开代码库,惊人的相似:

// 业务线A的BFF
router.get('/a/order/detail', async (ctx) => {
  const data = await fetchData();
  return { code: 0, data };
});

// 业务线B的BFF
router.get('/b/order/detail', async (ctx) => {
  const data = await fetchData(); // 几乎一样的逻辑
  return { code: 0, data };
});

// 业务线C的BFF
router.get('/c/order/detail', async (ctx) => {
  const data = await fetchData(); // 又一遍
  return { code: 0, data };
});

这种重复劳动,本质上是在浪费我们前端的价值。我们本该花时间研究组件复用、性能优化、用户体验,结果天天在写重复的数据聚合代码。

“数据对不上”的锅,永远是我们背

最憋屈的是扯皮的时候。

前端调BFF接口,返回的数据缺字段。产品问:谁的问题?

后端说:“我接口返回了,你自己去看。” BFF说:“我透传了,没动过。” 最后查出来,是后端某个服务升级,字段名改了。

// 后端悄悄改了字段名,BFF层还在用旧的
// 后端返回:{ nickname: '张三' }
// BFF层还在用:user.name
// 前端收到:undefined

但沟通成本已经花了,时间已经耽误了,项目已经延期了。

三、杀死BFF的,不是后端,是新技术

如果说内部问题是“慢性病”,那新技术的出现,就是对BFF的“降维打击”。

Serverless:终于不用半夜修服务器了

我第一次接触Serverless,是帮朋友搞一个小程序。

// 云函数版本的BFF
exports.main = async (event, context) => {
  const { userId, orderId } = event.query;
  
  // 一样的聚合逻辑
  const [user, order] = await Promise.all([
    fetchUser(userId),
    fetchOrder(orderId)
  ]);
  
  return { user, order };
};

不用买机器、不用配nginx、不用考虑扩缩容。写完代码,serverless deploy,完事。出问题了?看日志,改代码,再部署。全程不用碰服务器

而且成本低得惊人。以前BFF服务器7x24小时运行,半夜没人访问也在烧钱。Serverless按调用次数计费,低流量时期几乎不花钱。

// 传统BFF:一直运行
app.listen(3000, () => {
  console.log('server running'); // 半夜也在运行
});

// Serverless:按需启动
exports.handler = async (event) => {
  // 有请求才执行,执行完就销毁
  return { statusCode: 200, body: 'hello' };
};

GraphQL:让前后端“吵架”变少了

GraphQL刚出来时,我们觉得它不就是BFF的另一种形式吗?但用了一段时间才发现,最大的改变是:前后端终于有了一份清晰的“契约”

# 前端声明要什么
query {
  order(id: "123") {
    amount
    status
    user {
      name
      avatar
    }
    products {
      name
      price
    }
  }
}
// GraphQL resolver:聚合逻辑还在,但契约更清晰了
const resolvers = {
  Order: {
    user: (order) => fetchUser(order.userId),
    products: (order) => fetchProducts(order.id)
  }
};

以前调BFF接口,返回什么全靠看代码、靠猜。用GraphQL,前端明确声明要哪些字段,返回的数据结构是强类型的,IDE里还有智能提示。

后端终于“开窍”了

这几年,后端也在变化。

// 以前:后端坚持原子接口
GET /user/123
GET /orders?userId=123
GET /products?orderId=456

// 现在:后端提供聚合接口
GET /web/profile?userId=123
// 返回:{ user: {...}, recentOrders: [...], favoriteProducts: [...] }

后端团队也开始重视文档、规范字段命名、保证数据契约的稳定性。前端对BFF的依赖,自然就降低了。

四、但我们真的做错了吗?

写到这里,可能会觉得BFF是一个“错误的选择”。

但我想说:在那个时间点,BFF就是最好的解。

BFF解决了当时最痛的三个问题:

  1. 不用调N个接口了:一次请求,拿到所有数据
  2. 不同端可以定制数据了:Web、App、小程序各取所需
  3. 不用求后端改接口了:我们自己能改

对于我们前端来说,BFF给了我们更大的话语权和自主权。它让我们从“切图仔”变成了“能掌控数据链路的人”。

这段经历,也让我们学会了后端思维:缓存、并发、熔断、限流...这些知识,现在依然在用。

五、今天,我们前端该怎么玩?

如果你问我,现在要不要学Node.js中间层,我的答案是:要学,但不是以前那种玩法。

优先拥抱Serverless

// 传统BFF
const app = require('express')();
app.get('/api/order', async (req, res) => {
  // 业务逻辑
});
app.listen(3000);

// Serverless版本
exports.handler = async (event) => {
  // 同样的业务逻辑
  return { statusCode: 200, body: JSON.stringify(data) };
};

除非有特殊需求,否则优先用云函数。运维成本几乎为零,咱们前端可以真正专注于业务逻辑。

学GraphQL,但别只会写resolver

// 理解GraphQL的设计思想
type User {
  id: ID!
  name: String!
  orders: [Order!]!
}

type Order {
  id: ID!
  amount: Float!
  status: String!
}

Schema优先、强类型契约、按需查询——这些思想,会让你对“前后端协作”有更深的理解。

把BFF当“学习后端思维”的跳板

即使以后不用BFF了,那段经历也是宝贵的。你学会了如何处理并发、如何设计缓存、如何做服务熔断、如何排查线上问题。

// 这些能力依然有用
Promise.all([fetchA(), fetchB(), fetchC()]); // 并发控制
node --inspect-brk app.js // 调试技巧

这些能力,会让你成为“更懂后端”的前端,在协作中更有话语权。

六、写在最后

技术的世界,没有永恒的真理,只有不断变化的语境。

BFF从崛起到回落,不是一个失败的故事,而是一个成长的印记。它见证了前端从“切图”到“全栈”的探索,也见证了架构演进的必然规律。

对于我们每个亲身经历过的人来说,重要的是:不要停留在过去的荣光里,也不要否定曾经的探索

保持学习,保持思考,保持对新技术的好奇。

这才是我们前端最宝贵的品质。


如果你也经历过BFF的起起落落,欢迎在评论区聊聊你的故事。

❌