阅读视图

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

追觅造车,从“火箭”开始

4月27日,旧金山艺术宫,这座美国百年历史地标迎来了一场科技发布会。

追觅科技以“DREAME NEXT”为主题的全球发布会周在此开幕,展台上那台红色超跑的尾部,两个固体火箭推进器格外醒目。这便是追觅“星空计划”重磅打造的Nebula NEXT 01 JET Edition,业内称其为“火箭车”。 

从车企研发负责人到供应链从业者再到行业分析师,目光都聚焦于此。 

这款火箭车搭载专属定制双固体火箭助推系统,可实现150毫秒瞬时响应,最大推力100千牛,与波音737单台发动机的推力接近,车辆零百加速仅需0.9秒。 

同时亮相的还有旗舰级激光雷达DHX1、首款可量产全固态电池、全线控智能底盘等一系列“星空十二全栈技术体系”。 

然而,比参数更值得关注的是这场发布会的性质。与其说这款火箭车是追觅的最新成果展示,倒不如说追觅是以这款火箭车为载体,开启了一场面向全球的技术预演。 

这背后,是追觅一以贯之的理念:先有技术,后有产品。 

追觅造车,早有预兆

当下,中国汽车产业正面临一场升级:如今的中国新能源汽车在制造能力与智能化水平上已走在世界前列,但激烈角逐仍多局限于国内市场,尚未在全球高端舞台形成真正突破。 

而全球高端新能源市场,眼下仍是一片蓝海。传统超豪华品牌电动化步伐谨慎,新兴品牌也尚未建立起稳固的标杆。这正是追觅用技术试图切入的空白。 

但汽车终究是高度复杂的产业。想要登顶全球市场,出色的软硬件实力只是入场券,更关键的是能否为行业开辟全新的发展路径,带来更新的技术与产品定义。 

追觅火箭车的出现,正是在尝试回应这一更深层的命题。 

自追觅创立以来,便始终坚持着一个底层信条:核心技术是一切的根本。创始人俞浩在创立公司之时,便明确了“产品必须要具备一定技术壁垒”的理念,公司创立后的第一个项目,便是做比肩国际最高水平的10万转速数字马达。 

有意思的是,智能清洁设备中的高速马达与新能源汽车所搭载的电驱动马达,在核心逻辑上高度相通:两者均需实现电能的高效转化与动力的精准调控,同时必须满足高负荷工况下的运行稳定性等严苛量产要求。  

而动力性能一直是高端市场的图腾和标志,也是技术研发的痛点和难点。 

相较燃油车的发动机,新能源汽车的驱动电机技术难度以及功能虽然已经大幅下降,例如10万元级的产品,其零百加速都有可能进入8秒甚至6秒。但实际上,超高端车的动力性能仍有不小的挑战。 

驱动电机的研发难点,在于如何超高速下的材料物理极限,以及运行的稳定性和效率。 

而对电动马达的研发已经长达十年的追觅,不仅在气动、电磁、驱动、电力电子等关键技术领域有所突破,更是在动平衡、减震、降噪、热设计等量产痛点上积累了相当的经验。在国际厂商的电动马达转速仍普遍停留在12.5万转时,追觅已将这一数字突破至20万转。 

更重要的是,追觅对核心技术的投入远不止于高速马达这一个领域。在智能算法、仿生机械臂、全域智能芯片等诸多前沿领域,追觅早已持续布局。 

技术的共通性与多年耕耘的厚积薄发,让追觅“跨界造车”早已注定。 

追觅的筹码,不止技术

回望百年汽车工业,每个时代的行业标杆,从来都是重构整车核心概念、开辟全新发展赛道的创新先行者。如奔驰以初代机械架构奠定现代汽车根基,特斯拉凭智能座舱极简理念定义新能源造车逻辑。 

整车核心概念,是一款车的灵魂,更是品牌立足行业的核心底气与发展上限。 

行业内具备超高速驱动电机研发能力的企业,远不止追觅一家。然而,追觅却是少数能将技术真正推向突破,并成功应用于智能汽车这一复杂场景的企业,这其中的关键不仅在于持续投入,更在于对技术本质的深刻理解,以及对市场趋势的精准洞察。 

追觅火箭车的真正价值,在于它跳出了固有的行业范式。百公里加速的极限难道只有2秒?驱动方式是否只能在燃油与纯电之间二选一?在电动马达与火箭助推技术均有布局的追觅看来,或许有更多新的可能。 

而这也是当下行业技术创新的核心:融合与突破。汽车是典型的重资产行业,确保产品从设计到量产顺利落地,一直以来都是中国车企面临的现实挑战。也因此,大量精力被生产制造环节所占据,往往挤占了企业在核心技术上的持续投入。 

随着行业制造能力整体提升,焦点正重新回归到核心技术本身。只有通过技术突破,才能为行业打开新的增长空间。 

追觅扮演的正是这样的角色。作为消费电子领域的长期参与者,它将自身对技术的理解与对市场的洞察相融合,精准切入汽车产业的升级节点。 

当然,仅有愿景远远不够。想要真正造车,工程实现能力是不可或缺的一课。追觅的跨界实践,正是在验证这条从技术到产品、从洞察到实现的全链路。 

在智能感知层面,追觅星空计划与全球顶尖核心合作伙伴联合开发推出超高线激光雷达DHX1,基于其6D全彩千线激光雷达平台,可同步感知三维空间坐标XYZ与物体RGB色彩信息,直接生成原生彩色点云。 

DHX1支持最高4320线全彩4K超高清感知,最远测距可达600米,10%反射率下测距仍达400米,可清晰识别300米内的水马、280米内的小动物等细小目标。无论是红绿灯、车道线,还是各类微小目标,都能实现精准识别。 

底盘操控层面,全线控与AI协同的智能底盘,则打破了传统机械操控局限,让“火箭车”得以兼顾极限加速与行车稳定;动力续航层面,全固态电池搭配一体化封装技术,“火箭车”更是解决了当前新能源汽车的能源痛点。 

理念、技术与工程能力,从这三个维度上,追觅正努力构建高端新能源车所需的核心技术闭环。这不仅是一家企业的进取方向,也契合着中国汽车产业向上突破的整体路径。 

中国新能源产业的第三次跃迁

中国新能源汽车产业历经十余年高速发展,造车新势力早已完成两轮迭代升级。 

第一代造车新势力以特斯拉、蔚来、理想、小鹏为核心代表,解决了新能源汽车从概念到规模化量产的挑战。 

第二代造车新势力则以鸿蒙智行为典型代表,其核心发展逻辑不在于亲自下场造车、布局整车重资产,而是依托自身科技研发的优势,聚焦智能座舱、智能驾驶、车载生态等核心领域深度赋能车企,让新能源汽车从平价走向高端。 

追觅星空计划和火箭车的问世,正式标志着第三代造车新势力登上行业舞台。 

经过多年深耕积淀,中国已经形成全球最完备、技术最领先、配套最高效的新能源汽车全产业链体系,上游核心零部件、中游整车制造、下游智能生态配套均已实现自主可控,产业硬实力已经具备冲击全球高端赛道的基础条件。但进军全球高端市场,更需要在原创技术、品牌塑造与全球运营有所突破。 

追觅代表的,正是这样一种“第三种力量”:它既延续了前两代技术攻坚的核心优势,又避开了重资产内卷与纯软件赋能现实情况。凭借多年在消费电子与智能硬件领域的积累,掌握了电驱动、AI 感知、智能算法、多品类硬件集成的全链条能力,搭建起独有的人车家一体技术生态。 

从这个角度来看,火箭车更像是追觅对外展示技术成果的一项里程碑,它向外界传达的信息很简单:追觅造车,并非从零开始,其根基是追觅多年以来的深厚技术储备,也是其先有技术,后有产品的理念体现。 

从清华工场到火箭车,从10万转马达到20万转,从扫地机器人的激光雷达到汽车的超高清4K感知,追觅用时间证明:想要真正能够在一个行业里有所创新的人,从不跟随既定的规则,它们会选择以底层技术的持续突破,重新定义赛道本身。 

现在的中国汽车产业,正在进入一个由技术生态驱动的新阶段。而追觅,已经站在了这场变革的前沿。 

封面来源 | 企业

苹果现金战略重大转向

苹果首席财务官凯文・帕雷在分析师电话会上透露,公司正式放弃长期奉行的净现金中性现金管理策略。帕雷并未直白解释战略转向原因,仅用官方套话含糊带过。但此次调整意味着,苹果未来或将加大现金储备、留存更多现金流。(新浪财经)

React 性能优化精讲

在日常 React 项目开发中,绝大多数开发者都会陷入一个核心误区:默认 React 框架本身高性能,业务项目就一定流畅无卡顿。但在真实企业级项目落地中,我们频繁遇到各类性能问题:首屏白屏耗时久、页面滚动帧率暴跌、表单输入响应延迟、应用长期运行越用越卡、偶发全局白屏崩溃等。

究其本质:React 仅封装了高效的底层视图更新机制,并不会自动优化业务代码。框架解决了原生 JS 频繁操作 DOM 的低效问题,但项目中出现的无效重渲染、重复计算、资源冗余、主线程阻塞、内存泄漏等核心性能问题,全部源于业务代码不规范、状态设计不合理、工程配置不完善。

本文将从浏览器底层渲染原理、React 核心更新机制、组件级精准优化、大数据场景专项优化、首屏全链路工程优化、应用稳定性治理、React18 高阶并发调度、状态架构源头优化八个核心维度,由浅入深、层层递进,结合通俗解读与专业原理,搭建一套闭环、可落地、成体系的 React 性能优化方案。全文逻辑严谨、流程清晰、案例完整可复用,既适合开发者深度学习沉淀技术笔记,也可直接用于团队技术分享、项目性能复盘与架构优化落地。

一、底层基石:前端性能优化的本质逻辑

所有前端页面的性能问题,最终都指向浏览器主线程。浏览器的 JS 解析执行、DOM 节点操作、CSS 样式计算、页面布局绘制、交互事件响应全部依赖主线程,且主线程为单线程串行执行,同一时间仅能处理一项任务。一旦主线程被耗时任务长时间阻塞,页面就会出现卡顿、输入延迟、点击无响应、卡死、白屏等问题。

因此,前端性能优化的终极本质可归纳为四条核心准则,所有 React 优化方案均围绕这四点展开:

  1. 减少无效 JS 计算:规避重复执行、冗余计算、无意义逻辑执行,降低 JS 执行耗时;

  2. 减少冗余 DOM 更新:最小化真实 DOM 操作频次,减少浏览器重排、重绘开销;

  3. 精简网络资源:压缩资源体积、减少请求次数、优化加载策略,极速首屏渲染;

  4. 规避主线程阻塞:拆分耗时任务、优先级调度任务,保障用户交互高优先级执行。

想要做好 React 性能优化,不能只靠 API 堆砌,必须先吃透浏览器渲染底层逻辑与 React 视图更新机制,从根源理解性能瓶颈的产生原因。

1.1 浏览器完整渲染流水线(核心性能理论)

浏览器从接收前端代码到最终页面可视化展示,遵循一套固定、不可逆的渲染流水线,任意环节耗时过长都会直接影响用户体验,完整流程如下:

解析HTML生成DOM树 → 解析CSS生成CSSOM树 → 合成渲染树 → 布局(重排)计算元素尺寸位置 → 绘制(重绘)像素填充 → 图层合成 → 页面最终展示

在整条流水线中,**重排(Reflow)重绘(Repaint)**是影响页面性能的两大核心概念,必须精准区分:

  • 重排(Reflow,回流):当元素的布局尺寸、位置、层级、盒模型属性发生变更时,浏览器需要重新计算页面所有相关元素的布局信息,触发完整渲染流水线。重排开销极高,是页面卡顿的核心元凶

  • 重绘(Repaint):仅元素颜色、背景色、透明度、阴影等纯样式属性变更,不改变页面布局结构,无需重新计算元素位置尺寸。开销远低于重排,但高频、大批量重绘依然会造成页面掉帧卡顿。

而 React 虚拟 DOM + Diff 算法的核心价值,正是精准对比视图差异,只推送最小粒度的 DOM 更新补丁,最大限度减少真实 DOM 操作,从源头降低浏览器重排、重绘的性能开销。

1.2 React 视图更新完整链路

React 采用经典的数据驱动视图设计思想,摒弃原生手动操作 DOM 的模式,通过状态变更自动触发视图更新。其完整更新流程分为协调阶段提交阶段两大核心阶段,两个阶段的执行特性完全不同,也是性能优化的关键切入点。

完整更新链路流程

State / Props / Context 状态变更 → 对应组件标记为待更新状态 → 进入协调阶段(生成新虚拟DOM、新旧虚拟DOM Diff 比对、计算最小更新补丁) → 进入提交阶段(批量操作真实 DOM、触发浏览器渲染流水线) → 完成页面视图更新

两大阶段核心差异

  • 协调阶段(内存计算):纯 JS 内存运算,不涉及任何真实 DOM 操作,运行开销极低。React18 及以上版本支持任务暂停、中断、优先级插队,调度灵活性大幅提升。

  • 提交阶段(DOM 操作):执行真实 DOM 增删改操作,触发浏览器重排重绘,开销极大。该阶段为同步执行、不可中断,是 React 项目绝大多数性能瓶颈的核心场景

1.3 React 原生机制的四大天然性能缺陷

很多人误以为 React 框架自带极致性能,实则不然。React 为了兼顾通用性、灵活性与开发体验,底层设计天然存在性能冗余,这也是我们需要手动做业务层优化的根本原因:

  1. 无条件递归更新:父组件触发重渲染时,默认会递归触发整棵子组件树重渲染,与子组件自身数据是否变更无关,产生大量无效渲染。

  2. 无自动缓存机制:函数组件每次重渲染都是一次全新的函数执行,内部定义的函数、对象、数组都会生成全新内存引用,极易触发不必要的更新。

  3. 浅比较局限性:Diff 算法、所有 React 缓存 API 均采用浅比较策略,无法识别嵌套对象、深层数组的属性变更,容易出现更新失效或过度更新问题。

  4. 同步阻塞渲染(React18 前):旧版本 React 渲染任务一旦启动必须执行完毕,无法中断,大数据渲染、复杂视图更新会直接阻塞主线程,造成交互卡顿。

二、组件层核心优化:彻底解决无效重渲染问题

无效重渲染是 React 项目最普遍、性价比最高、优先级最靠前的优化点。所谓无效重渲染,即组件的状态、props、依赖无任何有效变更,但组件依然重复执行渲染逻辑、参与 Diff 比对,白白消耗主线程资源,长期累积就会造成页面卡顿。

2.1 无效重渲染三大核心根源

经过大量企业项目复盘,99% 的组件无效重渲染均来自以下三种场景,其中后两种为高频隐形坑:

  1. 自身状态更新:组件内部 state、context 发生有效变更触发渲染,属于合理渲染,无需优化;

  2. 父组件渲染传导:父组件任意状态更新,无论子组件 props、数据是否变动,子组件都会无条件跟随重渲染,是最核心的无效渲染场景

  3. 引用地址陷阱:组件内联定义函数、对象、数组,每次组件渲染都会生成全新内存引用,浅比较机制会判定数据更新,强制触发子组件重渲染,隐蔽性极强。

2.2 优化前置原则:先测速,后优化

性能优化最大的误区是盲目堆砌 memo、useMemo、useCallback。所有缓存 API 都存在内存开销和代码复杂度成本,滥用、错用不仅无法提升性能,反而会造成内存冗余、代码可读性下降,引发负优化

企业级标准优化流程:先定位瓶颈、再精准优化、最后验证效果,杜绝无意义优化。

React DevTools Profiler 性能排查完整流程

  1. 安装官方 React 开发者工具插件,切换至 Profiler 性能面板;

  2. 开启录制按钮,复现页面卡顿、频繁更新、输入延迟等问题场景;

  3. 停止录制,查看组件渲染耗时、渲染次数、更新链路;

  4. 通过 Why did this render 功能精准定位更新诱因:自身状态更新/父级传导/Props 引用变更;

  5. 根据定位结果做靶向优化,实现精准降本提效。

2.3 三大记忆化 API 深度实战(完整缓存体系)

React 提供三套互补的记忆化 API,形成「组件渲染+计算逻辑+函数引用」的完整缓存体系,核心原理统一:依赖不变,复用上次执行结果,跳过无效计算与渲染

2.3.1 React.memo|组件级渲染缓存

React.memo 是官方高阶组件(HOC),专门用于缓存函数组件渲染结果。它会对组件 Props 执行浅比较,若 Props 无任何变更,直接复用上次渲染结果,跳过本次重渲染和 Diff 比对,从根源拦截无效渲染。

适用场景:纯展示组件、无内部状态组件、被父组件高频带动更新的通用 UI 组件、列表项组件;

不适用场景:高频动态变更组件、渲染耗时极短的小型组件(缓存开销大于优化收益)。

// 基础用法:纯组件浅比较缓存
const UserCard = React.memo(({ name, avatar }) => {
  return (
    <div className="card">
      <img src={avatar} alt="用户头像" />
      <p className="name">{name}</p>
    </div>
  )
})

// 进阶用法:复杂嵌套Props自定义比对,解决浅比较失效问题
// 仅核心业务ID一致,判定组件无需更新,精准规避无效渲染
const CustomMemoComp = React.memo(Component, (prevProps, nextProps) => {
  return prevProps.info.id === nextProps.info.id
})

2.3.2 useMemo|计算值与引用数据缓存

useMemo 用于缓存组件内部的耗时计算逻辑对象、数组等引用类型数据。当依赖项不变时,不会重复执行计算逻辑,同时稳定数据的内存引用地址,配合 React.memo 可彻底杜绝因引用变更导致的无效重渲染。

核心两大作用:1. 避免耗时筛选、计算、遍历逻辑重复执行;2. 稳定引用类型数据地址,补齐 memo 缓存能力。

const ListPage = ({ originList = [] }) => {
  // 仅原始列表数据变化时,重新执行筛选计算,否则复用缓存结果
  const validList = useMemo(() => {
    return originList.filter(item => item.status === 1 && item.isValid)
  }, [originList])

  // 稳定数据引用,子组件memo生效,杜绝无效渲染
  return <List data={validList} />
}

2.3.3 useCallback|函数引用固化

函数组件每次重渲染,内联函数都会被重新创建,生成全新内存引用。即便函数逻辑完全不变,引用地址变更也会让 memo 缓存失效,触发子组件重渲染。

useCallback 的核心作用是固化函数引用地址,依赖不变时,函数地址永久不变,完美配合 memo 实现组件缓存。

关键注意点:useCallback 必须配合 React.memo 使用,单独使用无任何渲染优化效果。

const ListPage = () => {
  const [count, setCount] = useState(0)

  // 依赖为空数组,组件生命周期内函数引用永久固定
  const handleItemClick = useCallback((id) => {
    console.log('点击列表条目:', id)
  }, [])

  // 子组件ListItem搭配memo即可实现缓存生效
  return <ListItem onClick={handleItemClick} />
}

2.4 高频优化误区深度解析(避坑核心)

大量开发者优化无效、越优化越卡,本质是踩中了缓存 API 的使用误区,四大高频坑点务必规避:

  1. 过度缓存:对简单组件、极简计算逻辑使用 memo、useMemo,缓存的内存开销、代码维护成本大于优化收益,造成负优化;

  2. API 单独使用:仅写 useCallback/useMemo 但不配合 memo,无法拦截组件重渲染,优化完全失效;

  3. 浅比较局限忽略:嵌套对象、深层数组的属性变更,浅比较无法识别,会出现「数据更新、视图不更新」的隐性 bug;

  4. 依赖项不规范:随意省略、篡改 Hook 依赖项,导致缓存数据陈旧,出现视图与数据不一致的业务问题。

三、场景化实战优化:大数据长列表卡顿终极解决方案

在后台管理系统、内容信息流、数据大屏、日志列表等业务场景中,长列表滚动卡顿是最典型的性能瓶颈。当单页数据量超过 500 条时,全量 DOM 渲染会直接导致首屏加载缓慢、滚动帧率暴跌、页面卡死,传统分页、懒加载仅能缓解问题,无法根治。

3.1 长列表卡顿底层核心原理

  1. DOM 节点过载:DOM 节点的解析、挂载、样式计算、渲染成本极高,上千个 DOM 节点会瞬间耗尽主线程资源,造成首屏渲染阻塞;

  2. 高频重排重绘:滚动过程中,海量列表项持续更新位置、样式,高频触发浏览器渲染流水线,持续阻塞主线程,导致滚动掉帧;

  3. 内存持续累积:非可视区域的列表项常驻 DOM 树,不会自动销毁,长期滚动会持续累积内存,出现「页面越滑越卡」的现象。

3.2 终极方案:虚拟滚动原理详解

虚拟滚动是解决长列表卡顿的行业最优方案,核心思想:放弃全量 DOM 渲染,仅渲染用户可视区域内的 DOM 节点,让页面常驻 DOM 数量始终维持在 20-50 个,从根源解决 DOM 过载、渲染卡顿问题。

虚拟滚动完整执行流程(文字流程图解):

  1. 定义外层固定高度容器,锁定列表可视区域范围;

  2. 设定单条列表项固定/动态尺寸,计算可视区域可容纳的最大条目数;

  3. 监听页面滚动事件,实时获取滚动偏移量;

  4. 根据偏移量、单条尺寸、可视高度,精准计算当前需要渲染的数据区间;

  5. 仅渲染区间内的少量 DOM 节点,通过 transform 位移模拟完整列表的滚动高度;

  6. 滚动过程中实时更新渲染区间,复用 DOM 节点,实现无缝滚动。

3.3 技术选型与完整实战代码

业界主流两大成熟方案,可根据业务场景选型:

  • react-window:轻量、高性能、体积小,适配绝大多数常规长列表场景,优先推荐;

  • react-virtualized:功能全面,支持复杂表头、不定高、分组列表,适配重度复杂业务。

import { FixedSizeList } from 'react-window'

// 定高虚拟列表完整实战Demo,适配绝大多数大数据列表场景
const BigDataList = ({ dataList = [] }) => {
  return (
    <FixedSizeList
      height={500}     // 列表可视区域高度
      width="100%"     // 列表自适应宽度
      itemCount={dataList.length} // 数据源总条数
      itemSize={50}    // 单条列表固定高度
    >
      {({ index, style }) => {
        // 实时获取当前渲染条目数据
        const item = dataList[index] || {}
        return (
          <div style={style} className="list-item">
            {item.title}
          </div>
        )
      }}
    </FixedSizeList>
  )
}

3.4 组合优化与避坑细则

虚拟滚动可解决滚动卡顿,搭配以下优化可实现极致体验:

  1. 分页+虚拟滚动组合:接口分页控制单次加载数据量,减少首屏渲染压力,滚动触底懒加载增量数据,适配无限滚动场景;

  2. 不定高列表适配:不规则列表使用 VariableSizeList 动态计算条目高度,避免滚动错位、空白问题;

  3. 简化列表节点:列表条目避免嵌套重型组件、复杂计算、高频动画,降低单条 DOM 渲染耗时;

  4. 关闭滚动监听冗余逻辑:滚动过程中禁止执行耗时计算、接口请求,仅保留视图更新逻辑。

四、首屏性能优化:从打包到传输全链路提速

首屏加载速度直接决定用户留存率与产品体验核心指标。React 项目默认会将所有业务代码、第三方依赖打包为单一 bundle 文件,随着项目迭代,代码量和依赖持续膨胀,会出现首屏白屏时间长、资源加载慢、LCP(最大内容绘制)指标不达标、首屏交互延迟等问题。

首屏优化核心思路:拆包减量、按需加载、资源压缩、加速传输,确保首屏仅加载核心必需资源,非核心资源延迟加载。

4.1 代码分割与懒加载(最高收益优化)

基于 ES6 动态 import 语法,Webpack 可自动实现代码块分割,搭配 React 官方的 lazy + Suspense 实现路由级、组件级按需加载,是中大型 React 项目首屏优化的必备方案,优化收益最高。

4.1.1 路由级懒加载(核心优化)

路由页面是天然的按需加载单元,非当前路由无需在首屏加载,可最大程度压缩首屏包体积。

import { lazy, Suspense } from 'react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'

// 核心首页、高频页面常驻首屏,保证基础体验
import Home from './pages/Home'
// 非核心路由、低频页面懒加载,首屏不加载
const About = lazy(() => import('./pages/About'))
const UserCenter = lazy(() => import('./pages/UserCenter'))

// 优雅加载兜底,避免首屏白屏,提升用户感知
const PageLoading = () => <div className="loading">页面加载中,请稍候...</div>

const App = () => {
  return (
    <Router>
      {/* 懒加载页面统一兜底 */}
      <Suspense fallback={<PageLoading />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/user" element={<UserCenter />} />
        </Routes>
      </Suspense>
    </Router>
  )
}

4.1.2 组件级懒加载(精细化优化)

针对弹窗、抽屉、富文本编辑器、数据图表、Excel 导出等非首屏、重型、触发式使用的组件,实现按需加载,进一步精简首屏资源体积。

4.2 工程化打包深度优化

基于 Webpack/Vite 工程配置,从打包层面全方位精简代码、优化资源:

  1. 开启 Tree-Shaking 摇树优化:项目统一使用 ES6 Module 模块化规范,生产环境自动剔除未引用的死代码、冗余依赖、无效逻辑;

  2. 资源压缩处理:生产环境开启 JS、CSS、HTML 代码压缩,去除注释、空格、冗余代码,关闭 sourceMap 减少打包体积;

  3. 第三方依赖拆分:将 React、ReactDOM、UI 组件库、Axios 等稳定不常更新的依赖单独拆包,利用浏览器长效缓存,避免每次迭代重复加载;

  4. 服务端传输压缩:服务器开启 Gzip、Brotli 压缩,资源传输体积可缩减 60% 以上,大幅提升加载速度;

  5. 静态资源 CDN 托管:图片、静态资源、第三方库全部托管至 CDN,利用 CDN 就近加速能力,规避服务器带宽限制。

4.3 静态资源精细化优化

  1. 图片懒加载:非可视区域图片统一开启 loading="lazy",延迟加载,减少首屏资源请求量;

  2. 图片格式升级:使用 WebP、AVIF 高压缩率格式替代 PNG、JPG,同等清晰度下体积减半;

  3. 图标轻量化:小尺寸图标统一使用 IconFont 字体图标或 SVG 图标,替代图片图标,减少网络请求次数与资源体积。

五、稳定性优化:异常容错与内存泄漏治理

真正高性能的企业级应用,不仅要加载快、交互流畅,更要长期稳定运行。很多项目短期使用流畅,长时间运行后出现内存飙升、页面卡顿、偶发白屏、崩溃等问题,核心原因是缺少异常容错兜底和内存泄漏治理。

5.1 错误边界:隔离局部异常,杜绝整页白屏

React 中任意子组件出现渲染报错、生命周期报错,错误会逐层向上冒泡,最终导致整个应用白屏崩溃。**错误边界(Error Boundary)**可捕获子组件渲染异常,隔离错误范围,展示降级 UI,保障应用主体可用。

注意:错误边界仅类组件支持,可捕获渲染、生命周期、构造函数错误,无法捕获异步请求、定时器、事件回调中的错误

import React from 'react'

class ErrorBoundary extends React.Component {
  state = { hasError: false, errorMsg: '' }

  // 捕获错误,更新状态触发降级渲染
  static getDerivedStateFromError(error) {
    return { hasError: true, errorMsg: error.message }
  }

  // 收集错误信息,用于日志上报
  componentDidCatch(error, errorInfo) {
    console.error('组件渲染异常:', error, errorInfo)
    // 可对接前端监控平台,实现异常自动上报
  }

  render() {
    // 异常降级展示
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <h3>模块加载异常</h3>
          <p>{this.state.errorMsg}</p>
          <button onClick={() => window.location.reload()}>刷新重试</button>
        </div>
      )
    }
    // 无异常则正常渲染子组件
    return this.props.children
  }
}

5.2 内存泄漏根治方案

页面长期运行卡顿、内存占用持续升高、页面越用越卡,核心原因是:组件卸载后,副作用逻辑未彻底销毁。前端高频内存泄漏场景:定时器、全局事件监听、未取消的异步请求、WebSocket 订阅、全局变量挂载。

统一解决方案:在 useEffect 清理函数中,批量销毁所有副作用,彻底杜绝内存泄漏。

import { useEffect, useState } from 'react'

const DemoComponent = () => {
  const [count, setCount] = useState(0)

  useEffect(() => {
    // 开启定时器
    const timer = setInterval(() => setCount(prev => prev + 1), 1000)
    // 绑定全局滚动监听
    const handleScroll = () => console.log('滚动监听')
    window.addEventListener('scroll', handleScroll)
    // 异步请求中断控制器
    const abortController = new AbortController()

    // 组件卸载时统一清理所有副作用
    return () => {
      clearInterval(timer) // 清空定时器
      window.removeEventListener('scroll', handleScroll) // 移除事件监听
      abortController.abort() // 取消未完成请求
    }
  }, [])

  return <div>计数器:{count}</div>
}

六、React18高阶优化:并发渲染与任务优先级调度

React18 版本最核心的底层升级就是并发渲染模式(Concurrent Mode),彻底解决了旧版本同步阻塞渲染的痛点。通过任务优先级分级调度,让高优先级的用户交互任务优先执行,低优先级的视图更新任务可中断、可插队,极致提升用户交互流畅度。

6.1 并发模式核心原理

React18 之前,所有渲染任务均为同步、不可中断,一旦渲染任务开始,必须执行完毕才能响应其他交互。遇到大数据渲染、复杂视图更新时,主线程被长时间阻塞,直接造成输入延迟、点击卡顿、页面无响应。

React18 并发模式将任务分为两大优先级:

  • 高优先级任务:用户输入、按钮点击、弹窗开关、手势交互等即时用户操作,优先执行,绝不阻塞;

  • 低优先级任务:数据筛选、列表渲染、视图更新、状态同步等非即时操作,支持暂停、中断、恢复、插队。

6.2 useTransition 实战落地

useTransition 是 React18 核心高阶 Hook,用于手动标记非紧急低优先级任务,避免繁重的数据处理、视图更新阻塞用户实时交互,完美解决搜索输入、筛选、排序等场景的卡顿问题。

import { useState, useTransition } from 'react'

const SearchPage = () => {
  const [keyword, setKeyword] = useState('')
  const [list, setList] = useState([])
  // 开启过渡任务,isPending标记低优先级任务执行状态
  const [isPending, startTransition] = useTransition()

  const handleInputChange = (e) => {
    const value = e.target.value
    // 高优先级:实时更新输入框内容,保证输入丝滑无延迟
    setKeyword(value)
    // 低优先级:将数据筛选、列表更新纳入过渡任务,可被中断
    startTransition(() => {
      // 模拟大数据筛选、复杂计算逻辑
      const filterResult = mockFilterData(value)
      setList(filterResult)
    })
  }

  return (
    <>
      <input value={keyword} onChange={handleInputChange} placeholder="关键词搜索" />
      {/* 低优先级任务执行中展示加载状态,优化用户感知 */}
      {isPending ? <div>数据筛选中...</div> : <List data={list} />}
    </>
  )
}

七、源头架构优化:状态设计决定性能上限

经过大量项目复盘得出结论:80% 的 React 性能问题,根源不是不会用缓存 API,而是状态架构设计不合理。混乱的状态定义、冗余的状态存储、不合理的状态层级,会从源头产生大量无效渲染和重复计算。优秀的状态设计,可以无需堆砌优化代码,从根源规避性能问题,是性价比最高的底层优化。

7.1 状态设计四大黄金原则

  1. 状态最小化:不存储可通过现有数据计算的冗余状态,仅存储核心原始数据,减少状态更新频次;

  2. 状态下沉:局部状态定义在最小使用单元组件中,避免顶层状态更新带动整棵组件树联动渲染;

  3. 状态扁平化:摒弃嵌套对象式 State,扁平化存储状态,减少无效深层属性变更导致的引用变化;

  4. 高低频拆分:高频更新状态(输入框、滚动位置)与低频更新状态(用户信息、配置数据)拆分管理,避免高频状态带动低频视图更新。

7.2 正反案例对比(规范落地)

// ❌ 错误示范:嵌套冗余、顶层聚合、包含静态数据、高低频混杂
const [userInfo, setUserInfo] = useState({ name: '', age: 0, token: '' })

// ✅ 正确示范:扁平化、按需拆分、剥离静态数据、高低频分离
const [name, setName] = useState('')
const [age, setAge] = useState(0)
// 静态/全局数据抽离至状态管理库,不占用组件本地state
const { token } = useUserStore()

八、工程落地规范与全文总结

8.1 企业项目优化优先级(收益从高到低)

在实际项目优化中,无需盲目全量优化,可按照以下优先级落地,低成本获取最高性能收益:

  1. 代码分割+工程打包优化:首屏加载速度提升最明显,用户感知最强;

  2. 状态架构优化+无效重渲染优化:根治日常交互卡顿,从源头减少性能消耗;

  3. 大数据列表虚拟滚动优化:解决特定场景重度卡顿问题,刚需优化;

  4. 内存泄漏治理+错误边界兜底:保障应用长期稳定运行,规避远期性能劣化;

  5. React18 并发渲染优化:极致优化交互体验,解决输入、筛选等高频场景卡顿;

  6. 静态资源精细化优化:低成本、高收益,全方位辅助提效。

8.2 核心优化准则

所有 React 性能优化必须遵循核心准则:先测速、后优化,先源头、后补丁,按需优化、拒绝过度。绝不以牺牲代码可读性、可维护性、可扩展性为代价,换取微小的性能提升,避免过度优化导致的工程负债。

8.3 全文总结

本文从浏览器底层渲染原理出发,完整覆盖了 React 视图更新机制、组件级缓存优化、大数据场景专项优化、首屏全链路工程优化、应用稳定性治理、React18 高阶并发调度、状态架构源头优化八大模块,搭建了一套从底层原理到业务落地、从短期提速到长期稳定的闭环性能优化体系。

React 性能优化的本质可概括为四句话:减少无效渲染、减少重复计算、减少资源体积、减少主线程阻塞

真正的高阶性能优化,不是熟练堆砌各类缓存 API,而是吃透底层运行机制,在项目开发初期就规避性能隐患,让 React 应用实现高速加载、流畅交互、长期稳定的极致体验。

美银:全球超大规模云计算企业2027年AI资本开支或达1万亿美元

美银证券发布半导体行业研究报告,分析师Vivek Arya团队在四大美国科技巨头——谷歌、微软、亚马逊、Meta相继公布2026年第一季度财报并更新资本支出展望后,将2026年全球超大规模云计算企业资本支出预测上调至超过8000亿美元,同比增长67%,并预计2027年将进一步突破1万亿美元,同比增长约25%。(财联社)

苹果美股盘前涨近3%

苹果美股盘前涨近3%。消息面上,苹果第二财季营收1111.8亿美元,高于市场预期;并批准一项至多1000亿美元的股票回购计划,提高派息至每股0.27美元。(界面)

美股总市值首次突破75万亿美元 创历史新高

数据显示,截至上个交易日,美股总市值首次突破75万亿美元,比年初增长3万亿美元,创历史新高。总市值排名前三的公司分别为英伟达(4.85万亿美元)、谷歌(4.66万亿美元)、苹果(3.98万亿美元)。(财联社)

GitHeron:把网页标注写到 GitHub

最近写了一个 Chrome 插件,叫 GitHeron。它想解决的问题很简单:

Web highlights and clippings, synced to GitHub as Markdown.

我一直使用 Hypothesis 来同步冲浪记录,然后使用一个 Obsidian 插件来同步到我的知识库。但 hypothesis 的浏览器插件体验不佳,时不时需要登录,选中文字打算做备注时又偶尔无法激活,我就想自己写个插件来解决这个问题。

GitHeron 的思路是直接使用 Github token 访问私有 repo,通过 API 把数据写入仓库。GitHub 当然不是传统意义上的数据库。但对个人工具来说,它已经提供了很多“数据库”才有的能力:同步、历史、权限、备份、API、跨设备访问。更重要的是,这些都完全是由自己控制的。

网页备注和高亮

GitHeron 最核心的功能是网页标注。

在网页上选中一段文字,按下快捷键 (默认 Ctrl+E),就可以打开 note 编辑框。写完之后,这段文字会在页面上变成高亮。下次再打开同一个页面,GitHeron 会自动把之前的高亮恢复出来。

这件事听起来不复杂,但体验上很重要。很多阅读笔记工具只能把内容保存走,却不能在原网页上重新建立上下文。GitHeron 更在意的是“回到现场”:当你再次打开这篇文章时,能立刻看到自己上次为什么停在这里。

写 note 时也可以加 tags,最近使用过的 tags 会出现在输入框附近:

保存整篇网页

除了高亮,GitHeron 还可以保存当前网页的主要内容。

按下快捷键 (默认 Ctrl + O) 后,它会提取页面正文,转换成 Markdown,然后保存到仓库中的 Clippings 目录。这里保存的是 main content,不是整个网页 HTML,所以导航栏、广告、推荐列表这些内容会尽量被过滤掉。

网页剪藏部分使用 Defuddle 来提取 main content,再转换成 Markdown。它不能保证所有网页都完美,但比直接保存整个 DOM 更接近“我真正想留下来的文章内容”。

这个功能更接近 Obsidian Web Clipper:遇到一篇值得完整保存的文章,不需要复制粘贴,也不需要手工整理格式,直接让它进入自己的 Markdown 仓库。

使用体验

在 Settings 填入一个 Github repo 地址,私有的或者公开的都行,然后去 Github token 页面生成一个 token 含有写入这个 repo 权限的 token,填入插件的配置里即可。

默认有两个快捷键:

  • Ctrl+E:给当前选中文字添加 note;
  • Ctrl+O:保存当前网页正文。

如果喜欢鼠标操作,也可以开启选中文字后的悬浮按钮;如果不喜欢它打扰阅读,可以在 settings 里关掉,只使用快捷键。

同步 GitHub 时也有两种模式。普通模式会等 GitHub 写入完成再结束;后台同步模式则会先更新页面状态,把任务放到后台慢慢同步。网络失败或 GitHub 返回错误时,可以在 settings 的 tasks 里看到最近任务,并进行 retry。

写 note 应该是一个很轻的动作,不应该因为网络慢而打断阅读节奏。

技术方案

GitHeron 是一个 Chrome MV3 插件,主要由 content script 和 service worker 组成。

content script 负责页面里的交互:选区、高亮、快捷键、弹框和正文提取。为了避免被网页自身样式影响,弹框和面板都放在 Shadow DOM 里。

service worker 负责设置、后台任务和 GitHub API。写入仓库时使用 GitHub 的 Git Data API 来生成 commit,这样一次保存可以同时更新 Markdown 内容和用于恢复高亮的辅助数据。

这里有一个取舍:Markdown 文件应该尽量保持可读,不应该塞进大段元数据。所以 GitHeron 会把可读内容写进 .md,把用于定位高亮的 selector 信息放到旁边的 JSON 文件里。这样仓库里既有人能直接读的笔记,也有插件重新打开网页时需要的结构化数据。

小结

GitHeron 是一个很个人化的工具,它的目标不是做一个复杂的标注系统,而是让“读到有用内容”到“进入自己的知识库”之间少一点摩擦。

对了,我最近还 Vibe coding 了另一个小插件 atuo tabs,也是解决我日常的具体问题的。在 AI 时代,稍微有点编程经验的人都会把自己的工作流优化到极致。

一天上线 + 零返工:我如何给复杂前端需求建立“安全感”

一天上线一个高不确定性需求:状态矩阵 + E2E 让前端交付不返工

最近用一个工作日上线了一个"容易反复改"的前端需求,过程几乎没有返工。

说真的,这次上线给我一种很少见的感觉:我对这段逻辑有安全感。不是那种"大概没问题"的心虚。

需求本身不复杂,但很典型:AI 流式回答过程中,根据"思考步骤"和"正文"的返回情况动态切换 UI。

难点不在 UI,在时序不确定性。


一个看着简单、写起来容易错的需求

场景:

某个 AI 对话模式下,如果没有"思考步骤",先展示等待态;有了就切换。

但实际跑起来:

  • 正文可能先到,步骤后到
  • 步骤可能先到,正文后到
  • 中间几秒到十几秒的空窗
  • 不同对话模式的逻辑还不一样

写个简单的 if 会怎样?

一边还在显示"等待灵感",另一边正文已经开始滚动了。这种 UI 上线后就是反复改的开始。


先让 AI 找现状,不要直接改

这个需求一开始容易误判。

我最初以为是:没有思考步骤时显示金句,有了之后金句和步骤都显示。后来跟产品确认才知道正确逻辑是:没步骤时显示金句,有步骤后只显示步骤。再往后又发现一个遗漏:如果已经有正文了,即使还没步骤,也不能继续显示金句。

三轮理解修正,才算把需求搞清楚。

这里 AI 的价值不是"直接给答案",而是快速把相关文件串起来。它帮我定位到几个关键文件:展示思考状态的组件、消息列表的渲染入口、全局 UI 状态管理、聊天服务和流式处理逻辑。

最关键的发现是,等待态组件和 Markdown 正文是并列渲染的:

{showProgress && <MessageProgress2 />}
{showMD && <MdRender text={handledContent} />}

只看等待态组件本身,很容易漏掉"金句 + 正文同屏"的问题。得从渲染入口一层层往下看才能发现。

到这一步我意识到:直接写代码大概率改了又改。它不是 UI 问题,是状态问题。


用状态矩阵把需求说清楚

我没有继续讨论"什么时候显示等待态",而是把所有状态列出来:

场景 chatType 是否有步骤 是否有正文 期望
1 agent 显示等待态(gif + 金句)
2 agent 显示正文,隐藏金句
3 agent 无/有 显示步骤,不显示金句
4 非 agent 任意 任意 保持原逻辑

这一步把讨论从"感觉对不对"变成了"每个状态怎么渲染"。

而且我们确实在这里抓到了一个错误:我一开始把正文判断放在了外层条件上,导致非 agent 场景被误伤。后来改成只作用在 agent 分支里。


用 E2E 锁住最容易出错的状态

没写很多测试,只覆盖了三个关键场景:

  1. agent + 无正文 + 无步骤 → 金句出现
  2. agent + 有正文 + 无步骤 → 金句消失
  3. agent + 有步骤 → 步骤树出现,金句消失

测试重点不是 UI 细节,而是:状态有没有切换正确。

为了让测试稳定,我加了几个选择器:

data-testid="progress-agent-quote"   // 金句容器
data-testid="progress-quote-text"    // 金句文本
data-testid="progress-analyzing"     // 分析中状态
data-testid="progress-tree"          // 步骤树

这些不是在测实现细节,而是稳定定位几个用户可见状态。

跑完之后我就知道了一件事:以后谁改这段逻辑,这几个状态不会被改坏。第一层安全感就是这样来的。


做 Demo,把时序问题变成可见的

E2E 能证明逻辑,但不适合肉眼看过程。尤其这个需求的重点是"数据从没有到有"的动态变化。

所以我做了一个 Demo 模式,按 375px 移动端视口打开浏览器,演示状态变化:

  1. 正文先到 → 金句消失 → 再到步骤
  2. 步骤先到 → 金句消失 → 再到正文
  3. 两者交错 → 步骤 → 正文 → 子步骤 → 完成态

页面会自动推进状态,每个 case 停留十秒左右,底部有倒计时。这个比截图有用,因为它能暴露"切换瞬间"有没有怪异 UI。

所有人可以"看到"状态变化,不用靠想象。而且是在后端还没准备好之前就把交互问题确认掉了——正文先到怎么办?步骤先到怎么办?loading 什么时候消失?这些如果等到联调才讨论,基本必返工。


一个取舍:不用 mock 网络,直接驱动 Store

一开始考虑过 mock SSE、模拟流式接口。但成本高,而且这次的核心不是网络层,是 UI 状态。

所以我选了一个更直接的方式:直接用脚本驱动 Store 状态。组件完全不变,只是数据来源变了。

这个方案的好处:不依赖后端、状态完全可控、每次演示一致、各种顺序都能模拟。本质是把"时间问题"转成"状态问题"。


测试 hook 的取舍

为了快速做 E2E 和 demo,我在开发模式下加了一个 hook,让 Playwright 可以直接 dispatch Redux 状态。优点是快、稳定、可控。缺点也明显:即使只在 dev 生效,它还是侵入了主入口。

后来讨论了三个方案:

  1. Playwright route mock SSE —— 最接近真实链路,但动态演示要处理本地 mock server、HTTPS、CORS 等问题,太重
  2. 单独 debug page —— 干净,但会新增一套页面
  3. 把 hook 抽到独立 dev-only 文件 —— 保留可控性,主入口侵入降到最低

最后选了方案 3。hook 逻辑放在独立的 dev 文件里,主入口只保留一行动态 import。方便以后整体删掉或替换。


结果

时间线:

  • 前一天下班:需求下达
  • 晚上(1~2 小时):完成状态建模 + 测试 + demo
  • 第二天 10 点:用 demo 和产品确认所有交互
  • 下午 4 点:联调完成
  • 下午 6 点前:上线

这次真正节省时间的不是写代码快,而是避免了后面的返工。状态在一开始就说清楚了,交互在 demo 阶段就确认了,测试锁住了关键逻辑。联调之后,前端几乎不需要再改。


代价

写 demo 需要额外时间,加了测试需要维护,测试 hook 有一定侵入性。

但跟"上线前反复改 UI + 心里不踏实"比,我觉得值得。


最大的收获

这次让我确认了一件事:在需求模糊、状态复杂、时序不确定的情况下,先确认状态和行为再写代码,其实是更快的路径。

AI 在这里面最有用的地方不是"替我写代码",而是帮我压缩探索时间。一个需求如果直接改,很容易只改一个组件,漏掉渲染入口里并列显示的问题。

而把需求变成状态表之后,几个关键问题自然就浮出来了:正文来了怎么办?不同对话模式是否一样?loading 结束后怎么办?simple 模式要不要动?

这些问题一列出来,代码就好写很多。


最后

这次需求很小,但很典型:状态多、时序乱、容易误解、容易反复改。

用的方法也不复杂:先让 AI 搜,不要先让 AI 改;用状态矩阵说清需求;用 E2E 锁关键状态,不覆盖所有细节;用 Demo 提前确认交互,有争议就跑一遍。

结果:一天上线,几乎零返工。最重要的是,有安全感。


异步 UI 的问题,本质是状态问题。先把状态说清楚,再写代码,才是最快的方式。

云深处科技完成IPO辅导

据证监会官网IPO辅导公示系统显示,杭州云深处科技股份有限公司(简称“云深处”)及辅导券商中信建投向浙江证监局提交《辅导工作完成报告》。去年12月23日,云深处科技正式启动上市辅导。(财联社)

小鹏集团4月共交付新车31011台

.36氪获悉,5月1日,小鹏集团公布交付数据,2026年4月共交付新车31,011台。根据小鹏汽车此前披露,2025年4月共交付新车35,045台。此外,“五一”期间,小鹏集团旗下多款车型推出0首付+限时0息购车方案,最长可享3年0息,至高贴息30,900元,有效期至2026年5月31日。

苹果称印度反垄断机构越权,双方争端愈演愈烈

文件显示,苹果指控印度竞争监管机构越权,强迫这家美国科技公司在涉及iPhone应用市场的反垄断案中提交财务数据,同时苹果对相关处罚法规提出质疑。媒体查阅了苹果4月24日提交给印度法院的一份非公开文件,这是该公司与印度调查人员就该案日益激化的对峙的最新迹象。苹果表示,该案可能使其面临高达380亿美元的罚款。(新浪财经)

界面财联社入股小红书关联公司

近日,小红书关联公司行吟信息科技(上海)有限公司注册资本从1980万元增至2000万元,新增上海界面财联社科技股份有限公司为股东,持股1%。据股权穿透显示,该公司现由毛文超、瞿芳及上述新增股东共同持股。(证券时报)

29家高股息且高增长公司连续派现

5月1日电,2025年A股年报披露收官。据数据宝统计,截至4月30日收盘,股息率超3%(以2025年度A股现金分红/最新A股市值测算)的公司有460余家。拉长周期来看,一批企业坚持长期稳定分红,成为价值投资标杆。据数据宝统计,上述股息率超3%的公司中,剔除上市时间不足3年的公司,上市以来每个年度均实施派现,且今年一季度净利润同比增长20%以上(含扭亏为盈)的公司有29家。二级市场表现方面,据数据宝统计,截至4月30日收盘,最新收盘价与年内高点相比,7股回撤幅度超20%,包括中国石化、菜百股份、巴比食品、金域医学、有友食品等。中国石化回撤幅度最高,公司股价于今年3月4日盘中创阶段高点,迄今回撤33.54%。公司今年一季度实现净利润170.06亿元,同比增长28.21%。(人民财讯)

vfojs:Vue 超集架构,外壳React灵魂Vue

vfojs

npm version npm 项目地址 node version license vue version

vfojs

  • React 体验:使用 TSX/JSX 构建 UI,支持同文件多组件组合。
  • Vue 性能:逻辑层直接复用 Vue 3 Composition API 响应式系统。
  • 非侵入式:通过 Vite 插件精准拦截 .vfo 文件,不干扰现有 Vue 代码,完美兼容所有 Vue 插件与 UI 库。

主要特性

  • Vue 超集架构:完全支持 Vue 生态(Router, Pinia, Element Plus),.vfo 组件可直接在 .vue 中引用,反之亦然。
  • Scoped CSS/SCSS/Less:支持在 .vfo 中直接声明样式变量,编译期自动实现作用域隔离。
  • 智能属性透传class/style/id 等 attrs 自动合并至根节点,保持与 Vue 一致的行为。
  • 响应式解构 (Writeable Ref)const { count } = props 自动转换为 toRef,支持跨组件双向绑定。
  • 指令语法糖<input $value={state.name} /> 自动展开为高性能的双向绑定逻辑。
  • 内置轻量状态管理useFoStore(key, init) 实现跨组件、跨文件的状态共享。

在 Vue 项目中使用

vfojs 的设计初衷是非侵入式。你可以在现有的 Vue 项目中开启“魔法模式”。


安装 vfojs

npm install @fo4/vfojs

1).vfo 组件的基本写法

.vfo 的默认导出是一个函数。你可以把它理解成 Vue 组件的 setup():写逻辑、返回 JSX 作为渲染内容。

export default () => {
  const count = ref(0)
  const inc = () => count.value++

  return (
    <div>
      <h2>计数</h2>
      <p>count:{count.value}</p>
      <button onClick={inc}>加 1</button>
    </div>
  )
}

2)自动注入的 API(无需 import)

.vfo 里可以直接使用(编译时自动注入):

  • Vue:ref/reactive/computed/watch/watchEffect/onMounted/onUnmounted/onUpdated/defineComponent/h/Fragment/Transition/useAttrs/useSlots/toRef
  • vfojs:useFoStore/useFoEffect/useVModel

3)子组件写法(同文件组件 / 组合组件)

你可以在同一个 .vfo 文件里用函数声明子组件,然后像 React 一样在 JSX 里使用:

const myComponent = (props) => {
  return <div>你好,{props.name}</div>
}

export default () => {
  return (
    <div>
      <myComponent name="vfojs" />
    </div>
  )
}

说明:

  • 只要某个函数变量被当成 <myComponent name="vfojs" /> 使用,vfojs 会把它自动包装成真正的 Vue 组件实例(支持生命周期)
  • props 里能直接拿到传入的属性(包含常规 props 和 attrs)
  • 也支持第二个参数 ctx,用于 ctx.slots(slot)等能力

4)插槽(slots)

const myCard = (props, ctx) => {
  const body = ctx?.slots?.default ? ctx.slots.default() : null
  return (
    <div style="border: 1px solid #e5e7eb; border-radius: 12px; padding: 12px;">
      <h3>{props.title}</h3>
      <div>{body}</div>
    </div>
  )
}

export default () => {
  return (
    <myCard title="标题">
      <div>这里是 slot 内容</div>
    </myCard>
  )
}

5)Scoped CSS / SCSS / Less

三种写法都支持:

  • CSS:export const css = \...``
  • SCSS:export const scss = \...``
  • Less:export const less = \...``

也支持在 .vfo 中直接引入样式文件:

import './app.scss'
import './app.less'

6)属性透传(Attribute Fallthrough)

像 Vue 一样,传给组件的 class/style/id 等 attrs 会自动合并到根节点:

const myComponent = () => <div class="box">子组件</div>

export default () => {
  return <myComponent class="外部class" style="background: #f8fafc;" />
}

7)响应式解构(可写 ref)与跨组件双向绑定

子组件:

const myComponent = (props) => {
  const { count } = props
  return <button onClick={() => (count.value = count.value + 1)}>count:{count.value}</button>
}

父组件用 onUpdate:count 接收回写:

export default () => {
  const state = reactive({ count: 1 })
  return (
    <div>
      <p>父:{state.count}</p>
      <myComponent count={state.count} onUpdate:count={(v) => (state.count = v)} />
    </div>
  )
}

7.1)编译期宏:$ref(极致“去 .value”)

你可以写:

export default () => {
  let count = $ref(0)
  const inc = () => count++
  return <button onClick={inc}>count:{count}</button>
}

vfojs 会在编译时自动把它变成 ref(...),并在使用 count 的地方自动补全 .value

如果你需要拿到“原始 ref 对象”(例如传给子组件做双向绑定),可以写 $$(count),它会在编译时被还原成 count(不会自动解包)。

8)指令语法糖:$value

你可以写:

<input $value={state.name} />

vfojs 会自动把它展开为双向绑定:

  • 原生表单元素(input/textarea/select):value/checked + onInput/onChange
  • 自定义组件:modelValue + onUpdate:modelValue

9)内置全局状态:useFoStore

同一个 key 在多个组件里拿到的是同一份状态(基于 reactive):

const A = () => {
  const store = useFoStore('demo', () => ({ count: 0 }))
  return <button onClick={() => store.count++}>A:{store.count}</button>
}

const B = () => {
  const store = useFoStore('demo', () => ({ count: 0 }))
  return <button onClick={() => store.count--}>B:{store.count}</button>
}

10)便捷 Hook:useFoEffect / useVModel

useFoEffect:更接近 React effect 的心智,组件卸载时自动停止监听并清理副作用:

useFoEffect(() => {
  console.log(count.value)
  return () => console.log('cleanup')
}, [count])

useVModel:复杂组件里快速创建一个双向绑定 ref(修改会触发 onUpdate:name):

const name = useVModel(props, 'name')
name.value = 'next'

11)显式 Props/Emits:defineProps / defineEmits

definePropsdefineEmits 由编译器自动注入(无需手动 import),用于在 .vfo 中显式声明组件的 props 与事件。

// defineProps 和 defineEmits 将由编译器自动注入,无需手动 import
export default (context) => {
  // 1. 定义 Props(带类型和默认值)
  const props = defineProps<{
    title: string;
    count?: number;
  }>({
    count: 0, // 默认值
  });

  // 2. 定义 Emits
  const emit = defineEmits<{
    (e: 'change', value: number): void;
    (e: 'update:count', value: number): void;
  }>();

  return (
    <div onClick={() => emit('change', props.count)}>
      {props.title}: {props.count}
    </div>
  );
}

事件映射规则:

  • emit('change', x) 会尝试调用 props.onChange(x)
  • emit('update:count', x) 会尝试调用 props['onUpdate:count'](x)

安装 vfojs

npm install @fo4/vfojs

1. 配置 Vite

vite.config.ts 中,将 vfojs 插件置于 vue 插件之前:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vfojs from '@fo4/vfojs'

export default defineConfig({
  plugins: [
    vfojs(), // 拦截并处理 .vfo 文件
    vue(),  // 处理标准 .vue 文件
  ],
})

2. 混合开发模式

App.vue 中调用 .vfo 组件:

<script setup>
import MyFoCard from './components/Card.vfo'
</script>
<template>
  <MyFoCard title="来自 vfojs 的组件" class="custom-style" />
</template>


快速上手 (CLI)

npx create-vfojs@latest my-app

创建完成后,你可以立即体验。

cd my-app
npm i
npm run dev

工具链

模块 说明
create-vfojs 快速创建项目的 CLI 脚手架
@fo4/vfojs-language-plugin 提供 IDE 类型检查与 JSX 属性提示
vscode-vfo 提供 IDE 插件,支持 vfojs 语法 (暂未上架)
fo-ui 基于 vfojs 构建的组件库(开发中)

Webpack vs Vite:一个是“老黄牛”,一个是“猎豹”,你选谁?

你准备搭一个新项目,打开搜索引擎:“Webpack还是Vite?” 答案一半一半,你更懵了。今天我们就来场正面PK:Webpack像头任劳任怨的老黄牛,啥都能干,但起步慢;Vite像只猎豹,瞬间冲刺,但偶尔挑食。看完你就能拍板:我的项目,就该用那个!

前言

前端工具链的“内卷”从未停止。Webpack多年霸主,几乎成了“打包”的代名词。但Vite横空出世,以“快”为刀,砍向Webpack的软肋:开发服务器启动慢、热更新慢。

两者没有绝对好坏,只有合不合适。今天我们从开发体验、生产构建、生态、配置复杂度四个维度,来场硬核对比。

一、核心原理:一个全量打包,一个按需编译

  • Webpack:开发时,从入口开始,递归分析所有模块依赖,打包成一个或多个bundle(哪怕你只用了一个组件,它也把你整个项目打包一遍)。启动慢,但随着项目变大越来越慢。热更新时,需要重新打包变更的模块及其依赖,可能还是慢。

  • Vite:利用浏览器原生ESM(<script type="module">),开发时不打包,只启动一个静态服务器。浏览器请求哪个文件,Vite实时编译哪个文件(比如把JSX转成JS,把TS转成JS)。启动极快(毫秒级),热更新也只更新被改的模块,速度飞快。

比喻:Webpack像搬家公司的卡车,把整个房子家具先打包再运;Vite像快递员,你点一个包裹,他送一个。

二、速度实测:秒开 vs 等咖啡

操作 Webpack Vite
冷启动(大型项目) 10~30秒 <1秒
热更新(改一行代码) 200~500ms(可能更多) <50ms
生产构建 中等(但可优化) 稍慢(用Rollup,但整体可接受)

Vite在开发体验上完胜。尤其大型项目,Webpack启动一次够你刷几条短视频,Vite眨眨眼就好了。

三、生产构建:Webpack还是稳

Vite开发时用ESM,但生产打包不用ESM(会产生太多请求),它底层用的是Rollup。Rollup对tree-shaking、代码分割也很强,但某些复杂场景(比如需要自定义打包逻辑的库)不如Webpack灵活。

Webpack经过多年打磨,插件生态极其丰富,任何你能想到的打包需求(比如特殊文件处理、自定义chunk分割、微前端),Webpack几乎都能找到现成方案。

结论:普通应用项目,Vite的生产构建够用;搞复杂库或需要精细控制打包,Webpack更成熟。

四、配置复杂度:Webpack劝退新手,Vite开箱即用

Webpack配置堪称“噩梦”。从零配置一个支持TypeScript、React、CSS Modules、热更新的项目,要写几十行甚至上百行。虽然官方有create-react-app等脚手架掩盖了配置,但一旦需要 eject 或自定义,头就大了。

Vite默认支持TS、JSX、CSS预处理器、热更新,配置文件极简。你需要做的只是:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
});

Vite还提供了create-vite脚手架,选择模板一键生成。

五、生态与兼容性:Webpack的护城河

Webpack的插件/loader生态是它的最大优势。比如:

  • file-loader/url-loader 处理静态资源。
  • raw-loader 导入文本。
  • html-webpack-plugin 生成HTML。
  • mini-css-extract-plugin 抽离CSS。
  • webpack-manifest-plugin 生成资源清单。

Vite虽然也支持大多数常见需求(通过插件),但一些老旧的、小众的loader可能没有直接替代。不过对于绝大多数项目,Vite的插件生态已经足够。

另外,Vite要求浏览器支持ESM(现代浏览器都支持),但如果你需要兼容IE11,那不好意思,Vite官方不支持(需要额外插件且很麻烦),这时候Webpack是唯一选择。

六、实战选择:到底用哪个?

用Vite,如果:

  • 新项目,没有历史包袱。
  • 追求极致的开发体验(快!)。
  • 不需要兼容IE11。
  • 项目是常规SPA或静态站点。

用Webpack,如果:

  • 项目已经用Webpack,迁移成本高。
  • 需要兼容IE11。
  • 用了大量Webpack专属插件或自定义loader。
  • 项目是非常复杂的库,需要精细化控制打包。

七、未来趋势:Vite会取代Webpack吗?

短期不会。Webpack在大型企业级项目、复杂构建场景仍有优势。但Vite作为“下一代前端工具链”,已经被Vue、React等官方推荐。尤其在Vue生态,Vite已经是默认配置。

长期看,Vite会逐渐蚕食Webpack在新项目中的份额。但Webpack也不会坐以待毙,Webpack 5 已经改进了缓存和模块联邦,但启动速度这个底层设计问题很难根治。

八、迁移指南:从Webpack到Vite

如果你决定尝鲜,步骤很简单:

  1. create-vite新建一个空项目,复制源码。
  2. require改成import(如果之前用CommonJS)。
  3. 把环境变量从process.env改成import.meta.env
  4. 找对应的Vite插件替代webpack loader。
  5. 测试。

对于中小项目,半天就能完成迁移。

九、总结:没有最好,只有最合适

  • Webpack:老黄牛,稳重、能干、啥都有,但动作慢、配置复杂。
  • Vite:猎豹,快、轻盈、开箱即用,但偶尔挑食(生态稍弱、不支持IE)。

新个人项目、创业项目,无脑上Vite,享受飞一般的开发体验。大厂遗留项目、需要IE兼容,继续Webpack。两者可以共存,甚至可以在一个项目里用Vite开发,Webpack打包(不常见)。

选工具就像选对象,适合的才是最好的。现在你知道该怎么选了。

LeetCode 5. 最长回文子串:DP + 中心扩展

在LeetCode字符串类题目中,「最长回文子串」是入门级经典题,也是动态规划、中心扩展法的典型应用场景。本文将从题目解析出发,详细讲解两种主流解法(动态规划+中心扩展),拆解思路、代码逻辑、避坑要点,兼顾新手理解与实战应用,帮助大家举一反三解决同类问题。

一、题目核心解析

1. 题目描述

给你一个字符串 s,找到 s 中最长的回文子串。

2. 关键概念区分

  • 回文子串:正读和反读完全相同的连续子串(如 "bab"、"bb")。

  • 回文子序列:正读和反读完全相同的非连续子序列(如 "babad" 的子序列 "bab",可跳过中间字符),本文重点聚焦「子串」。

3. 边界与示例

  • 边界情况:空字符串返回 "";单个字符返回其本身(如 "a" → "a")。

  • 示例1:输入 "babad" → 输出 "bab" 或 "aba"(两种均为最长回文子串)。

  • 示例2:输入 "cbbd" → 输出 "bb"(唯一最长回文子串)。

二、解法一:动态规划法(易懂通用版)

动态规划(DP)的核心思路是「复用子串状态,避免重复计算」,适合新手入门,思路可迁移到同类子串问题(如最长回文子序列)。

1. 核心思路拆解

(1)DP数组定义

定义 dp[i][j] 表示:字符串 s 中,从索引 i 到索引 j(闭区间)的子串 s[i..j] 是否是回文子串(true 为是,false 为否)。

(2)状态转移方程

判断 s[i..j] 是否为回文,核心依赖两个条件,分3种情况推导:

  • 子串长度为 1(i = j):单个字符必然是回文,故 dp[i][i] = true。

  • 子串长度为 2(j = i+1):首尾字符相等则为回文,即 s[i] === s[j] 时,dp[i][j] = true。

  • 子串长度 > 2(j > i+1):首尾字符相等 内部子串 s[i+1..j-1] 是回文,即 s[i] === s[j] && dp[i+1][j-1] = true 时,dp[i][j] = true。

(3)遍历顺序

由于 dp[i][j] 依赖 dp[i+1][j-1](内部子串状态),若按 i 或 j 直接遍历,会导致内部子串未计算就先判断外部,因此需按「子串长度」从小到大遍历:

  1. 先初始化所有长度为 1 的子串(dp[i][i] = true)。

  2. 再依次处理长度为 2 到 n 的子串,遍历所有可能的左边界 left,计算右边界 right = left + len - 1,判断是否为回文。

(4)结果记录

用两个变量记录最长回文子串的信息,避免遍历结束后再查找:

  • maxLen:最长回文子串的长度(初始为 1,覆盖单个字符的默认情况)。

  • start:最长回文子串的起始索引(初始为 0)。

2. 完整代码(TypeScript)

function longestPalindrome(s: string): string {
  const n = s.length;
  // 边界处理:空字符串或单个字符直接返回
  if (n <= 1) return s;

  // 初始化DP数组:n行n列,默认值为false
  const dp = Array.from({ length: n }, () => new Array(n).fill(false));

  let maxLen = 1;
  let start = 0;

  // 初始化长度为1的子串(所有单个字符都是回文)
  for (let i = 0; i < n; i++) {
    dp[i][i] = true;
  }

  // 遍历长度为2到n的子串
  for (let len = 2; len <= n; len++) {
    for (let left = 0; left < n; left++) {
      const right = left + len - 1;
      // 右边界超出字符串长度,终止当前循环
      if (right >= n) break;

      // 核心判断:首尾字符相等
      if (s[left] === s[right]) {
        // 长度为2直接是回文,长度>2依赖内部子串
        if (len === 2) {
          dp[left][right] = true;
        } else {
          dp[left][right] = dp[left + 1][right - 1];
        }
      }

      // 更新最长回文子串信息
      if (dp[left][right] && len > maxLen) {
        maxLen = len;
        start = left;
      }
    }
  }

  // 截取最长回文子串(substring左闭右开)
  return s.substring(start, start + maxLen);
};

3. 逐行解析与避坑要点

避坑核心:

  1. 边界处理:先判断 n ≤ 1 的情况,避免后续 DP 数组初始化报错(如 n=0 时无法创建 n×n 数组)。

  2. 右边界判断:left 遍历中,right = left + len - 1 可能超出 n-1(字符串最大索引),需及时 break,避免数组越界。

  3. 状态转移:长度 >2 时,必须依赖 dp[left+1][right-1],不可直接设为 true(如 "abcba",需判断中间 "bcb" 是回文)。

  • DP数组初始化:用 Array.from 创建 n 行 n 列的二维数组,默认填充 false,确保初始状态统一。

  • 长度为1的子串初始化:循环赋值 dp[i][i] = true,覆盖所有单个字符的情况。

  • 子串遍历:外层循环控制长度 len,内层循环控制左边界 left,计算右边界后判断首尾字符,再根据长度更新 dp 状态。

  • 结果截取:substring 方法是左闭右开区间,因此 end 为 start + maxLen,无需减1。

4. 复杂度分析

  • 时间复杂度:O(n^2),两层循环(len 从 2 到 n,left 从 0 到 n-len),每次判断为 O(1)。

  • 空间复杂度:O(n^2),需开辟 n×n 的 DP 数组存储子串回文状态。

三、解法二:中心扩展法(空间优化版)

动态规划法的空间复杂度较高,中心扩展法利用「回文子串中心对称」的特点,将空间优化至 O(1),执行效率更优,适合实战中追求空间性能的场景。

1. 核心思路

回文子串的本质是「中心对称」,因此可围绕两种中心向两边扩散,判断扩散后的子串是否为回文,同时记录最长回文信息:

  • 奇数长度回文:中心为单个字符(如 "aba",中心是 "b")。

  • 偶数长度回文:中心为两个相邻字符(如 "bb",中心是 "b" 和 "b")。

  • 辅助函数复用:定义 expandAroundCenter 函数,接收左右边界,返回该中心对应的最长回文子串的「起始索引」和「长度」,简化代码。

2. 完整代码(TypeScript)

// 中心扩展法实现(空间优化版)
function longestPalindromeCenterExpand(s: string): string {
  const n = s.length;
  // 边界处理:空字符串或单个字符直接返回
  if (n <= 1) return s;

  let maxLen = 1;
  let start = 0;

  // 辅助函数:从left和right向两边扩散,返回[起始索引, 回文长度]
  const expandAroundCenter = (left: number, right: number): [number, number] => {
    // 左右边界不越界,且首尾字符相等,继续扩散
    while (left >= 0 && right < n && s[left] === s[right]) {
      left--;
      right++;
    }
    // 扩散结束后,有效回文边界为[left+1, right-1],计算长度和起始索引
    const length = right - left - 1;
    const startIdx = left + 1;
    return [startIdx, length];
  };

  // 遍历所有可能的中心(奇数+偶数)
  for (let i = 0; i < n; i++) {
    // 奇数长度回文:中心为i(单个字符)
    const [start1, len1] = expandAroundCenter(i, i);
    // 偶数长度回文:中心为i和i+1(两个相邻字符)
    const [start2, len2] = expandAroundCenter(i, i + 1);

    // 更新最长回文子串信息
    const currentMaxLen = Math.max(len1, len2);
    if (currentMaxLen > maxLen) {
      maxLen = currentMaxLen;
      // 确定当前最长回文的起始索引
      start = currentMaxLen === len1 ? start1 : start2;
    }
  }

  // 截取并返回最长回文子串
  return s.substring(start, start + maxLen);
};

3. 逐行解析与避坑要点

避坑核心:

  1. 辅助函数边界回退:扩散结束后,left 和 right 已超出有效回文边界,需回退一位(left+1、right-1),因此长度为 right - left - 1。

  2. 中心不遗漏:需遍历所有奇数和偶数中心,共 2n-1 个(n 个奇数中心 + n-1 个偶数中心),避免遗漏最长回文子串。

  3. 边界处理:与 DP 法一致,先判断 n ≤ 1 的情况,避免后续扩散时越界。

  • 辅助函数设计:将扩散逻辑封装,避免重复代码,提高可读性和可维护性。

  • 中心遍历:循环变量 i 覆盖所有奇数中心,i 和 i+1 覆盖所有偶数中心,确保无遗漏。

  • 结果更新:每次扩散后对比长度,及时更新 maxLen 和 start,避免遍历结束后再查找,提升效率。

4. 复杂度分析

  • 时间复杂度:O(n^2),每个中心最多扩散 n 次,共 2n-1 个中心,整体为 O(n×n)。

  • 空间复杂度:O(1),仅使用常数个变量存储回文信息,无需额外开辟数组。

四、两种解法测试用例验证

为确保两种解法的正确性,以下测试用例分别验证两种方法,覆盖边界、常规、特殊场景:

1. 动态规划法测试

  • 测试用例1:s = "babad" → 输出 "bab"(start=0,maxLen=3,截取 s[0,3))。

  • 测试用例2:s = "cbbd" → 输出 "bb"(len=2,left=1,right=2,dp[1][2]=true)。

  • 测试用例3:s = "" → 输出 ""(边界处理生效)。

  • 测试用例4:s = "a" → 输出 "a"(初始 maxLen=1)。

2. 中心扩展法测试

  • 测试用例1:s = "babad" → 输出 "bab" 或 "aba"(奇数中心 i=1 扩散得到)。

  • 测试用例2:s = "cbbd" → 输出 "bb"(偶数中心 i=1、i+1=2 扩散得到)。

  • 测试用例3:s = "ac" → 输出 "a" 或 "c"(最长回文长度为1)。

  • 测试用例4:s = "ccc" → 输出 "ccc"(奇数中心 i=1 扩散得到,长度3)。

五、两种解法对比总结

对比维度 动态规划法 中心扩展法
核心思路 复用子串回文状态,避免重复计算 利用中心对称,向两边扩散判断
时间复杂度 O(n^2) O(n^2)
空间复杂度 O(n^2)(需n×n DP数组) O(1)(仅用常数变量)
优势 思路易懂,可迁移到同类子串/子序列问题 空间最优,执行效率更高,适合实战
适用场景 新手入门、同类问题迁移(如最长回文子序列) 实战优化、空间受限场景

六、总结与实战建议

LeetCode 5. 最长回文子串的核心是「回文子串的对称性」和「子问题复用」,两种解法各有侧重:

  • 若你是新手,优先掌握「动态规划法」,理解状态定义和转移逻辑,打好子问题复用的基础,后续可轻松迁移到 LeetCode 516. 最长回文子序列等题目。

  • 若你追求实战效率,优先使用「中心扩展法」,空间优化至 O(1),在面试中更易体现代码功底。

补充技巧:解题时可先判断边界情况(n ≤ 1),再执行核心逻辑,避免不必要的计算;同时可通过调试工具查看 DP 数组状态、中心扩散过程,加深对思路的理解。

用TS无法实盘量化? - 实盘均线策略

从零开始:用 DTrader TS SDK 写一个长期运行的均线买卖策略

本文会使用 DTrader 连接实盘账户和行情数据,完成一个从读 K 线到自动下单的小策略。DTrader 的接入说明和 API 文档可以查看:DTrader 文档

本文从零开始,只用 TypeScript 和 DTrader v3-api 的 TS SDK,写一个可以长期运行的均线买卖策略。

这次的小目标很简单:

脚本长期运行
每天 14:55 到点执行一次
读取日 K 线
计算短均线和长均线
读取当前持仓
短均线上穿长均线:没有持仓就买入
短均线下穿长均线:有持仓就卖出
用状态文件保证同一天只执行一次

它不讨论复杂量化理论,也不搭建庞大的策略框架。先把一条主线跑顺:读取行情、生成信号、查看持仓、执行交易。

1. 创建 TypeScript 项目

先创建一个目录:

mkdir dtrader-ma-strategy
cd dtrader-ma-strategy
npm init -y

安装 DTrader v3-api 的 TypeScript SDK,以及运行 TypeScript 需要的工具:

npm install @dtrader/v3-sdk
npm install -D typescript tsx @types/node

package.json 改成 ESM 项目:

{
  "name": "dtrader-ma-strategy",
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "start": "tsx moving-average-live.ts"
  },
  "dependencies": {
    "@dtrader/v3-sdk": "^0.1.0"
  },
  "devDependencies": {
    "@types/node": "^24.0.0",
    "tsx": "^4.0.0",
    "typescript": "^5.0.0"
  }
}

如果需要在本机 v3-api 仓库里调试 SDK,也可以把依赖临时指向本地路径:

npm install /Users/regan/work/go/src/github.com/DTrader-store/v3-api/sdk/ts

正式项目里,使用 npm install @dtrader/v3-sdk 会更清爽,后续升级也方便。

2. 配置环境变量

策略会连接 DTrader 服务,并在信号触发时调用买卖接口。先把这些环境变量准备好:

export DTRADER_BASE_URL="https://your-endpoint"
export DTRADER_AUTH="your-key"
export DTRADER_CODE="600519"
export DTRADER_SHORT_WINDOW="5"
export DTRADER_LONG_WINDOW="20"
export DTRADER_ORDER_VOLUME="100"
export DTRADER_ORDER_PRICE_OFFSET="0"
export DTRADER_POLL_INTERVAL_MS="30000"
export DTRADER_EXECUTE_AT="14:55"
export DTRADER_TIMEZONE="Asia/Shanghai"
export DTRADER_STATE_FILE=".dtrader-ma-state.json"

这些变量分别表示:

  • DTRADER_BASE_URL:DTrader v3-api 地址。
  • DTRADER_AUTH:认证 key。
  • DTRADER_CODE:策略交易的股票代码。
  • DTRADER_SHORT_WINDOW:短均线窗口,默认 5。
  • DTRADER_LONG_WINDOW:长均线窗口,默认 20。
  • DTRADER_ORDER_VOLUME:每次买入或卖出的数量。
  • DTRADER_ORDER_PRICE_OFFSET:下单价格偏移,默认 0。比如想比当前收盘价高 0.02 买入,可以设成 0.02
  • DTRADER_POLL_INTERVAL_MS:轮询间隔,默认 30 秒。
  • DTRADER_EXECUTE_AT:每天执行策略的时间,默认 14:55
  • DTRADER_TIMEZONE:时间判断使用的时区,默认 Asia/Shanghai
  • DTRADER_STATE_FILE:本地状态文件,用来记录当天是否已经执行过。

示例里的下单代码会调用真实交易接口。连接实盘环境前,先确认账户、标的、价格和数量都符合预期。

3. 先理解 DTrader TS SDK 的基本用法

DTrader TS SDK 的入口是 createClient

import { createClient } from "@dtrader/v3-sdk";

const client = createClient({
  baseUrl: process.env.DTRADER_BASE_URL!,
  auth: process.env.DTRADER_AUTH!,
});

读取 K 线:

const kline = await client.kline("600519", { period: "day" });

读取持仓:

const positions = await client.positions();

买入:

await client.buy([{ code: "600519", price: "1500", volume: "100" }]);

卖出:

await client.sell([{ code: "600519", price: "1500", volume: "100" }]);

后面的完整策略,就是把这些 API 按顺序串起来:读 K 线、算信号、看持仓、决定要不要交易。

4. 策略规则

策略规则先用最常见的双均线交叉:

金叉:
上一根 K 线短均线 <= 长均线
当前 K 线短均线 > 长均线
动作:如果当前没有持仓,则买入

死叉:
上一根 K 线短均线 >= 长均线
当前 K 线短均线 < 长均线
动作:如果当前有持仓,则卖出

为什么要看“上一根”和“当前”两组均线?

因为只看当前短均线大于长均线,只能说明现在偏强,不能说明刚刚发生了上穿。策略关心的是“穿越”这个动作,而不是每天看到短均线在长均线上方就重复买入。

5. 为什么每天 14:55 执行一次

长期运行不等于每隔几秒就认真思考一次。这个策略每天做一次决策就够了:

脚本可以从早上就挂着
每 30 秒醒来检查一次时间
没到 14:55:只等待
到了 14:55 且今天还没执行:读取 K 线、算信号、读持仓、决定买卖
今天已经执行过:继续等待明天

这样写会轻松很多:

  • 逻辑短,执行路径清楚。
  • 轮询可以很勤快,交易不会跟着重复。
  • 信号、持仓和下单都在同一轮完成。
  • 脚本重启后,也能知道今天已经处理到哪一步。

示例代码先用简化工作日判断:周一到周五执行,周末跳过。真实使用时,可以再接入交易日历,处理节假日、临时休市等情况。

6. 为什么长期运行需要状态文件

脚本长期运行时,14:55 之后还会继续轮询。如果没有状态文件,14:55:00 执行了一次,14:55:30 又可能执行第二次。

所以完整代码会在本地放一个小状态文件:

{
  "lastExecutedDate": "2026-04-30",
  "lastAction": "buy"
}

每轮策略都会检查:

  • 今天是不是工作日?
  • 现在是否已经到 DTRADER_EXECUTE_AT
  • 状态文件里是否已经记录今天执行过?

只要 lastExecutedDate 等于今天日期,就直接跳过。这个小文件不复杂,但很管用。

7. 完整代码

把下面代码保存为 moving-average-live.ts

import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { setTimeout as sleep } from "node:timers/promises";
import { createClient } from "@dtrader/v3-sdk";

type KlineRow = {
  close?: number | string;
  date?: string;
  day?: string;
  time?: string;
  datetime?: string;
  [key: string]: unknown;
};

type StrategyState = {
  lastExecutedDate?: string;
  lastAction?: "buy" | "sell" | "hold";
};

type Signal = {
  barKey: string;
  currentPrice: number;
  previousShortMa: number;
  previousLongMa: number;
  currentShortMa: number;
  currentLongMa: number;
  goldenCross: boolean;
  deathCross: boolean;
};

type Clock = {
  dateKey: string;
  weekday: number;
  minutes: number;
  label: string;
};

type Candle = {
  row: KlineRow;
  close: number;
};

function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Set ${name} before running this strategy.`);
  }
  return value;
}

function envInt(name: string, fallback: number): number {
  const raw = process.env[name];
  if (!raw) return fallback;

  const value = Number.parseInt(raw, 10);
  if (!Number.isFinite(value)) {
    throw new Error(`${name} must be an integer.`);
  }

  return value;
}

function envNumber(name: string, fallback: number): number {
  const raw = process.env[name];
  if (!raw) return fallback;

  const value = Number(raw);
  if (!Number.isFinite(value)) {
    throw new Error(`${name} must be a number.`);
  }

  return value;
}

function parseHHMM(value: string, name: string): number {
  const match = /^(\d{2}):(\d{2})$/.exec(value);
  if (!match) {
    throw new Error(`${name} must use HH:mm format.`);
  }

  const hours = Number(match[1]);
  const minutes = Number(match[2]);

  if (hours > 23 || minutes > 59) {
    throw new Error(`${name} is out of range.`);
  }

  return hours * 60 + minutes;
}

function currentClock(timeZone: string): Clock {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    weekday: "short",
    hour: "2-digit",
    minute: "2-digit",
    hour12: false,
    hourCycle: "h23",
  }).formatToParts(new Date());

  const get = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
  const weekdayMap: Record<string, number> = {
    Mon: 1,
    Tue: 2,
    Wed: 3,
    Thu: 4,
    Fri: 5,
    Sat: 6,
    Sun: 7,
  };

  const weekday = weekdayMap[get("weekday")] ?? 0;
  const hour = Number(get("hour"));
  const minute = Number(get("minute"));
  const dateKey = `${get("year")}-${get("month")}-${get("day")}`;

  return {
    dateKey,
    weekday,
    minutes: hour * 60 + minute,
    label: `${dateKey} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`,
  };
}

function isWeekday(clock: Clock): boolean {
  return clock.weekday >= 1 && clock.weekday <= 5;
}

function hasReachedExecuteTime(clock: Clock, executeAtMinutes: number): boolean {
  return clock.minutes >= executeAtMinutes;
}

function movingAverage(values: number[]): number {
  return values.reduce((sum, value) => sum + value, 0) / values.length;
}

function barKey(row: KlineRow, index: number): string {
  const value = row.date ?? row.day ?? row.datetime ?? row.time;
  return value ? String(value) : `index:${index}`;
}

function loadState(path: string): StrategyState {
  if (!existsSync(path)) return {};
  return JSON.parse(readFileSync(path, "utf8")) as StrategyState;
}

function saveState(path: string, state: StrategyState): void {
  writeFileSync(path, JSON.stringify(state, null, 2));
}

function extractRows(data: unknown): KlineRow[] {
  if (Array.isArray(data)) return data as KlineRow[];
  if (!data || typeof data !== "object") return [];

  const payload = data as {
    klines?: unknown;
    list?: unknown;
    data?: unknown;
  };

  if (Array.isArray(payload.klines)) return payload.klines as KlineRow[];
  if (Array.isArray(payload.list)) return payload.list as KlineRow[];
  if (Array.isArray(payload.data)) return payload.data as KlineRow[];

  return [];
}

function extractCandles(rows: KlineRow[]): Candle[] {
  const candles: Candle[] = [];

  for (const row of rows) {
    if (row.close === undefined || row.close === null) continue;

    const close = Number(row.close);
    if (Number.isFinite(close)) {
      candles.push({ row, close });
    }
  }

  return candles;
}

function buildSignal(rows: KlineRow[], shortWindow: number, longWindow: number): Signal | null {
  const candles = extractCandles(rows);
  const closes = candles.map((item) => item.close);
  const requiredCount = longWindow + 1;

  if (closes.length < requiredCount) {
    console.log(
      JSON.stringify(
        {
          event: "not_enough_kline_data",
          required: requiredCount,
          actual: closes.length,
        },
        null,
        2,
      ),
    );
    return null;
  }

  const previousShortMa = movingAverage(closes.slice(-shortWindow - 1, -1));
  const previousLongMa = movingAverage(closes.slice(-longWindow - 1, -1));
  const currentShortMa = movingAverage(closes.slice(-shortWindow));
  const currentLongMa = movingAverage(closes.slice(-longWindow));
  const currentPrice = closes[closes.length - 1];
  const latestCandle = candles[candles.length - 1]!;

  return {
    barKey: barKey(latestCandle.row, candles.length - 1),
    currentPrice,
    previousShortMa,
    previousLongMa,
    currentShortMa,
    currentLongMa,
    goldenCross: previousShortMa <= previousLongMa && currentShortMa > currentLongMa,
    deathCross: previousShortMa >= previousLongMa && currentShortMa < currentLongMa,
  };
}

function hasPosition(positions: unknown, code: string): boolean {
  if (!Array.isArray(positions)) return false;

  return positions.some((item) => {
    if (!item || typeof item !== "object") return false;

    const row = item as {
      stock_code?: string;
      vol_hold?: number;
      vol_actual?: number;
      vol_remain?: number;
    };

    if (row.stock_code !== code) return false;

    const volume = Number(row.vol_hold ?? row.vol_actual ?? row.vol_remain ?? 0);
    return volume > 0;
  });
}

function orderPriceFrom(currentPrice: number, offset: number): string {
  const price = currentPrice + offset;
  if (price <= 0) {
    throw new Error("Order price must be positive.");
  }

  return price.toFixed(2);
}

const baseUrl = requiredEnv("DTRADER_BASE_URL");
const auth = requiredEnv("DTRADER_AUTH");
const code = process.env.DTRADER_CODE ?? "600519";
const shortWindow = envInt("DTRADER_SHORT_WINDOW", 5);
const longWindow = envInt("DTRADER_LONG_WINDOW", 20);
const orderVolume = String(envInt("DTRADER_ORDER_VOLUME", 100));
const orderPriceOffset = envNumber("DTRADER_ORDER_PRICE_OFFSET", 0);
const pollIntervalMs = envInt("DTRADER_POLL_INTERVAL_MS", 30_000);
const executeAt = process.env.DTRADER_EXECUTE_AT ?? "14:55";
const executeAtMinutes = parseHHMM(executeAt, "DTRADER_EXECUTE_AT");
const timeZone = process.env.DTRADER_TIMEZONE ?? "Asia/Shanghai";
const stateFile = process.env.DTRADER_STATE_FILE ?? ".dtrader-ma-state.json";

if (shortWindow <= 0 || longWindow <= 0) {
  throw new Error("DTRADER_SHORT_WINDOW and DTRADER_LONG_WINDOW must be positive.");
}

if (shortWindow >= longWindow) {
  throw new Error("DTRADER_SHORT_WINDOW should be smaller than DTRADER_LONG_WINDOW.");
}

const client = createClient({
  baseUrl,
  auth,
  timeoutMs: envInt("DTRADER_TIMEOUT_MS", 30_000),
});

let stopping = false;

process.on("SIGINT", () => {
  stopping = true;
  console.log("received SIGINT, stopping after current iteration");
});

process.on("SIGTERM", () => {
  stopping = true;
  console.log("received SIGTERM, stopping after current iteration");
});

async function runOnce(): Promise<void> {
  const clock = currentClock(timeZone);

  if (!isWeekday(clock)) {
    console.log(`skip ${clock.label}: not a weekday`);
    return;
  }

  if (!hasReachedExecuteTime(clock, executeAtMinutes)) {
    console.log(`skip ${clock.label}: wait until ${executeAt}`);
    return;
  }

  const state = loadState(stateFile);

  if (state.lastExecutedDate === clock.dateKey) {
    console.log(`skip ${clock.label}: already executed today with action ${state.lastAction ?? "unknown"}`);
    return;
  }

  const kline = await client.kline(code, { period: "day" });
  const rows = extractRows(kline.data);
  const signal = buildSignal(rows, shortWindow, longWindow);

  if (!signal) return;

  console.log(
    JSON.stringify(
      {
        event: "moving_average_signal",
        date: clock.dateKey,
        code,
        shortWindow,
        longWindow,
        ...signal,
      },
      null,
      2,
    ),
  );

  const positions = await client.positions();
  const holding = hasPosition(positions.data, code);
  const orderPrice = orderPriceFrom(signal.currentPrice, orderPriceOffset);
  const order = [{ code, price: orderPrice, volume: orderVolume }];

  if (signal.goldenCross && !holding) {
    console.log(JSON.stringify({ event: "buy_order", order }, null, 2));
    const response = await client.buy(order);
    console.log(JSON.stringify({ event: "buy_response", response }, null, 2));
    saveState(stateFile, { lastExecutedDate: clock.dateKey, lastAction: "buy" });
    return;
  }

  if (signal.deathCross && holding) {
    console.log(JSON.stringify({ event: "sell_order", order }, null, 2));
    const response = await client.sell(order);
    console.log(JSON.stringify({ event: "sell_response", response }, null, 2));
    saveState(stateFile, { lastExecutedDate: clock.dateKey, lastAction: "sell" });
    return;
  }

  console.log(
    JSON.stringify(
      {
        event: "hold",
        holding,
        reason: holding
          ? "holding position but no death cross"
          : "no position and no golden cross",
      },
      null,
      2,
    ),
  );
  saveState(stateFile, { lastExecutedDate: clock.dateKey, lastAction: "hold" });
}

async function main(): Promise<void> {
  console.log(
    JSON.stringify(
      {
        event: "strategy_started",
        code,
        shortWindow,
        longWindow,
        orderVolume,
        orderPriceOffset,
        pollIntervalMs,
        executeAt,
        timeZone,
        stateFile,
      },
      null,
      2,
    ),
  );

  while (!stopping) {
    try {
      await runOnce();
    } catch (error) {
      console.error("strategy iteration failed");
      console.error(error);
    }

    if (!stopping) {
      await sleep(pollIntervalMs);
    }
  }

  console.log("strategy stopped");
}

await main();

8. 运行策略

启动前确认环境变量已经设置:

export DTRADER_BASE_URL="https://your-endpoint"
export DTRADER_AUTH="your-key"
export DTRADER_CODE="600519"
export DTRADER_SHORT_WINDOW="5"
export DTRADER_LONG_WINDOW="20"
export DTRADER_ORDER_VOLUME="100"
export DTRADER_ORDER_PRICE_OFFSET="0"
export DTRADER_POLL_INTERVAL_MS="30000"
export DTRADER_EXECUTE_AT="14:55"
export DTRADER_TIMEZONE="Asia/Shanghai"
export DTRADER_STATE_FILE=".dtrader-ma-state.json"

启动:

npm start

脚本会一直运行。每轮都会:

  1. 判断今天是否是工作日。
  2. 判断当前时间是否已经到 14:55
  3. 读取状态文件,判断今天是否已经执行过。
  4. 如果今天还没执行,就读取日 K。
  5. 计算均线信号。
  6. 读取当前持仓。
  7. 金叉且没有持仓时,直接买入。
  8. 死叉且有持仓时,直接卖出。
  9. 没有动作时记录 hold,当天不再重复判断。

停止时按 Ctrl+C。脚本会在当前轮结束后退出。

9. 代码解读

9.1 SDK 初始化

const client = createClient({
  baseUrl,
  auth,
  timeoutMs: envInt("DTRADER_TIMEOUT_MS", 30_000),
});

createClient 来自 @dtrader/v3-sdk。这一步只做三件事:

  • 指定 v3-api 地址。
  • 带上认证 key。
  • 设置请求超时时间。

后面所有交易和行情能力都从 client 发起。

9.2 周期控制

const clock = currentClock(timeZone);

if (!hasReachedExecuteTime(clock, executeAtMinutes)) {
  console.log(`skip ${clock.label}: wait until ${executeAt}`);
  return;
}

if (state.lastExecutedDate === clock.dateKey) {
  console.log(`skip ${clock.label}: already executed today`);
  return;
}

这是长期运行策略里最值得留意的部分。

脚本可以全天挂着,但只有到了 DTRADER_EXECUTE_AT=14:55 之后,当天第一次轮询才会进入交易逻辑。执行完成后,代码写入 lastExecutedDate。后面即使脚本继续轮询,也会因为“今天已经执行过”而安静跳过。

这里用 >= 14:55,不是只认 14:55:00 那一秒。脚本是轮询运行的,网络、机器调度和接口耗时都可能让它错过精确秒点。用“14:55 之后当天第一次执行”更顺手。

9.3 读取 K 线

const kline = await client.kline(code, { period: "day" });
const rows = extractRows(kline.data);

这里读取日 K。选择日 K 是为了让策略保持简单:每天只判断一次,不处理分钟级噪音。

如果行情源在 14:55 时还没有把当日数据合入日 K,可以把执行时间调晚,或者把 period 改成 v3-api 支持的更短周期。这个示例先固定采用“14:55 执行一次”的模型。

9.4 计算均线

const previousShortMa = movingAverage(closes.slice(-shortWindow - 1, -1));
const previousLongMa = movingAverage(closes.slice(-longWindow - 1, -1));
const currentShortMa = movingAverage(closes.slice(-shortWindow));
const currentLongMa = movingAverage(closes.slice(-longWindow));

这里同时计算上一根 K 线和当前 K 线对应的短均线、长均线。

如果只计算当前均线,只能知道当前短均线是否大于长均线;但无法知道它是不是刚刚上穿。交易信号关心的是“变化”,所以要比较前后两组均线。

9.5 判断金叉和死叉

goldenCross: previousShortMa <= previousLongMa && currentShortMa > currentLongMa,
deathCross: previousShortMa >= previousLongMa && currentShortMa < currentLongMa,

金叉用于买入,死叉用于卖出。

  • 金叉:短均线从弱转强。
  • 死叉:短均线从强转弱。

这两个条件只是策略信号,不是收益保证。它们的好处是简单、可解释,很适合作为理解 DTrader 自动交易流程的第一个例子。

9.6 查询持仓

const positions = await client.positions();
const holding = hasPosition(positions.data, code);

策略不只看信号,还会顺手看一下当前是否已经持仓。

  • 金叉但已经持仓:不重复买。
  • 死叉但没有持仓:不卖不存在的仓位。

这一步之后,脚本就不只是会喊“有信号了”,而是能结合账户状态做决定。

9.7 直接下单

14:55 到点后,代码会在同一轮里完成信号计算、持仓确认和交易执行。

买入:

const order = [{ code, price: orderPrice, volume: orderVolume }];
const response = await client.buy(order);

卖出:

const order = [{ code, price: orderPrice, volume: orderVolume }];
const response = await client.sell(order);

订单格式是:

[{ code, price: orderPrice, volume: orderVolume }]

pricevolume 都按字符串传入,和 v3-api 的 TS SDK 示例保持一致。

这里直接展示 DTrader API 的使用方式:信号满足时,就调用 client.buy()client.sell()

9.8 状态文件

saveState(stateFile, { lastExecutedDate: clock.dateKey, lastAction: "buy" });

状态文件很小,但对长期运行很有用。没有它,脚本在 14:55 之后每次轮询都可能重复交易。

这份代码只记录两件事:

  • lastExecutedDate:最近执行过的日期。
  • lastAction:最近动作,可能是 buysellhold

如果今天没有金叉或死叉,也会写入 hold。这表示“今天已经看过了,没动作”,后面不再重复判断。

9.9 错误处理和持续运行

while (!stopping) {
  try {
    await runOnce();
  } catch (error) {
    console.error("strategy iteration failed");
    console.error(error);
  }

  if (!stopping) {
    await sleep(pollIntervalMs);
  }
}

长期运行时,请求失败、网络抖动、接口临时错误都可能发生。这里先打印错误,然后等下一轮继续。

如果下单接口抛错,代码不会写入 lastExecutedDate,下一轮还会再试。只有买入、卖出或明确 hold 成功走完之后,才会记录“今天已经执行”。

10. 真实使用前要补的东西

这个例子已经能从零跑起一个长期运行的均线买卖策略。真实使用前,可以继续补这些能力:

  • 真实交易日历:替换掉示例里的简化工作日判断,处理节假日和临时休市。
  • 订单状态确认:下单后读取 client.orders()client.order(id) 确认成交状态。
  • 仓位比例控制:不要只按固定数量交易。
  • 失败告警:连续失败时推送到飞书、邮件或短信。
  • 日志持久化:把每次信号和订单写入文件或数据库。
  • 多标的支持:把 DTRADER_CODE 扩展成股票列表。

到这里,主线就跑通了:用 DTrader TS SDK 读取 K 线、生成均线信号、读取持仓,并在每天 14:55 执行一次买卖决策。

❌