阅读视图

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

Agent Skills:智能 IDE 正在掀起一场“能力模块化”革命

从“靠嘴指挥”到“直接上手”,AI 终于学会像工程师一样干活了。


AI 从聊天走向执行

一、Agent Skills 是什么?🤔

如果你最近频繁玩 Cursor、Claude Code、Gemini CLI、Codex CLI、Trae 这类“智能 IDE”,应该已经察觉到一个明显的趋势:

AI 不再满足于“你问我答”了,它开始直接上手帮你把事做完

而让这一切成为可能的核心机制之一,就是 Agent Skills

简单来说,Agent Skills 是一组可复用、可组合、能被 AI 主动调用的“技能包”。它不是一个简单的 Prompt,也不只是一个 API,而是:

  • 一套结构化的任务说明书(SOP)
  • 一组可以直接运行的脚本、模板或工具
  • 一种能被 IDE 或 AI 理解并随时调用的能力描述方式

你可以把它想象成:

扔给 AI 的一本「岗位职责 + 操作手册」

来看一个真实的 Skill 长啥样

在 Claude / Cursor 的生态里,一个 Skill 其实就是一个文件夹,核心是里面的 SKILL.md

.claude/skills/
  react-component-generator/
    SKILL.md
    templates/
    scripts/

SKILL.md 的开头是 YAML 配置,后面跟着 Markdown 格式的执行指南:

---
name: react-component-generator
description: 根据需求生成 React 组件
---

当用户描述一个 UI 或功能需求时:
1. 分析组件的职责与结构
2. 匹配合适的组件模板
3. 生成符合当前项目规范的 React 组件代码

AI 不再是“随意发挥”,而是在明确流程约束下执行任务,大幅降低随机性


Skill = SOP + 工具箱

二、技能进化史:Agent Skills 是怎么来的?📜

Agent Skills 不是石头缝里蹦出来的,它的出现其实是一条清晰的技术演进路线的终点。

1️⃣ Prompt Engineering 时代:纯靠“嘴遁”

早期我们只能靠 Prompt 来指挥 AI:

“你是一个资深前端,请按以下规范生成代码……”

但问题很明显:

  • 不稳定(上下文一变就跑偏)
  • 难复用(每次都要重新说一遍)
  • 团队经验无法沉淀

2️⃣ Tool / Function Calling:AI 学会“用工具”了

接着出现了 Toolformer、Function Calling

  • AI 能自己判断“什么时候该调用工具”
  • 还能把参数整理好传给函数

但依然存在一个短板:

工具只告诉 AI“你能做什么”,却没告诉它“你应该怎么做”

3️⃣ Agent + Skills:开始真正“像人一样工作”

Agent Skills 补上了最后一块拼图:

  • 人类工程师的工作流固化下来
  • 让 AI 面对复杂任务时,有清晰的步骤可循
  • 支持按需加载技能,避免“一次性吃成胖子”(上下文爆炸)

这也是为什么 Claude、Cursor、VSCode Agent、Trae 都在 2024~2025 年间集体拥抱了 Skills 机制。


Prompt vs Skill

三、Skills vs MCP:别再傻傻分不清楚!🚨

这是很多人容易搞混、但又必须弄清楚的一个点。

MCP 是什么?

MCP(Multi-agent / Model Context Protocol) 解决的是:

Agent 怎么“连接外部世界”

比如:

  • 读取数据库
  • 调用搜索引擎
  • 操作浏览器或文件系统

你可以把它理解成:

接口协议 / 数据管道 / 外设驱动

Skills 又是什么?

Skills 解决的则是另一件事:

Agent 应该“按照什么流程正确做事”

一个超形象的类比

角色 类比
MCP USB 接口 / API 网关
Skills 说明书 / 标准作业程序(SOP)

👉 MCP 管的是“能不能做”
👉 Skills 管的是“该怎么做”

它们俩是互补关系,而不是谁替代谁。


MCP vs Skills

四、上手实战:在智能 IDE 里怎么玩转 Skills?🎮

我们拿 Claude Code / Cursor 来举个完整的例子。

1️⃣ 安装 Skills 管理工具

npm install -g openskills

2️⃣ 安装官方或社区的 Skills

openskills install anthropics/skills

你可以挑着装,比如只装这几个:

  • pptx-generator(自动做 PPT)
  • git-changelog-analyzer(分析 Git 日志)
  • code-review-helper(辅助代码审查)

3️⃣ 生成 AGENTS.md 索引文件

openskills sync

这个文件非常关键:

它就像是 AI 的“技能清单”

4️⃣ 实际体验:一句话让 AI 干活

在 Cursor / Claude Code 里直接输入:

pptx skill,基于最近 10 次 git commit,生成一份技术周报 PPT

接下来 AI 会自动:

  1. 查看 AGENTS.md
  2. 找到 pptx 这个技能
  3. 拉取 git 日志
  4. 调用模板和脚本生成 PPT 文件

全程无需你再手写任何一句 Prompt


Skill 调用流程

五、真实场景:Skills 能用来做什么?💼

这里才是 Agent Skills 真正让人兴奋(甚至有点吓人)的地方。

🧩 对开发者

  • 统一项目代码风格(ESLint + Prettier 自动化)
  • 自动生成组件、API 层、Mock 数据
  • 一键补全单元测试
  • 按团队规范批量重构代码

📦 对产品与技术管理者

  • 自动生成 PRD / 技术方案框架
  • 版本发布自动生成变更说明
  • 定期生成技术周报、月报

🛠 对团队协作

  • 把团队大佬的经验封装成 Skill
  • 新人入职直接“加载技能包”
  • AI 化身真正能干的“虚拟同事”

一句话总结:

只要你能写成标准流程的活儿,都有可能变成一个 Skill。


Skills 应用场景

六、冷静一下:Skills 的隐患与未来 ⚠️

能力越强,责任越大,风险也越真实。

⚠️ 已经露头的风险

  • 恶意 Skill 偷偷注入危险指令
  • 一次授权,AI 就可能长期“失控”
  • 诱导 AI 执行高权限操作(比如读 SSH Key、跑系统命令)

现实中已有安全团队警告:

部分 Skill 可在用户无感知的情况下,读取敏感文件或执行命令。

🛡️ 必须补上的防护

  • Skill 权限分级管理
  • 脚本在沙箱中执行
  • 安全扫描与审核机制
  • 关键操作需人类确认

🌱 未来的想象空间

  • Skill 应用商店 & 行业标准
  • 一次开发,多 IDE 通用
  • 企业私有 Skill 仓库
  • Skill + MCP + Agent 的完整工程体系

写在最后 ✨

如果你最近觉得:

“AI 终于像个靠谱的工程师了”

那很可能是因为:

它不再只是一个语言模型,而是一个装备了 Skills 的智能体。

下一阶段的竞争,或许不再是比谁的 Prompt 写得妙, 而是看谁更会给 AI 设计“技能架构”

欢迎从今天开始,

少写 Prompt,多写 Skills。 🚀


React扩展

CSS

styled-components: yarn add styled-components

// props透传
// attrs的使用
// 传入state作为props属性
const StyledInput = styled.input.attrs({
  type: 'password',
  placeholder:'请输入',
  bgColor:'#000'
})`
  color: ${props => props.color};
  background-color: ${props => props.bgColor};
`

// 继承
const StyledInput2 = styled(StyledInput)`
  margin-top: 20px;
  padding: 10px;
`

动画

npm install react-transition-group --save

<CSSTransition
    nodeRef={this.nodeRef}
    in={this.state.show}
    timeout={500}
    classNames="fade"
    unmountOnExit
    appear
>
    <h1 ref={this.nodeRef}>CSS Transition</h1>
</CSSTransition>

.fade-enter, .fade-appear{
    opacity: 0;
}

.fade-enter-active, .fade-appear-active{
    opacity: 1;
    transition: opacity 500ms ease-in-out;
}

.fade-enter-done, .fade-appear-done{
    opacity: 1;
}
.fade-exit{
    opacity: 1;
}


.fade-exit-active{
    opacity: 0;
    transition: opacity 500ms ease-in-out;
}

.fade-exit-done{
    opacity: 0;
}

Redux

// index.js
import { createStore } from 'redux'
import reducer from './reducer'
const store = createStore(reducer)
store.subscribe(() => {
  console.log(store.getState())
})
store.dispatch(addAction(1))
store.dispatch(subAction(10))

// action.js
export const addAction= (num) => ({
  type: ADD_NUMBER,
  num
})
export const subAction= (num) => ({
  type: SUB_NUMBER,
  num
})

// reducer.js
const defaultlState = {
  count: 0,
}
function reducer(state = defaultlState, action) {
  switch (action.type) {
    case ADD_NUMBER:
      return {
        ...state,
        count: state.count + action.num,
      } 
    case SUB_NUMBER:
      return {
        ...state,
        count: state.count - action.num,
      } 
    default:
      return state
  }
}


// 加入connect
// index.js
import { createStore, applyMiddleware } from 'redux'
**import { thunk } from 'redux-thunk'**
import reducer from './reducer'

const storeEnhancer = applyMiddleware(thunk)

const store = createStore(reducer, storeEnhancer)    

export default store

// reducer.js
import { createStore, applyMiddleware } from 'redux'
import { thunk } from 'redux-thunk'
import reducer from './reducer'

const storeEnhancer = applyMiddleware(thunk)

const store = createStore(reducer, storeEnhancer)    

export default store

// actionCreator.js
import { ADD_NUMBER, SUB_NUMBER } from './constans'

export const addAction= (num) => ({
  type: ADD_NUMBER,
  num
})

export const subAction= (num) => ({
  type: SUB_NUMBER,
  num
})

**export const getBannerList = (dispatch) => (
  console.log('getBannerList')
)**

// connect.js
import { PureComponent } from "react";
import store from '../store'

export function connect(mapStateToProps, mapDispatchToProps) {
  return function (Component) {
    return class ConnectedComponent extends PureComponent {
      constructor(props) {
        super(props)
        this.state = {
            storeState: mapStateToProps(store.getState()),
        }
      }
      componentDidMount() {
        this.unsubscribe = store.subscribe(() => {
          this.setState({
            storeState: mapStateToProps(store.getState()),
          })
        })
      }
      componentWillUnmount() {
        this.unsubscribe()
      }
      render() {
        return (
          <Component
            {...this.props}
            {...this.state.storeState}
            {...mapDispatchToProps(store.dispatch)}
          />
        );
      }
    };
  };
}
// home.js
import { connect } from '../utils/connect'
import { addAction, subAction, getBannerList } from '../store/actionCreator'

const mapStateToProps = (state) => ({
  count: state.count
})

const mapDispatchToProps = (dispatch) => ({
  addAction: (num) => dispatch(addAction(num)),
  subAction: (num) => dispatch(subAction(num)),
  getBannerList: () => dispatch(getBannerList)
})

function Home2(props) {
    return (
        <>
            <div>
                <h1>HOME</h1>
                <h2>当前计数:{props.count}</h2>
            </div>
            <button onClick={() => props.addAction(1)}>+1</button>
            <button onClick={() => props.subAction(1)}>-1</button>
            <button onClick={() => props.getBannerList()}>获取轮播图</button>
        </>
    ) 
}

export default connect(mapStateToProps, mapDispatchToProps)(Home2)

router

npm install react-router-dom
BrowserRouter: history模式
HashRouter: hash模式

<BrowserRouter>
    <Link to="/a">首页</Link>
    <Link to="/about">关于</Link>
    <Routes>
        <Route path="/a" element={<Home />} />
        <Route path="/about" element={<About />} />
    </Routes>
</BrowserRouter>

function Routes() {
  return useRoutes([    
      {
        path: '/home',
        element: <Home />
      },
      {
        path: '/about',
        element: <About />,
        children: [
          {
            path: 'history',
            element: <AboutHistory />
          },
          {
            path: 'school',
            element: <AboutSchool />
          },
          {
            path: 'company',
            element: <AboutCompany />
          }
        ]
      }
])}

Hooks

class组件比函数式组件的优势(没有hooks的时候)
    1.class组件可以定义自己的state,用来保存组件自己内部的状态
      函数式组件不可以,因为函数每次调用都会产生新的临时变量
    2.class组件有自己的生命周期,比如在componentDidMounted中发送网络请求,
      并且在生命周期函数只会执行一次
      在函数中发送网络请求,意味着每次重新渲染都会重新发送一次网络请求
    3.class组件可以在状态改变时只会重新执行rendr函数以及生命周期componentDidUpdate
      函数式组件在重新渲染时,整个函数都会被执行
      
useState
本身是一个函数,来自react包
参数和返回值
    1.参数:给创建出来的状态一个默认值
    2.返回值:数组,元素1:当前state的状态值,元素2是更新状态的函数
只能在函数最外层调用Hook,不要在循环,条件判断或者子函数中使用
只能在react的函数组件中调用hook,不能在其他js函数中调用

useCallback
// memo 用于包裹函数组件,使其在 props 未发生变化时跳过重新渲染,从而提升性能。
// 父组件重新渲染,子组件也会重新渲染,但是如果子组件被 memo 包裹,且 props 未发生变化,那么子组件就不会重新渲染。
// HYBButton1的increment函数会重新创建,所以memo包裹的props发生变化,会重新渲染
// HYBButton2的increment函数不会重新创建,所以memo包裹的props没有发生变化,不会重新渲染
const HYBButton = memo(({title, increment}) => {
  console.log('重新渲染', title)
  return (
    <button onClick={increment}>
      +1
    </button>
  )
})

export default function Callback() {
  const [count, setCount] = useState(0)
  const [isShow, setIsShow] = useState(true)
  const handleClick1 = () => {
    setCount(count + 1)
    console.log(111)
  }

  const handleClick2 = useCallback(() => {
    setCount(count + 1)
    console.log(222)
  },[count])

  return (
    <div>
      <p>当前计数: {count}</p>
      <p>当前显示状态: {isShow ? '显示' : '隐藏'}</p>
      <HYBButton title="增加1" increment={handleClick1}  />
      <HYBButton title="增加2" increment={handleClick2} />
      <button onClick={() => setIsShow(!isShow)}>显示/隐藏</button>
    </div>
  )
}

useContext
App.js
export const UserContext = createContext()
<React.StrictMode>
    <UserContext.Provider value={{name: '张三', age: 18}}>
      <MemoDemo />
    </UserContext.Provider>
</React.StrictMode>

index.js
import { UserContext } from '../index'
export default function User() {
  const user = useContext(UserContext)
  return (
    <div>
      <p>当前用户: {user.name}</p>
      <p>当前用户年龄: {user.age}</p>
    </div>
  )
}

useMemo
import { useState,useMemo,memo } from 'react';
function calcNum(count){
    let total = 0
    for(let i = 0; i < count; i++) {
        total += i
        console.log('total',total)
    }
    return total
}

const HYUser = memo(function HYUser(props) {
  const {name, age} = props
  console.log('HYUser', name, age)
  return(
    <div>
      <p>当前用户: {name}</p>
      <p>当前年龄: {age}</p>
    </div>
  )
})

export default function MemoDemo() {
  const [count, setCount] = useState(0)
  const [isShow, setIsShow] = useState(true)
//   let total = 0
//   for(let i = 0; i < count; i++) {
//     // 切换show每次都会执行for循环
//     total += i
//   }
  const total = useMemo(() => calcNum(count), [count])
  const info = useMemo(() => ({name: '张三', age: 18}), []) // 依赖项为空数组,只在组件挂载时执行一次
//const info = {name: '张三', age: 18} 每次重新渲染都创建一个新对象,会导致HYUser组件重新渲染

  return(
    <div>
      <p>当前计数: {count}</p>
      <p>当前显示状态: {isShow ? '显示' : '隐藏'}</p>
      <p>当前total: {total}</p>
      <HYUser info={info} />
      <button onClick={() => setCount(count + 1)}>增加</button>
      <button onClick={() => setIsShow(!isShow)}>切换显示状态</button>
    </div>
  )
}
- useMemo :缓存值 ,用于优化复杂计算
- useCallback :缓存函数 ,用于优化子组件渲染
- useCallback 是 useMemo 的特例 : useCallback(fn, deps) 等价于 useMemo(() => fn, deps)

useReducer
import { useReducer } from 'react'
function reducer(state, action) {
  switch(action.type) {
    case 'increment':
      return {counter: state.counter + action.payload}
    case 'decrement':
      return {counter: state.counter - action.payload}
    default:
      return state
  }
}
export default function User() {
  const [state, dispatch] = useReducer(reducer, {counter: 0})
  return(
    <div>
      <p>当前状态: {state.counter}</p>
      <button onClick={() => dispatch({type: 'increment', payload: 1})}>增加</button>
      <button onClick={() => dispatch({type: 'decrement', payload: 1})}>减少</button>
    </div>
  )
}

useLayoutEffect
useEffect会在渲染的内容更新到DOM上后执行,不会阻塞DOM的更新
useLayoutEffect会在渲染的内容更新到DOM上之前执行,会阻塞DOM的更新
如果我们希望在某些操作发生之后再更新DOM,那么应该将这个操作放到useLayoutEffect

拒绝重复造轮子?我们偏偏花365天,用Vue3写了款AI协同的Word编辑器

ps:开源版SDK已发布,github地址:github.com/MrXujiang/j…

又到了我们定期AI创业复盘的时间了。今天和大家分享一下,我们决定花1年时间打造AI Word编辑器的理由,以及做AI文档创业过程中踩过的坑。图片

为什么"偏偏"要造个Word轮子

我们之所以下定决心做这件事,主要是因为前两年我们的一个产品,需要集成word能力对外服务,但是我们调研了一圈,得到的结论是:1. 大厂的文档产品集成成本过高,对外商业化受限头部大厂做的文档产品,要么是按次付费(并发次数):图片比如上面这张,如果高频使用,我们团队算了一笔账,每年api的调用基本都要花10-20w左右,更别说对外给客户服务了,那成本只有大公司能玩了。另一方面,国内B端客户大部分的Saas场景都需要私有化部署,国企事业金融企业更要求内网部署,所以基本上不可能集成大厂的SDK,这条商业模式已经被堵死了。2. 大厂的文档产品技术债过重,扩展度和开放程度受限这基本上是行业的共识了。普通企业只能用大厂的系统,如果要开发,要么动辄百万,要么就等“等更新”。但是我们的AI文档场景,并不需要很多冗余的功能,而是保留最核心的能力,其他的我们希望开发给企业自定义。同时,现有文档编辑器都是"互联网时代的产物",为"人写给人看"设计。AI无法真正理解文档结构,只能当"高级搜索框"。我们结合这两年AI的发展,洞察的结果是:内容创作正在从"人驱动"转向"人机协作",但工具没有跟上。所以综合上面分析,再结合我们团队大厂架构和文档产品的研发经验,我们毅然决定自研。

365天我们做了什么

图片

说实话,规划了1年,其实并不是单纯的时间维度的概念,我们打算将 JitWord 打造成一个未来我们 all in 的一个产品长链路的方向:基于JitWord的文档引擎,扩展出企业共建版,JitOffice办公软件,JitCloud AI云服务生态。所以可能未来1-2年,我们还是会持续深耕在“知识内容传承”这个赛道。

第一步,架构重构:从"富文本"到"结构化数据"

在做 JitWord 之前,我们对 Office 文档做了大量的调研,从docx格式到文档的复杂操作,都意味着传统的富文本格式(html)难以驾驭复杂的文档场景。

我们也调研了很多开源方案,比如 tiptap,quill,editorjs等。最终我们选择了tiptap的一个早期稳定的版本,作为我们的底层文档组件,并对 tiptap 的架构做了上层的优化,已支持复杂的文档操作。

下面是我们第一版的文档设计架构:

图片

其实单纯实现类似 Office 的UI界面,难度不是很大,只需要花时间来开发,我相信每一个前端工程师都能实现,其难点在于:

  • 如何高效的做文档解析(需要对docx进行高精度格式还原)
  • 如何基于文档做高性能协同(支持多人协同编辑)
  • 如何在web文档处理复杂公式编辑和渲染
  • 实现文档的复杂操作(划词评论,版本对比,高级排版,分页视图等)
  • 实现文档的权限控制和高性能渲染(100w+字秒级渲染)

等等,每一个骨头都比较硬,基本上都是我们花大量实现自研,比如:

基于文档做高性能协同(支持多人协同编辑)

目前市面上其实有些协同方案,比如CRDT算法驱动的YJS,当然我们也是基于YJS实现的文档协同算法。但是单纯使用Yjs,只能实现基础协同,在协同过程中我们需要考虑很多复杂场景:

  1. 操作可交换性不同用户的操作可以以任意顺序执行,最终结果一致
  2. 操作可合并性多个操作可以智能合并,减少网络传输
  3. 最终一致性所有客户端最终会收敛到相同的文档状态
  4. 无需中央协调不依赖中央服务器进行冲突解决

下面是我们设计的协同操作流程:

图片

在协同编辑的性能上,我们也做了进一步算法优化,保证我们在普通服务器上(2核4G)也能支持10-50人的高效协同编辑,如果扩大服务器性能和集群,我们将有可能支持数千上万人的协同编辑。

下面是我们的文档协同和存储架构数据流:

图片

实现在web端处理复杂公式编辑和渲染

基本上市面上的开源方案都达不到我们对复杂公式的极致追求,大部分是让用户直接编辑latex,但是到了导出docx后,公式要么无法导出,要么导出后无法二次编辑。

基于上面的痛点,我们对docx文件做了数据结构的分析和算法层的兼容,同时对用户编辑公式的体验也做了进一步升级:

图片

我们提供了数学公式的编辑面板,我们可以实时编辑和预览公式,同时我们内置了100+高频的数学和科研公式,方便大家更快速的编写复杂公式。

下面是渲染到 JitWord 中的公式效果:

图片

同时,我们也能直接导出带复杂公式的文档,并在 word 中二次编辑。

文档的高效渲染(100w字秒级渲染)

图片

上面是我们文档中渲染了100w字的效果,目前测试下来还是可以编辑文档,只有略微的延迟。

这一结果归功于我们对文档性能的极致追求,在文档渲染层我们做了极致的优化,并全方面测试了渲染协同稳定性能:

图片

实现文档的复杂操作(划词评论,版本对比,高级排版,分页视图等)

任何一个富文本编辑器,都很难自带划词评论,版本对比,高级排版,分页视图这些高级操作功能。

我们研究这些功能花费了很多时间,也在每个月以2-3个版本的迭代速度更新着JitWord。

终于在2025年底实现了上面提到的这几个功能,下面我也给大家一一介绍一下。

  1. 划词评论

图片

当然大家可不要以为我们只是做了划词评论功能。我们在划词评论之后,还做了通信机制,在多人协同过程中可以实时通知给其他人,让协作者可以第一时间看到划词的内容,这个流程我们完全打通了。

  1. 版本管理功能

图片

我们的操作会定期存储,可以一键恢复,并支持版本的差异对比。这个功能基本上也是市面上web端文档独一份的,当然我们还在持续优化。

  1. 分页视图功能

图片

这个功能是最难坑的,不容置疑。但是我们花了2个月 all in 这个功能,终于实现了类似 word 的分页功能。它的难点在于分页之后内容的排版和位置自动计算机制,需要消耗大量的 js 性能,所以我们各种性能优化的方式都用上了,结合我们设计的独有的dom组织模式,终于实现了分页能力。

大家可以在 JitWord 共建版体验到分页的功能。

当然,我们还会支持页眉页脚功能,全面对标 Office。

第二步,AI协作引擎:让文档"活"起来

图片

上面是我们设计的 AI Native 驱动的模式架构,保证我们在文档编辑的全生命周期里,都能体验AI创作的乐趣。

下面是演示效果:

图片

我们除了直接让AI创作内容,还可以基于文本和段落,进行二次AI优化,比如文本润色,纠错,续写等:

图片

最近我们还迭代上线了AI公文助手,可以通过AI一键生成合同和公文:

图片图片

当然后续会实现更多和AI结合的场景,提高用户办公的效率。

第三步,Vue3的执念:为什么死磕Vue?

很多人问:文档编辑器为什么要强调Vue3?用React不是生态更好?

我们的回答是:响应式性能决定了AI协作的流畅度

技术细节:

  • Proxy-based响应式:10万字符文档的实时协作,传统Object.defineProperty会卡成PPT,Vue3的Proxy实现了毫秒级更新
  • Composition API:AI建议卡片、协作光标、公式渲染器……这些复杂组件的组合逻辑,用Composables管理比HOC清晰10倍
  • Tree-shaking友好:最终打包体积比同类React方案小40%,SaaS嵌入时客户感知明显

一个真实案例:

一个客户把我们的产品嵌入他们的CRM系统,原先用的React方案首屏加载3.2s,替换为 JitWord 后降到1.1s。客户CTO的原话:"你们这个Vue3版本,让我们的SaaS看起来像原生应用。 "

这就是"造轮子"的意义:不是为造而造,是为跑得更快。

并且国产化环境,很多客户都是更倾向用 Vue 技术栈,所以站在客户和用户体验的角度,我们Vue的策略是完全经得起考验的。

拒绝自嗨,持续打磨应用场景

技术人最容易犯的错:拿着锤子找钉子。

我们花了两个月时间,把产品扔到真实场景里验证,我们上线了我们开源SDK,让大家可以集成到自己的真实项目中来体验:

图片

目前有各行各业的客户给我们反馈了大量的建议,我们也在持续排期优化,下面分享一下我们内部的需求列表:

图片

目前已经有100多个我们评估后觉得非常有价值的功能点,在今年的一年里,我们会陆续上线。

当然大家有好的建议,也欢迎随时交流反馈~

github地址:github.com/MrXujiang/j…


开源与商业化思考

写到这里,必须回答那个最尖锐的问题:

你们最后要卖钱吗?还是说只是技术情怀?

我的答案是:部分开源,商业闭环。


为什么开源?

开源了基础的文档引擎SDK,包括:

  • 结构化文档模型
  • Vue3组件基础
  • 公式渲染模块
  • docx导入导出功能

目的不是做慈善,是建立标准。

如果 JitWord 的文档模型成为行业事实标准,第三方开发者自然会基于我们的格式开发插件。这等于用社区力量帮我们扩展生态——比招100个产品经理都有效


为什么保留商业版?

商业版包含:

  • AI协作引擎(调用大模型API,成本敏感)
  • 企业级协作功能(权限管理、审计日志)
  • SaaS嵌入解决方案

这不是"开源版阉割功能",是"开源版定义基础,商业版解决复杂问题"。

类比:Android开源,但GMS(Google服务)收费;MySQL开源,但企业版有高级监控。这是经过验证的商业模式。

一个创业者的坦白:

我们考虑过完全开源、靠服务变现。但过去一年和几十家企业聊过后,我发现B端客户真正付费的不是"软件",是"确定性" ——出了问题能找到人、能签SLA、能定制开发。这些只能靠商业团队支撑。

所以,造轮子不是目的。

让轮子跑得更快,让更多人用上更快的轮子,同时让造轮子的人能持续造下去——这才是目的。


关于作者:前大厂技术专家,现 JitWord 联合创始人。相信AI时代的生产力工具,应该由懂产品和技术的人重新做一遍。

我们团队均来自一线中大厂,资深工程师和技术专家,配合3个Agent,开启6人协作的创业之旅~

如果大家有好的建议,想法,欢迎留言反馈~

前端 AI Coding 落地指南(二)Rules篇

本篇是《前端 AI Coding 落地指南》的续作,专门讲 Rules 的实践:演进过程、设计原则、如何划分模块、何时写 Rules 何时写 Skills,以及项目级 Rules 的完整清单与每条 rule 的头部、介绍和通用化示例。首篇中已给出 Rules 的简要历程与清单,这里展开为可落地的细节。


Rules 是什么、解决什么问题

Rules 是写在项目里的持久化指导,用 Markdown 等形式约定项目的规范、约束和惯例。Agent 在「需要确认某类规范时」按需读取对应规则文件,从而在生成或审查代码时遵守「做什么、不做什么」——命名、目录、接口契约、样式变量、错误处理等有据可查。

作用:统一边界(减少模型随意发挥)、按需加载(控制上下文长度)、提高接纳率(生成结果更符合项目既有约定)。能解决的 AI Coding 问题:规范不一致(没有规则时 AI 易按通用习惯写,与项目脱节);约束缺失(如「接口必须放某目录」不写进规则就容易漏);上下文漂移(规则按场景拆分,需要时只加载相关文件,避免一次性塞入整本规范导致注意力分散)。


演进过程

一开始:整个项目一个庞大的 Rules
所有规范、步骤、示例都塞在一个或少数几个大文件里。带来的问题是:单次上下文过大(容易顶到上下文上限或挤占对话)、容易丢记忆/注意力分散(长文档里模型容易「看了后面忘前面」)、难以按场景聚焦(比如只想加个路由,却不得不带着 UI 验收、设计稿分析等无关内容)。结果是步骤漏、检查项漏,接纳率反而不稳定。

中期:按模块拆成多个 Rules
把「项目概述、编码、结构、组件、API、路由、状态、通用约束、样式、文档、测试」等拆成 01~11 这样的独立文件,需要哪类规范就读哪几个。按需加载效果好很多,上下文压力下来,也便于维护。但还有一个问题没解决:「如何落地」的步骤和示例仍然写在 Rules 里——例如「怎么加一个路由」的详细步骤、目录示例、检查清单,和「路由必须和菜单一致」这类约束混在一起,单文件仍然偏长,且步骤化、可复用工作流不好跨项目复用。

最近:Skills 盛行,Rules 简化 + 实施指导迁到 Skills
引入 Skills 之后,做了两件事:Rules 只保留「原则与约束」(做什么、不做什么、目录/命名/契约),把「如何一步步做、怎么写示例、产出长什么样」迁到 Skills,并利用 Skills 的渐进式披露(只在执行该任务时加载对应 SKILL 和技能内 rules)。这样 Rules 变薄、按需读;具体落地在 Skills 里按场景加载,既避免上下文爆炸,又避免模型在长文档里「抓不住重点」。Skills 的详细实践、何时封装、技能下 rules 如何拆分见《前端 AI Coding 落地指南(三)Skills 篇》。


设计原则:哪些写 Rules、哪些写 Skills

原则概括Rules 回答「是什么 / 不能是什么」,Skills 回答「怎么做 / 先做什么再做什么」。

模块划分:按「开发时会被问到的决策类型」来拆,而不是按代码目录拆。例如:项目背景与技术栈(项目概述)、命名与风格(编码规范)、文件放哪(项目结构)、组件怎么拆(组件规范)、接口怎么定义和调用(API 规范)、路由和菜单怎么对(路由规范)、状态放哪怎么用(状态管理)、全局约定(通用约束)、样式和主题(样式规范)、注释与测试(文档/测试规范)。这样 Agent 在「要做一个什么类型的决策」时,能对应到某一个或少数几个规则文件。

什么留在 Rules:原则、约束、契约、目录与命名约定。例如「接口必须放在某目录下」「路由配置必须和菜单配置一致」「错误必须用统一方式处理」「主题色必须走 CSS 变量」——这些是「是/否」判断,不涉及长步骤和大量示例,适合放在 Rules,篇幅可控。

什么放到 Skills:多步骤流程、检查清单、产出模板、示例代码。例如「创建提案的 5 步」「设计稿分析的 4 步」「加一个路由要创建哪几个文件、每步检查什么」「UI 分析清单/问题清单长什么样」——这些一旦写进 Rules 会让单文件很长,且和「原则」混在一起,不利于按场景加载;放到 Skills 里,用 SKILL.md + 技能内 rules 做渐进披露更合适。

判断方式:若一句话能说清且不需要示例,放 Rules;若需要「第一步、第二步、检查点、模板、示例」,放 Skills。


项目级 Rules 清单(目录结构)

.agents/rules/
├── 01-项目概述.md
├── 02-编码规范.md
├── 03-项目结构.md
├── 04-组件规范.md
├── 05-API规范.md
├── 06-路由规范.md
├── 07-状态管理.md
├── 08-通用约束.md
├── 09-样式规范.md
├── 10-文档规范.md
├── 11-测试规范.md
└── README.md

由于篇幅问题,以下每条 rule 示例是简化后的,主要作为参考,后续会把完整的代码放到 github 上。


01-项目概述

---
alwaysApply: false
description: 项目定位与技术栈概览。当需要了解项目背景、使用的技术栈时读取此规则。
---

# 项目概述

## 项目定位
一段话说明项目是什么、面向谁、核心能力。

## 技术栈
| 领域 | 技术 | 说明 |
|------|------|------|
| UI 框架 | React x.x | 强制使用 |
| 类型系统 | TypeScript x.x | 强制使用,禁止 JavaScript |
| 状态管理 | Zustand / Redux 等 | 统一使用,禁止其他方案 |
| 组件库 | Ant Design / 其他 | 交互 UI 基于此二次封装 |
| 样式方案 | SCSS Modules | 强制使用,禁止全局样式 |

02-编码规范

---
alwaysApply: false
description: 编码规范,包括 TypeScript 使用、命名约定(变量、常量、接口、组件等)、业务函数命名。编写或审查代码时读取。
---

# 编码规范

## TypeScript 规范
- 所有代码必须使用 TypeScript,禁止使用 JavaScript
- 禁止使用 `any`(除非有明确理由并注释)
- 接口、类型使用 PascalCase;组件 Props 必须定义清晰接口

## 命名规范
| 类型 | 规则 | 示例 |
|------|------|------|
| 文件夹/路由 | kebab-case | user-profile |
| 变量/函数 | camelCase | userList, onSubmit |
| 常量 | UPPER_CASE | API_TIMEOUT |
| 接口/类 | PascalCase | UserInfo |
| React 组件 | PascalCase | UserProfile |
| 自定义 Hooks | use 开头 | useUserStore |

## 业务函数命名
- 事件处理:onXxx(如 onSubmit)
- 内部处理:handleXxx(如 handleDelete)

03-项目结构

---
alwaysApply: false
description: 目录结构规范,各目录用途与约束(禁止新建非标准目录)。确定代码放哪时读取。
---

# 项目结构(NON-NEGOTIABLE)

## 目录结构
src/
├── assets/        # 静态资源
├── components/    # 可复用 UI 组件
├── constants/     # 业务常量、枚举
├── hooks/         # 自定义 Hook
├── http/          # 请求封装
├── interfaces/    # 类型声明(model、api)
├── routes/        # 路由页面
├── stores/        # 全局状态,按业务拆分
├── utils/         # 工具函数
└── 根级入口文件

## 结构约束
- 常量 → constants/;状态 → stores/;接口类型 → interfaces/(每模块含 model.ts、api.ts)
- 组件 → components/ 或 routes/<name>/components/;路由 → routes/,每路由含 Page、Loader、index.module.scss

04-组件规范

---
alwaysApply: false
description: 组件目录结构、样式方案、通用 vs 页面级。创建或拆分组件时读取。
---

# 组件规范

## 组件结构
- 组件在 src/components 或路由下 components,每组件单独目录
- 文件:index.tsx、index.module.scss;必须 SCSS Modules,禁止全局样式
- Props 接口必须明确;使用 classnames 管理动态 class

## 组件层级
- 页面级:仅单页使用 → routes/<page>/components/
- 通用:跨页面复用 → src/components/
- 单文件建议不超过 400 行(拆分参考)

05-API规范

---
alwaysApply: false
description: 接口请求封装、函数命名、错误处理。新增或修改接口时读取。
---

# API 规范

## 接口请求规范
- 请求使用 src/http 下封装;类型 Params/Body/Response 放在 src/interfaces/<module>/api.ts
- 所有接口集中在 src/http/<module>.ts

## 接口函数命名(NON-NEGOTIABLE)
| 操作 | 命名规则 | 示例 |
|------|----------|------|
| 获取列表 | getXxxList | getBannerList |
| 获取详情 | getXxxDetail | getBannerDetail |
| 创建/更新/删除 | createXxx / updateXxx / deleteXxx | 禁止 fetch 前缀 |

## 错误处理(NON-NEGOTIABLE)
- 接口错误由拦截器统一处理,业务代码只处理成功逻辑;禁止重复 message.error

06-路由规范

---
alwaysApply: false
description: 路由目录结构、Page/Loader 模式、路由配置集中管理。新增页面或配置路由时读取。
---

# 路由规范(NON-NEGOTIABLE)

## 路由结构
- 路由目录在 src/routes 下,每页单独目录,kebab-case
- 必须包含:Page.tsx、Loader.tsx、index.module.scss
- Loader 只负责懒加载对应 Page,不嵌套 Routes/Route

## 路由与菜单
- 禁止多处维护路由:全局唯一路由配置集中管理
- 同一页面只在一处被 lazy 引入

07-状态管理

---
alwaysApply: false
description: 全局状态使用 Zustand(或项目约定方案)。新增或重构状态时读取。
---

# 状态管理规范(NON-NEGOTIABLE)

## 基础规范
- 必须使用项目约定的状态库(如 Zustand),所有 store 放在 src/stores
- 禁止在 src 下新建其他目录存放 store

## Store 组织
- 按业务模块划分,每模块单独文件;暴露 useXxxStore Hook
- Store 只存数据与业务行为,不耦合 UI 组件
- 需持久化时使用 persist 等中间件

08-通用约束

---
alwaysApply: false
description: 语言、可观测性、占位元素等通用约束。确认通用约束时读取。
---

# 通用约束

## 语言规范(NON-NEGOTIABLE)
- 文档和代码注释使用中文;变量/函数命名仍用英文

## 可观测性与兜底
- 任务需完整链路:任务 ID、引用、日志;失败时兜底提示与重试

## 占位元素
- 图标/图片未定时:用占位 div + TODO 注释,禁止临时 svg/占位图服务;明确标记替换点

09-样式规范

---
alwaysApply: false
description: SCSS Modules、主题 CSS 变量。编写样式或主题适配时读取。
---

# 样式规范

## 基础原则
- 样式采用 SCSS Modules(index.module.scss);classnames 管理动态 class
- 自定义样式必须使用主题色 CSS 变量,禁止硬编码颜色

## 主题变量(NON-NEGOTIABLE)
- 主色/文本/背景/边框等使用 var(--ant-color-xxx) 或项目自定义 var(--xxx)
- 确保主题切换(浅色/暗色)一致

10-文档规范

---
alwaysApply: false
description: JSDoc 与注释规范。编写或审查注释时读取。
---

# 文档规范

## 注释规范
- 使用 JSDoc(/** */),解释「为什么」而非「是什么」
- 文件头、函数/方法、复杂逻辑、类型定义均需必要说明

11-测试规范

---
alwaysApply: false
description: 测试覆盖与质量门禁。确认测试要求时读取。
---

# 测试规范

## 测试要求
- 新增功能需必要类型定义,关键业务逻辑需测试用例
- 所有代码通过 TypeScript 与 ESLint 检查

以上就是项目级 Rules 的完整清单与通用化示例。

前端 AI Coding 落地指南(一)架构篇

你是不是也遇到过这些情况:AI 生成的代码风格和现有项目不一致;项目结构不符合预期,设计稿要么看不懂、要么分析漏项,页面 UI 还原度低;改来改去返工多,需求越走越偏,最后还得自己重写一版?

我在实际项目里经过多次迭代的实践,落地了一套

以规范、能力扩展和流程为主线的 AI Coding 架构

把「风格不一致、步骤漏、设计稿不会分析/验收、提案任务格式乱」都收进可控范围。落地之后最直观的效果是:

前端页面可以做到 90% 还原设计稿,需求实现更准确,代码风格一致,结构设计合理


架构简介

架构运用了多种现下流行的 Agent 通用能力:

架构组成 说明
SDD 开发流程控制,保证需求不遗漏,开发不脱节,可进行需求分析/开发规划/验收测试/复盘归档
MCP 对接外部能力,如设计稿读取、浏览器访问页面查看实际效果、接口文档
Rules 约定「做什么、不做什么」,如保证 AI 遵循项目结构,规范约束,代码风格
Skills 约定「怎么做」,渐进披露按需指导 AI 怎么做,如怎么写UI,怎么验收UI

Rules 管「怎么才算对」,Skills 管「怎么一步步做」,MCP 让 Agent 能看到真实世界,而 SDD 负责记录「这次为什么要改、应该改成什么样」并把每次变更沉淀成新的真相,四者合在一起,才是真正稳定可演进的 AI Coding 底座。

从需求到归档的完整链路如下:

flowchart LR
    A[需求输入] --> B{有设计稿?}
    B -->|是| C[设计稿 MCP 读稿]
    C --> D[design-analysis 产出 UI 分析清单]
    B -->|否| E[create-proposal + SDD]
    D --> E
    E --> F[proposal / tasks / spec 增量 校验]
    F --> G[按 tasks 实施]
    G --> H[Rules + create-route/component/theme 页面开发]
    H --> I{有实现页?}
    I -->|是| J[Browser MCP 打开页面]
    J --> K[ui-verification 比对设计稿/清单]
    K --> L[产出 UI 问题清单 修复再验]
    L --> M[create-api/store + 接口/Context7 业务联调]
    G --> M
    M --> N[SDD 归档 更新 specs]
  1. 需求输入:明确是否有设计稿、是否有接口、交付形态(页面/组件/其它)。
  2. 设计稿分析(有稿时):用 Pencil(或 Figma)MCP 读稿 → 执行 design-analysis 技能 → 产出 UI 分析清单。
  3. 创建提案:执行 create-proposal,产出 SDD 的 proposal、tasks、spec 增量;若有设计稿,在 tasks 中写明依据 UI 分析清单实现、实现后用 ui-verification 验收;validate 通过后进入实施。
  4. 页面/UI 开发:按 tasks 顺序做;读 Rules(项目结构、组件、路由、样式等)+ Skills(create-route、create-component、theme-variables);依据 UI 分析清单还原布局与样式。
  5. UI 验收:用 Cursor IDE Browser(或 Playwright)打开实现页,执行 ui-verification,与设计稿或分析清单比对,按 P0/P1/P2 产出问题清单;修复后再次用 Browser 验证。
  6. 业务开发与联调:Rules(API、状态、通用约束)+ create-api、create-store;接口文档 MCP、Context7 按需使用。
  7. 归档:tasks 全部勾选后执行 SDD archive,更新 specs,变更挪入 archive。

主流 Agent 通用

本套架构方案,适用所有支持 skills/mcp 的 Agent,且 只维护一份,所有 Agent 共用

首先在你的项目根目录创建 .agents,下面放 rulesskills,然后根据你所使用的 Agent 或多个 Agent 做如下配置:

  • Cursor:在项目根创建 .cursor 目录,其下的 rulesskills软链接 指到 .agents/rules.agents/skills
  • Claude:同理,创建 .claude,将 rulesskills 软链到 .agents 下同名目录。
  • OpenCode:创建 .opencode,同理软链。
  • Gemini:创建 .agent,同理软链。
  • Trae:创建 .trae,同理软链。

这样只需维护 .agents 一份,即可同时支持上述所有 Agent。

目录结构示例(仅展示与本文相关的部分):

项目根/
├── .agents/                    # 唯一维护的规范与技能目录
│   ├── rules/                  # 规则
│   └── skills/                 # 技能
│
├── .cursor/                    # Cursor:内部 rules、skills 软链到 .agents
├── .claude/                    # Claude:同上
├── .opencode/                  # OpenCode:同上
├── .agent/                     # Gemini:同上
└── .trae/                      # Trae:同上

SDD

SDD 是什么
Specification-Driven Development,用「规范/spec」来驱动开发:

  • 先写 spec 再写代码:任何需求或变更,先写清楚需求,「现在是怎样 → 期望变成怎样」,再开干;
  • 变更可追踪:变更以「提案 + 任务 + spec 增量」管理,实施完归档,可回溯是谁、什么时候、为什么改了什么;
  • 验收有据可依:验收按 spec 来,而不是「感觉差不多」。

SDD 的选择

Spec-kit 和 OpenSpec 是两个比较热门的 SDD 工具,我在尝试过两个工具后,最终选择使用 openspec 因为他比 speckit 简单很多,speckit 的流程更严谨,也更繁琐,其复杂度适合从 0 到 1 规划一个新项目。

OpenSpec

通过 openspec init 初始化项目后,你就会得到如下的一个典型的 OpenSpec 目录:

openspec/
├── project.md          # 项目约定与 AI 使用说明
├── specs/              # 当前「真相」:已经生效的能力与约束
└── changes/            # 进行中的变更:proposal、tasks、design、spec 增量
    ├── <change-id>/
    │   ├── proposal.md
    │   ├── tasks.md
    │   ├── design.md      # 可选:草图/交互/边界情况
    │   └── spec-delta.md  # 本次变更对 specs 的增量
    └── ...

首先你需要编写 project.md,将你项目的规范告诉 openspec,以此作为规范进行推动后续的工作,这是我项目的 project.md,起初也是一个庞大的文件,现在已经拆分到 rules 和 skills 了(后面会介绍):

# 项目上下文

## 项目概述

当前项目是一个基于 React + TypeScript 的单页应用(SPA)。

## 技能与规范

本项目定义了两层指导体系,统一存放在 `.agents/` 目录下。

### `.agents/rules/` - 开发规范

规范文件包含项目开发的核心规则,不会自动加载。当需要确认规范时,主动读取对应文件:

| 文件             | 何时读取                   |
| ---------------- | -------------------------- |
| `01-项目概述.md` | 需要了解项目背景、技术栈时 |
| `02-编码规范.md` | 编写或审查代码时           |
| `03-项目结构.md` | 确定代码应放在哪个目录时   |
| `04-组件规范.md` | 创建或拆分组件时           |
| `05-API规范.md`  | 新增或修改接口时           |
| `06-路由规范.md` | 新增页面或配置路由时       |
| `07-状态管理.md` | 新增或重构状态管理时       |
| `08-通用约束.md` | 确认通用约束时             |
| `09-样式规范.md` | 编写组件样式或主题适配时   |
| `10-文档规范.md` | 编写或审查代码注释时       |
| `11-测试规范.md` | 确认测试要求时             |

### `.agents/skills/` - 实践技能

技能文件包含具体落地步骤与示例代码,按需读取:

| 场景              | 技能文件                                   |
| ----------------- | ------------------------------------------ |
| 创建提案时        | `.agents/skills/create-proposal/SKILL.md`  |
| 新增接口          | `.agents/skills/create-api/SKILL.md`       |
| 创建/拆分组件     | `.agents/skills/create-component/SKILL.md` |
| 新增页面路由      | `.agents/skills/create-route/SKILL.md`     |
| 新增全局状态      | `.agents/skills/create-store/SKILL.md`     |
| 编写样式/主题适配 | `.agents/skills/theme-variables/SKILL.md`  |

技能索引文件:`.agents/skills/README.md`

配置了规范后,就可以使用 openspec 的指令进行需求的实现,先是 openspec proposal 创建提案,Agent 会根据你的描述,生成详细的需求文档,规划开发任务,待你确认。

开发任务确认后,再执行 openspec apply 开始实施任务,任务完成 openspec archive 对本次需求归档,这会作为后续新需求的一个参考,让 Agent 对项目的现状更了解。

OpenSpec 在这套架构里的作用
结合前面讲的 Rules / Skills / MCP,这里的 OpenSpec 主要在三个环节发力:

  • 需求 / 变更收敛:不管输入来自 PRD、口头、IM 还是设计稿,先用 OpenSpec 写一条变更(proposal + tasks + spec 增量),把「要改什么、影响到哪里」说清楚,避免 AI 直接在代码上「盲改」;
  • 开发执行对齐:开发时,Agent 读取对应变更的 proposal / tasks / spec 增量,再结合 Rules(项目级约束)、Skills(如 create-route / create-api / design-analysis)和 MCP(设计稿 / 浏览器 / 接口文档),按 tasks 一项项完成,实现过程始终围绕同一份 spec 展开;
  • 验收与归档:UI 验收阶段,ui-verification 会同时参考 UI 分析清单(design-analysis 产物)和 OpenSpec 中的 spec 增量,验证是否满足这次变更的业务与交互预期;验收通过后,将变更移动到 archive,并把 spec 增量合到 specs,下次再改同一块能力时,所有上下文都在这里。

MCP

MCP 是什么:Model Context Protocol,模型通用的上下文协议,让模型能安全、结构化地调用外部能力。设计稿、浏览器、接口文档等通过 MCP 暴露成工具,Agent 按需调用,拿到真实数据或执行真实操作。

这套架构中用到如下的 MCP:

MCP 用途 环节 说明
Pencil .pen 设计稿结构、布局、节点、截图 设计稿分析 设计稿分析首选,效果优于 Figma MCP
Figma Figma 链接的截图与设计上下文 设计稿分析(Figma 稿时) 有 .pen 时优先 Pencil
Cursor IDE Browser 打开页面、快照、截图 UI 验收 Cursor 内优先,效果优于 Playwright
Playwright 页面打开、截图、自动化 UI 验收(非 Cursor 或脚本化) 可作为 Browser 的补充
ApiFox OAS 等接口定义 业务开发/联调 按当前文档生成/校验请求
Context7 主流库最新文档与示例注入 页面/业务开发 避免过时 API、幻觉

设计稿类

  • Pencil MCP:面向 .pen 设计稿。能读层级、节点、布局、截图(如 snapshot_layoutget_screenshotbatch_get 等),把设计稿里的元素尺寸、间距、字体、颜色直接触达 Agent。在「设计稿分析」环节作为主数据源,效果明显好于 Figma MCP——层级与细节更完整,分析清单更准,因此有 .pen 稿时优先用 Pencil。可以直接拷贝 Figma 粘贴到 Pencil 设计稿。
  • Figma MCP:面向 Figma 链接。可解析 file key / node id、取截图与设计上下文,适合设计稿在 Figma 的场景,但 Figma 可复制到 Pencil ,95%+ 样式复制后还原,个别暂不支持的例如 Pencil 没有虚线边框。

浏览器类

  • Cursor IDE Browser:在 Cursor 内打开目标页、拉快照、截图、简单交互。用于 UI 验收时「真实页面 vs 设计稿」比对。在 Cursor 里做 UI 验收时,优先用 Cursor IDE Browser,效果比 Playwright MCP 更好(集成度、稳定性、截图比对流程更顺)。
  • Playwright MCP:同样可打开页面、截图、操作,适合不在 Cursor 或需要脚本化验收时使用。

接口与文档类

  • 接口文档 MCP(如 ApiFox):读/刷新项目 OAS 等,在业务开发/联调时按当前文档生成或校验请求与类型,避免接口变更后模型用旧假设。
  • Context7:按需注入当前版本的库文档与代码示例,在页面/业务开发时减少依赖过时、模型幻觉 API 的问题。

Rules

Rules 是什么:写在项目里的持久化指导,约定规范、约束和惯例(项目概述、编码、结构、组件、API、路由、状态、样式、文档、测试等),让 Agent 遵守「做什么、不做什么」。

实践中的演进历程

  • 最初是一整个大 Rule.md,上下文过大、易丢记忆、难按场景聚焦;
  • 后来按模块拆成多份(如 01~11),按需读;
  • Skills 加入后,Rules 只需要保留原则与约束,把步骤、检查点、模板、示例迁到 Skills,实现渐进式披露。

项目级 Rules 清单

.agents/rules/
├── 01-项目概述.md
├── 02-编码规范.md
├── 03-项目结构.md
├── 04-组件规范.md
├── 05-API规范.md
├── 06-路由规范.md
├── 07-状态管理.md
├── 08-通用约束.md
├── 09-样式规范.md
├── 10-文档规范.md
├── 11-测试规范.md
└── README.md

Rules 目录下的 README 清晰的介绍了各个 rule 的作用和使用时机

# 项目规范索引

本目录包含前端项目的开发规范,按模块分类组织,便于快速查找。

## 规范模块列表

### 📋 [01-项目概述](./01-项目概述.md)

- 项目定位
- 技术栈

### 💻 [02-编码规范](./02-编码规范.md)

- TypeScript 规范
- 命名规范(文件夹、变量、常量、接口、组件等)
- 业务函数命名(事件处理、内部处理)

### 📁 [03-项目结构](./03-项目结构.md)

- 目录结构概览
- 各目录使用约束(禁止新建非标准目录)

### 🧩 [04-组件规范](./04-组件规范.md)

- 组件结构规范
- 组件层级规划(通用 vs 页面级)

### 🌐 [05-API规范](./05-API规范.md)

- 接口请求规范
- 接口函数命名(NON-NEGOTIABLE)
- 接口错误处理(NON-NEGOTIABLE)

### 🛣️ [06-路由规范](./06-路由规范.md)

- 路由结构规范
- 路由与菜单规范(NON-NEGOTIABLE)

### 🗄️ [07-状态管理](./07-状态管理.md)

- Zustand 使用规范
- Store 组织规范
- 持久化策略

### ⚙️ [08-通用约束](./08-通用约束.md)

- 语言规范(中文注释)
- 可观测性与兜底
- 约束汇总

### 🎨 [09-样式规范](./09-样式规范.md)

- SCSS Modules 使用规范
- 主题色 CSS 变量(NON-NEGOTIABLE)

### 📝 [10-文档规范](./10-文档规范.md)

- 注释规范
- JSDoc 使用

### ✅ [11-测试规范](./11-测试规范.md)

- 测试覆盖要求
- 质量门禁

## 使用说明

1. 根据需要的规范类型,点击对应的模块链接查看详细内容
2. 所有规范文件都包含 `alwaysApply: true` 标记,确保规范自动应用
3. 详细示例与落地步骤见 `.agents/skills/` 目录下的技能文件

## 快速查找

| 需求 | 规范文件 |
|------|----------|
| 项目背景是什么?使用哪些技术栈? | 01-项目概述 |
| 如何命名函数/变量? | 02-编码规范 |
| 代码放在哪个目录? | 03-项目结构 |
| 如何创建/拆分组件? | 04-组件规范 |
| 如何调用接口? | 05-API规范 |
| 如何配置路由? | 06-路由规范 |
| 如何使用 Zustand? | 07-状态管理 |
| 有哪些通用约束? | 08-通用约束 |
| 如何使用主题变量? | 09-样式规范 |
| 如何写注释? | 10-文档规范 |
| 有何测试要求? | 11-测试规范 |

更详细的 rules 落地示例见:《前端 AI Coding 落地指南(二)Rules 篇》


Skills

Skills 是什么:用 Markdown 写的技能文件,教会 Agent 完成某一类任务。一个技能一个目录,必有 SKILL.md(YAML 头 + 步骤 + 示例),可带子规则。

Skills 有什么用:按场景加载、渐进披露,例如在 Rules 划好边界的前提下给「怎么做」的步骤与示例拆分成 skills,避免大量 rules 导致上下文庞大,模型无法集中,出现幻觉和记忆丢失问题。

开源 Skills

有许多开源的优质 skills,直接安装到你的项目中大有裨益,在我的 React 项目中就用到了以下开源 skills

技能 简介 使用环节
vercel-react-best-practices React 代码编写的最佳实践 页面/业务开发时按需引用
vercel-composition-patterns 复合组件、状态提升、避免 boolean props 等 设计组件 API、组合与状态时
web-design-guidelines 通用 UI/UX 设计指南 UI 分析、实现时参考
find-skills 查找并选用可用技能 需要发现技能时
skill-creator 创建新技能的结构与规范 扩展能力、新增 Skills 时

自封装 Skills

除了开源 skills,你还可以自己封装 skills,当有固定步骤流程且会反复做的任务时,就可以封装成 Skill。在我的项目中就有以下两类自封装的 skill:

  1. 从 Rules「怎么做」拆出来的(create-route、create-component、create-api、create-store、theme-variables);

  2. 可复用的流程,给予 Agent 指导(create-proposal、design-analysis、ui-verification)。

像 design-analysis、ui-verification 这种内容多的技能,还可以再把细分工具能力等拆到技能下的 rules 子目录里,充分发挥渐进披露的优势,SKILL 只做入口说明。

.agents/skills/
├── create-proposal/     # 创建提案(SDD proposal、tasks、spec 增量)
│   └── SKILL.md
├── design-analysis/     # 设计稿分析 → UI 分析清单
│   ├── SKILL.md
│   └── rules/          # 分析顺序、四类重点、工作流、输出模板、工具等
├── ui-verification/    # UI 验收 → 问题清单
│   ├── SKILL.md
│   └── rules/          # 比对维度、常见错误、工作流、工具等
├── create-route/       # 新增路由、Page、Loader
├── create-api/         # 新增接口、类型、请求封装
├── create-store/       # 新增 Zustand store
├── create-component/   # 新增/拆分组件
├── theme-variables/    # 主题 CSS 变量使用
└── README.md

skills 在 AI Coding 过程中发挥的作用

  • 创建 SDD 提案时同时使用 create-proposal;
  • 有设计稿时用 design-analysis 产出 UI 分析清单;
  • 页面/UI 开发时用 create-route、create-component、theme-variables;
  • 实现完成后用 ui-verification 做还原验收;
  • 业务开发/联调时用 create-api、create-store。
  • 其他开源第三方 skills 在写代码、做架构决策时按需引用。

skills 目录下的 README

---
name: huayiyi-skills-index
description: 前端项目的技能索引,帮助 Agent 在具体开发场景下选择合适的技能文件。
---

# 画衣衣项目技能索引

项目在 `.agents/skills` 下定义了一些与 RULE 配套的技能,用于承载**具体实践步骤与示例代码**,避免在 RULE 中塞入过多细节。

## 当前技能列表

- `create-api`:创建与维护 HTTP 接口(配合 `05-API规范` 使用)
- `create-component`:创建与拆分通用组件/页面级组件(配合 `03/04` 使用)
- `create-route`:创建与维护路由目录与 Loader/Page(配合 `06-路由规范` 使用)
- `create-store`:使用 Zustand 创建与维护全局状态(配合 `07-状态管理` 使用)
- `theme-variables`:正确使用 Antd 与自定义主题 CSS 变量(配合 `09-样式规范` 使用)

后续如有新的实践场景(例如:测试用例编写、文档撰写模板等),也建议以新的技能目录形式补充到本目录中。

更详细的 Skills 落地示例见:《前端 AI Coding 落地指南(三)Skills 篇》


总结

当下的局限性:要高接纳率、少返工,需要较好的模型与算力;在无限额度、高配模型下稳定性才有保障,成本不低。配合 git worktree + 多 Agent 多任务并行,不计成本时接近 100% AI Coding 在技术上可行,成本和流程需按团队权衡。

AI Coding 展望:模型会更聪明,上下文管理会更智能,交互会更简洁。在这套 MCP + Rules + Skills + SDD 的底座上,只靠 AI Coding 完成日常前端开发不会太远;先跑通「需求 → 提案 → 分析 → 开发 → 验收」闭环,再逐步扩展,接纳率和效率都会有可见提升。

当 AI 学会了写博客:Cursor AI Skill 如何让你的开发效率翻倍

前言

你有没有想过,写完代码之后,AI 自动帮你把技术博客也写了?

这不是幻想。我在自己的博客系统 Ink & Codeptclove.com)上实现了这个能力——通过 Cursor 的 AI Skill 机制,只需一句话,AI 就能分析代码、撰写文章、一键发布。

本文会深入介绍 AI Skill 的实际应用,以及支撑这一切的技术架构:Next.js 16 + Prisma + Tailwind CSS 4 + Tiptap

什么是 Cursor AI Skill

AI Skill 是 Cursor IDE 提供的一种扩展机制,本质上是一份 Markdown 格式的指令文件(SKILL.md),告诉 AI 在特定场景下该做什么、怎么做。

与普通的 Prompt 不同,Skill 有几个核心优势:

  1. 场景触发:当用户提到"写博客"、"发布文章"时自动激活
  2. 结构化流程:定义清晰的多步骤工作流,而不是一次性提问
  3. 工具集成:可以调用 Shell 脚本、API 接口,实现端到端的自动化

实战:一个自动写博客的 Skill

我在项目中创建了 .cursor/skills/generate-blog/SKILL.md,定义了博客生成的完整流程:

---
name: generate-blog
description: 生成技术博客并发布到 Ink & Code
---

# 生成博客文章

## 工作流程

### 1. 确定生成模式
- commit 模式:根据 Git 提交改动生成
- topic 模式:根据特定主题生成
- repo 模式:介绍整个项目

### 2. 收集上下文
根据模式收集相关代码上下文...

### 3. 生成博客内容
撰写高质量技术博客,包含背景、方案、实现、总结...

### 4. 发布文章
使用脚本发布到博客系统:

关键在于第 4 步——Skill 不仅能生成内容,还能通过 Shell 脚本直接发布:

# publish.sh - 一键发布到博客
TITLE="$1"
TAGS="$2"

# 读取内容(支持文件、stdin、剪贴板)
if [ -n "$CONTENT_FILE" ] && [ -f "$CONTENT_FILE" ]; then
    CONTENT=$(cat "$CONTENT_FILE")
elif [ ! -t 0 ]; then
    CONTENT=$(cat)
else
    CONTENT=$(pbpaste 2>/dev/null || echo "")
fi

# 构建 JSON 并调用 API
jq -n --arg title "$TITLE" --arg content "$CONTENT" \
  '{title: $title, content: $content, published: false}' \
  > /tmp/blog_payload.json

curl -X POST "${INK_AND_CODE_URL}/api/article/create-from-commit" \
  -H "Authorization: Bearer $INK_AND_CODE_TOKEN" \
  -d @/tmp/blog_payload.json

这意味着从代码变更到文章发布,整个链路都是自动化的。

更进一步:GitHub Actions + AI 自动发文

除了本地 Skill,我还配置了 GitHub Actions 实现 CI/CD 级别的博客自动化:

# .github/workflows/auto-blog.yml
on:
  push:
    branches: [main]

jobs:
  generate-blog:
    # 只有提交信息包含 [blog] 时才触发
    if: contains(github.event.head_commit.message, '[blog]')
    steps:
      - name: Gather project context
        run: |
          git diff HEAD~1 HEAD > /tmp/commit_diff.txt
          # 收集项目结构、改动文件、配置文件...

      - name: Generate and publish
        run: |
          # 将上下文发送给 AI,生成博客
          # 解析标题、内容、标签
          # 调用 API 发布

工作原理:当我提交代码时,在 commit message 中加上 [blog] 标记,GitHub Actions 会自动收集项目上下文(diff、文件结构、配置文件),调用 DeepSeek/Claude/GPT 生成技术博客,最后通过 API 发布到网站。

整个过程无需人工干预,写完代码,博客就自动出现在网站上了。

支撑一切的技术架构

这套自动化能跑通,离不开底层的技术架构。Ink & Code 采用的是 Next.js 16 + Prisma + Tailwind CSS 4 + Tiptap 的组合。

Next.js 16 App Router

项目使用 Next.js 16 的 App Router 架构,目录结构清晰:

app/
├── api/          # API 路由
│   ├── article/  # 文章 CRUD
│   ├── chat/     # AI 聊天
│   └── auth/     # 认证
├── admin/        # 后台管理
├── blog/         # 博客页面
├── components/   # 组件
└── u/[username]/ # 用户主页

API 路由提供了完整的 RESTful 接口,支持两种认证方式:

// Session 认证(用户登录)
const session = await auth();

// Token 认证(外部调用,如 GitHub Actions)
const token = request.headers
  .get('Authorization')?.replace('Bearer ', '');
const hashedToken = hashToken(token);
const apiToken = await prisma.apiToken.findUnique({
  where: { tokenHash: hashedToken }
});

这种双认证机制让博客系统既支持浏览器登录,又支持脚本和 CI/CD 的外部调用。

Prisma 数据建模

数据层使用 Prisma ORM,核心模型设计:

model Post {
  id         String    @id @default(cuid())
  title      String
  slug       String
  content    String    @db.Text  // TipTap JSON
  excerpt    String?
  coverImage String?
  published  Boolean   @default(false)
  tags       String[]
  sortOrder  Int       @default(0)

  user       User      @relation(fields: [userId])
  category   Category? @relation(fields: [categoryId])

  @@unique([userId, slug])
}

model Category {
  id       String     @id @default(cuid())
  name     String
  slug     String
  icon     String?
  parentId String?    // 树形结构
  parent   Category?  @relation("children", fields: [parentId])
  children Category[] @relation("children")

  @@unique([userId, slug])
}

文章内容以 TipTap JSON 格式存储,而非原始 Markdown。这意味着从外部(如 GitHub Actions)提交的 Markdown 内容需要经过转换:

// lib/markdown-to-tiptap.ts
export function markdownToTiptap(markdown: string): string {
  const doc: TiptapDoc = { type: 'doc', content: [] };
  const lines = markdown.split('\n');

  while (i < lines.length) {
    const line = lines[i];
    if (line.startsWith('```')) {
      // 代码块 → codeBlock 节点
    } else if (line.match(/^#{1,6}\s/)) {
      // 标题 → heading 节点
    } else if (line.match(/^\d+.\s/)) {
      // 有序列表 → orderedList 节点
    }
    // ...更多格式处理
  }
  return JSON.stringify(doc);
}

Tiptap 富文本编辑器

后台管理使用 Tiptap 作为编辑器,支持丰富的内容格式:

const editor = useEditor({
  extensions: [
    StarterKit,
    CodeBlockLowlight.configure({
      lowlight  // 代码高亮
    }),
    Image,           // 图片
    Link,            // 链接
    Table,           // 表格
    Placeholder.configure({
      placeholder: '开始写作...'
    }),
  ],
});

编辑器还实现了自动保存、快捷键(Cmd+S)、图片上传(阿里云 OSS)等实用功能。

Tailwind CSS 4

样式层使用最新的 Tailwind CSS 4,通过 CSS 变量实现主题切换:

:root {
  --color-primary: #b8860b;
  --color-background: #faf8f5;
  --color-foreground: #1a1a1a;
}

[data-theme='dark'] {
  --color-primary: #d4a537;
  --color-background: #1a1a1a;
  --color-foreground: #e8e8e8;
}

配合 ThemeProvider 组件,实现了丝滑的明暗主题切换。

Ink & Code 核心功能一览

除了上面提到的技术架构,ptclove.com 还有这些核心功能:

  • AI 助手:内置 AI 聊天组件,基于 DeepSeek 实时流式响应,随时提问
  • 智能目录:自动从文章标题生成目录树,支持大标题折叠子标题、滚动高亮、点击跳转
  • 分类体系:支持树形分类结构,多层级管理文章
  • 文档树管理:后台采用树形文档管理,拖拽排序,所见即所得
  • 多用户支持:每个用户有独立主页(/u/username),独立分类和文章
  • 深色模式:跟随系统或手动切换,全站适配
  • 响应式设计:从手机到桌面端完美适配
  • 一键部署:GitHub Actions 自动构建、SSH 部署到服务器,PM2 进程管理

总结

Cursor AI Skill 的价值不在于它是一个多高深的技术,而在于它提供了一种思路:让 AI 不只是回答问题,而是融入你的工作流

在 Ink & Code 这个项目中,AI Skill 串联了从代码提交到博客发布的完整链路。而 Next.js + Prisma + Tiptap + Tailwind 这套技术栈,则提供了足够灵活的底层能力来支撑这种自动化。

如果你也想体验 AI 驱动的博客写作,欢迎访问 ptclove.com ,也欢迎在评论区交流你的 AI Skill 实践。

你不知道的JS上-(八)

this 和对象原型

对象

语法

对象可以通过两种形式定义:声明(文字)形式和构造形式。

文字语法:

var myObj = {
  key: value,
  //...
};

构造语法:

var myObj = new Object();
myObj.key = value;

构造形式和文字形式的区别就是,文字声明中可以添加多个健/值对,构造语法只能逐个添加。

类型

对象是JavaScript的基础。在JavaScript中一个有六种主要类型(术语是“语言类型”):string、number、boolean、null、undefined、object

注意,简单基本类型本身并不是对象。null有时会被当作一种对象类型,但这是语言本身的一个bug,即对null执行typeof null时会返回字符串“object”。null本身是基本类型。

实际上,JS中有许多特殊的对象子类型,我们称之为复杂基本类型。

函数、数组都是对象的一种类型。

内置对象

JS中还有一些对象子类型,通常被称为内置对象。有些内置对象的名字看起来和基础类型一样,不过它们的关系更加复杂。String、Number、Boolean、Object、Function、Array、Date、RegExp、Error。

这些内置对象从表现形式上和其他语言中的类型(type)或者类(class)很像,比如Java中的String类。

但在JS中,它们实际上只是一些内置函数。这些内置函数可以当作构造函数来使用,从而构造一个对应子类型的新对象。

var strPrimitive = "I am a String";
typeof strPrimitive; // "string"
strPrimitive instanceof String; // false

var strObject = new String("I am a String");
typeof strObject; // "object"
strObject instanceof String; // true

// 检查sub-type对象
Object.prototype.toString.call(strObject); // [object String]

原始值"I am a String"并不是一个对象,它只是一个字面量,并且是一个不可变的值。语言会自动把字符串字面量转换成一个String对象以便对其执行一些操作,比如获取长度、访问其中某个字符等。

var strPrimitive = "I am a String";

console.log(strPrimitive.length); // 13

console.log(strPrimitive.charAt(3)); // "m"

同样的事也会发生在数值字面量和布尔字面量上。

null和undefined没有对应的构造形式,它们只有文字形式。相反,Date只有构造,没有文字形式。

对于Object、Array、Function和RegExp来说,无论使用文字还是构造形式,它们都是对象,不是字面量。

Error对象很少在代码中显示创建,一般是在抛出异常时被自动创建。也可以使用new Error(..)来创建。

内容

对象的内容是由一些存储在特地命名位置的(任意类型的)值组成的,我们称之为属性。

在引擎内部,属性的存储方式是多种多样的,一般并不会存在对象容器内部。存储在对象容器内部的是这些属性的名称,它们就像指针(从技术角度来说就是引用)一样,指向这些值真正的存储位置。

var myObject = {
  a: 2,
};

myObject.a; // 2

myObject["a"]; // 2

“.a”语法通常被称为“属性访问”,“["a"]”语法通常被称为“键访问”。它们访问的都是同一个位置。

两种语法的主要区别为“.”操作符要求属性名满足标识符的命名规范,“[".."]”语法可以接受任意UTF-8/Unicode字符串作为属性名。

在对象中,属性名永远都是字符串。如果使用的是string(字面量)以外的其他值作为属性名,那它首先会被转换为一个字符串。

var myObject = {};

myObject[true] = "foo";
myObject[3] = "bar";
myObject[myObject] = "baz";

myObject["true"]; // "foo"
myObject["3"]; // "bar"
myObject["[object Object]"]; // "baz"
可计算属性名

ES6 增加了可计算属性名:

var prefix = "foo";

var myObject = {
  [prefix + "bar"]: "hello",
  [prefix + "baz"]: "world",
};

myObject["foobar"]; // hello
myObject["foobaz"]; // world
数组

数组也支持[]访问形式。数组期望额是数值下标,也就是值存储的位置(通常被称为索引)是非负整数:

var myArray = ["foo", 42, "bar"];

myArray.length; // 3
myArray[0]; // "foo"
myArray[2]; // "bar"

数组也是对象,可以给数组添加属性(但不建议这么做):

var myArray = ["foo", 42, "bar"];

myArray.baz = "baz";
myArray.length; // 3
myArray.baz; // "baz"
复制对象

在JS中复制一个对象是复杂的,需要判断深复制还是浅复制。对于浅拷贝,会和旧对象使用相同的引用;对于深拷贝,存在循环引用的情况就会导致死循环。

许多JS框架都提出了自己的解决办法,但是JS采用的标准又不统一。

对于JSON安全(也就是可以被序列化为一个JSON字符串并且可以根据这个字符串解析出一个结构和值完全一样的对象)的对象来说:

var newObj = JSON.parse(JSON.stringify(someObj));

ES6定义了Object.assign(..)方法实现浅复制:

var newObj = Object.assign({}, myObject);
属性描述符

ES5之前按,JS语言本身没有提供可以直接检测属性特性的方法,比如判断属性是否是只读。

ES5开始,所有的属性都具备了属性描述符。

var myObject = {
  a: 2,
};

Object.getOwnPropertyDescriptor(myObject, "a");
// {
//   value: 2,
//   writable: true,
//   enumerable: true,
//   configurable: true
// }

如我们所见,该普通的对象属性对应的属性描述符(也被称为“数描述符”,因为它只能保存一个数据),不仅只是2。还有另外三个特性:writable(可写)、enumerable(可枚举)和configurable(可配置)。

我们可以使用Object.defineProperty(..)来添加一个新的属性或修改已有属性(如果它是configurable)对特性进行设置。

  1. writable writable决定是否可以修改属性的值
var myObject = {};

Object.defineProperty(myObject, "a", {
  value: 2,
  writable: fasle, // 不可写
  enumerable: true,
  configurable: true
});

myObject.a = 3;

myObject.a; // 2

  1. configurable 只要属性是可配置的,就可以使用defineProperty(..)方法来修改属性描述符
var myObject = {
  a: 2
};

myObject.a = 3;
myObject.a; // 3

Object.defineProperty(myObject, "a", {
  value: 4,
  writable: true,
  enumerable: true,
  configurable: fasle // 不可配置
});

myObject.a; // 4
myObject.a = 5;
myObject.a; // 5

Object.defineProperty(myObject, "a", {
  value: 6,
  writable: true,
  enumerable: true,wrai
  configurable: true
}); // TypeError

可见将configurable修改为false是单向操作,无法撤销。

要注意一个例外,即便属性是configurable: false,还是能够将 writable 的状态由 true 改为 false,但是无法由 fasle 改为 true。

且configurable: false 还会禁止删除这个属性:

var myObject = {
  a: 2
};

myObject.a; // 2

delete myObject.a;
myObject.a; // undefined

Object.defineProperty(myObject, "a", {
  value: 2,
  writable: true,
  enumerable: true,
  configurable: fasle // 不可配置
});

myObject.a; // 2
delete myObject.a;
myObject.a; // 2
  1. enumerable

该描述符控制属性是否会出现在对象的属性枚举中,比如for..in循环,如果设置enumerable为false,这个属性就不会出现在枚举中。

不变性

有时我们希望属性或者对象是不可改变的,H5中可以通过很多方法实现,但所有的方法都是浅不变性,它们只会影响目标对象和它的直接属性。

  1. 对象常量 结合 writable: false 和 configurable: fasle就可以创建一个真正的常量属性(不可修改、重新定义或者删除):
Object.defineProperty(myObject, "FAVORITE_NUMBER", {
  value: 42,
  writable: false,
  configurable: fasle
});
  1. 禁止拓展 可以使用Object.preventExtensions(..):
var myObject = {
  a: 2,
};

Object.preventExtensions(myObject);

myObject.b = 3;
myObject.b; // undefined
  1. 密封 Object.seal(..) 会创建一个“密封”的对象,这个方法会一个在现有的对象上调用Object.preventExtensions(..),并把所有的属性标记为configurable: false。

  2. 冻结 Object.freeze(..) 会创建一个“冻结”的对象,这个方法会一个在现有的对象上调用Object.seal(..),并把所有“数据访问”属性标记为writable: false,这样旧无法修改值。

该方法是我们可以应用在对象上的最高的不可变性,它会禁止对象本身及其任意直接属性的修改(不过该对象引用的其他对象是不受影响的)。

[[Get]]
var myObjedt = {
  a: 2,
};
myObjedt.a; // 2

myObjedt.a通过[[Get]]操作实现对a属性的访问。对象默认的内置[[Get]]操作会首先查找是否有同名的属性,有就返回该对象的值。没有就会遍历可能存在的[[Prototype]]链,还是没找到的话就会返回undefined。

[[Put]]

[[Put]]被触发时,会取决于许多因素,包括对象中是否存在这个属性。

如果已经存在这个属性,[[Put]]算法大致会检查下面这些内容。

  1. 属性是否是访问描述符?如何是并且存在setter就调用setter。
  2. 属性的数据描述符中writable是否是false?如果是,在非严格模式静默失败,在严格模式抛出TypeError异常。
  3. 如果都不是将值设为属性的值。

如果对象中不存在这个属性,[[Put]]操作会更加复制。后续详细介绍。

Getter和Setter

对象默认的[[Put]]和[[Get]]操作分别可以控制属性值的设置和获取。

在ES5中可以使用getter和setter部分改写默认操作,但是只能应用在单个属性上,无法应用在整个对象上。getter是一个隐藏函数,会在获取属性值时调用。setter也是一个隐藏函数,会在设置属性值时调用。

当你给一个属性定义getter、setter 或者两者都有时,这个属性会被定义为“访问描述符”(和“数据描述符”相对)。对于访问描述符来说,JavaScript 会忽略它们的value和 writable特性,取而代之的是关心set和get(还有configurable和enumerable)特性

var myObject = {
  // 给a定义一个getter
  get a() {
    return 2;
  },
};

Object.defineProperty(
  myObject, // 目标对象
  "b", // 属性名
  {
    get: function () {
      return this.a * 2;
    },
    enumerable: true,
  },
);

myObject.a; // 2

myObject.b; // 4

通常getter和setter是成对出现的(只定义一个的话通常会产生意料之外的行为):

var myObject = {
  // 给a定义一个getter
  get a() {
    return this._a_;
  }

  // 给a定义一个setter
  set a(val) {
    this._a_ = val * 2;
  }
}

myObject.a = 2;

myObject.a; // 4
存在性

当myObject.a的属性访问返回值为undefined时,这个值可能时属性中存储的undefined,也可能是属性不存在而返回undefined。我们可以在不访问属性值的情况下判断对象中是否存在这个属性:

var myObject = {
  a: 2,
};

"a" in myObject; // true
"b" in myObject; // false

myObject.hasOwnProhaperty("a"); // true
myObject.hasOwnProperty("b"); // false

in 操作符会检查属性是否在对象及其[[Prototype]]原型链中。

hasOwnproperty(..)只会检查属性是否在myObject对象中,不会检查[[Prototype]]链。

枚举
var myObject = {};

Object.defineProperty(
  myObject,
  "a",
  // 让a可枚举
  { enumerable: true, value: 2 },
);

Object.defineProperty(
  myObject,
  "b",
  // 让b不可枚举
  { enumerable: false, value: 3 },
);

myObject.b; // 3
"b" in myObject; // true
myObject.hasOwnProperty("b"); // true

// .............

for (var k in myObject) {
  console.log(k, myObject[k]);
} // "a" 2

可以看到myObject.b确实存在并且有访问值,但不会出现在for..in循环中。

也可以通过其他方式来区分属性是否可枚举:

var myObject = {};

Object.defineProperty(
  myObject,
  "a",
  // 让a可枚举
  { enumerable: true, value: 2 },
);

Object.defineProperty(
  myObject,
  "b",
  // 让b不可枚举
  { enumerable: false, value: 3 },
);

myObject.propertyIsEnumerable("a"); // true
myObject.propertyIsEnumerable("b"); // false

Object.keys(myObject); // ["a"]
Object.getOwnPropertyNames(myObject); // ["a", "b"]

遍历

for..in 循环可以用来遍历对象的可枚举属性列表(包括[[Prototype]]链)

ES6中增加了 for..of 循环语法来遍历数组(如果对象本身定义了迭代器的话也可以遍历对象):

var myArray = [1, 2, 3];

for (var v of myArray) {
  console.log(v);
}
// 1
// 2
// 3

for..of 循环首先会向被访问对象请求一个迭代器对象,然后通过调用迭代器对象的next()方法来遍历所有返回值。

数组内有内置的@@iterator,因此for..of可以直接应用在数组上。我们使用内置的@@iterator来手动遍历数组,看看它是怎么工作的:

var myArray = [1, 2, 3];
var it = myArray[Symbol.iterator]();

it.next(); // {value:1,done:false}
it.next(); // {value:2,done:false}
it.next(); // {value:3,done:false}
it.next(); // {done:true}

和数组不同,普通的对象没有内置的@@iterator,所有无法自动完成for..of遍历。之所以要这样做,有许多复杂的原因,不过简单来说这样是为了避免影响未来的对象类型。

我们可以给想遍历的对象定义@@iterator:

var myObject = {
  a: 2,
  b: 3,
};

Object.defineProperty(myObject, Symbol.iterator, {
  enumerable: false,
  writable: false,
  configurable: true,
  value: function () {
    var o = this;
    var idx = 0;
    var ks = Object.keys(o);
    return {
      next: function () {
        return {
          value: o[ks[idx++]],
          done: idx > ks.length,
        };
      },
    };
  },
});

// 手动遍历myObject
var it = myObject[Symbol.iterator]();
it.next(); // {value: 2,done: false}
it.next(); // {value: 3,done: false}
it.next(); // {value: undefined,done: true}

// 用for..of遍历myObject
for (var v of myObject) {
  console.log(v);
}
// 2
// 3

JavaScript的数据类型 —— Boolean类型

Boolean(布尔值)类型有两个字面值:truefalse。 这两个布尔值不同于数值,因此 true 不等于 1,false 不等于 0。

虽然布尔值只有两个,但所有其他类型的值都有相应布尔值的等价形式,可以调用特定的Boolean() 转型函数:

let message = "Hello world!"; 
let messageAsBoolean = Boolean(message);

Boolean()转型函数可以在任意类型的数据上调用,而且始终返回一个布尔值。

下面是不同类型与布尔值之间的转换规则:

数据类型 Truthy(真值) Falsy(假值)
Boolean true false
String 非空字符串 ""(空字符串)
Number 非零数值(包括无穷值) 0、-0、NaN
Object 任意对象,包括空数组 []和空对象 {} null
Undefined N/A(不存在) undefined

直接赋值:最直接的方式。

let isActive = true;
let isLoading = false;

通过表达式:比较或逻辑运算的结果是布尔值。

let isGreater = 5 > 3; // true
let isEqual = (10 === "10"); // false
let logicResult = (5 > 3) && (4 <= 4); // true

显式转换:使用 Boolean()函数或双重非运算符 !!

// 使用 Boolean() 函数
console.log(Boolean("Hello")); // true (非空字符串是 truthy)
console.log(Boolean(0)); // false (0 是 falsy)
console.log(Boolean({})); // true (空对象是 truthy)

// 使用 !! 运算符,效果相同但更简洁
console.log(!!"Hello"); // true
console.log(!!0); // false

不建议使用Boolean构造函数(通过 new关键字):

// 创建原始布尔值
let primitiveTrue = true;
// 创建 Boolean 对象
let objectTrue = new Boolean(true);

console.log(typeof primitiveTrue); // "boolean"
console.log(typeof objectTrue); // "object"

// 即使包装的对象值为 false,其本身作为对象在条件判断中仍为 true
let objectFalse = new Boolean(false);
if (objectFalse) {
  console.log("This will be executed."); // 这行会被执行
}

布尔值的应用:

//条件判断:控制代码分支。
let isLoggedIn = true;
if (isLoggedIn) {
  console.log("Welcome back!");
} else {
  console.log("Please log in.");
}

//循环控制:决定循环是否继续执行。
let count = 0;
while (count < 5) { // 条件为 true 时循环继续
  console.log(count);
  count++;
}

//函数返回:函数常用布尔值返回操作结果或状态检查。
function isAdult(age) {
  return age >= 18;
}
let canVote = isAdult(20); // true

//数据过滤:例如,结合数组的 filter方法快速过滤出有效项。
const mixedArray = [1, 0, "hello", "", null, 42];
const truthyValues = mixedArray.filter(Boolean); // [1, "hello", 42]
// Boolean 函数作为回调,会自动过滤掉所有 falsy 值

基于uview-pro的u-dropdown扩展自己的dropdown组件

基于uview-pro的u-dropdown扩展自己的dropdown组件

uview-pro的u-dropdown只能是菜单,且只能向下展开,当前组件采用它的核心逻辑,去除多余逻辑,兼容上/下展开,以及自定义展示的内容,不再局限于菜单形式

e4043c3d-df06-4a4f-9d61-237d8254cf54.png

import type { ExtractPropTypes, PropType } from 'vue';
import { baseProps } from 'uview-pro/components/common/props';

/**
 * u-dropdown 下拉菜单 Props
 * @description 该组件一般用于向下展开菜单,同时可切换多个选项卡的场景
 */
export const DropdownProps = {
  ...baseProps,
  /** 点击遮罩是否关闭菜单 */
  closeOnClickMask: { type: Boolean, default: true },
  /** 过渡时间 */
  duration: { type: [Number, String] as PropType<number | string>, default: 300 },
  /** 下拉出来的内容部分的圆角值 */
  borderRadius: { type: [Number, String] as PropType<number | string>, default: 20 },
  /** 展开方向 down/up */
  direction: { type: String as PropType<'down' | 'up'>, default: 'up' },
  /** 弹出层最大高度 */
  maxHeight: { type: String as PropType<`${number}rpx` | `${number}vh`>, default: '80vh' },
  /** 弹出层最小高度 */
  minHeight: { type: String as PropType<`${number}rpx` | `${number}vh`>, default: '0rpx' },
  /** 是否隐藏关闭按钮 */
  hiddenClose: { type: Boolean, default: false },
  /** 弹出层标题 */
  title: { type: String, default: '' }
};

export type DropdownProps = ExtractPropTypes<typeof DropdownProps>;

<template>
  <view class="u-dropdown" :style="$u.toStyle(styles, customStyle)" :class="customClass">
    <view class="u-dropdown__menu">
      <slot></slot>
    </view>
    <view
      class="u-dropdown__content"
      :style="[
        contentStyle,
        {
          transition: `opacity ${Number(duration) / 1000}s linear`,
          [currentDirection === 'down' ? 'top' : 'bottom']: menuHeight + 'px',
          height: contentHeight + 'px'
        }
      ]"
      @tap="maskClick"
      @touchmove.stop.prevent>
      <view @tap.stop.prevent class="u-dropdown__content__popup" :style="[popupStyle]">
        <slot name="close" v-if="!hiddenClose">
          <view class="u-dropdown__content__popup__close" @click="close">
            <u-icon name="close" size="48" custom-prefix="custom-icon" />
          </view>
        </slot>

        <slot name="header">
          <view class="u-dropdown__content__popup__header" v-if="title"> {{ title }} </view>
        </slot>

        <view class="u-dropdown__content__popup__body">
          <scroll-view scroll-y class="u-dropdown__content__popup__scroll-view">
            <slot name="content"></slot>
          </scroll-view>
        </view>
        <view class="u-dropdown__content__popup__footer">
          <slot name="footer"></slot>
        </view>
      </view>
      <view class="u-dropdown__content__mask"></view>
    </view>
  </view>
</template>

<script lang="ts">
  export default {
    name: 'hj-dropdown',
    options: {
      addGlobalClass: true,
      // #ifndef MP-TOUTIAO
      virtualHost: true,
      // #endif
      styleIsolation: 'shared'
    }
  };
</script>

<script setup lang="ts">
  import { ref, computed, onMounted, getCurrentInstance, watch, type CSSProperties } from 'vue';
  import { $u } from 'uview-pro';
  import { DropdownProps } from './types';

  /**
   * dropdown 下拉菜单
   * @description 该组件一般用于向下展开菜单,同时可切换多个选项卡的场景
   * @tutorial https://uviewpro.cn/zh/components/dropdown.html
   * @property {Boolean} close-on-click-mask 点击遮罩是否关闭菜单(默认true)
   * @property {String | Number} duration 选项卡展开和收起的过渡时间,单位ms(默认300)
   * @property {String | Number} border-radius 菜单展开内容下方的圆角值,单位任意(默认20)
   * @property {String} direction 展开方向 down/up(默认up)
   * @property {String} max-height 弹出层最大高度(默认80vh)
   * @property {String} min-height 弹出层最小高度
   * @property {Boolean} hidden-close 是否隐藏关闭按钮(默认false)
   * @property {String} title 弹出层标题
   * @property {Boolean} show 是否显示下拉菜单(默认false)
   * @event {Function} open 下拉菜单被打开时触发
   * @event {Function} close 下拉菜单被关闭时触发
   * @example <hj-dropdown></hj-dropdown>
   */

  const props = defineProps(DropdownProps);
  const emit = defineEmits(['open', 'close']);

  // 展开状态
  const active = ref(false);
  // 外层内容样式
  const contentStyle = ref<CSSProperties>({
    zIndex: -1,
    opacity: 0
  });
  // 下拉内容高度
  const contentHeight = ref<number>(0);
  // 菜单实际高度
  const menuHeight = ref<number>(0);
  // 当前展开方向
  const currentDirection = ref<'down' | 'up'>(props.direction);
  // 子组件引用
  const instance = getCurrentInstance();

  const vShow = defineModel('show', {
    type: Boolean,
    default: false
  });

  watch(vShow, val => {
    if (val === active.value) return;
    if (val) {
      open();
    } else {
      close();
    }
  });

  // 监听方向变化
  watch(
    () => props.direction,
    val => {
      currentDirection.value = val;
    }
  );

  // 兼容头条样式
  const styles = computed<CSSProperties>(() => {
    const style: CSSProperties = {};
    // #ifdef MP-TOUTIAO
    style.width = '100vw';
    // #endif
    return style;
  });

  // 下拉出来部分的样式
  const popupStyle = computed<CSSProperties>(() => {
    const style: CSSProperties = {};
    const isDown = currentDirection.value === 'down';
    const hiddenTransformLate = isDown ? '-100%' : '100%';

    style.maxHeight = props.maxHeight;
    style.minHeight = props.minHeight;
    style.transform = `translateY(${active.value ? 0 : hiddenTransformLate})`;
    style[isDown ? 'top' : 'bottom'] = 0;
    // 进行Y轴位移,展开状态时,恢复原位。收起状态时,往上位移100%(或下),进行隐藏
    style.transitionDuration = `${Number(props.duration) / 1000}s`;

    if (isDown) {
      style.borderRadius = `0 0 ${$u.addUnit(props.borderRadius)} ${$u.addUnit(props.borderRadius)}`;
    } else {
      style.borderRadius = `${$u.addUnit(props.borderRadius)} ${$u.addUnit(props.borderRadius)} 0 0`;
    }
    return style;
  });

  // 生命周期
  onMounted(() => {
    getContentHeight();
  });

  /**
   * 打开下拉菜单
   * @param direction 展开方向 'down' | 'up'
   */
  function open(direction?: 'down' | 'up') {
    currentDirection.value = direction || props.direction;

    // 重新计算高度,因为方向可能改变
    getContentHeight();

    // 设置展开状态
    active.value = true;

    // 展开时,设置下拉内容的样式
    contentStyle.value = {
      zIndex: 11,
      opacity: 1
    };
    vShow.value = true;
    emit('open');
  }

  /**
   * 关闭下拉菜单
   */
  function close() {
    // 下拉内容的样式进行调整,不透明度设置为0
    active.value = false;
    contentStyle.value = {
      ...contentStyle.value,
      opacity: 0
    };

    // 等待过渡动画结束后隐藏 z-index
    vShow.value = false;
    setTimeout(() => {
      contentStyle.value = {
        zIndex: -1,
        opacity: 0
      };
      emit('close');
    }, Number(props.duration));
  }

  /**
   * 点击遮罩
   */
  function maskClick() {
    if (!props.closeOnClickMask) return;
    close();
  }

  /**
   * 获取下拉菜单内容的高度
   * @description
   * dropdown组件是相对定位的,下拉内容必须给定高度,
   * 才能让遮罩占满菜单以下直到屏幕底部的高度。
   */
  function getContentHeight() {
    const windowHeight = $u.sys().windowHeight;

    $u.getRect('.u-dropdown__menu', instance).then((res: any) => {
      // 获取菜单实际高度
      menuHeight.value = res.height;

      /**
       * 尺寸计算说明:
       * 在H5端,uniapp获取尺寸存在已知问题:
       * 元素尺寸的top值为导航栏底部到元素的上边沿的距离
       * 但元素的bottom值却是导航栏顶部到元素底部的距离
       * 为避免页面滚动,此处取菜单栏的bottom值进行计算
       */
      if (currentDirection.value === 'up') {
        contentHeight.value = res.top;
      } else {
        contentHeight.value = windowHeight - res.bottom;
      }
    });
  }

  // 暴露方法
  defineExpose({
    close,
    open
  });
</script>

<style scoped lang="scss">
  @import 'uview-pro/libs/css/style.components';

  .u-dropdown {
    flex: 1;
    width: 100%;
    position: relative;
    background-color: #fff;

    &__content {
      position: absolute;
      z-index: 8;
      width: 100%;
      left: 0;
      overflow: hidden;

      &__mask {
        position: absolute;
        z-index: 9;
        background: rgba(0, 0, 0, 0.3);
        width: 100%;
        left: 0;
        top: 0;
        bottom: 0;
      }

      &__popup {
        position: absolute;
        width: 100%;
        z-index: 10;
        transition: all 0.3s;
        transform: translate3D(0, -100%, 0);
        overflow: hidden;
        background-color: var(--gray-2);

        &__close {
          width: 40rpx;
          height: 40rpx;
          display: flex;
          align-items: center;
          justify-content: center;
          position: absolute;
          right: 24rpx;
          top: 30rpx;
          z-index: 9;
        }

        &__header {
          display: flex;
          color: var(--title-1);
          font-size: var(--ft-32);
          font-weight: 500;
          line-height: 44rpx;
          padding: 30rpx 24rpx;
        }

        &__body {
          flex: 1;
          overflow: hidden;
          display: flex;
        }

        &__scroll-view {
          flex: 1;
        }

        &__footer {
          display: flex;
        }
      }
    }
  }
</style>

详解 IEEE 754 标准定义的 JavaScript 64 位浮点数

JavaScript 使用 IEEE 754 标准定义的 64 位浮点格式表示数值。

64位 = 1位符号位(S) + 11位指数位(E) + 52位尾数位(M)

  • 符号位(S):0表示正数,1表示负数,决定数值的正负;
  • 指数位(E):存储指数的偏移值(偏移量1023),决定数值的数量级;
  • 尾数位(M):存储数值的有效数字(二进制),且有一个隐含的1位(规格化数,也叫归一化数,normalized number),所以实际有效位数是 52 + 1 = 53位。

指数位(E)的偏移量 1023 是标准人为规定的,为的是让原本的 11 位无符号位的二进制指数位能表示正负指数,并且正负指数尽可能对称(-1022 ~ 1023)。

尾数位(M)的隐藏位 1 也是标准人为规定的,利用了非零二进制数归一化后整数位必为 1 的特性。

任何一个非零的二进制数,都能唯一表示为 1.xx × 2^e 的形式。

  • e 是二进制的指数(整数,可正可负);
  • 归一化后整数位必然是固定不变的 1,这是二进制的特性。

IEEE 754 标准正是利用了「归一化后整数位必为 1」的特性,做了一个巧妙设计:

既然这个 1 是所有非零浮点数的固定前缀,那就不用在 64 位中实际存储它,而是「默认存在这个 1」,仅将小数点后的 xx 有效数字部分存入 52 位的尾数位(M)中。

原本尾数位只有 52 位,加上这 1 位隐藏的固定不变的 1 后,实际可用的有效数字位就变成了 53 位(1 + 52),直接提升了浮点数的精度,且没有占用额外的存储空间。

只有非零的归一化浮点数才有隐藏位。对于接近 0 的极小值(非归一化数),IEEE 754 会放弃归一化,此时没有隐藏位,只有 52 位有效数字。

推导:53 位二进制能表示的最大整数是 2^53 - 1

当 53 位二进制尾数位(M)(包含隐藏位 1),所有位都为 1 时,就是它能表示的最大数,计算如下:

111...111(53个1) = 2^52 + 2^51 + ... + 2^1 + 2^0 = 2^53 - 1

这个数就是 JS 中能精确表示的最大安全整数 Number.MAX_SAFE_INTEGER

一个整数 n 被称为安全整数,当且仅当:它自身和前后相邻的两个整数(n−1、n、n+1),都能被唯一且精确地存储表示,三者的 64 位浮点数编码互不重复,且不存在截断舍入后的失真情况。

在 2^53 ~ 2^1023 这区间内的 2 的整数次幂能精确表示,但都是「孤立的精确数」(相邻数失真,无连续性)。

少数能精确存储的特殊十进制小数

绝大多数小数的二进制,都是无限循环的小数形式,都不能精确存储,只能截断舍入近似存储。

如果一个十进制小数转二进制后是「有限位小数」,就能被 64 位浮点数精确存储,这类小数有明确的数学规则:

十进制小数能精确转为有限位二进制的充要条件:小数部分转化成最简分数形式后,它的分母仅包含质因子 2(即分母是 2 的正整数次幂:2¹、2²、2³...)。

简单说:小数部分是 0.5(1/2)、0.25(1/4)、0.125(1/8)、0.0625(1/16)... 的组合,就能精确存储。

浮点数的「可表示值步长」随数值增大而变大(精度衰减规律)

64 位浮点数的可表示值不是连续的,而是离散的、等步长的(同一量级内步长固定),且数值越大,量级越高,步长越大。这是整数和小数的精度都会随数值增大而衰减的根本原因。

步长是(同一量级内)相邻两个可表示的浮点数之间的差值,由归一化后的指数 e 决定

步长 = 2^(e-52) (52 是尾数位 M 的位数)。

  • 步长越小,可表示值越密集,精度越高;
  • 步长越大,可表示值越稀疏,精度越低。

步长对整数和小数的影响:

- 安全整数区(e≤52):步长 = 2^(e-52) ≤1 → 整数的步长为 1,能连续精确表示;小数的步长极小,误差可忽略;

  • 2⁵³~2¹⁰²³ 区:步长≥2 → 整数的步长大于 1,无法连续精确表示(相邻整数重叠);小数的步长极大,几乎无法区分相近小数;
  • 2¹⁰²³ 以上:超出指数范围,直接变成 Infinity,无法表示为常规数。

MAX_VALUE 和 MIN_VALUE

最大正值:Number.MAX_VALUE = 1.7976931348623157×10³⁰⁸

指数位取归一化数的最大存储值 2046(实际指数 1023),尾数位 52 位全 1(此时浮点数取到归一化数的最大极值,再大就超出指数范围,变成Infinity);

最小正值:Number.MIN_VALUE = 5×10⁻³²⁴(无限接近 0)

指数位取全 0(非归一化数,实际指数 -1023),尾数位仅最后 1 位为 1、其余全 0(此时浮点数取到能表示的最小非 0 正值,再小就会被舍入为 0)。

这是 64 位双精度浮点数基于 IEEE 754 标准的硬件存储极限,是浮点数能表示的所有数值(整数 + 小数)的整体边界,而非专门针对整数的范围。

±Infinity 和 NaN 如何表示

Infinity(正无穷) -Infinity(负无穷) NaN(非数)
1位符号位(S) 0表示正数 1表示负数 0和1都可,无意义
11位指数位(E) 11位全1 11位全1 11位全1
52位尾数位(M) 52位全0 52位全0 52位非全0(即任意1位是1即可)
S0 + E全1 + M全0 S1 + E全1 + M全0 S01 + E全1 + M非0

E全1 表示 ±Infinity 和 NaN 的特殊值,而 E全0 表示非归一化数(接近 0 的极小值,无隐藏位)。

React-create-app使用cesium并且渲染3d倾斜摄影

先上效果 image.png

一、cesium在react-create-app中的引用

首先 yarn add cesium然后yarn add copy-webpack-plugin -D然后yarn add customize-cra react-app-rewired --dev

设置了customize-cra react-app-rewired就可以改写webpack

image.png 新建一个这个文件,在里面改写webpack

const {
  override,
 
  addWebpackPlugin,
} = require("customize-cra");
const path = require("path");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const webpack = require("webpack");


const cesiumSource = 'node_modules/cesium/Source';
const cesiumWorkers = '../Build/Cesium/Workers';



module.exports = override(
  
 
  addWebpackPlugin(
    new CopyWebpackPlugin({
      patterns: [
        
        { from: path.join(cesiumSource, cesiumWorkers), to: 'cesium/Workers' },
        { from: path.join(cesiumSource, 'Assets'), to: 'cesium/Assets' },
        { from: path.join(cesiumSource, 'Widgets'), to: 'cesium/Widgets' }
      ],
    })
  ),
  addWebpackPlugin(
    new webpack.DefinePlugin({
      // Define relative base path in cesium for loading assets
      CESIUM_BASE_URL: JSON.stringify("/cesium"),
    })
  )
  // addWebpackPlugin(new BundleAnalyzerPlugin())
);


package.json里的打包脚本变成

"scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    
  },

这样就会走我们新设定的webpack,将将node_modules/cesium/Source/Assets, node_modules/cesium/Source/Widgets, node_modules/cesium/Build/Cesium/Workers自动文件打包到build文件里,如图

image.png 这样yarn start 或者yarn build就可以正常使用cesium了

下面这篇我直接按可发布到掘金的技术文章结构帮你整理好了:有背景、有思路、有代码拆解、有优化建议,基本不需要再大改就能发 👍

二、创建 Viewer 且必须只创建一次!

否则:

  • 内存暴涨
  • WebGL context 丢失
  • 页面卡死
useEffect(() => {
  if (!containerRef.current) return;
  if (viewerRef.current) return;

  const viewer = new Cesium.Viewer(containerRef.current, {
    timeline: false,
    animation: false,
    infoBox: false,
    fullscreenButton: false,
  });

  viewerRef.current = viewer;

  return () => {
    viewerRef.current?.destroy();
    viewerRef.current = null;
  };
}, []);
<div ref={containerRef} className="cesium-container" />

这是 Cesium + React 的标准写法


三、加载倾斜摄影模型(3DTiles)

Cesium.Cesium3DTileset.fromUrl("tileset.json")
  .then((tileset) => {
    viewer.scene.primitives.add(tileset);
    viewer.zoomTo(tileset);
  });

一个强烈建议 ⭐⭐⭐⭐⭐

建议监听瓦片失败:

tileset.tileFailed.addEventListener((error) => {
  console.error("瓦片加载失败:", error);
});

否则生产环境排查问题会非常痛苦。


四、动态绘制监测点(核心)

很多人喜欢用:

👉 Primitive
👉 PointPrimitive

但在业务系统中,我更推荐:

⭐ Entity

因为:

  • 开发简单
  • 支持属性绑定
  • 支持 pick
  • 易维护
function createColorIcon(color: string) {
    const canvas = document.createElement("canvas");
    const size = 48;
    canvas.width = size;
    canvas.height = size;
    const ctx = canvas.getContext("2d");
    if (ctx) {
      const cx = size / 2;
      const r = 12;
      const tipY = size - 6;



      // 图钉形状(圆 + 尖)
      ctx.beginPath();
      ctx.moveTo(cx, tipY);
      ctx.quadraticCurveTo(cx + r, r + 12, cx + r, r + 4);
      ctx.arc(cx, r + 4, r, 0, Math.PI, true);
      ctx.quadraticCurveTo(cx - r, r + 12, cx, tipY);
      ctx.closePath();
      ctx.fillStyle = color;
      ctx.fill();



      ctx.fill();
    }

    return canvas;
  }

  const getWarningColor = (p: any) => {
    const level =
      p?.alarmLevel ?? 0;
    switch (Number(level)) {
      case 4:
        return "#D7263D99"; // 红色预警(约 60% 不透明)
      case 3:
        return "#FF6B0099"; // 橙色预警
      case 2:
        return "#C7A20099"; // 黄色预警
      case 1:
        return "#00BEFF99"; // 蓝色预警
      default:
        return "#d3f26199"; // 约 60% 不透明
    }
  };
  useEffect(() => {
    const viewer = viewerRef.current;
    if (!viewer) return;

    if (dataSource && dataSource?.length > 0) {
      pointEntityIdsRef.current.forEach((id) => {
        try {
          viewer.entities.removeById(id);
        } catch (e) {
          // ignore
        }
      });
      pointEntityIdsRef.current = [];
     
      console.log('dataSource', dataSource);
     

      (dataSource || []).forEach((p: any) => {


        const lng = Number(p.longitude);
        const lat = Number(p.latitude);
        if (Number.isNaN(lng) || Number.isNaN(lat)) return;

        const id = `point-${p.id}`;
        viewer.entities.add({
          id,
          position: Cesium.Cartesian3.fromDegrees(lng, lat, Number(p.height) || 0),
          billboard: {
            image: createColorIcon(getWarningColor(p)),
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            // 防止被倾斜摄影挡住
            disableDepthTestDistance: Number.POSITIVE_INFINITY
          },
          label: {
            text: p.pointName || "",
            font: "bold 15px sans-serif",
            fillColor: Cesium.Color.fromCssColorString("rgba(68, 229, 255, 0.92)"),
            outlineColor: Cesium.Color.fromCssColorString("rgba(124, 121, 121, 0.9)").withAlpha(0.85),
            outlineWidth: 2,
            style: Cesium.LabelStyle.FILL_AND_OUTLINE,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            pixelOffset: new Cesium.Cartesian2(0, -50),
            disableDepthTestDistance: Number.POSITIVE_INFINITY,
            distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
              0,
              5000
            ),
          },
          properties: {
            pickable: true,
            pointId: p.id,
            pointType: p?.pointType,
            pointName: p?.pointName,
          }
        });

        pointEntityIdsRef.current.push(id);
      });
      const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);

      handler.setInputAction((movement: any) => {
        const picked = viewer.scene.pick(movement.endPosition);


        if (
          Cesium.defined(picked) &&
          picked.id &&                     // Entity
          picked?.id?.properties?.pickable?.getValue()
        ) {

          viewer.canvas.style.cursor = 'pointer';


        } else {
          viewer.canvas.style.cursor = 'default';


        }
      }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
      // 点击测点
      handler.setInputAction((click: any) => {
        const picked = viewer.scene.pick(click.position);

        if (
          Cesium.defined(picked) &&
          picked.id &&
          picked.id.billboard
        ) {
          const entity = picked.id;

          console.log('点到了:', entity.id, picked?.id?.properties?.pointId?.getValue());

         

          // 示例:相机飞过去
          viewer.flyTo(entity);
          
        }
      }, Cesium.ScreenSpaceEventType.LEFT_CLICK);


    }


  }, [dataSource]);

五、点击测点飞行定位

pick 实体

const picked = viewer.scene.pick(click.position);

判断:

if (Cesium.defined(picked) && picked.id?.billboard) {

然后:

viewer.flyTo(entity);

体验直接拉满。


鼠标 Hover 手型

细节决定高级感:

viewer.canvas.style.cursor = 'pointer';

六、相机环绕动画

核心思路:

👉 clock.onTick

const remove = viewer.clock.onTick.addEventListener(() => {
  heading += Cesium.Math.toRadians(0.2);

  viewer.camera.lookAt(
    center,
    new Cesium.HeadingPitchRange(
      heading,
      Cesium.Math.toRadians(-30),
      range
    )
  );
// 转满一圈停止
  if (heading >= Cesium.Math.TWO_PI) {
    remove();
    viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
  }
});

本质:

👉 每帧改变 heading。

Ant Design Drawer + Autofit.js 布局抽动问题

现象

在社区看到了这样一个问题:Drawer组件设置了getContainer并且项目中使用了autofit.js,在打开时会出现动画和整体页面布局抽动的问题。

542617471-fc201930-0c07-4190-acc8-65af565634f7.gif

问题连接:github.com/ant-design/…

根据描述,我本地复现了一下,发现确实有这个问题,当快速点击的时候,抽屉看起来把页面顶上去了 但是很快又恢复正常,现象如下:

  • Drawer 的 mask 被 content-wrapper 顶出视野
  • 按钮和其他元素被推到屏幕外
  • IntersectionObserver 检测到元素不在视野
  • 关闭 mask 或移除 Drawer 内容后恢复正常

背景

1.autofit.js

根据官网介绍:

autofit.js 是一个可以让你的PC项目自适应屏幕的工具,其原理非常简单,即在 scale 等比缩放的基础上,向右或向下增加了宽度或高度,以达到充满全屏的效果,使用 autofit.js 不会挤压、拉伸元素,它只是单纯的设置了容器的宽高。

Autofit.js 的作用: 它通过 transform: scale(...) 强制缩放根容器以适应屏幕。 关键点: transform 属性会创建一个新的堆叠上下文 (Stacking Context)包含块 (Containing Block)。更重要的是,它改变了浏览器对元素位置(Client Rect)的计算方式。

其实就是监听resize/ 视口尺寸变化,实时重计算页面元素的尺寸 / 位置,效果就是滚轮缩放的时候看起来页面是一致的。

2. Drawer 的 DOM 结构

<div class="rc-drawer rc-drawer-bottom" style="overflow: hidden;">
  <!-- mask 先渲染 -->
  <div class="rc-drawer-mask" style="position: absolute; inset: 0; z-index: 1050;">
  </div>
  
  <!-- content-wrapper 后渲染 -->
  <div class="rc-drawer-content-wrapper" 
       style="position: absolute; bottom: 0; z-index: 1051;">
    <!-- 动画进入时添加 transform -->
    <div class="panel-motion-bottom-enter" 
         style="transform: translateY(100%);">
    </div>
  </div>
</div>

问题排查

Ant Design 的 Drawer 在打开瞬间(open={true}),其 Panel 的入场动画通常是从 translateY(100%) (底部) 或类似位置开始。此时,Drawer 内部的内容实际上位于视口之外或者包含块边缘。 此时,Drawer 内部的内容实际上位于视口之外或者包含块边缘

这个效果是正常的,在慢速点击的时候,Drawer由下方动画进入,translateY(100%)最后变成了translateY(0%),问题是,快速点击,Drawer由下方进入,此时竟然把页面元素顶上去了。

也就是问题里描述的抽动。

初步定位方向

聚焦冲突核心:Drawer 的动画效果、mask(遮罩)、内部元素与 autofit.js 的缩放逻辑存在交互异常,导致页面布局瞬态偏移(“推挤” 效应)。

链路分析

1. 容器与动画交互

  • 疑问:Drawer 默认使用绝对定位(absolute/fixed),为何仍会推挤其他元素?
  • 观察:Drawer 的入场动画依赖transform: translateY(100%)(从底部滑入),且通过getContainer指定了自定义容器,而该容器同时被 autofit.js 应用了transform: scale(等比缩放)。
  • 嵌套的 transform 属性可能导致浏览器布局计算异常,尤其动画执行时容器尺寸 / 位置的瞬态变化,被 autofit.js 的resize监听捕捉,触发不必要的重排。

2. mask 的作用

  • 验证:禁用 mask(mask={false})后问题消失,说明 mask 并非直接原因,而是触发了某种关联逻辑。
  • 进一步分析:mask 启用时,Drawer 会激活焦点锁定(Focus Trap)功能;禁用 mask 时,焦点锁定同步失效,推测焦点管理与布局偏移存在关联。

3. 焦点管理机制

  • 排查依赖:定位到 antd Drawer 的底层依赖rc-drawer,其内部的useFocusable Hook(焦点管理钩子)。
  • 发现:useFocusable Hook 在 Drawer 的open属性变为true时,会执行getContainer()?.focus({ preventScroll: true }),即自动聚焦 Drawer 的容器元素。

一开始直接在仓库里,发现复现不了,现在才发现没有同步远程分支,焦点管理是最近才加上的(捂脸)。

层级一:Ant Design (antd/es/drawer)

  • Drawer.tsx: 接收 open, mask 等属性。
  • useFocusable (Antd): 这是一个配置预处理 Hook。它计算出 trap 默认为 true (只要 mask 存在)。
  • 组件传递: 将处理后的 props 传给 rc-drawer

层级二:RC-Drawer (@rc-component/drawer)

  • Drawer.tsx (Wrapper): 渲染 Portal。
  • DrawerPopup.tsx: 实际的 DOM 结构渲染的地方。
  • useFocusable.ts (核心钩子):
    • 代码: useLockFocus(open && mergedFocusTrap, getContainer)
    • 时机: 这里的 useLockFocus 副作用(Effect)通常在 DOM 挂载和 Layout 之后执行。

这一层焦点管理做两件事:

1. 焦点还原,当 Drawer 关闭时,焦点应该回到打开 Drawer 之前的那个按钮上,否则键盘用户会迷失方向。思路就是记下来在 Drawer 打开前,谁是焦点的拥有者?然后在Drawer 关闭后的回调中,让之前的元素重新获得焦点。代码如下:
// src/Drawer.tsx
const Drawer: React.FC<DrawerProps> = props => {
  // ...
  
  // 1. 记录案发地:在 Drawer 打开前,谁是焦点的拥有者?
  const lastActiveRef = React.useRef<HTMLElement>(null);
  useLayoutEffect(() => {
    if (mergedOpen) {
      // 记录当前的 activeElement
      lastActiveRef.current = document.activeElement as HTMLElement;
    }
  }, [mergedOpen]);

  // 2. 还魂:Drawer 关闭后的回调
  const internalAfterOpenChange: DrawerProps['afterOpenChange'] = nextVisible => {
      // ...
      if (
        !nextVisible && // Drawer 关闭了
        focusTriggerAfterClose !== false && // 用户没禁用这个功能
        lastActiveRef.current // 之前记录过
      ) {
        // 让之前的元素重新获得焦点
        lastActiveRef.current?.focus({ preventScroll: true });
      }
    };
  // ...
}


2. 初始聚焦 ,当 Drawer 打开时,焦点必须立刻转移到 Drawer 内部,否则屏幕阅读器用户不知道新内容出现了。但是useLockFocus是干啥的呢?
// src/hooks/useFocusable.ts
export default function useFocusable(
  getContainer: () => HTMLElement,
  open: boolean,
  autoFocus?: boolean,
  // ...
) {
  // ...
  // Focus lock
  useLockFocus(open && mergedFocusTrap, getContainer);
  // Auto Focus 逻辑
  React.useEffect(() => {
    // 如果打开,且 autoFocus 为 true(默认是 true)
    if (open && autoFocus === true) {
      // 强制让 Drawer 的容器获得焦点
      getContainer()?.focus({ preventScroll: true });
    }
  }, [open]);
}

层级三:RC-Util (@rc-component/util)

rc-util 中的 focus.js 实现了 Focus Trap (焦点陷阱) 。它的目的是:把焦点锁死在 Drawer 内部,不让 Tab 键跑到外面的页面去。 useLockFocus里面直接调用了lockFocus,这个函数会在全局添加监听

function lockFocus(element) {
  if (element) {
    // ... 将当前 element 推入栈中管理(支持多层 Drawer 嵌套)

    // 核心:添加全局事件监听
    window.addEventListener('focusin', syncFocus); // 监听焦点移动
    window.addEventListener('keydown', onWindowKeyDown, true); // 监听键盘按键,使用捕获阶段
    syncFocus();
  }
}

可以看到这里添加了focusin的事件监听,继续看syncFocus


function syncFocus() {
  const lastElement = getLastElement(); // 当前激活的 Drawer 容器
  // ...
  if (lastElement && !hasFocus(lastElement)) {
    // 如果焦点不在 Drawer 内部
    // 强制聚焦回 Drawer 内的某个元素
    const matchElement = focusableList[0];
    matchElement?.focus();
  }
}

如果不小心(比如鼠标点击了外部,或者程序代码强行 focus 了外部元素),焦点跑出去了,syncFocus 负责把它抓回来。

低情商:用户手贱点了 Drawer 外面的空白 -> 焦点短暂跑出去 -> focusin 触发 -> syncFocus 发现越界 -> 瞬间把焦点抓回 Drawer 内最近的那个输入框。

image.png

那为什么既需要useLockFocus还需要getContainer()?.focus,既然 useLockFocus 里面已经有 syncFocus 试图把焦点拉进来了,为什么还要再手动 focus 一次?

猜测一下,是为了解耦“锁定”与“初始聚焦”

  • useLockFocus 是持续性状态。它负责的是 open 期间的每一秒,监控 Tab 键和鼠标点击。

  • useEffect 是一次性动作。它只在 open 变为 true 的那一瞬间执行一次。

  • 场景支持:如果你设置 autoFocus={false} 但 focusTrap={true}

    • 期望:打开 Drawer 时,焦点自动跳进去(比如用户可能还在读之前的文章),但如果用户一旦按了 Tab 键或者想点 Drawer 里的东西,焦点就再也出不去了。
    • 如果完全依赖 useLockFocus 的初始化逻辑来做聚焦,你就很难实现这种精细的控制。

事件冒泡:

  • focus 和 blur 事件不冒泡。这意味着当一个元素获得或失去焦点时,只有该元素本身会触发这些事件,其父元素不会收到通知。
  • focusin 和 focusout 事件会冒泡。这意味着当一个元素获得或失去焦点时,该元素本身会触发事件,并且事件会沿着 DOM 树向上传播,触发其祖先元素上的相应事件。

事件触发顺序:

当一个元素获得焦点时,事件触发的顺序是:focusin -> focus

当一个元素失去焦点时,事件触发的顺序是:blur -> focusout

使用场景:

  • 由于 focus 和 blur 不冒泡,它们更适用于处理特定元素的焦点变化,例如:

    • 表单验证:在 blur 事件中检查输入字段的值是否有效。
    • UI 更新:在 focus 事件中高亮输入框,在 blur 事件中移除高亮。
  • 由于 focusin 和 focusout 会冒泡,它们更适用于处理包含多个可聚焦元素的容器的焦点变化,例如:

    • 跟踪焦点:监听容器的 focusin 和 focusout 事件,可以知道焦点是否在容器内,而无需监听每个子元素。
    • 动态添加/移除事件监听器:在容器的 focusin 事件中为获得焦点的元素添加事件监听器,在 focusout 事件中移除监听器。

总结一下链路

  1. 用户操作: 点击按钮打开 Drawer。

  2. AntD: 接收 open={true},将配置传递给 RC-Drawer

  3. RC-Drawer (Drawer.tsx) :

    • useLayoutEffect 记录当前焦点位置(比如那个按钮)到 lastActiveRef
  4. RC-Drawer (DrawerPopup.tsx) :

  5. RC-Drawer (hooks/useFocusable.ts) :

    • useEffect 检测到 open,执行 container.focus() (初始聚焦)。
    • 调用 rc-util 的 useLockFocus
  6. RC-Util (focus.js) :

    • lockFocus 启动。
    • onWindowKeyDown 拦截 Tab 键,确保焦点在 Drawer 内部循环。
    • syncFocus 确保焦点不逃逸。
  7. 用户关闭 Drawer:

    • RC-Util: 解除事件监听,释放“结界”。
    • RC-Drawer (Drawer.tsx)internalAfterOpenChange 触发,读取 lastActiveRef,执行 .focus(),焦点回到最初的按钮。

回到最初的问题

  • Autofit 使用了 transform: scale。这不仅缩放了元素,还改变了浏览器对于“将元素滚动到可视区域”的计算逻辑。
  • Drawer 初始动画位置通常在 translateY(100%)(即屏幕外或边缘)。
  • 当 focus() 发生时,浏览器试图把这个“屏幕边缘”的元素滚动到中心。也就是动画刚开始,聚焦逻辑生效了,把正要进场的抽屉拉到了视野中,把页面元素顶了上去。

因此解决方法就是加一个preventScroll

element.focus({ preventScroll: true });

但是其实还有一些疑问,

1. 为什么只有 Autofit + Drawer 会出问题?(Autofit 到底破坏了什么?)

  • 正常场景 (Without Autofit)

    • Drawer 的 CSS 是 position: fixed
    • 在标准 W3C 规范中,fixed 元素的包含块(Containing Block)是 浏览器视口 (Viewport)
    • 当你对一个刚开始进场、还在屏幕边缘外的 fixed 元素调用 focus() 时,浏览器知道它是“固定”在屏幕上的。即使它是看不见的(off-screen),现代浏览器通常足够智能,或者因为它是相对于视口的,浏览器无法通过滚动 <body> 来让它显示(因为它根本不随 body 滚动),所以浏览器通常会忽略或静默处理这种聚焦请求引发的滚动
  • Autofit 场景 (With Autofit)

    • Autofit 为了做全屏适配,给 <body> 或根容器如 #app 加了一个 transform: scale(...)

    • 核心物理规则变化:根据 CSS 规范,任何设置了 transform 属性非 none 的祖先元素,都会成为其内部 position: fixed 后代元素的包含块

    • 后果

      1. Drawer 的 position: fixed 失效了。虽然它 CSS 还写着 fixed,但它在渲染引擎眼里变成了 position: absolute(相对于被 transform 的那个父容器)。
      2. 当 Drawer 刚打开时(动画第0帧),它位于容器底部(例如 translateY(100%))。
      3. rc-util 执行 focus()
      4. 浏览器现在的逻辑是:“哦,这是一个在容器底部的绝对定位元素,用户想看它,但它现在在可视区域下面。那我必须滚动父容器把这个元素挪进视野里。”
      5. BOOM:页面发生了剧烈滚动。

我试了一下,在容器上加transform:scale(1.0);还真就出现问题了,不过原因是不是上面说的那样,浏览器视口 (Viewport) 不用滚动就不知道了。

2. 为什么很快就正常了?为什么快速点击由于?(时序与竞态分析)

动画结束了......translateY(100%)--------->translateY(0%)

结束

好了,问题解决了,但一般还真想不到,看似是布局引起的问题,最后竟然是焦点管理引起的。。。。。

Electron 打包白屏排查实录:一个 continue_on_error 引发的三天三夜"血案"

Electron 打包白屏排查实录:一个 continue_on_error 引发的三天三夜"血案"

本文记录了我在开源项目 AionUi(一个统一 AI Agent 图形界面)中遭遇的一次 Electron 打包白屏事故。从发现问题到最终定位根因,历时三天三夜。如果你也在用 Electron + GitHub Actions 做 CI/CD,这篇文章或许能帮你避开一个隐蔽的大坑。

故事的开始:一个"完美"的 CI 构建

那是一个平静的夜晚。

我像往常一样推送了一个版本到 dev 分支,GitHub Actions 开始了它忠实的自动构建工作。十几分钟后,CI 亮起了绿灯——所有平台构建成功。

"不错,今天又是顺利的一天。"

我下载了 macOS 的 DMG,拖进 Applications,双击启动——

白屏。

纯白的、一尘不染的、令人窒息的白屏。像是这个 App 在用行为艺术表达它对这个世界的失望。

没有报错弹窗,没有崩溃提示,就是一片白。

第一天:本地没问题,那一定是 CI 的问题

作为一个经历过无数"在我机器上是好的"名场面的工程师,我的第一反应是——先在本地复现。

npm start  # 本地开发 ✅ 正常
node scripts/build-with-builder.js arm64 --mac --arm64  # 本地打包 ✅ 正常

本地打包出来的 DMG 安装后运行完全正常。

这就尴尬了。经典的"本地好好的,CI 就炸"。

我打开 Electron 的开发者工具日志,终于看到了这个错误:

Not allowed to load local resource:
file:///Applications/AionUi.app/Contents/Resources/app/.webpack/renderer/main_window/index.html

ERR_FILE_NOT_FOUND——Electron 找不到渲染进程的入口文件。这个 index.html 是 webpack 打包生成的,没有它,整个界面就是一片白。

第一个嫌疑人:tar v7

翻看最近的 package.json 变更,我注意到一个依赖升级:

{
  "overrides": {
    "tar": "^7.5.7"  // 从 ^6.2.1 升级
  }
}

tar v7 是一个 breaking change 版本,API 完全重写。而 electron-builder 在打包时需要处理 tar/asar 归档。会不会是 tar v7 导致 asar 打包出了问题?

我花了大半天时间研究 tar v7 的兼容性:

  • 翻了 npm 的 changelog
  • 查了 electron-builder 的源码
  • 对比了 tar v6 和 v7 的 API 差异

结论:tar v7 是无辜的。它修复的是 CVE-2026-23745 安全漏洞,electron-builder 并不直接依赖 tar 的 API。

第一天,白忙。

第二天:深入 asar 的内心世界

既然不是 tar 的问题,那就得看看 CI 打包出来的产物到底长什么样。

我从 CI artifacts 下载了 DMG,挂载后检查 asar:

# 本地打包的 asar
npx asar list /Applications/AionUi-Local.app/Contents/Resources/app.asar | grep index.html
# ✅ .webpack/renderer/main_window/index.html  存在

# CI 打包的 asar
npx asar list /Applications/AionUi-CI.app/Contents/Resources/app.asar | grep index.html
# ❌ 没有任何输出

找到了! CI 打包出来的 asar 里压根没有 index.html

这意味着 webpack 的产物在 CI 环境下根本没有生成。但 CI 构建明明显示成功了啊?

转折点:一条被吞掉的错误

带着疑惑,我仔细翻看了 CI 的构建日志。在数百行日志的角落里,我发现了这个:

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed
- JavaScript heap out of memory

Node.js 内存溢出了!

webpack 在打包过程中吃光了内存,进程直接崩溃。但是——为什么 CI 没有报错?

答案就在 GitHub Actions 的 workflow 配置里:

- name: Build with electron-builder (non-Windows)
  uses: nick-fields/retry@v3
  with:
    timeout_minutes: 80
    max_attempts: 2
    continue_on_error: true  # ← 就是你!
    command: ${{ matrix.command }}

continue_on_error: true——这个设置的本意是:macOS 构建有时会因为 Apple 公证服务超时而失败,为了不阻塞其他平台的构建,设置了继续执行。

但它也悄悄地吞掉了 OOM 错误。webpack 崩溃了,HTML 没生成,但 CI 一脸无事地继续往下走,把一个残缺的产物打包成了 DMG 并上传。

这就像一个建筑工人告诉你"房子盖好了",但实际上里面连墙都没有。

为什么只有 macOS 炸了?

这里有一个有趣的细节:Windows 和 Linux 的构建都正常,唯独 macOS 白屏。

原因在于 Node.js V8 引擎的内存管理机制。V8 有一个人为设定的堆内存上限(64 位系统默认约 4GB),这个限制独立于物理内存。

GitHub Actions 各平台 Runner 虽然标称内存相同(7GB),但实际可用内存差异很大:

GitHub Actions Runner (7GB RAM)
├── macOS 系统开销 (~1.5GB)
├── Xcode / 签名工具 (~0.5GB)
├── npm / 其他进程 (~0.5GB)
└── Node.js 可用 (~4.5GB)
    └── V8 默认堆上限: ~4GB  ← webpack 需要 ~5GB,超了!

macOS 系统本身就比 Linux 更"臃肿",加上 Xcode 工具链、代码签名服务等额外负担,留给 Node.js 的空间更少。当 webpack 打包一个包含 Monaco Editor、Arco Design、多个 AI SDK 的大型 Electron 项目时,内存消耗刚好卡在了 macOS 的临界点上

Windows 和 Linux 刚好没超,macOS 刚好超了。差的可能就是那几百 MB。

这也解释了为什么这个问题之前没出现——随着项目不断壮大,依赖越来越多,webpack 的内存消耗在某个版本终于突破了 macOS 上的 4GB 天花板。

第三天:修复与反思

修复方案

修复本身只需要一行:

env:
  NODE_OPTIONS: "--max-old-space-size=8192"

这不是"给 Node.js 更多物理内存"——Runner 的物理内存还是 7GB,不会凭空变多。

这是解除 V8 引擎的人为限制。告诉 V8:"如果需要,你可以用到 8GB"。实际上 webpack 只会用到它需要的量(约 5GB)。因为 5GB < 8GB(新上限),所以不再触发 OOM。

情况 V8 堆上限 实际需要 系统可用 结果
修复前 ~4GB (默认) ~5GB ~4.5GB OOM
修复后 8GB (手动) ~5GB ~4.5GB 成功

防御性措施

光修 OOM 不够。真正的问题是 continue_on_error: true 让构建失败变成了"沉默的杀手"。

我重写了 macOS 构建步骤,将公证失败构建失败区分开来:

- name: Build with electron-builder (macOS)
  run: |
    set +e
    ${{ matrix.command }} 2>&1 | tee build.log
    BUILD_EXIT_CODE=${PIPESTATUS[0]}

    # 检查 DMG 是否生成
    if ls out/*.dmg 1>/dev/null 2>&1; then
      DMG_EXISTS=true
    fi

    if [ $BUILD_EXIT_CODE -eq 0 ]; then
      exit 0  # 完全成功 ✅
    fi

    # DMG 存在但构建失败 → 可能只是公证问题
    if [ "$DMG_EXISTS" = true ]; then
      if grep -qiE "notariz|staple" build.log; then
        echo "⚠️ DMG 构建成功,但公证失败"
        exit 0  # 允许继续
      fi
    fi

    # DMG 都没生成 → 真正的构建失败
    exit 1  # ❌ 阻断 CI

逻辑很简单:

  • 构建成功 + 公证成功 → 全部通过 ✅
  • DMG 生成了但公证失败 → 警告但不阻塞 ⚠️(用户右键打开即可)
  • DMG 都没生成 → 直接失败 ❌(这才是真问题)

不再一刀切地 continue_on_error,而是精准区分错误类型

番外篇:AionUi 的"混血"打包架构

讲完了 bug 本身,我想聊聊这个 bug 之所以能藏这么深的根本原因——AionUi 的打包流程本身就不走寻常路。

传统开源项目怎么打包?

大多数 Electron 开源项目的打包流程是这样的:

方案 A:纯 Electron Forge
源代码 → electron-forge make → DMG/EXE/DEB
(开发、编译、打包一条龙)

方案 B:纯 electron-builder
源代码 → webpack/vite 编译 → electron-builder → DMG/EXE/DEB
(自己编译,builder 负责打包)

简单直接。一个工具从头管到尾,出了问题也好排查。

AionUi 为什么要"混血"?

AionUi 的打包流程长这样:

源代码
  
Step 1: Electron Forge (webpack 编译)
  ├── WebpackPlugin 编译 main process
  ├── WebpackPlugin 编译 renderer process  index.html   白屏就是这里没生成
  └── 输出到 .webpack/ 目录
  
Step 2: 产物校验
  └── 检查 .webpack/renderer/main_window/index.html 是否存在
  
Step 3: electron-builder (分发打包)
  ├── 读取 electron-builder.yml 配置
  ├──  .webpack/ 打入 asar 归档
  ├── afterPack: 重建原生模块 (better-sqlite3, node-pty...)
  └── afterSign: 代码签名 + Apple 公证
  
DMG / ZIP / EXE / DEB

为什么不直接用一个工具?因为 AionUi 的需求太"拧巴"了:

需要 Electron Forge 的原因:

  • 它的 WebpackPlugin 对 Electron 多进程架构(main + renderer + preload)有开箱即用的支持
  • 开发时的 HMR 热更新、DevServer、日志端口管理都做得很好
  • FusesPlugin 可以在打包时控制 Electron 安全特性(禁用 RunAsNode、启用 Cookie 加密等)

需要 electron-builder 的原因:

  • macOS 代码签名 + Apple 公证(Forge 的 maker 支持有限)
  • 跨架构编译(在 arm64 机器上构建 x64 包,反之亦然)
  • 精细的 asar 控制(哪些模块打包、哪些解压、哪些排除)
  • 多格式输出(DMG + ZIP 同时生成)
  • 更成熟的 CI/CD 集成

单独用 Forge 做不了完善的公证;单独用 electron-builder 又没有 Forge 的 webpack 集成好用。所以 AionUi 用了一个混血方案——Forge 负责编译,electron-builder 负责打包。

混血的代价:两个工具之间的"信任边界"

这个方案的核心脚本是 build-with-builder.js,它充当了两个工具之间的"桥梁":

// Step 1: 让 Forge 编译 webpack
execSync(`npm exec electron-forge -- package --arch=${targetArch}`);

// Step 2: 把 .webpack/ 目录结构整理成 electron-builder 期望的样子
ensureDir(sourceDir, webpackDir, 'main');
ensureDir(sourceDir, webpackDir, 'renderer');

// Step 3: 校验关键产物
if (!fs.existsSync(rendererIndex)) {
  throw new Error('Missing renderer entry');
}

// Step 4: 让 electron-builder 打包
execSync(`npx electron-builder ${builderArgs} --${targetArch}`);

问题就出在这里:Forge 和 electron-builder 之间没有原生的握手机制。Forge 编译完就完了,至于 .webpack/ 目录里到底有没有该有的文件,它不管。electron-builder 拿到 .webpack/ 就打包,至于里面是不是空的,它也不管。

当 webpack 因为 OOM 中途崩溃时,.webpack/ 目录可能是"半成品"——main 进程的代码可能已经编译好了(因为它先编译),但 renderer 的 index.html 还没来得及生成。electron-builder 照样把这个半成品打进了 asar,产出了一个"看起来正常但其实没有界面"的 DMG。

这就是混血架构的代价:两个工具之间的信任边界,恰好是 bug 的藏身之处

与传统方案的对比

维度 传统方案 (单工具) AionUi (混血方案)
编译 工具内置 Electron Forge (WebpackPlugin)
打包 同一工具 electron-builder
签名/公证 工具内置或手动 electron-builder + afterSign.js
原生模块 工具自动处理 afterPack.js 手动重建
错误传播 直通,容易发现 跨工具,可能被吞掉
灵活性 受限于单工具 高(可单独定制每个环节)
复杂度

原生模块:另一个深坑

AionUi 不是一个纯 JS 应用。它依赖了多个原生 C++ 模块:

  • better-sqlite3 — 本地数据库,存储对话历史和设置
  • node-pty — 终端模拟,用于运行 CLI AI 工具
  • tree-sitter — 代码解析,用于语法高亮

这些模块必须针对目标平台和架构编译成 .node 二进制文件。在 afterPack.js 中,AionUi 实现了一套完整的跨架构重建逻辑:

// 交叉编译时,先清理错误架构的二进制
if (isCrossCompile) {
  // 删除 build/ 目录(包含错误架构的编译产物)
  fs.rmSync(buildDir, { recursive: true, force: true });
  // 删除对立架构的可选依赖包
  // 比如目标是 arm64,就删除所有 *-x64 的包
}

// 然后为目标架构重新编译
rebuildSingleModule({
  moduleName, moduleRoot,
  platform: electronPlatformName,
  arch: targetArch,
  electronVersion
});

这意味着一次 macOS arm64 构建实际上要经历:webpack 编译 → Forge 打包 → electron-builder 打包 → 原生模块重建 → 代码签名 → Apple 公证,六个步骤。任何一步失败都可能导致最终产物有问题。

为什么不简化?

说实话,我也想简化。但现实是:

  1. Forge 的 maker 不支持 Apple notarytool — 这是硬伤,没法绕过
  2. electron-builder 的 webpack 集成不如 Forge — 特别是多入口(main + renderer + preload + worker)场景
  3. 原生模块的跨架构编译 — 需要精细控制,两个工具各自的方案都不够灵活
  4. 安全特性 — Electron Fuses 只有 Forge 的 FusesPlugin 支持得好

所以这个"混血"方案虽然复杂,但在当前的 Electron 生态下,它是 AionUi 这种重量级桌面应用的实际最优解

代价就是——当 bug 出现在两个工具的"交界处"时,排查难度会指数级上升。就像这次的白屏事故。

经验总结

1. continue_on_error 是一把双刃剑

它能防止非关键失败阻塞流水线,但也能让关键错误悄无声息地溜走。如果一定要用,请确保有额外的产物校验逻辑,而不是无条件信任构建命令的退出码。

2. CI 绿灯 ≠ 构建成功

特别是在 Electron 这种多步骤构建(webpack → electron-forge → electron-builder → 签名 → 公证)的场景下,任何一个环节的静默失败都可能产出一个"看起来没问题但其实不能用"的安装包。

建议:在 build 脚本最后加一个产物校验:

const rendererIndex = path.join(webpackDir, 'renderer', 'main_window', 'index.html');
if (!fs.existsSync(rendererIndex)) {
  throw new Error('Missing renderer entry: .webpack/renderer/main_window/index.html');
}

3. OOM 是一个平台相关的"薛定谔 Bug"

同样的代码,同样的 webpack 配置,在 Windows 不 OOM、在 macOS 就 OOM。它可能今天不出现,明天加了一个依赖就出现了。对于大型 Electron 项目,主动设置 --max-old-space-size 是一个好习惯,不要等到 OOM 了才想起来。

4. 永远验证最终产物

不要相信过程,要验证结果。在 CI 流水线里加一步检查最终产物是否存在且完整,能省去无数个排查白屏的深夜。

写在最后

三天三夜,从怀疑 tar v7、到拆解 asar、到翻遍数百行 CI 日志,最终发现是一个 continue_on_error: true 配合 Node.js OOM 造成的"完美犯罪"。

修复只用了一行配置。但找到这一行的过程,让我深刻理解了一个道理:

最难调试的 bug,不是会报错的 bug,而是假装没有 bug 的 bug。


如果你也在做 Electron 开源项目,或者正在被 CI 打包问题折磨,欢迎关注 AionUi —— 一个将命令行 AI Agent 变成现代聊天界面的桌面应用,支持 Gemini CLI、Claude Code、Codex、通义灵码等多种 AI 工具。

扫码_搜索联合传播样式-白色版.png

《TanStack Start 深入解析:Single Flight Mutations 机制(第二篇)》

原文:Single Flight Mutations in TanStack Start: Part 2

作者:Adam Rackis

日期:2026年1月28日

翻译:TUARAN

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


TL;DR

这篇文章延续 Part 1 的思路:把一次 mutation 所需的 UI 更新数据,在同一次网络往返里一起带回来(避免 mutation 后再额外发请求 refetch)。

Part 2 的重点是把“要 refetch 哪些查询”抽成可复用的 middleware:调用 server function 时传入 react-query 的 QueryKey[],middleware 会在客户端从 Query Cache 找到每个 query 对应的 serverFn 和参数,把这些信息通过 sendContext 送到服务端统一执行,然后把结果回传给客户端并用 setQueryData 写回缓存。


Part 1 里,我们聊过 single flight mutations:它让你在更新数据时,同时把 UI 需要的所有相关“已更新数据”重新获取回来,并且整个过程只需要一次跨网络的往返。

我们当时做了个很朴素的实现:在“更新数据”的 server function 里直接把需要的东西 refetch 一遍。它确实能用,但可扩展性和灵活性都一般(耦合也偏重)。

这篇文章我们会实现同样的效果,但方式更通用:定义一个“refetch middleware”,把它挂到任意 server function 上。这个 middleware 允许我们通过 react-query 的 key 指定要 refetch 的数据,剩下的事情它会自动完成。

我们会先做一个最简单版本,然后不断加能力、加灵活性。到最后会稍微复杂一些,但请别误会:你不需要把文中讲的全部都用上。事实上,对绝大多数应用来说,single flight mutations 可能完全无关紧要。更别被“高级做法”迷惑了:对很多小应用而言,直接在 server function 里 refetch 一点数据可能就足够了。

不过,跟着做一遍,我们会看到一些很酷的 TanStack(甚至 TypeScript)特性。即便你永远不用 single flight mutations,这些内容也很可能在别的场景派上用场。

我们的第一个 Middleware

TanStack Query(我们有时也会称它为 react-query,这是它的包名)已经有一套非常好用的层级 key 系统。如果我们的 middleware 能直接接收“要 refetch 的 query keys”,然后就……自动搞定,那该多好?

问题在于:middleware 要怎么知道“怎么 refetch”呢?第一眼看确实有点难。我们的 queries(刻意保持简单)本质上都是对 server functions 的调用。但我们没法把一个普通函数引用传到服务端;函数不可序列化,这很合理。你能把字符串/数字/布尔值序列化成 JSON 在线上传输,但一个函数可能带状态、闭包、上下文……传过去根本说不清。

除非——它是 TanStack Start 的 server function。

这个项目背后的工程师们为序列化引擎做了定制,使其支持 server functions。也就是说:你可以从客户端把一个 server function “发到”服务端,它能正常工作。底层原理是:server functions 有一个内部 ID。TanStack 会捕捉到它、发送 ID,然后在另一端把 ID 反序列化成对应的 server function。

为了让事情更简单,我们不妨把 server function(以及它需要的参数)直接放到我们已经定义好的 query options 上。这样 middleware 只要拿到 query keys,就能从 TanStack Query 的 cache 里找到对应的 query options,拿到“如何 refetch”的信息,然后把整个流程串起来。

开始吧

首先引入一些好用的东西:

import { createMiddleware, getRouterInstance } from "@tanstack/react-start";
import { QueryClient, QueryKey } from "@tanstack/react-query";

接着更新我们的 epics 列表查询(主要的 epics 列表)的 query options:

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    queryKey: ["epics", "list", page],
    queryFn: async () => {
      const result = await getEpicsList({ data: page });
      return result;
    },
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
    meta: {
      __revalidate: {
        serverFn: getEpicsList,
        arg: page,
      },
    },
  });
};

注意这个新增的 meta 区块。它允许我们往 query 上塞任何我们需要的元数据。这里我们放了 getEpicsList 这个 server function 的引用以及它需要的参数。这样写确实会有“重复”(queryFn 写了一次调用方式,meta 又写了一次),如果你觉得别扭,先别急,后面会处理。summary 查询(用于统计数量)我们也会同样更新,不过这里没贴代码。

接下来我们把 middleware 一点点拼出来:

// the server function and args are all `any`, for now, 
// to keep things simple we'll see how to type them in a bit
type RevalidationPayload = {
  refetch: {
    key: QueryKey;
    fn: any;
    arg: any;
  }[];
};

type RefetchMiddlewareConfig = {
  refetch: QueryKey[];
};

export const refetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

我们为 middleware 定义了一个输入。这个输入会自动与“挂载该 middleware 的 server function 的输入”合并。

我们把输入写成可选的(config?),因为完全可能出现这种情况:你只想调用 server function,但并不想 refetch 任何东西。

然后开始写 .client 回调(在浏览器中运行):先拿到要 refetch 的 keys:

const { refetch = [] } = data ?? {};

接着我们拿到 queryClient 和它的 cache,并创建一个 payload,之后会通过 sendContext 发到 .server 回调,让它执行真正的 refetch。

如果你对 TanStack middleware 不熟,我之前写的 middleware 文章 可能会更适合作为入门。

const router = await getRouterInstance();
const queryClient: QueryClient = router.options.context.queryClient;
const cache = queryClient.getQueryCache();

const revalidate: RevalidationPayload = {
  refetch: [],
};

我们的 queryClient 已经挂在 TanStack router 的 context 上,所以只要拿到 router 再取出来即可。

还记得我们把 __revalidate 塞到 query options 的 meta 里吗?现在我们针对每个 key 去 cache 里找对应 query,并把 serverFn/arg 抽出来组装成要发给服务端的 payload。

refetch.forEach((key: QueryKey) => {
  const entry = cache.find({ queryKey: key, exact: true });
  if (!entry) return;

  const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

  if (revalidatePayload) {
    revalidate.refetch.push({
      key,
      fn: revalidatePayload.serverFn,
      arg: revalidatePayload.arg,
    });
  }
});

if (!entry) return; 是为了防止请求里包含了“当前缓存里根本不存在”的 query(也就是说,它可能从未在 UI 里被请求过)。这种情况下我们拿不到 serverFn,也就无法 refetch。

你也可以把 middleware 输入扩展得更丰富:比如对那些“无论是否在缓存里都必须执行”的 refetch,直接把 serverFn + arg 一起传上去。比如你打算 mutation 后 redirect,并希望新页面的数据能预取。本文不实现这个变体,但它只是同一主题的另一种组合。

接着我们调用 next,触发真正的 server function(以及其它 middleware)。通过 sendContext 我们把 revalidate 发到服务端:

const result = await next({
  sendContext: {
    revalidate,
  },
});

result 是 server function 调用的返回值。它的 context 上会有一个 payloads 数组(由下方 .server 回调返回),其中每一项都包含 key(query key)和 result(对应数据)。我们遍历并写回 query cache。

我们稍后会修复这里用 // @ts-expect-error 遮掉的 TS 错误:

// @ts-expect-error
for (const entry of result.context?.payloads ?? []) {
  queryClient.setQueryData(entry.key, entry.result);
}

return result;

服务端回调

服务端回调完整代码如下:

.server(async ({ next, context }) => {
  const result = await next({
    sendContext: {
      payloads: [] as any[]
    }
  });

  const allPayloads = context.revalidate.refetch.map(refetchPayload => {
    return {
      key: refetchPayload.key,
      result: refetchPayload.fn({ data: refetchPayload.arg })
    };
  });

  for (const refetchPayload of allPayloads) {
    result.sendContext.payloads.push({
      key: refetchPayload.key,
      result: await refetchPayload.result
    });
  }

  return result;
});

我们会立刻调用 next(),它会执行这个 middleware 所挂载的 server function。我们在 sendContext 里传入一个 payloads 数组:这个数组决定了“服务端最终会发回给客户端回调的数据结构”(也就是 .client 里循环的那份 payloads)。

然后我们遍历客户端通过 sendContext 传上来的 revalidate payload,并从 context 上读出来(是的:send context,发上来再从 context 读出来)。接着调用所有 server functions,并把结果 push 到 payloads 数组里。

把前后拼起来,这就是完整 middleware:

export const refetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;
    const cache = queryClient.getQueryCache();

    const revalidate: RevalidationPayload = {
      refetch: [],
    };

    refetch.forEach((key: QueryKey) => {
      const entry = cache.find({ queryKey: key, exact: true });
      if (!entry) return;

      const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

      if (revalidatePayload) {
        revalidate.refetch.push({
          key,
          fn: revalidatePayload.serverFn,
          arg: revalidatePayload.arg,
        });
      }
    });

    const result = await next({
      sendContext: {
        revalidate,
      },
    });

    // @ts-expect-error
    for (const entry of result.context?.payloads ?? []) {
      queryClient.setQueryData(entry.key, entry.result);
    }

    return result;
  })
  .server(async ({ next, context }) => {
    const result = await next({
      sendContext: {
        payloads: [] as any[],
      },
    });

    const allPayloads = context.revalidate.refetch.map(refetchPayload => {
      return {
        key: refetchPayload.key,
        result: refetchPayload.fn({ data: refetchPayload.arg }),
      };
    });

    for (const refetchPayload of allPayloads) {
      result.sendContext.payloads.push({
        key: refetchPayload.key,
        result: await refetchPayload.result,
      });
    }

    return result;
  });

修复 TypeScript 报错

为什么下面这一行是无效的?

// @ts-expect-error
for (const entry of result.context?.payloads ?? []) {

这段代码运行在 .client 回调里,并且是在我们调用 next() 之后运行的。本质上,我们是在服务端读取“发送回客户端的数据”(通过 sendContext 传回来的 payload)。这段代码在运行时确实能工作,那为什么类型对不上?

我在上面提到的 middleware 文章里解释过:服务端回调能“看见”客户端发给它的内容,但反过来不成立。这种信息天生就不是双向可见的;类型推断也没法倒着跑。

解决方式很简单:把 middleware 拆成两段,让后一段 middleware 依赖前一段。

const prelimRefetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;

    // same
    // as
    // before

    return await next({
      sendContext: {
        revalidate,
      },
    });

    // those last few lines are removed
  })
  .server(async ({ next, context }) => {
    const result = await next({
      sendContext: {
        payloads: [] as any[],
      },
    });

    // exactly the same as before

    return result;
  });

export const refetchMiddleware = createMiddleware({ type: "function" })
  .middleware([prelimRefetchMiddleware]) // <-------- connect them!
  .client(async ({ next }) => {
    const result = await next();

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;

    // and here's those last few lines we removed from above
    for (const entry of result.context?.payloads ?? []) {
      queryClient.setQueryData(entry.key, entry.result);
    }

    return result;
  });

整体逻辑不变,只是把 .client 回调里 next() 之后那部分移到了单独的 middleware 里。其余部分留在另一个 middleware 中,并作为输入传给新的这个 middleware。这样当我们在 refetchMiddleware 里调用 next 时,TypeScript 就能看到“从服务端发下来的 context 数据”,因为这些数据是在 prelimRefetchMiddleware 里发送的,而它又是本 middleware 的输入,因此 TS 可以完整看清类型流动。

接起来

现在我们回到“更新 epic”的 server function:把之前的手动 refetch 移除,改为使用 refetch middleware。

export const updateEpic = createServerFn({ method: "POST" })
  .middleware([refetchMiddleware])
  .inputValidator((obj: { id: number; name: string }) => obj)
  .handler(async ({ data }) => {
    await new Promise(resolve => setTimeout(resolve, 1000 * Math.random()));
    await db.update(epicsTable).set({ name: data.name }).where(eq(epicsTable.id, data.id));
  });

在 React 组件中通过 useServerFn 来调用它;这个 hook 会自动处理错误、重定向等。

const runSave = useServerFn(updateEpic);

还记得我说过:middleware 的输入会自动与底层 server function 的输入合并吗?当我们调用这个 server function 时就能看到:

图 1:一个 handleSaveFinal 函数的代码片段,保存输入值并调用 runSave,参数对象包含 id 和 name。转存失败,建议直接上传图片文件

unknown[] 对 react-query 的 query key 来说就是正确类型)

现在我们可以这样调用它,并指定要 refetch 的查询:

await runSave({
  data: {
    id: epic.id,
    name: newValue,
    refetch: [
      ["epics", "list", 1],
      ["epics", "list", "summary"],
    ],
  },
});

运行后,一切正常:epics 列表和 summary 都会在没有任何新网络请求的情况下更新。测试 single flight mutations 时,你其实不是在找“发生了什么”,而是在找“什么都没发生”——也就是 Network 面板里缺少那些本该出现的额外请求。

再改进

react-query 的 query keys 是层级结构的,你可能很熟悉这种写法:

queryClient.invalidateQueries({ queryKey: ["epics", "list"] });

它会 refetch 任何 key 以 ["epics", "list"] 开头的 queries。我们的 middleware 能不能也支持这种“key 前缀”呢?也就是只传一个 key prefix,让它找出所有匹配项并 refetch。

可以,开干。

匹配 key 会稍复杂一点:每个传入的 key 可能是 prefix,会匹配多条 cache entry,所以我们用 flatMap 来找出所有匹配项,再利用 cache.findAll(很好用)。

const allQueriesFound = refetch.flatMap(
  k => cache.findAll({ queryKey: k, exact: false })
);

然后循环并做和之前一样的事:

const allQueriesFound = refetch.flatMap(
  k => cache.findAll({ queryKey: k, exact: false })
);

allQueriesFound.forEach(entry => {
  const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

  if (revalidatePayload) {
    revalidate.refetch.push({
      key: entry.queryKey,
      fn: revalidatePayload.serverFn,
      arg: revalidatePayload.arg,
    });
  }
});

这就能用了。

更进一步

不过我们的方案仍然不理想。假设用户在 epics 页面翻页:到第 2 页、到第 3 页、再回到第 1 页。我们的逻辑会找到第 1 页和 summary query,但也会把第 2、3 页一并找到(因为它们现在也在 cache 里)。然而第 2、3 页并不活跃,也不在屏幕上展示,我们不应该 refetch 它们。

我们可以只 refetch active queries:只要给 findAll 加上 type 参数即可。

cache.findAll({ queryKey: key, exact: false, type: "active" });

于是代码就变成这样:

const allQueriesFound = refetch.flatMap(key => cache.findAll({ queryKey: key, exact: false, type: "active" }));

allQueriesFound.forEach(entry => {
  const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

  if (revalidatePayload) {
    revalidate.refetch.push({
      key: entry.queryKey,
      fn: revalidatePayload.serverFn,
      arg: revalidatePayload.arg,
    });
  }
});

更更进一步

这样就能工作了。但你仔细想想,那些 inactive 的 queries 其实应该被 invalidated。我们不希望立刻 refetch 它们(浪费资源,而且用户没在看),但如果用户又翻回那些页面,我们希望触发一次重新获取。TanStack Query 通过 invalidateQueries 很容易做到。

我们把这段加到“被依赖的那个 middleware”的 client 回调里:

data?.refetch.forEach(key => {
  queryClient.invalidateQueries({ queryKey: key, exact: false, type: "inactive", refetchType: "none" });
});

遍历传入的 query keys,把所有匹配的 inactive queries 标记为无效,但不立刻 refetch(refetchType: "none")。

下面是更新后的完整 middleware:

const prelimRefetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;
    const cache = queryClient.getQueryCache();

    const revalidate: RevalidationPayload = {
      refetch: [],
    };

    const allQueriesFound = refetch.flatMap(key => cache.findAll({ queryKey: key, exact: false, type: "active" }));

    allQueriesFound.forEach(entry => {
      const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

      if (revalidatePayload) {
        revalidate.refetch.push({
          key: entry.queryKey,
          fn: revalidatePayload.serverFn,
          arg: revalidatePayload.arg,
        });
      }
    });

    return await next({
      sendContext: {
        revalidate,
      },
    });
  })
  .server(async ({ next, context }) => {
    const result = await next({
      sendContext: {
        payloads: [] as any[],
      },
    });

    const allPayloads = context.revalidate.refetch.map(refetchPayload => {
      return {
        key: refetchPayload.key,
        result: refetchPayload.fn({ data: refetchPayload.arg }),
      };
    });

    for (const refetchPayload of allPayloads) {
      result.sendContext.payloads.push({
        key: refetchPayload.key,
        result: await refetchPayload.result,
      });
    }

    return result;
  });

export const refetchMiddleware = createMiddleware({ type: "function" })
  .middleware([prelimRefetchMiddleware])
  .client(async ({ data, next }) => {
    const result = await next();

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;

    for (const entry of result.context?.payloads ?? []) {
      queryClient.setQueryData(entry.key, entry.result, { updatedAt: Date.now() });
    }

    data?.refetch.forEach(key => {
      queryClient.invalidateQueries({ queryKey: key, exact: false, type: "inactive", refetchType: "none" });
    });

    return result;
  });

我们告诉 TanStack Query:把匹配 key 的 inactive queries 置为 invalid(但不 refetch)。

这个方案非常好用:如果你浏览到第 2、3 页,然后回到第 1 页,再编辑一个 todo,你会看到第 1 页列表和 summary 立刻更新。之后如果你再翻回第 2、3 页,你会看到网络请求触发,从而拿到新数据。

锦上添花

还记得我们把 server function 和参数塞进 query options 时的写法吗?

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    queryKey: ["epics", "list", page],
    queryFn: async () => {
      const result = await getEpicsList({ data: page });
      return result;
    },
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
    meta: {
      __revalidate: {
        serverFn: getEpicsList,
        arg: page,
      },
    },
  });
};

我之前提过:在 metaqueryFn 里重复写 serverFn/arg 有点“脏”。我们来修一下。

先从最简单的 helper 开始:

export function refetchedQueryOptions(queryKey: QueryKey, serverFn: any, arg?: any) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

这个 helper 会接收 query key、server function 和参数,然后返回 query options:

  • 拼好的 queryKey(必要时把 arg 追加进去)
  • queryFn(直接调用 server function)
  • meta.__revalidate(同样记录 server function 和参数)

于是 epics 列表 query 就可以写成:

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    ...refetchedQueryOptions(["epics", "list"], getEpicsList, page),
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
  });
};

它能工作,但类型不好:到处都是 any,意味着传给 server function 的参数不做类型检查;更糟的是,queryFn 的返回值也不会被检查,于是你的 query(比如这个 epics 列表)会变成返回 any

我们来加点类型。

server functions 本质上是函数:接收一个对象参数;如果 server function 定义了输入,那么这个对象会包含一个 data 属性,里面就是输入。说一堆大白话不如看调用例子:

const result = await runSaveSimple({
  data: {
    id: epic.id,
    name: newValue,
  },
});

第二版 helper 可以这样写:

export function refetchedQueryOptions<T extends (arg: { data: any }) => Promise<any>>(
  queryKey: QueryKey,
  serverFn: T,
  arg: Parameters<T>[0]["data"],
) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async (): Promise<Awaited<ReturnType<T>>> => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

我们把 server function 约束为一个 async 函数,且它的参数对象上有 data;然后用它来静态推断 arg 的类型。这已经不错了,但当你把它用在“没有参数”的 server function 上时会报错:

...refetchedQueryOptions(["epics", "list", "summary"], getEpicsSummary)
// Expected 3 arguments, but got 2.

你传 undefined 可以解决,功能也正常:

...refetchedQueryOptions(["epics", "list", "summary"], getEpicsSummary, undefined),

如果你是个正常人,你大概会觉得这已经很好了,而且确实如此。但如果你像我一样有点“怪”,你可能会想能不能做到更完美:

  • 当 server function 有参数时:必须传入且类型要正确
  • 当 server function 没参数时:允许省略 arg

TypeScript 有一个特性正好适合:函数重载(overloaded functions)

这篇文章已经够长了,所以我直接贴代码,解读留作读者练习(以及可能的未来文章)。

import { QueryKey, queryOptions } from "@tanstack/react-query";

type AnyAsyncFn = (...args: any[]) => Promise<any>;

type ServerFnArgs<TFn extends AnyAsyncFn> = Parameters<TFn>[0] extends infer TRootArgs
  ? TRootArgs extends { data: infer TResult }
    ? TResult
    : undefined
  : never;

type ServerFnHasArgs<TFn extends AnyAsyncFn> = ServerFnArgs<TFn> extends infer U ? (U extends undefined ? false : true) : false;

type ServerFnWithArgs<TFn extends AnyAsyncFn> = ServerFnHasArgs<TFn> extends true ? TFn : never;
type ServerFnWithoutArgs<TFn extends AnyAsyncFn> = ServerFnHasArgs<TFn> extends false ? TFn : never;

type RefetchQueryOptions<T> = {
  queryKey: QueryKey;
  queryFn?: (_: any) => Promise<T>;
  meta?: any;
};

type ValidateServerFunction<Provided, Expected> = Provided extends Expected ? Provided : "This server function requires an argument!";

export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithArgs<TFn>,
  arg: Parameters<TFn>[0]["data"],
): RefetchQueryOptions<Awaited<ReturnType<TFn>>>;
export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ValidateServerFunction<TFn, ServerFnWithoutArgs<TFn>>,
): RefetchQueryOptions<Awaited<ReturnType<TFn>>>;
export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithoutArgs<TFn> | ServerFnWithArgs<TFn>,
  arg?: Parameters<TFn>[0]["data"],
): RefetchQueryOptions<Awaited<ReturnType<TFn>>> {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

有了它之后,当 server function 需要参数时,你可以这样调用:

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    ...refetchedQueryOptions(["epics", "list"], getEpicsList, page),
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
  });
};

参数类型会被正确检查:

...refetchedQueryOptions(["epics", "list"], getEpicsList, "")
// Argument of type 'string' is not assignable to parameter of type 'number'.

如果你忘了传参数,它也会报错:

...refetchedQueryOptions(["epics", "list"], getEpicsList)
// Argument of type 'RequiredFetcher<undefined, (page: number) => number, Promise<{ id: number; name: string; }[]>>' is not assignable to parameter of type '"This server function requires an argument!"'.

最后这个报错信息不算特别直观,但如果你把代码读到最后,会发现它已经在尽力提示你哪里错了,靠的就是这个小工具类型:

type ValidateServerFunction<Provided, Expected> = Provided extends Expected ? Provided : "This server function requires an argument!";

而对于“没有参数”的 server function,它也能正常工作。完整解释留给未来文章。

总结

single flight mutations 是一个很不错的优化工具:当你做一次 mutation 后,UI 需要的更新数据不必再额外发请求获取,而是可以在同一次往返里顺便带回来。

希望这篇文章把各个拼图都讲清楚了:如何用 middleware 收集要 refetch 的查询、如何借助 TanStack Start 的 server function 序列化能力把“要执行的 refetch”发送到服务端、以及如何在客户端用 setQueryData 把数据写回缓存。

同样是 setCount,为啥定时器里传函数才管用?

// ❌ 错误

useEffect(() => {

  const timer = setInterval(() => {

    setCount(count + 1); 

  }, 1000);

  return () => clearInterval(timer);

}, []);

  


// ✅ 正确:使用函数式更新

useEffect(() => {

  const timer = setInterval(() => {

    setCount(prev => prev + 1);

  }, 1000);

  return () => clearInterval(timer);

}, []);

分析一下上面两个方法,差别在于一个给setCount传的是计算值一个传的是函数

接下来分析一下为什么第一个方法有问题

首先,空依赖的useEffect只在组件第一次渲染时跑一次。我们可以将组件每一次渲染,理解成一个「独立的快照」,里面的变量(count)、函数都是全新的;空依赖的 useEffect 由于只在第一次渲染时执行,绑定的定时器回调,永远属于「第一次渲染的快照」,只能拿到这次快照里的 count。

以下是执行步骤:

  • 第一次渲染(快照 1) :React 创建了「count=0」这个变量,然后执行 useEffect(空依赖,仅一次),启动定时器,定时器的回调函数被绑定在快照 1 上,只能访问快照 1 里的「count=0」;

  • 定时器执行,setCount (0+1) 后:React 发现 count 变了,触发第二次渲染(快照 2) ,这次 React 会创建一个全新的「count=1」变量(注意:是新的,不是修改原来的 0),然后根据新的 count 更新 DOM,页面显示 1;

  • 但因为 useEffect 只执行一次,定时器是在第一次渲染时创建的,它的回调函数从诞生开始,就「粘」在了快照 1上,这辈子都跳不出去。相应地,它只能访问「count=0」,此时过了一秒,再次触发定时器,由于count + 1 是 1,与最新的count一样,不会触发dom更新。因此,页面不会继续出现2 3 4 5 6 ,而是一直是1

为什么传入函数就可以了?

因为函数式更新是让 React 帮你拿最新状态,而不是自己从闭包作用域里拿
这个问题问到了 React 状态更新的底层设计逻辑,因为当你传给 setCount 的是一个「函数」,而非「直接的计算值」时,React 会对这种「函数式更新」做特殊处理 —— 主动把最新状态传给这个函数的参数(prev),而非让函数自己去外部作用域找状态。

  • setCount(count + 1):让定时器回调自己去「外部作用域」找 count 的值,再计算后传给 setCount(找得到旧的,找不到新的);

  • setCount(prev => prev + 1):告诉 React「我要更新状态,你把当前最新的 count 传给我,我基于这个最新值计算」(让 React 帮你找,它永远能找到最新的)。

传入函数可以找到最新的count值,因此,在定时器执行时,就可以获取到最新的count值,从而视图不断更新,显示 1 -> 2 -> 3...

结论

只要是定时器 / 延时器里更新状态,且更新需要 “基于上一个状态”(比如 + 1、-1、拼接),直接用函数式更新,把算账的活交给 React,绝对不踩坑!

vue3 页面缓存KeepAlive示例

KeepAlive 示例

1.全部缓存

//APP.vue
<router-view v-slot="{ Component }">
    <KeepAlive>
      <component :is="Component"/>
    </KeepAlive>
</router-view>

2.根据路由配置缓存

  • 通过v-if来实现、配置在路由即可
//router/index.js
const routes = [
  {
    path: '/',
    component: () => import('@/views/home/index.vue'),
    name: 'home',
    meta: {
      title: '首页',
      keepAlive: true,//是否缓存
    },
  },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
//APP.vue
<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive>
      <component :is="Component" :key="$route.fullPath" v-if="route.meta.keepAlive" />
    </keep-alive>
    <component :is="Component" :key="$route.fullPath" v-if="!route.meta.keepAlive" />
  </router-view>
</template>

3.动态控制页面缓存:include

  • 通过include匹配实现;
  • 要求:值要和页面组件的name一致;
  • 方便:为方便取值,页面组件的name要和对应路由name保持一致,直接获取路由的name即可
  • 管理:借助pinia进行管理
  • 缺点:组件需要手动设置name且和路由一致,麻烦点
  • 1、路由:/router/index.js
const routes = [
  {
    path: '/',
    component: () => import('@/views/home/index.vue'),
    name: 'home',//这里和component组件文件的defineOptions({ name: 'home'})保持一致
    meta: {
      title: '首页',
      keepAlive: true,//是否缓存
    },
  },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
  • 2、App.vue
<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive :include="appStore.cacheList">
      <component :is="Component" :key="route.fullPath" />
    </keep-alive>
  </router-view>
</template>
<script setup>
import { useAppStore } from '@/store/app'

const router = useRouter()
const appStore = useAppStore()

// 初始化缓存列表:根据路由配置添加需要缓存的组件
onMounted(() => {
  appStore.initCacheList(router)
})
</script>
<style scoped></style>
  • 3、pinia实现控制逻辑:/store/app.js
import { defineStore } from 'pinia'

/**
 * 应用状态管理 Store
 * 主要用于管理 keep-alive 缓存列表
 */
export const useAppStore = defineStore('app', {
    state: () => ({
        // 缓存列表,存储需要缓存的组件名称
        // keep-alive 的 include 需要匹配组件的 name
        cacheList: []
    }),

    getters: {
        /**
         * 获取缓存列表(只读)
         */
        getCacheList: (state) => {
            return [...state.cacheList]
        },

        /**
         * 检查某个组件是否在缓存列表中
         */
        isCached: (state) => {
            return (componentName) => {
                return state.cacheList.includes(componentName)
            }
        }
    },

    actions: {
        /**
         * 初始化缓存列表:根据路由配置添加需要缓存的组件
         * @param {Object} router - Vue Router 实例
         */
        initCacheList(router) {
            if (!router) {
                console.warn('initCacheList: router 参数不能为空')
                return
            }

            router.getRoutes().forEach(route => {
                if (route.meta?.keepAlive && route.name) {
                    // keep-alive 的 include 需要匹配组件的 name
                    // 这里使用路由 name,需要确保路由 name 和组件 defineOptions 中的 name 一致
                    const componentName = route.name
                    if (!this.cacheList.includes(componentName)) {
                        this.cacheList.push(componentName)
                    }
                }
            })
        },

        /**
         * 添加组件到缓存列表
         * @param {string} componentName - 组件名称
         */
        addCache(componentName) {
            if (!componentName) {
                console.warn('addCache: componentName 参数不能为空')
                return
            }

            if (!this.cacheList.includes(componentName)) {
                this.cacheList.push(componentName)
                console.log(`已添加 ${componentName} 到缓存列表`)
            }
        },

        /**
         * 从缓存列表中移除组件(临时取消缓存)
         * @param {string} componentName - 组件名称
         */
        removeCache(componentName) {
            if (!componentName) {
                console.warn('removeCache: componentName 参数不能为空')
                return
            }

            const index = this.cacheList.indexOf(componentName)
            if (index > -1) {
                this.cacheList.splice(index, 1)
                console.log(`已移除 ${componentName} 的缓存`)
            } else {
                console.warn(`未找到 ${componentName} 的缓存`)
            }
        },

        /**
         * 清空所有缓存
         */
        clearAllCache() {
            this.cacheList = []
        },

        /**
         * 根据路由信息自动管理缓存
         * 如果路由配置了 keepAlive 为 true,则自动添加到缓存列表
         * @param {Object} route - 路由对象
         */
        autoManageCache(route) {
            if (!route) return

            const componentName = route.name
            if (route.meta?.keepAlive && componentName) {
                if (!this.cacheList.includes(componentName)) {
                    this.addCache(componentName)
                }
            }
        }
    }
})

4.解决上面麻烦点

  • 动态赋值组件name为路由的name值
  • 1.在路由页面/router/index.js 使用工具处理
import { processRoutes } from '@/utils/route-helper';//自动为组件设置路由 name的辅助工具
const routes = [....];//如上
// 自动为所有路由组件设置 name(如果组件没有设置 name,则使用路由 name)
const processedRoutes = processRoutes(routes);

const router = createRouter({
  history: createWebHashHistory(),
  routes: processedRoutes,
})
export default router;
  • 2.@/utils/route-helper
/**
 * 路由辅助工具
 * 自动为组件设置路由 name,避免每个页面都手动设置 defineOptions
 */

import { defineComponent, h, markRaw } from 'vue'

/**
 * 包装路由组件,自动设置组件 name 为路由 name
 * @param {Function|Object} component - 组件导入函数或组件对象
 * @param {string} routeName - 路由名称
 * @returns {Function|Object} 包装后的组件
 */
export function withRouteName(component, routeName) {
    if (!routeName) {
        return component
    }

    // 如果是异步组件(函数)
    if (typeof component === 'function') {
        return () => {
            return component().then((module) => {
                const comp = module.default || module

                // 如果组件已经有 name,直接返回
                if (comp.name) {
                    return module
                }

                // 使用 defineComponent 包装组件,设置 name
                const wrappedComponent = defineComponent({
                    name: routeName,
                    setup(props, { slots, attrs }) {
                        // 渲染原组件
                        return () => h(comp, { ...props, ...attrs }, slots)
                    }
                })

                // 标记为原始对象,避免响应式
                markRaw(wrappedComponent)

                // 返回包装后的组件
                // 注意:需要先展开 module 再覆盖 default,否则 module.default 会把 wrappedComponent 覆盖掉
                return {
                    ...module,
                    default: wrappedComponent,
                }
            })
        }
    }

    // 如果是同步组件(对象)
    if (typeof component === 'object' && component !== null) {
        // 如果组件已经有 name,直接返回
        if (component.name) {
            return component
        }

        // 使用 defineComponent 包装组件,设置 name
        const wrappedComponent = defineComponent({
            name: routeName,
            ...component
        })

        markRaw(wrappedComponent)
        return wrappedComponent
    }

    return component
}

/**
 * 批量处理路由配置,自动为组件设置 name
 * @param {Array} routes - 路由配置数组
 * @returns {Array} 处理后的路由配置数组
 */
export function processRoutes(routes) {
    return routes.map(route => {
        // 如果有 name 和 component,则自动设置组件 name
        if (route.name && route.component) {
            route.component = withRouteName(route.component, route.name)
        }

        // 递归处理子路由
        if (route.children && Array.isArray(route.children)) {
            route.children = processRoutes(route.children)
        }

        return route
    })
}

以上已验证

  • 登录后 根据接口返回用户可访问的菜单信息 动态添加的路由 缓存功能同样适用

React 中的竞态条件问题及解决方案:从一个日历组件说起

在 React 开发中,我们经常会遇到这样的场景:用户快速切换筛选条件,触发多个异步请求。由于网络延迟的不确定性,请求的返回顺序可能与发送顺序不一致,导致页面显示错误的数据。这就是经典的 竞态条件(Race Condition)问题。

一、问题场景

1.1 业务背景

我们有一个变更日历组件,用于展示每日的需求创建数和服务发布数。用户可以通过多个条件进行筛选:

// 筛选条件
- 年份
- 月份

1.2 原始代码

const useCalendar = () => {
  const [calendarData, setCalendarData] = useState([])
  const [loading, setLoading] = useState(false)
  
  // 获取日历数据
  useEffect(() => {
    const loadData = async () => {
      setLoading(true)
      try {
        const data = await fetchCalendarData(year, month)
        setCalendarData(data)
      } catch (error) {
        console.error("获取日历数据失败:", error)
        setCalendarData([])
      } finally {
        setLoading(false)
      }
    }
    loadData()
  }, [year, month])
  
  // ...
}

1.3 问题复现

当用户快速从 1月 → 2月 → 3月 切换时:

时间线 ────────────────────────────────────────────────────────►

用户操作:    选1月        选2月        选3月
              │            │            │
              ▼            ▼            ▼
发出请求:   请求1月      请求2月      请求3月
              │            │            │
              │            │            └────► 返回3月数据 (200ms)
              │            │
              │            └─────────────────► 返回2月数据 (500ms)
              │
              └──────────────────────────────► 返回1月数据 (800ms)

问题:用户最后选择的是 3月,但因为 1月的请求最后返回,页面最终显示的是 1月的数据!

这就是竞态条件:多个异步操作竞争同一个资源(state),结果取决于它们完成的顺序,而非发起的顺序。

二、解决方案

2.1 核心思路

我们需要一种机制来标记和忽略过时的请求。当用户切换条件时,把之前的请求标记为"已取消",即使它返回了数据,也不更新 state。

2.2 使用 isCancelled 标志位

useEffect(() => {
  // 1️⃣ 每次 useEffect 执行时,创建一个新的标志位
  let isCancelled = false
  
  const loadData = async () => {
    setLoading(true)
    try {
      const params = buildCalendarParams(year, month)
      const response = await get("xxx", params)
      
      // 3️⃣ 请求返回时,检查是否已被取消
      if (isCancelled) {
        console.log("请求已取消,忽略响应")
        return
      }
      
      // 只有未取消的请求才更新数据
      if (response.status === 0) {
        setCalendarData(response.data || [])
      } else {
        setCalendarData([])
      }
    } catch (error) {
      if (isCancelled) return
      console.error("获取日历数据失败:", error)
      setCalendarData([])
    } finally {
      if (!isCancelled) {
        setLoading(false)
      }
    }
  }
  
  loadData()
  
  // 2️⃣ 清理函数:依赖变化时,标记旧请求为已取消
  return () => {
    isCancelled = true
  }
}, [year, month])

三、原理详解

3.1 useEffect 的清理函数

React 的 useEffect 可以返回一个清理函数。这个清理函数会在以下时机执行:

  1. 依赖变化时:先执行上一次 effect 的清理函数,再执行新的 effect
  2. 组件卸载时:执行最后一次 effect 的清理函数
useEffect(() => {
  // effect 逻辑
  console.log("effect 执行")
  
  return () => {
    // 清理逻辑
    console.log("cleanup 执行")
  }
}, [dependency])

当 dependency 从 A 变为 B 时,执行顺序是:

1. cleanup(A)  ← 先清理旧的
2. effect(B)   ← 再执行新的

3.2 闭包的作用

这个方案能够生效的关键是 JavaScript 闭包

每次 useEffect 执行时,都会创建一个全新的、独立的 isCancelled 变量。清理函数和异步请求的回调函数通过闭包记住它们所属的那个 isCancelled

// 第1次执行(选择1月)
useEffect(() => {
  let isCancelled_1 = false  // 独立的变量
  
  // 请求1的回调闭包引用 isCancelled_1
  fetch(...).then(() => {
    if (isCancelled_1) return  // 检查的是 isCancelled_1
    setData(...)
  })
  
  return () => {
    isCancelled_1 = true  // 修改的是 isCancelled_1
  }
}, [month])

// 第2次执行(选择2月)
useEffect(() => {
  let isCancelled_2 = false  // 另一个独立的变量
  
  // 请求2的回调闭包引用 isCancelled_2
  fetch(...).then(() => {
    if (isCancelled_2) return  // 检查的是 isCancelled_2
    setData(...)
  })
  
  return () => {
    isCancelled_2 = true  // 修改的是 isCancelled_2
  }
}, [month])

3.3 完整执行流程

让我们用具体的例子走一遍完整流程:

═══════════════════════════════════════════════════════════════

【步骤1:用户选择 1月】

useEffect 第1次执行:
  ├─ 创建 isCancelled_1 = false
  ├─ 发出请求1(获取1月数据)
  └─ 返回清理函数 cleanup_1

内存状态:
  ┌─────────────────────┐
  │ isCancelled_1 = false│
  └─────────────────────┘

═══════════════════════════════════════════════════════════════

【步骤2:用户选择 2月】(依赖变化)

React 执行顺序:
  1. 执行 cleanup_1()
     └─ isCancelled_1 = true  ✅ 标记请求1已过时
  
  2. useEffect 第2次执行
     ├─ 创建 isCancelled_2 = false
     ├─ 发出请求2(获取2月数据)
     └─ 返回清理函数 cleanup_2

内存状态:
  ┌─────────────────────┐
  │ isCancelled_1 = true │ ← 已标记为取消
  └─────────────────────┘
  ┌─────────────────────┐
  │ isCancelled_2 = false│ ← 新的,有效的
  └─────────────────────┘

═══════════════════════════════════════════════════════════════

【步骤3:用户选择 3月】(依赖变化)

React 执行顺序:
  1. 执行 cleanup_2()
     └─ isCancelled_2 = true  ✅ 标记请求2已过时
  
  2. useEffect 第3次执行
     ├─ 创建 isCancelled_3 = false
     ├─ 发出请求3(获取3月数据)
     └─ 返回清理函数 cleanup_3

内存状态:
  ┌─────────────────────┐
  │ isCancelled_1 = true │ ← 已取消
  └─────────────────────┘
  ┌─────────────────────┐
  │ isCancelled_2 = true │ ← 已取消
  └─────────────────────┘
  ┌─────────────────────┐
  │ isCancelled_3 = false│ ← 当前有效
  └─────────────────────┘

═══════════════════════════════════════════════════════════════

【步骤4:请求陆续返回】

请求3 返回(最快,200ms):
  ├─ 回调检查 isCancelled_3
  ├─ isCancelled_3 === false ✅
  └─ 更新数据为 3月 ✅

请求2 返回(500ms):
  ├─ 回调检查 isCancelled_2
  ├─ isCancelled_2 === true ❌
  └─ 直接 return,不更新数据

请求1 返回(最慢,800ms):
  ├─ 回调检查 isCancelled_1
  ├─ isCancelled_1 === true ❌
  └─ 直接 return,不更新数据

═══════════════════════════════════════════════════════════════

【最终结果】页面显示 3月数据 ✅ 正确!

四、其他解决方案

4.1 使用 AbortController

如果你的请求支持取消(如 fetch API),可以使用 AbortController 来真正取消请求:

useEffect(() => {
  const controller = new AbortController()
  
  const loadData = async () => {
    try {
      const response = await fetch(url, {
        signal: controller.signal
      })
      const data = await response.json()
      setCalendarData(data)
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('请求被取消')
        return
      }
      console.error(error)
    }
  }
  
  loadData()
  
  return () => {
    controller.abort()  // 真正取消请求
  }
}, [dependencies])

4.2 使用第三方库

一些流行的数据获取库已经内置了竞态条件处理:

  • React Query / TanStack Query
  • SWR
  • RTK Query
// 使用 React Query
import { useQuery } from '@tanstack/react-query'

const { data, isLoading } = useQuery({
  queryKey: ['calendar', year, month, type],
  queryFn: () => fetchCalendarData(year, month, type)
})
// React Query 自动处理竞态条件

五、总结

概念 说明
竞态条件 多个异步操作竞争同一资源,结果取决于完成顺序
闭包 函数可以访问它被创建时所在作用域的变量
清理函数 useEffect 返回的函数,在依赖变化或组件卸载时执行
解决思路 标记过时请求,忽略其响应

关键代码模板

useEffect(() => {
  let isCancelled = false
  
  const fetchData = async () => {
    try {
      const data = await api.get(...)
      if (isCancelled) return  // 关键检查
      setState(data)
    } catch (error) {
      if (isCancelled) return
      handleError(error)
    }
  }
  
  fetchData()
  
  return () => {
    isCancelled = true  // 标记取消
  }
}, [dependencies])

理解这个模式后,你就能在任何需要处理异步请求竞态条件的场景中应用它。这是 React 开发中的一个重要技巧

Vue 2.7 封装全屏弹窗组件:基于命名空间的样式定制

在 Vue 2.7 + Element UI 项目中,封装全屏 Iframe 弹窗常遇到样式覆盖无效的问题。特别是开启 append-to-body 后,弹窗 DOM 位于根节点,常规的 scoped 样式难以生效。

本文介绍一种不依赖 scoped,通过CSS 命名空间来实现安全样式隔离的方案。

1. 核心需求

  • 全屏沉浸:弹窗无边距、无默认内边距。
  • DOM 结构安全:必须使用 append-to-body,防止被父级容器截断。
  • 样式无污染:在全局样式模式下,确保只影响当前组件。

2. 组件实现 (SurveyPortal.vue)

Template 结构

关键在于设置 custom-class。这个唯一的类名将作为我们的“样式防火墙”。

<template>
  <el-dialog
    :visible.sync="dialogVisible"
    :title="title"
    fullscreen
    :append-to-body="true"
    :destroy-on-close="true"
    custom-class="survey-portal-dialog" 
    @close="handleClose"
  >
    <div class="iframe-wrapper" v-loading="loading">
      <iframe
        :src="surveyUrl"
        frameborder="0"
        width="100%"
        height="100%"
        @load="onIframeLoad"
      ></iframe>
    </div>
  </el-dialog>
</template>

Script 逻辑

保持标准的 Vue 2.7 写法,计算属性处理 URL,Watch 处理双向绑定。

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

const props = defineProps({
  visible: Boolean,
  surveyId: { type: [String, Number], required: true },
  title: { type: String, default: '外部页面' }
});

const emit = defineEmits(['update:visible', 'close']);

const dialogVisible = ref(false);
const loading = ref(true);

const surveyUrl = computed(() => `/wj/${props.surveyId}`);

watch(() => props.visible, (val) => {
  dialogVisible.value = val;
  if (val) loading.value = true;
});

watch(dialogVisible, (val) => {
  emit('update:visible', val);
});

const onIframeLoad = () => {
  loading.value = false;
};

const handleClose = () => emit('close');
</script>

3. 样式处理(命名空间隔离法)

由于 append-to-body 将 DOM 移出了组件作用域,我们放弃 scoped,转而使用全局样式。为了防止污染全局,我们将所有样式严格包裹在 custom-class 定义的唯一类名中。

CSS 实现原理

  1. 去掉 scoped:让样式变为全局可见。
  2. 顶层包裹:所有规则必须写在 .survey-portal-dialog 内部。
  3. 覆盖 Element UI:直接选中 .el-dialog__body 进行重置。
<style lang="scss">
/* * 注意:不加 scoped 
 * 通过 ".survey-portal-dialog" 这个唯一类名实现逻辑隔离
 */
.survey-portal-dialog {
  display: flex;
  flex-direction: column;

  /* 1. 修正头部样式 */
  .el-dialog__header {
    padding: 15px 20px;
    border-bottom: 1px solid #ebeef5;
  }

  /* 2. 暴力清除 Body 内边距,实现全屏无缝 */
  .el-dialog__body {
    padding: 0 !important;
    margin: 0 !important;
    flex: 1;
    overflow: hidden;
    height: 100%;
  }

  /* 3. 内部 Iframe 容器高度计算 */
  .iframe-wrapper {
    /* 减去 Header 高度(约54px),避免出现双重滚动条 */
    height: calc(100vh - 54px);
    width: 100%;
    overflow: hidden;
  }
}
</style>

4. 方案优劣分析

  • 优点

    • 极度稳定:不受 Vue Loader 版本或 scoped 穿透语法(/deep/ vs ::v-deep)变更的影响。
    • 符合直觉:完美兼容 append-to-body 的 DOM 移动行为。
  • 注意点

    • 命名唯一性:必须保证 survey-portal-dialog 这个类名在项目中是唯一的,避免与其他弹窗冲突。

5. 总结

在处理 Element UI 的 append-to-body 弹窗时,“全局样式 + 唯一类名包裹”是最简单且副作用最小的方案。它通过 CSS 选择器的嵌套规则,手动建立了一个“样式沙箱”,既解决了全屏覆盖问题,又规避了全局污染风险。

一个月手搓 JavaScript runtime

原文:building a javascript runtime in one month

作者:themackabu

日期:2026年1月2日

翻译:TUARAN

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


TL;DR

我做了一个叫 Ant 的小型 JavaScript runtime(大概 2MB)。源码、测试和文档都在 GitHub:github.com/themackabu/…


我在 11 月初开始做这个项目时,脑子里只有一个简单念头:

如果能做一个足够小、能嵌进 C 程序里,但又足够完整、能跑真实代码的 JavaScript 引擎,会怎么样?

一个你可以发布出去、却不用捆上几百 MB 的 V8 或 Node 的东西。我以前也试过做“极简版 Deno”的路子,但始终不够。

我没想到这会花一个月;更没想到一个月真的做得出来。但不设 deadline 的项目有个特点:你会一直往前推,推着推着就做出来了。

第一周:纯纯生存模式

我是一边做一边学——说白了就是不断试错,然后把每一个错误也一起“发布”出去。最开始的工作只围绕最基本的东西:

  • 数值运算
  • 字符串内建函数
  • 一个非常粗糙的 CommonJS 模块系统

每一次提交都像是在虚无里抢回一点点地盘。

最核心的问题是 解析(parsing)。在其它东西能工作之前,你必须先有 parser。而 parser 往往比看起来复杂得多。JavaScript 这种语言尤其“诡异”:

  • 自动分号插入(ASI)是规范的一部分,你得处理
  • this 的绑定会随上下文变化
  • var 的提升(hoisting)意味着变量会在赋值前就“存在”
  • 甚至 window.window.window 这种写法都是合法的……

我前几天做的主要是把基本流程跑通,类似一个“能算数、也能调用函数”的计算器。由于动量已经起来了,我就一直继续。

runtime 的核心数据表示大概长这样:

typedef uint64_t jsval_t;

在这个 runtime 里,每一个 JavaScript 值都用一个 64 位整数表示:NaN-boxing

IEEE 754 浮点规范有个“洞”:理论上存在 2532^{53} 种 NaN,其中绝大多数从来不会被用到。所以我把它们“偷”来用了。

如果把一个 64 位值按 double 解释时它看起来像 NaN,同时 exponent 与 mantissa 又满足你定义的模式,那么你就可以在这些 bit 里塞一个 tag。你有足够空间同时存一个指针和一个类型标签:把对象引用和类型 tag 一起塞进 64 bit,瞬间所有 JS 值都能塞进一个 machine word。

编译期断言也证明了前提:

_Static_assert(sizeof(double) == 8, "NaN-boxing requires 64-bit IEEE 754 doubles");
_Static_assert(sizeof(uint64_t) == 8, "NaN-boxing requires 64-bit integers");
_Static_assert(sizeof(double) == sizeof(uint64_t), "double and uint64_t must have same size");

这就成了 runtime 表示“一切”的心脏:每个数字、对象、字符串、函数、Promise、参数、作用域……全部都是一个 jsval_t

没有“带标签联合体”、没有 vtable、也不需要额外分配元数据——只有 bits。为了把它调顺,我迭代了好几天;但一旦跑通,其它东西就会更快更顺。NaN 和 Infinity 当然也有坑,不过通过微调 boxing 布局也能解决。

大约第 4 天我让变量能用了,第 5 天函数能用了,第 6 天循环能跑了。早期提交非常散:箭头函数、IIFE、可选链、空值合并……我就是一边翻 MDN 一边想起啥加啥。

垃圾回收(GC)灾难

然后就撞上了真正的硬骨头:内存管理

一个 JavaScript runtime 必须有 GC,你不可能要求用户手动 free 对象。所以到第二周左右,我开始尝试自己实现 GC。

结果是一场噩梦:

  • 我加新特性会把 GC 搞崩
  • 我修 GC 又会把性能搞崩
  • 我试着接入别人写的 GC,又发现集成复杂到不可控

这段时间我非常痛苦。手写的 free-list GC 被我开开关关上百次,每次都能把另一个核心模块弄坏。有些日子我明显已经快崩了:凌晨三点 debug,试图弄清为什么协程栈没被保护好、为什么内存泄漏、为什么加了 JSON 支持之后一切都坏了。

转折点是:放弃手写 GC,改用 bdwgc

这是一个生产级 GC(很多语言都在用)。我把它和自己手写的“带前向引用跟踪的内存压缩”结合起来:它能做 mark、能做 forwarding 的哈希表、能做生产 GC 会做的所有事。

一旦集成上去,内存问题大部分就消失了。我写代码的“语气”也变了:东西开始更稳定地工作起来,我加了 process 模块、把错误信息做得更友好——速度从这里开始明显加快。

Promise / async:另一个野兽

你以为 async/await 很简单,直到你尝试自己实现它。

要实现 async/await,你需要 Promise;Promise 需要 microtask 与定时器;microtask 与定时器又需要事件循环;事件循环还要有地方存异步操作的状态。

我为这件事折腾了好几天:

  • 想让 async 工作,你需要协程
  • 协程需要调度
  • 调度需要事件循环
  • 事件循环还要知道协程什么时候结束

如果协程在等 I/O,你不能阻塞;如果某个协程死了,它也不该把整个系统拖死。

你看提交历史就能感受到痛苦:"async promise pushback""segfault when event loop empty""prevent dead task from blocking"……这些坑都是做到一半才会冒出来的。

更要命的是:JS Promise 不能“简化”。它必须支持 .then() 链式调用,必须正确 reject,还要能与 async function 配合——而 async function 本质上是 generator 的语法糖,而 generator 又是 Promise 与回调的语法糖……

大约第 10 天,我引入了 minicoro 作为协程支持。这个决定大概救了整个项目。minicoro 很优雅:你定义基于栈的协程,然后让系统在它们之间切换。有了协程,我终于能让 async 真正跑起来。

typedef struct coroutine {
struct js *js;
coroutine_type_t type;
jsval_t scope;
jsval_t this_val;
jsval_t awaited_promise;
jsval_t result;
jsval_t async_func;
jsval_t *args;
int nargs;
bool is_settled;
bool is_error;
bool is_done;
jsoff_t resume_point;
jsval_t yield_value;
struct coroutine *prev;
struct coroutine *next;
mco_coro* mco;
bool mco_started;
bool is_ready;
} coroutine_t;

所有 async 执行相关的信息都塞进了这个结构:scope、this、正在等待哪个 promise、是否出错……接着我只需要调度这些东西并管理事件循环。

有了协程以后,Promise 才“成真”:.then() 链能跑,await 会真正暂停并在之后恢复执行。runtime 的 async 侧开始成形。后面我再补齐 Promise 内建时就快很多了,因为最难的那部分已经解决。

JavaScript 的“诡异边缘案例”

中间两周基本就是:不停发现 JavaScript 比我预想中更诡异。

不可配置属性、freeze/seal、可选链的边缘语义、严格模式……听起来都不难,但每一个背后都是几十年的规范细节,真实世界的代码会依赖这些行为。

我一个个啃过去:

  • 处理冻结/密封对象
  • 支持不可配置属性
  • 第 10 次修解构
  • 给属性查找加 getter/setter 的访问器支持

每天都在撞一个新边缘案例。有时候一天修好几个:我实现一个功能、跑一致性测试、发现三个 bug、修完之后又冒出五个新 bug。

你知道 JavaScript 有多少种方式访问原型链吗?

  • __proto__
  • Object.getPrototypeOf()
  • Object.setPrototypeOf()
  • [[Prototype]] 内部槽

你得把它们全部做对,而且还要彼此一致。一个看起来很短的提交信息,比如 “use descriptor tables for getters/setters/properties”,背后可能就是几周的工作。

解构看起来也很简单:const [a, b] = arr

但稀疏数组怎么办?对象的可枚举属性怎么办?嵌套解构、默认值、...rest 参数怎么办?每次修一个点都像打地鼠:修好这里,那里又坏。

一致性测试在“最好的意义上”非常残酷:每次跑都会失败在一个我根本不知道存在的语义上。然后我修掉它,继续失败在下一个。这个循环发生了几十次。

后半程:开始变得“能用”

第二周时,我已经有了一个能执行代码的 JavaScript runtime。它不完整,但它是真的。

然后我开始加那些让它变得“有用”的东西:文件系统、路径工具、URL 模块、以及那个因为 Bun 而变得很有名的内建 HTTP server。突然之间,真实程序开始能在 Ant 上跑了。

比如一个 Web 服务器只要写:

import { join } from 'ant:path';
import { readFile } from 'ant:fs';
import { createRouter, addRoute, findRoute } from 'rou3';

const router = createRouter();

addRoute(router, 'GET', '/status/:id', async c => {
await new Promise(resolve => setTimeout(resolve, 1000));

const result = await Promise.resolve('Hello');
const name = await readFile(join(import.meta.dirname, 'name.txt'));

const base = '{{name}} {{version}} server is responding with';
const data = { name, version: Ant.version() };

return c.res.body(`${base.template(data)} ${result} ${c.params.id}!`);
});

async function handleRequest(c) {
console.log('request:', c.req.method, c.req.uri);
const result = findRoute(router, c.req.method, c.req.uri);

if (result?.data) {
c.params = result.params;
return await result.data(c);
}

c.res.body('not found: ' + c.req.uri, 404);
}

console.log('started on http://localhost:8000');
Ant.serve(8000, handleRequest);

运行起来就是:

$ ant examples/server/server.js
started on http://localhost:8000

$ curl http://localhost:8000/status/world
Ant 0.3.2.6 server is responding with Hello world!

这就是“真 JavaScript”跑在 Ant 里:async/await、文件 I/O、HTTP、带参数路由、网络、字符串操作。

之后节奏更快:每天更自信,修更多 bug,加更多特性。然后到了“冷门但必须”的阶段:Proxy、Reflection、Symbol,甚至 class 私有字段/方法。它们也许很少人用,但规范里写了就得支持。

我最喜欢的一类能力之一是 Atomics

const sharedBuffer = new SharedArrayBuffer(256);

const int32View = new Int32Array(sharedBuffer);
Atomics.store(int32View, 0, 42);
const value = Atomics.load(int32View, 0);
console.log('stored 42, loaded:', value);

Atomics.store(int32View, 1, 10);
const oldValue = Atomics.add(int32View, 1, 5);
console.log('old value:', oldValue);

Atomics.store(int32View, 2, 100);
const result = Atomics.compareExchange(int32View, 2, 100, 200);
console.log('exchanged, new value:', Atomics.load(int32View, 2));
$ ant examples/atomics.js
stored 42, loaded: 42
old value: 10
exchanged, new value: 200

最后一周:多米诺骨牌一样倒下

当 Ant 的核心 runtime 能跑、GC 稳了、Promise 也通了之后,其它东西就像多米诺骨牌一样:小问题被修掉、缺的方法补齐、边缘语义逐个处理。

我重新加回了数组 length 校验,修了对象的属性缓存失效逻辑;为了优化 hash 性能又掉进“复杂算法 + 安全影响”的兔子洞——因为我已经在打磨一个“能工作的东西”。

到第 28 天,我给一个真的能用的 runtime 收尾:支持 async/await、靠谱的内存管理、网络、文件 I/O、并通过 ES1–ES5 的一致性测试,还混搭了一堆更现代的特性。

我甚至在别人提醒之后才“想起来”打开 LTO 和一些编译器 flag 😅

uzaaft

最终结果

一个月后,Ant 作为 JavaScript runtime:

  • 通过 javascript-zoo 测试套件中 ES1 到 ES5 的每一个一致性测试(25 年规范跨度的完整兼容)
  • 实现 async/await,并具备正确的 Promise 与 microtask 行为
  • 拥有一个真的能用、且不漏内存的 GC
  • 基于 libuv 运行 Web 服务器(和 Node 类似的网络底座)
  • 支持通过 FFI 调用系统库,例如:
import { dlopen, suffix, FFIType } from 'ant:ffi';

const sqlite3 = dlopen(`libsqlite3.${suffix}`);

sqlite3.define('sqlite3_libversion', {
args: [],
returns: FFIType.string
});

console.log(`version: ${sqlite3.sqlite3_libversion()}`);
$ ant examples/ffi/basic/sqlite.js
version: 3.43.2
  • 支持读写文件与异步 I/O
  • 支持正确的作用域、提升、变量遮蔽
  • 支持 class、箭头函数、解构、展开、模板字符串、可选链
  • 覆盖一些多数人根本不会想到的“怪边缘”:__proto__ 赋值、属性描述符、不可配置属性、冻结/密封对象(可参考测试:tests/__proto__.js
  • 实现 ES Module(import / export)
  • 支持 Symbol、Proxy、Reflect、WeakMap/WeakSet、Map/Set
  • 支持共享内存与 Atomics 并发原语

把这些串起来,你会发现你面对的已经几乎是一个“完整的 JavaScript runtime”,不太像玩具。

代价

我不知道代价是什么。

可能是睡眠,可能是健康,可能是本来可以拿去做任何其它事情的大把时间。

有些日子我连续工作 10+ 小时;有些日子一天 20+ commits。项目不会减速,只会加速:每天更自信、更快、修更多 bug、加更多特性。

到最后,我开始撞上那些必须去读 ECMAScript 规范、去理解 V8 行为、去对比其它引擎怎么处理某个怪角落的工作。改符号计数、优化 class、把内部属性迁移到 slots(像 V8 那样)……这类优化正常应该等代码稳定后再做,但因为地基已经稳了,我在最后一周反而有了余力去做。

发布后:优化阶段

首个 release 是 11 月 26 日。之后是一段沉默——那种“发完版本之后就没声了”的沉默。直到 12 月 20 日左右,开发又恢复。

这一次不同:runtime 能跑、能过测试,但总有更多优化空间。xctrace 让我看清什么才是真正的瓶颈。12 月下旬和 1 月初的提交呈现一种模式:找到瓶颈 → 修复 → 测量提升。

fast

我先为 typed array 加了 arena allocator。之前 typed array 散落在 heap 的各处;我把它们集中起来,加速分配并改善 cache locality。

然后我把 getter/setter/property 从“每个 descriptor 单独分配”改成“descriptor table 批处理”:更少的分配、更少的指针追逐。

. 运算符支持 property reference 也很烦:每次查属性都要全量解析;于是我加了 reference table 跳过重复工作。

我很喜欢 dispatch table。我把 FFI、JSON 等路径改为 computed goto,让 CPU 直接跳到正确的 handler:少一次分支、少一次查找。

把 properties 迁到 slots 是最侵入的一次重构。对象之前用的是灵活但慢的属性系统;slots 则是按对象类型固定结构,让 runtime 能做更多假设,减少 indirection。

某个时刻我开始拿它对比 Node:跑同样 benchmark,Ant 表现如何?结果开始变得很好——好到你会想:我是不是能在某些点上赢 Node?

bunnerfly wow

优化 Ant 的过程中我会保留一些可工作的 snapshot:如果某次优化把东西搞坏了,我还能退回到一个稳定点。于是就能持续小步推进:每次提交都比上一次快一点。有些优化有效,有些没用,但整体模式始终成立:profile → optimize → measure → commit。

然后是 GC 的改进。在最初那一个月里 bdwgc 集成得挺好,但在优化阶段的某个时刻它被禁掉了,runtime 就开始漏内存。我重新加回“可延迟 GC”的机制,并把旧 GC 的大部分代码取消注释。

但这次不是老办法:我做的是一个 mark-copy + compact 的 GC,能真正做内存碎片整理。旧 GC 的问题是它在错误的时机运行,导致热路径卡顿。所以我让它“可延迟”:在逻辑工作单元之间再收集;同时用前向引用跟踪保证对象移动后指针不坏。GC 回来了,但更聪明:它会等到合适的点暂停,并在运行时压缩堆。

为什么会做这件事

老实说,我也不知道。

也许是赌气?也许是想证明点什么?也许是纯粹的执念。

那种“进入心流”的状态:你写着写着,八小时就过去了,已经凌晨四点,然后你把代码 commit 掉,第二天又继续。

这个项目之所以存在,是因为我脑子里某个东西决定“它必须存在”,并且直到它真的存在之前都不会停。

它并不完美。代码里可能还有没发现的 bug;可能还有没做的性能优化;可能还有漏掉的规范角落。

但它能跑:你可以写真实 JavaScript,它会执行;你可以用 async/await;你可以写服务器;你可以拿它去做真实事情。

如果你曾经好奇:一个人如果足够执着、又不睡觉,能做到什么?答案就是:做出一个规范兼容的 JavaScript 引擎。

源码、测试与文档都在:github.com/themackabu/…

❌