阅读视图

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

🚀 告别“变形”与“留白”:前端可视化大屏适配的终极方案(附源码)

摘要: 当产品拿着一份炫酷的 1920x1080 设计稿让你开发时,你是否还在为如何适配客户现场 4K 屏、异形屏甚至拼接屏而发愁?网上流传的 remvw/vh 方案在大屏场景下往往力不从心。

本文将深入剖析大屏适配的核心痛点,并推荐目前社区最主流的开源适配工具,带你用最少的代码(不到 20 行)搞定全屏适配。


🤔 引言:为什么大屏适配这么难?

在普通 Web 开发中,我们习惯了流式布局,容器宽度自适应。但在数据可视化大屏领域,情况截然不同:

  1. 分辨率碎片化:设计通常基于 1920x1080 (16:9) 开发,但现场可能是 4K (3840x2160)、超宽屏 (32:9) 甚至老旧的 4:3 投影仪。
  2. 像素级要求:大屏通常包含复杂的背景图、装饰边框,一旦拉伸变形,视觉效果将大打折扣。
  3. 硬件差异:LED 拼接屏可能存在物理像素点距差异。

传统的响应式方案(如 flex%)无法满足这种**“既要铺满屏幕,又要保持比例,还不能变形”**的苛刻需求。


🧠 核心原理:为什么选择 transform: scale

在深入工具之前,我们需要明确一点:目前大屏适配的主流方案是基于 CSS3 的 transform: scale

1. 方案对比

方案 原理 优点 缺点 适用场景
rem / vw/vh 动态改变根字体大小或视口单位 适合文本流式布局 计算繁琐,背景图难处理,容易出现 1px 偏差 普通自适应网页
媒体查询 针对不同分辨率写多套 CSS 精准控制 代码量爆炸,维护困难 极少数固定分辨率场景
Scale 缩放 以设计稿为基准,整体缩放 代码少,不丢失精度,完美还原设计稿 需要处理缩放后的定位问题 大屏可视化 (推荐)

2. 核心逻辑

大屏适配的本质是:将基于特定设计尺寸(如 1920x1080)的内容,通过矩阵变换,缩放到不同尺寸的屏幕中。


🔧 推荐工具:开源社区的“三驾马车”

如果你不想从零造轮子,以下是目前掘金和 GitHub 上热度最高、最值得信赖的三个开源适配方案。

1. autofit.js —— 简单粗暴的通用方案

这是一个轻量级的无框架依赖库,非常适合原生 JS 或老项目快速接入。

  • 特点
    • 不依赖任何框架,引入即用。
    • 核心逻辑就是获取屏幕宽高,计算缩放比,然后对 body 或容器进行 scale
  • 适用场景:简单的 Vue/React 项目,或者不需要复杂局部定位的静态大屏。

2. vfit.js —— Vue 3 的“高定”裁缝

如果你的项目是基于 Vue 3 + TypeScript,那么 vfit 是目前的最佳选择。它由社区开发者针对 Vue 3 生态深度优化。

  • 特点
    • 组件化思维:它不仅仅是一个缩放工具,更提供了一个 <FitContainer> 组件。
    • 解决定位痛点:它完美解决了缩放后绝对定位(position: absolute)元素的偏移问题。你可以通过设置 unit="%"unit="px" 来让元素跟随缩放自动调整位置。
    • 响应式:与 Vue 3 的 Composition API 配合得天衣无缝。
  • 适用场景:复杂的 Vue 3 可视化项目,特别是包含大量需要精确定位的图表、装饰物的场景。

3. DataV (Vue) / DataV-React —— “开箱即用”的大屏全家桶

虽然阿里云有商业版 DataV,但开源社区有两个非常优秀的仿制品(通常由社区维护,如 DataV-Team 出品)。

  • 特点
    • 自带适配:这些库在设计之初就考虑了适配问题,通常内置了 full-screen-container 这样的组件。
    • 视觉组件丰富:提供了边框、装饰、飞线图等大屏专用组件,这些组件内部已经处理好了缩放逻辑。
  • 适用场景:不想自己写 CSS 和适配逻辑,想直接拖拽组件快速搭建大屏的开发者。

💻 代码实战:不到 20 行代码搞定适配

不想引入第三方库?其实原生 JS 实现一个高性能的适配器也非常简单。以下是一个基于 “等比缩放” 策略的通用方案。

1. 核心代码 (flexible.ts)

// 定义设计稿基准
const DESIGN_WIDTH = 1920;
const DESIGN_HEIGHT = 1080;

// 计算缩放比例的核心逻辑
const calculateScale = () => {
  const { clientWidth, clientHeight } = document.documentElement;
  
  // 策略:取宽度和高度缩放比例的最小值,确保内容完整显示(类似 background-size: cover)
  const scaleX = clientWidth / DESIGN_WIDTH;
  const scaleY = clientHeight / DESIGN_HEIGHT;
  
  return Math.min(scaleX, scaleY);
};

// 应用缩放
const applyScale = () => {
  const scale = calculateScale();
  const container = document.getElementById('screen-container');
  
  if (container) {
    // 关键 CSS:以左上角为原点进行缩放
    container.style.transform = `scale(${scale})`;
    container.style.transformOrigin = '0 0';
    
    // 可选:如果需要居中显示,可以计算偏移量
    // const offsetX = (clientWidth - DESIGN_WIDTH * scale) / 2;
    // container.style.marginLeft = `${offsetX}px`;
  }
};

// 监听窗口变化
window.addEventListener('resize', applyScale);
export default applyScale;

2. 在 Vue/React 中使用

main.js 或根组件的 mounted 阶段调用即可:

import applyScale from './utils/flexible';
// 初始化
applyScale();

3. CSS 样式配合

为了让容器撑满全屏并承载缩放,CSS 需要这样写:

html, body, #app {
  width: 100%;
  height: 100%;
  overflow: hidden; /* 隐藏滚动条 */
    margin: 0;
    padding: 0;
}

#screen-container {
  width: 1920px; /* 固定设计稿宽度 */
  height: 1080px; /* 固定设计稿高度 */
  position: relative;
  /* 背景图等样式 */
}

📝 总结与建议

在 2025 年的今天,面对可视化大屏适配,我的建议是:

  1. 首选 scale 方案:除非你有特殊的业务逻辑要求,否则不要尝试用 rem 去硬抗大屏适配,transform: scale 是目前社区公认的最优解。
  2. 技术栈匹配
    • 如果是 Vue 3 项目,强烈推荐使用 vfit.js,它能帮你省去 80% 的定位调试时间。
    • 如果是 React 项目,可以寻找类似的 react-fit-screen 库,或者直接使用上述的通用 JS 代码。
    • 如果追求极致开发速度,直接上开源版 DataV
  3. 设计沟通:在开发前,务必确认大屏的部署环境。如果是 16:9 的标准屏,上述方案完美适用;如果是超宽屏(如 32:9),可能需要考虑“两边留白”或者“背景拉伸”的特殊处理。

希望这篇文章能帮你搞定那个让人头疼的“大屏适配”需求,早点下班!✨

new Array() 与 Array.from() 的差异与陷阱

JS 数组初始化的两种方式:空槽(hole) vs undefined。 下面从结果结构、可遍历性、行为差异、使用场景几个维度,系统对比

一、最直观的差异(重点)

new Array(10)
// [ <10 empty items> ]

Array.from({ length: 10 })
// [ undefined, undefined, ..., undefined ](10 个)

核心区别

  • new Array(10)稀疏数组(holes)
  • Array.from({ length: 10 })密集数组(元素存在,值为 undefined)

二、数组“空槽(hole)” vs undefined

特性 new Array(10) Array.from({ length: 10 })
数组长度 10 10
是否有元素 ❌ 没有(hole) ✅ 有
0 in arr false true
arr[0] undefined undefined
JSON.stringify [null,null,...] [null,null,...]
const a = new Array(10)
const b = Array.from({ length: 10 })
// 检查0下标
0 in a // false
0 in b // true

⚠️ undefined !== hole

三、遍历 & 高阶函数行为差异(非常重要)

1️⃣ map / forEach / filter

new Array(3).map(() => 1)
// [ <3 empty items> ]

Array.from({ length: 3 }).map(() => 1)
// [1, 1, 1]

原因

  • 高阶方法会 跳过 hole
  • 不会跳过 undefined

2️⃣ for...of

for (const x of new Array(3)) {
  console.log(x)
}
// undefined undefined undefined

for (const x of Array.from({ length: 3 })) {
  console.log(x)
}
// undefined undefined undefined

for...of 会遍历 hole(与 map 不同)

3️⃣ for...in

for (const i in new Array(3)) console.log(i)
// 什么都不输出

for (const i in Array.from({ length: 3 })) console.log(i)
// 0 1 2

四、性能与语义差异

维度 new Array(10) Array.from({ length: 10 })
创建速度 更快 稍慢
内存 更省(无元素) 占用更多
可预测性 ❌ 容易踩坑 ✅ 行为一致
函数式友好

五、典型使用场景

✅ 适合 new Array(10) 的情况

// 只关心 length
const buffer = new Array(1024)

// 之后立即填充
const arr = new Array(10)
arr.fill(0)

✅ 适合 Array.from({ length: 10 })

// 需要 map / filter / reduce
const list = Array.from({ length: 10 }, (_, i) => i)

// JSX / Vue 渲染
{Array.from({ length: 5 }).map((_, i) => (
  <Item key={i} />
))}

六、等价但更常见的写法

Array.from({ length: 10 }, (_, i) => i)

// 等价于
[...Array(10).keys()]

七、总结一句话(面试 / 设计建议)

new Array(n) 创建的是“空槽数组”,
Array.from({ length: n }) 创建的是“真实元素数组”。

推荐原则

  • 遍历 / map / 渲染Array.from
  • 只当 占位容器new Array

深度复盘 III: 核心逻辑篇:构建 WebGL 数字孪生的“业务中枢”与“安全防线”

🚀 前言

在 Z-TWIN 污水处理厂项目的前两篇复盘中,我们解决了 渲染管线(Rendering Pipeline) 的性能瓶颈与 HMI 工程化 的多端适配问题。这两步走完,我们构建了一个“好看”且“能跑”的系统骨架。

然而,从 POC(概念验证) 走向 Production(生产环境) 的过程中,真正的挑战在于如何让这套 3D 系统承载复杂的工业业务。在实际工程交付中,我们深知:视觉只是表层,逻辑才是骨架。 一个合格的工业级数字孪生系统,必须具备极低的操作门槛、绝对的安全控制机制以及深度的数据追溯能力。

本文将剥离表面的视觉特效,深入源码的 HTML/CSS/JS 铁三角,复盘我们在 交互约束体系工业控制协议 以及 时空数据架构 中的核心设计决策。


🧭 一、 交互设计的辩证:基于“物理约束”的巡检逻辑

在 Web 3D 开发初期,为了展示技术能力,开发者常通过 OrbitControls 给予用户无限的自由度。但在高压力的工业运维场景下,过度的自由往往导致操作迷失。

为了解决这一痛点,我们通过代码构建了一套**“带阻尼的第一人称巡检模式”**。我们认为,适度的约束能显著降低认知负荷。

1. HTML:语义化的引导结构

我们在系统入口处预置了强制引导层,用于建立用户的心理模型,明确操作逻辑。

文件:index.html (部分核心结构)

<div id="welcome-guide" class="welcome-overlay">
    <div class="keyboard-grid">
        <!-- 模拟物理控制台的键位映射 -->
        <div class="keyboard-key"><div class="key-cap">W</div><span>推进</span></div>
        <div class="keyboard-key"><div class="key-cap">S</div><span>退行</span></div>
        <div class="keyboard-key"><div class="key-cap wide">Shift</div><span>巡检加速</span></div>
    </div>
</div>

2. JS:基于向量的物理运动逻辑

在逻辑层,我们并没有简单地修改相机坐标,而是引入了速度向量阻尼系数。这种处理方式模拟了真实的人体运动惯性,消除了画面急停急转带来的眩晕感。

文件:logic/PlayerController.js (物理计算核心逻辑)

function updateCamera(delta) {
    // 1. 根据按键输入计算加速度 (Acceleration)
    const acceleration = new THREE.Vector3(0, 0, 0);
    if (inputState.moveForward) acceleration.z -= 1.0;
    if (inputState.moveBackward) acceleration.z += 1.0;
    
    // 2. 应用速度与阻尼 (Velocity & Damping)
    velocity.add(acceleration.multiplyScalar(delta * params.speed));
    velocity.multiplyScalar(1.0 - params.damping * delta); 
    
    // 3. 碰撞检测与位置更新 (Collision & Update)
    const nextPosition = camera.position.clone().add(velocity);
    if (!checkWallCollision(nextPosition)) {
        camera.position.copy(nextPosition);
    }
    
    // 4. 强制高度锁定 (模拟人眼高度 1.7m)
    camera.position.y = 1.7; 
}

设计思考: 这种“降维”设计迫使用户放弃容易迷失的上帝视角,专注于平视的设备状态巡检,显著降低了非技术人员(如现场老师傅)的学习成本。


🔒 二、 工业安全锁:构建“三步握手”控制闭环

数字孪生的深水区是反向控制(Reverse Control)。在工业现场,前端的一次误触可能导致严重的生产事故。因此,我们坚决摒弃了“点击 3D 模型直接触发 API”的短路逻辑,构建了一套严密的 UI 拦截机制

1. HTML:独立的物理拦截层

我们在 index.html 中预埋了一个模态框,利用 DOM 层级遮挡 Canvas,实现了交互上的物理隔离。

文件:index.html (安全确认弹窗结构)

<div id="confirm-dialog" class="confirm-dialog hidden">
    <div class="confirm-card">
        <h3>确认操作</h3>
        <p>该操作将实时下发至 PLC 控制柜,请确认!</p>
        <div class="confirm-device">
            <span class="label">设备ID:</span>
            <span class="value" id="confirm-device-name">--</span>
        </div>
        <button id="confirm-ok-btn" class="ok-btn">执行启动</button>
    </div>
</div>

2. JS:严格的“三步握手”协议

在逻辑层,我们实现了展示与控制的完全解耦,并坚持状态驱动原则。只有物理世界的设备真正响应了,数字孪生中的状态才会随之改变。

文件:logic/InteractionManager.js (指令流逻辑)

// 第一步:拾取与拦截 (Pick & Intercept)
function onDeviceClick(deviceMesh) {
    // 仅弹出面板,绝不直接发送指令
    showPropertyPanel(deviceMesh.userData);
}

// 第二步:UI 握手 (Handshake)
document.getElementById('panel-start-btn').onclick = () => {
    // 挂起 3D 交互,弹出全屏遮罩
    controls.enabled = false; 
    document.getElementById('confirm-dialog').classList.remove('hidden');
};

// 第三步:执行与状态回显 (Execute & Feedback)
document.getElementById('confirm-ok-btn').onclick = async () => {
    const deviceId = currentSelection.id;
    
    // 发送指令 (UI 进入 Loading 态)
    setButtonLoading(true);
    await mqttClient.publish(`device/${deviceId}/control`, 'START');
    
    // 注意:此处绝不修改模型颜色!
    // 模型颜色的变更,严格等待后端 WebSocket 的状态推送
};

// 第四步:数据驱动视图 (Data Driven)
socket.on('device_status_change', (msg) => {
    if (msg.id === deviceId && msg.status === 'RUNNING') {
        // 只有收到物理世界的确认,数字世界才随之改变
        targetMesh.material.color.setHex(0x00FF00); 
    }
});

设计思考: 屏幕上的绿色,必须代表物理水泵真的转起来了,而不是代表用户点击了按钮。这种闭环确认机制是工业软件可信度的基石。


⏳ 三、 数据架构的升维:从“状态监视”到“时空推演”

传统的监控大屏通常只展示 Current State(当前值)。但在故障排查(RCA)场景中,**“过去发生了什么”往往比“现在是什么”**更有价值。我们通过一套双模态架构,赋予了系统“时空穿梭”的能力。

1. HTML/CSS:时间轴组件

我们在控制面板中集成了一个时间轴组件,通过 CSS 样式明确区分当前系统的运行模式。

文件:index.html (部分结构)

<div class="replay-controls">
    <button id="replay-play-btn"></button>
    <!-- 核心组件:进度条 -->
    <input type="range" id="replay-slider" min="0" max="100" value="100">
</div>

文件:css/panels.css (状态样式)

/* 实时模式为蓝色 */
.replay-controls input[type="range"] {
    accent-color: var(--color-primary); 
}
/* 回放模式变黄,警示用户数据非实时 */
.replay-mode .replay-controls input[type="range"] {
    accent-color: var(--color-warning); 
}

2. JS:双模态数据流架构

DataManager.js 中,我们重构了数据消费逻辑,支持在实时流历史快照之间无缝切换。

文件:data/DataManager.js (模式切换逻辑)

let isReplayMode = false;

// 监听滑块拖动
slider.addEventListener('input', (e) => {
    const value = parseInt(e.target.value);
    
    if (value < 100) {
        // 进入回放模式
        isReplayMode = true;
        document.body.classList.add('replay-mode');
        // 暂停实时 WebSocket 处理,防止数据污染
        socketManager.pause(); 
        // 从 IndexedDB 读取历史快照并插值渲染
        renderHistoricalFrame(value); 
    } else {
        // 回到实时模式
        isReplayMode = false;
        document.body.classList.remove('replay-mode');
        socketManager.resume();
        // 追赶最新状态
        syncLatestState();
    }
});

function renderHistoricalFrame(timePercent) {
    // 线性插值算法 (Lerp) 计算历史状态,保证动画平滑
    const snapshot = timeSeriesDB.getSnapshotAt(timePercent);
    sceneGraph.updateFromData(snapshot);
}

设计思考: 这一架构将数字孪生从单纯的“监视器”升级为“故障推演机”,运维人员可以像看视频一样回溯事故现场,极大提升了系统的业务价值。


🔭 四、 演进与展望:下一代技术布局

虽然目前的架构已满足交付标准,但面对日益复杂的工业场景,我们正在探索更前沿的技术边界:

  1. 计算性能:WebAssembly & WebGPU 目前的架构受限于 JS 单线程。我们正在尝试引入 WebAssembly 处理复杂的流体物理计算,并将大规模粒子系统迁移至 WebGPU Compute Shader,以释放 CPU 性能,支持更大规模的场景。

  2. 智能辅助:AI Agent 集成 结合 LLM,未来的交互将不再局限于点击。操作员可以说“高亮显示所有温度异常的机柜”,前端 AI Agent 自动解析语义、调用 3D API 并规划漫游路径,实现真正的智能辅助。

  3. 端云协同:Pixel Streaming 针对老旧的移动终端,我们测试引入 Pixel Streaming(像素流送) 技术。将高保真渲染卸载至云端,前端仅作为视频流接收端,在 iPad 上实现电影级的画质体验。


🤝 五、 技术探讨与落地

工业级 Web 3D 开发是一项复杂的系统工程,从模型资产、渲染优化到业务逻辑闭环,每一个环节都需要精细打磨。

我们团队在实战中沉淀了这套全链路解决方案。我们非常乐意与同行或有需求的朋友进行深度交流

如果您正面临以下场景,欢迎沟通:

  1. 业务团队互补:拥有深厚的后端/工业协议积累,但急需一支能打硬仗的 3D 前端团队。
  2. 项目集成合作:手头有智慧城市、智慧工厂或 IDC 可视化项目,需要集成高性能的 Web 3D 模块。
  3. 技术瓶颈突破:现有的 3D 场景卡顿、交互混乱或效果不达标,寻求优化方案。

在线演示环境: 👉 www.byzt.net:70/ (注:建议使用 PC 端 Chrome 访问以获得最佳体验)

不管是技术探讨源码咨询还是项目协作,都欢迎在评论区留言或点击头像私信,交个朋友,共同进步。


声明:本文核心代码与架构思路均为原创,转载请注明出处。

JavaScript 事件循环机制详解及项目中的应用

第一部分:基础概念

1. JavaScript 执行环境

JavaScript 是单线程的,这意味着它一次只能执行一个任务。为了处理异步操作,JavaScript 使用事件循环机制。

2. 核心组件

  • 调用栈(Call Stack) :执行同步代码的地方
  • 任务队列(Task Queue) :分为宏任务队列和微任务队列
  • 事件循环(Event Loop) :协调调用栈和任务队列的机制

第二部分:举例详细解析

console.log('1. 同步任务开始');

setTimeout(() => {
    console.log('2. setTimeout 回调');
}, 0);

Promise.resolve().then(() => {
    console.log('3. Promise.then 回调');
});

console.log('4. 同步任务结束');

执行步骤分析:

第1步:同步任务执行

  1. console.log('1. 同步任务开始') 压入调用栈,立即执行,输出 1
  2. setTimeout 压入调用栈,Web API 开始计时(0ms),回调函数放入宏任务队列
  3. Promise.resolve().then() 压入调用栈,.then() 的回调函数放入微任务队列
  4. console.log('4. 同步任务结束') 压入调用栈,立即执行,输出 4

此时状态:

  • 调用栈:空
  • 微任务队列[Promise.then回调]
  • 宏任务队列[setTimeout回调]

第2步:事件循环检查

  1. 调用栈为空,事件循环开始工作
  2. 优先检查微任务队列,发现有一个任务
  3. 执行微任务:console.log('3. Promise.then 回调'),输出 3
  4. 微任务队列清空

第3步:继续事件循环

  1. 微任务队列为空,现在检查宏任务队列
  2. 执行宏任务:setTimeout 回调,输出 2
  3. 宏任务队列清空

最终输出顺序:1 4 3 2

console.log('script start') 
async function async1() { 
   await async2() 
   console.log('async1 end')
} 
async function async2() { 
   console.log('async2 end') 
} 
async1() 
setTimeout(function() { 
   console.log('setTimeout') 
}, 0)
new Promise(resolve =>{
  console.log('Promise')
  resolve()
}).then(function(){
  console.log('Promise1')
})

关键概念:async/await

  • async 函数总是返回一个 Promise
  • await 会暂停 async 函数的执行,直到 Promise 解决
  • await 后面的代码相当于放在 .then() 中,属于微任务

执行步骤分析:

第1步:同步任务执行

  1. console.log('script start') → 输出 script start

  2. 定义函数 async1 和 async2(不执行)

  3. 调用 async1()

    • 进入 async1,遇到 await async2()
    • 调用 async2() → console.log('async2 end') → 输出 async2 end
    • await 暂停执行,console.log('async1 end') 被包装成微任务放入微任务队列
  4. setTimeout → 回调函数放入宏任务队列

  5. 执行 new Promise

    • console.log('Promise') 是同步代码 → 输出 Promise
    • resolve() 执行,.then() 的回调放入微任务队列

此时状态:

  • 调用栈:空
  • 微任务队列[async1 end, Promise1](注意顺序!)
  • 宏任务队列[setTimeout回调]

第2步:事件循环检查微任务

  1. 调用栈为空,执行微任务

  2. 按入队顺序执行微任务:

    • 第一个微任务:console.log('async1 end') → 输出 async1 end
    • 第二个微任务:console.log('Promise1') → 输出 Promise1
  3. 微任务队列清空

第3步:执行宏任务

  1. 执行 setTimeout 回调 → 输出 setTimeout

最终输出顺序:script start → async2 end → Promise → async1 end → Promise1 → setTimeout

那么到此,应该是可以理解到事件循环的感觉了,那接下来我们就开始看看事件循环的完整逻辑

1. 任务分类

宏任务(Macrotasks)

  • setTimeoutsetInterval
  • setImmediate(Node.js)
  • requestAnimationFrame(浏览器)
  • I/O 操作
  • UI 渲染(浏览器)
  • 主线程的 script 标签内容

微任务(Microtasks)

  • Promise.then().catch().finally()
  • process.nextTick()(Node.js,优先级最高)
  • MutationObserver(浏览器)
  • queueMicrotask()
  • async/await 的后续代码

2. 事件循环执行顺序

1. 执行一个宏任务(script标签内容)
2. 执行过程中遇到异步任务:
   - 宏任务 → 放入宏任务队列
   - 微任务 → 放入微任务队列
3. 当前宏任务执行完毕
4. 检查微任务队列,依次执行所有微任务
5. 如有必要,进行UI渲染
6. 从宏任务队列取出下一个宏任务执行
7. 回到步骤3,形成循环

3. 重要规则

规则1:微任务优先

  • 每执行完一个宏任务,都要清空所有微任务
  • 微任务执行期间产生的新微任务会加入当前队列,并在本次循环中执行

规则2:async/await 转化

javascript

async function example() {
  await foo()        // 相当于 Promise.resolve(foo()).then(...)
  console.log('A')   // 这部分在微任务队列中
}

规则3:多个任务队列

  • 宏任务可能有多个来源(定时器、I/O等),有各自的队列
  • 微任务只有一个队列,按入队顺序执行

在举例理解一下

javascript

// 测试微任务嵌套
Promise.resolve().then(() => {
    console.log('微任务1');
    Promise.resolve().then(() => {
        console.log('微任务中的微任务');
    });
}).then(() => {
    console.log('微任务2');
});

// 输出顺序:微任务1 → 微任务中的微任务 → 微任务2

javascript

// 测试多个宏任务
setTimeout(() => console.log('宏任务1'), 10);
Promise.resolve().then(() => console.log('微任务1'));
setTimeout(() => {
    console.log('宏任务2');
    Promise.resolve().then(() => console.log('宏任务2中的微任务'));
}, 1);
Promise.resolve().then(() => console.log('微任务2'));
setTimeout(() => {
    console.log('宏任务3'); 
    Promise.resolve().then(() => console.log('宏任务3中的微任务')); 
}, 0);

结果:
微任务1
微任务2
宏任务3
宏任务3中的微任务
宏任务1
宏任务2
宏任务2中的微任务
为什么呢?聪明的你已经会了
一开始 微任务 放入 微任务1, 微任务2;然后宏任务放入 宏任务3
这个时候, 计时器还没有到底100ms的时候, 打印微任务1、微任务2;然后微任务清空,开始 宏任务3, 放入微任务 宏任务3中的微任务,然后打印宏任务3中的微任务; 然后计时器到了,打印宏任务1、宏任务2
、放入微任务, 打印宏任务2中的微任务;大概就是这种感觉

总结

  1. 同步任务立即执行
  2. 微任务宏任务优先级高
  3. 每个宏任务执行后,都要清空所有微任务
  4. async/await 本质是 Promise 的语法糖,await 后面的代码是微任务
  5. 事件循环确保了 JavaScript 的单线程能够处理异步操作

Vite 8 发布 beta 版本了,升级体验一下 Rolldown

Vite 8 发布 beta 版本了,升级体验一下 Rolldown

编译时间

当前是用 beta.2 版本,因为测试项目非常的小(真实项目),编译时间是从大概 260ms 到 80ms 的量级 🐶,这可能是个不公平的对比,因为发现 rolldown 第一次也是 260+ms,然后原地再编译才是前面的结果(有cache?),也就是 rollup 始终差不多的速度不区分第一次,再次编译 rolldown 会快, 区分是不是第一次。

vite.config 的变化

我有一个特殊的需求,就是每个页面都非常小,而且页面不多,希望是不要生成那么多 js 文件,所以之前的做法是 rollup 的选项。

rollupOptions: {
    output: {
        manualChunks(id) {
            if (id.includes('pages')) {
                return 'pages'
            }
        }
    }
}
// 之前的结果 vite7 rollup
// dist/index.html                  0.45 kB │ gzip:  0.30 kB
// dist/assets/style-D2W9t87U.css   5.50 kB │ gzip:  1.58 kB
// dist/assets/index-D5uKrQyu.js   28.56 kB │ gzip: 11.05 kB
// dist/assets/pages-BL4EWsDv.js   83.42 kB │ gzip: 32.14 kB

rolldown 没有这个概念,manualChunks 对等的是 advancedChunks,折腾半天 advancedChunks 达不到想要的效果(可能是针对 node_modules 比较强,可能是没找对方法),找了半天找到一个方法可以做到,这个做法对我的项目有用,但是大项目应该不行,所有项目文件都生成一个文件了。

rolldownOptions: {
    output: {
        inlineDynamicImports: true
    }
}
// rolldown 的输出内容多了一个 rolldown-runtime
// dist/index.html                           0.54 kB │ gzip:  0.31 kB
// dist/assets/style-Bac8aBaN.css            5.46 kB │ gzip:  1.56 kB
// dist/assets/rolldown-runtime-BcdYIZKG.js  0.19 kB │ gzip:  0.17 kB
// dist/assets/index-pOi5csA0.js             25.03 kB │ gzip:  9.38 kB
// dist/assets/vendor-p3Su3_y3.js            85.17 kB │ gzip: 33.12 kB

不加上面选项的结果

// 文件很多 vite 8 & rolldown, vite 7 类似
dist/index.html                     0.45 kB │ gzip:  0.29 kB
dist/assets/style-Bac8aBaN.css      5.46 kB │ gzip:  1.56 kB
dist/assets/not-found-DAToitwy.js   0.12 kB │ gzip:  0.13 kB
dist/assets/api-Br4_g19J.js         0.42 kB │ gzip:  0.17 kB
dist/assets/home-BSTox8wn.js        0.59 kB │ gzip:  0.38 kB
dist/assets/things-BUk3p1b5.js      0.83 kB │ gzip:  0.53 kB
dist/assets/eol-WmdroNCa.js         1.88 kB │ gzip:  0.97 kB
dist/assets/angling-Bh7xqOUY.js     1.92 kB │ gzip:  0.78 kB
dist/assets/fin-5dWvAvtB.js         1.97 kB │ gzip:  0.87 kB
dist/assets/genuine-DKX79-2_.js     2.40 kB │ gzip:  1.01 kB
dist/assets/frame-main-CGg78mi8.js  2.41 kB │ gzip:  1.27 kB
dist/assets/pc-hLrRv_Pv.js          2.55 kB │ gzip:  0.68 kB
dist/assets/vers-COZW8I_8.js        2.90 kB │ gzip:  1.15 kB
dist/assets/index-CbFw2-HB.js       3.76 kB │ gzip:  1.60 kB
dist/assets/about-BcSEGuXl.js       4.18 kB │ gzip:  2.39 kB
dist/assets/vendor-BMuo1oit.js      84.70 kB │ gzip: 32.75 kB

结论

  • 只依赖 vue, vue-router 的项目没问题,生产可用。
  • rolldown 的 node_modules 大小居然比 vite7 的 esbuild 和 rollup 的还大了一点 47.5MB > 35.2MB 🥴
"dependencies": {
    "vue": "^3.5.25",
    "vue-router": "^4.6.4"
},
"devDependencies": {
    "@vitejs/plugin-vue": "^6.0.3",
    "vite": "^8.0.0-beta.2"
}

Vue 3 做 todos , ref 能看懂,computed 终于也懂了

刚开始学 Vue 3,看到 refcomputedv-model 就有点晕乎乎。

为了练手,我抄着教程写了一个超简单的待办清单

结果写着写着,发现这个小玩具刚好能把我最不理解的那个家伙——computed——讲清楚。
下面就用我的视角,拆一下这个小 demo,到底在干嘛,以及 computed 为什么值得单拿出来说。

响应式数据:ref 开局

核心状态有两个:

const title = ref('');
const todos = ref([
  { id: 1, title: '打王者', done: true },
  { id: 2, title: '吃饭',   done: true }
]);
  • title
    输入框当前内容,双向绑定在输入框上。
  • todos
    一个数组,每一项是一个待办对象:idtitledone

在模板里通过 v-modelv-forv-if 等就能把这些数据“长”成界面。


模板怎么把数据“长”出来?

几个关键点:

  • 输入框双向绑定

    <input type="text" v-model="title" @keydown.enter="addTodo">
    
    • v-model="title":输入的内容自动同步到 title
    • keydown.enter="addTodo":按下回车,就调用 addTodo 新增待办。
  • 列表循环 + 勾选状态

    <ul v-if="todos.length">
      <li v-for="todo in todos" :key="todo.id">
        <input type="checkbox" v-model="todo.done">
        <span :class="{ done: todo.done }">{{ todo.title }}</span>
      </li>
    </ul>
    <div v-else>暂无待办事项</div>
    
    • v-for 负责把数组“摊开”成一个个 li
    • 每条的复选框用 v-model="todo.done",直接双向绑定完成状态。
    • :class="{ done: todo.done }" 决定要不要加上 .done 这个类,实现中划线 + 灰色。

样式就是很简单的:

.done {
  text-decoration: line-through;
  color: gray;
}

新增待办:一个小小的 addTodo

const addTodo = () => {
  if (!title.value) return;
  todos.value.push({
    id: Math.random(),
    title: title.value,
    done: false
  });
  title.value = '';
};
  • 为空就不加:简单的校验。
  • 往 todos 里 push 新对象:新待办默认 done: false
  • 清空输入框:体验自然一点。

这里用的是最基础的响应式数组操作:修改 todos.value,界面自然会跟着更新。

真正的主角:computed 计算未完成数量

来看统计那一行:

{{ active }} / {{ todos.length }}

前面那个 active,就是一个计算属性:

const active = computed(() => {
  return todos.value.filter(todo => !todo.done).length;
});

这行代码,核心逻辑其实就一句话:

把还没完成的待办筛出来,数一数有多少条。

你可能会问:
“那我为啥不干脆在模板里直接写呢,比如:

{{ todos.filter(todo => !todo.done).length }} / {{ todos.length }}

能不能这么写?当然可以。
但计算属性有几个很实际的好处。

computed 有什么好处?

1. 它是“派生数据”的家

像“未完成数量”这种数据:

  • 不需要自己单独存一份;
  • 完全可以根据 todos 推导出来。

这种就叫派生数据
computed 天生就是为它们准备的:

const active = computed(() => {
  return todos.value.filter(todo => !todo.done).length;
});

好处是:

  • 代码一眼就能看出:active 是“依据 todos 计算得来”的结果。
  • 模板里看到 {{ active }},基本就能猜到意思,不会被一长串过滤逻辑干扰。

2. 自带缓存:只在需要的时候重新算

模板里的表达式,每次渲染都会重新执行。

也就是说,如果写成:

{{ todos.filter(todo => !todo.done).length }}

只要组件重新渲染(不管是不是 because todos 变了),它就会再跑一次 filter

而 computed 则不一样:

  • 它会自动追踪依赖todos 以及每个 todo.done
  • 只有当这些依赖发生变化时,active 才会重新计算。
  • 其他不相干的响应式数据(比如 title)变了,并不会让它重算。

在这个小例子里,列表很短,差异你感觉不到。
但在真实项目里:

  • todos 很大;
  • 统计里用到的逻辑复杂;
  • 或者同一个统计在多个地方用到;

这时候 computed 的缓存机制,就能明显减少不必要的重复计算。

3. 模板更干净,逻辑集中在 JS 里

模板里写太多逻辑,阅读成本会明显升高。
想象一下,如果有好几个统计项都长这样:

{{ todos.filter(t => !t.done && t.priority === 'high').length }}

项目一大,很快你就会讨厌在模板里翻来翻去的复杂表达式。

把逻辑抽到 computed 里:

const activeHighPriority = computed(() =>
  todos.value.filter(t => !t.done && t.priority === 'high').length
);

模板里只保留结果:

{{ activeHighPriority }}
  • 模板更像“结构 + 文案”;
  • 逻辑都待在 JS 里,改起来更顺手,也好测试。

4. 复用方便

如果你有多个地方都要用到“未完成数量”,
用模板表达式的话,要把 todos.filter(...).length 复制来复制去。

computed 则只用定义一次:

const active = computed(...);

模板任何地方都可以直接:

{{ active }}

以后改规则(比如不统计某些类型的待办)也只需要改一处逻辑。

computed 的进阶用法:get / set 做“全选”

这个例子里还有一个更高级一点的用法:**带 **

get / set 的计算属性,用来实现“全选”:

const allDone = computed({
  get() {
    return todos.value.every(todo => todo.done);
  },
  set(value) {
    todos.value.forEach(todo => {
      todo.done = value;
    });
  }
});

再配合模板:

全选<input type="checkbox" v-model="allDone">

这里发生了几件很有意思的事:


  • get:从数据推导视图

    • 每当界面需要知道“当前是不是全选状态”,就会调用 get()。
    • every(todo => todo.done) 判断是不是所有都完成。
    • 如果全部完成,allDone 为 true,全选框就被勾上。

  • set:从视图反推数据

    • 当你点击“全选”复选框时,因为用了 v-model="allDone",会触发 set(value)。
    • value 是你勾选后的新值(true / false)。
    • set 里把每一条 todo.done 全部改成这个值。

这种写法的妙处在于:

  • 模板里看起来就像在绑一个普通的布尔值;

  • 实际上背后是一个可以双向联动的“计算属性”:

    • 列表状态决定“全选”的勾选;
    • “全选”的勾选又能反过来更新列表状态。

这也是 computed 非常有魅力的一面:**不只是“算结果”,还可以通过 **

set 去“驱动数据变化”

小结:一个小待办里,装着 Vue 的几个核心习惯

这个小例子里,其实就体现了几个很值得养成的编码习惯:

  • 状态集中在 ref / 响应式对象里管理
    titletodos 这样一眼明了。
  • 模板只做轻量逻辑,复杂逻辑交给 computed / 函数
    未完成数量用 computed,而不是长长的一串模板表达式。
  • 把“派生数据”都塞进 computed
    既清晰又有缓存,量一大就知道好处。
  • 用带 get/set 的 computed 实现更自然的双向绑定
    比如“全选”这种,同时依赖和影响其他状态的字段。

Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(三)

Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(三)

Flutter: 3.35.6

因为实现了单个的,给出github链接:github.com/yhtqw/Front…

前面我们简单实现了元素的平移和缩放,接下来我们继续实现旋转功能。

元素的旋转会改变角度,角度一变,那么响应事件的热区也会跟着改变,所以我们得提前考虑这些会因为角度改变而改变的地方。

先来简单实现一下旋转,先不考虑上述的热区问题。

要实现旋转,我们就得知道元素的旋转角度,主要得出旋转的角度,那么实现起来就比较简单,所以简单使用数学的知识分析一下吧

从我们这个需求中可以提取到的数据为按下点的坐标,拖动时变换的坐标;所以我们能否根据一个点的坐标,计算出该点与某点形成的夹角,好像刚好有个满足部分,就是arctan2,arctan2的主要作用是根据一个点的坐标,计算出该点与坐标原点所形成的夹角(主要作用);如果我们要知道给出点与任意(x', y')形成的夹角呢?前面的arctan2中将坐标原点换成任意某点不就行了?

使用 arctan2 计算两点连线的角度,核心是计算两点之间的坐标差 (Δx, Δy),然后将其作为 arctan2 的参数。所以实现起来就比较简单了。其实这个实现的原理在很多地方都一样,例如web端的元素拖动旋转也可以使用这个原理。实现的方式应该不止一种吧,只要能计算出这个角度就行了。

值得注意的是,现在我们研究的是单个元素,所以坐标系就是以元素自身形成的(响应的事件也是在这个元素上),等后期要实现多个,坐标系就得以外层容器作为参考了。

// 其他省略...

/// 新增旋转状态热区字符串
const String statusRotate = 'rotate';

/// 抽取响应旋转操作区域的大小
final double rotateWidth = 20;
final double rotateHeight = 20;

/// 旋转角度
double rotateNumber = 0;
double initRotateNumber = 0;

void _onPanUpdate(DragUpdateDetails details) {
  print('更新: $details');
  if (status == statusMove) {
    _onMove(details.localPosition.dx, details.localPosition.dy);
  } else if (status == statusScale) {
    _onScale(details.delta.dx, details.delta.dy);
  } else if (status == statusRotate) {
    // 新增旋转热区的响应事件
    _onRotate(details.localPosition.dx, details.localPosition.dy);
  }
}

void _onPanEnd() {
  print('抬起或者因为某些原因并没有触发onPanDown事件');
  setState(() {
    // 当次结束后重新记录,也可以在按下时记录
    initX = x;
    initY = y;
    // 新增旋转角度的记录
    initRotateNumber = rotateNumber;
  });
}

/// 处理旋转
void _onRotate(double dx, double dy) {
  /// 要计算点 (x, y) 与任意点 (x', y') 连线所成的角度,可以使用 arctan2 函数。
  /// 关键在于将两点之间的相对坐标差作为 arctan2 的输入参数。
  /// 这里我们以元素的中心为旋转中心
  /// 利用上述方法计算起始点(按下时)与中心的连线组成的夹角为初始夹角,
  /// 拖动的点与中心点连线组层的夹角为结束时的夹角,
  /// 通过初始夹角与结束夹角计算旋转的角度

  // 确定旋转中心,因为这里的拖动是单个元素,坐标都是相对于元素自身形成的坐标系,所以坐标中心始终都是元素的中心
  double centerX = elementWidth / 2;
  double centerY = elementHeight / 2;

  double diffStartX = startPosition.dx - centerX;
  double diffStartY = startPosition.dy - centerY;
  double diffEndX = dx - centerX;
  double diffEndY = dy - centerY;
  double angleStart = atan2(diffStartY, diffStartX);
  double angleEnd = atan2(diffEndY, diffEndX);

  setState(() {
    rotateNumber = initRotateNumber + angleEnd - angleStart;
  });
}

/// 判断点击在什么区域
String? _onDownZone(double x, double y) {
  if (
    x >= elementWidth - scaleWidth &&
    x <= elementWidth &&
    y >= elementHeight - scaleHeight &&
    y <= elementHeight
  ) {
    return statusScale;
  } else if (
    x >= elementWidth - rotateHeight &&
    x <= elementWidth &&
    y >= 0 &&
    y <= rotateHeight
  ) {
    // 固定右上角为旋转热区
    return statusRotate;
  } else if (
    x >= 0 &&
    x <= elementWidth &&
    y >= 0 &&
    y <= elementHeight
  ) {
    return statusMove;
  }

  return null;
}

// 新增响应旋转操作
Positioned(
  left: x,
  top: y,
  child: Transform.rotate(
    angle: rotateNumber,
    child: GestureDetector(
      onPanDown: _onPanDown,
      onPanUpdate: _onPanUpdate,
      onPanEnd: (details) => _onPanEnd(),
      onPanCancel: _onPanEnd,
      child: Container(
        width: elementWidth,
        height: elementHeight,
        color: Colors.transparent,
        child: Stack(
          alignment: Alignment.center,
          clipBehavior: Clip.none,
          children: [
            Container(
              width: elementWidth,
              height: elementHeight,
              color: Colors.amber,
            ),

            // 响应旋转操作
            Positioned(
              top: 0,
              right: 0,
              child: Container(
                width: scaleWidth,
                height: scaleHeight,
                color: Colors.white,
              ),
            ),

            // 响应缩放操作
          ],
        ),
      ),
    ),
  ),
),

// 其他省略...

运行效果:

image01.gif

这样就简单实现了旋转。然后我们继续考虑热区的问题,当旋转一定角度的时候,再次点击对应的热区,就无法响应事件了,因为旋转后热区坐标已经发生改变,所以我们得对点击判断中加入角度的影响。

已知某点坐标和旋转角度,求旋转后的坐标值?

要计算旋转后的坐标,可以使用旋转矩阵。给定一个点 (x, y) 绕原点逆时针旋转角度 θ 后的新坐标 (x', y') 计算公式如下:

x' = x * cosθ - y * sinθ; y' = x * sinθ + y * cosθ;

如果我们是绕任意点而不是原点,需要先平移坐标系

  1. 平移: 将 (x, y) 平移到原点,新坐标为 (x - a, y - b);
  2. 旋转: 按照上述公式计算 (x', y');
  3. 平移回原坐标系: 新坐标为(x' + a, y' + b)。

基于上面的公式,我们更改热区点击判断方法:

/// 判断点击在什么区域
String? _onDownZone(double x, double y) {
  final offsetScale = rotatePoint(elementWidth, elementHeight);
  // 设置都是最大的顶点坐标,方便下面判断区域的方式结构一致
  // 后续就好抽取方法
  final offsetRotate = rotatePoint(elementWidth, rotateHeight);

  if (
    x >= offsetScale.dx - scaleWidth &&
    x <= offsetScale.dx &&
    y >= offsetScale.dy - scaleHeight &&
    y <= offsetScale.dy
  ) {
    return statusScale;
  } else if (
    x >= offsetRotate.dx - rotateHeight &&
    x <= offsetRotate.dx &&
    y >= offsetRotate.dy - rotateHeight &&
    y <= offsetRotate.dy
  ) {
    return statusRotate;
  } else if (
    x >= 0 &&
    x <= elementWidth &&
    y >= 0 &&
    y <= elementHeight
  ) {
    return statusMove;
  }

  return null;
}

/// 计算旋转后的点坐标
Offset rotatePoint(double x, double y) {
  final deg = rotateNumber * pi / 180;
  // 确定旋转中心,因为这里的拖动是单个元素,坐标都是相对于元素自身形成的坐标系,所以坐标中心始终都是元素的中心
  final centerX = elementWidth / 2;
  final centerY = elementHeight / 2;
  final diffX = x - centerX;
  final diffY = y - centerY;

  final dx = diffX * cos(deg) - diffY * sin(deg) + centerX;
  final dy = diffX * sin(deg) + diffY * cos(deg) + centerY;
  return Offset(dx, dy);
}

image02.gif

可以看到的是旋转和缩放热区即使在旋转后依然能够正常响应,还有最后一点,就是移动的时候也要应用旋转角度计算,因为我们使用的是元素自身为坐标系,坐标系旋转了,自然移动时的计算方式也得跟着变,其实对于后期将事件应用到容器上了过后就不需要考虑这些了,因为外层容器并不会变换,所以后期不使用逆运算,所以我们这里直接使用globalPosition来计算值即可(变换计算坐标感兴趣的可以自行研究一下):

void _onPanDown(DragDownDetails details) {
  print('按下: $details');

  String? tempStatus = _onDownZone(details.localPosition.dx, details.localPosition.dy);

  print(tempStatus);

  setState(() {
    if (tempStatus == statusMove) {
      // 如果是移动,则使用globalPosition
      startPosition = details.globalPosition;
    } else {
      startPosition = details.localPosition;
    }
    status = tempStatus;
  });
}

void _onPanUpdate(DragUpdateDetails details) {
  print('更新: $details');
  if (status == statusMove) {
    _onMove(details.globalPosition.dx, details.globalPosition.dy);
  } else if (status == statusScale) {
    _onScale(details.delta.dx, details.delta.dy);
  } else if (status == statusRotate) {
    _onRotate(details.localPosition.dx, details.localPosition.dy);
  }
}

image03.gif

这样就对单个元素实现了变换的效果,前置就算时铺垫完成了,后续就开始实现多个的。

感兴趣的也可以关注我的微信公众号【前端学习小营地】,不定时会分享一些小功能~

今天的分享到此结束,感谢阅读~拜拜~

深入理解 useTransition:React 并发渲染的性能优化利器

引言

React 16 引入了 Fiber 架构,这是 React 核心算法的重构。Fiber 把渲染工作拆成多个小的工作单元(fiber 节点),每个工作单元可以独立执行、暂停和恢复。这种可中断的渲染机制让 React 能更好地控制渲染时机,为后续的并发特性打下了基础。关于 Fiber 架构的详细原理,可以参考这篇文章

基于 Fiber 架构,React 18 引入了并发特性(Concurrent Features),这是 React 历史上最重要的架构升级之一。并发渲染让 React 能在渲染过程中中断和恢复工作,从而保持用户界面的响应性。useTransition Hook 正是这一特性的核心 API 之一,它允许我们把某些状态更新标记为"非紧急",让 React 优先处理更重要的更新(比如用户输入),从而显著提升用户体验。

在本文中,我们会通过一个实际的演示案例,深入对比三种不同的更新策略:同步更新防抖更新并发更新(useTransition),然后从 React 源码层面解析 useTransition 的实现原理,帮你全面理解这个强大的性能优化工具。

交互式演示

在深入技术细节之前,我们先通过一个交互式演示来直观感受三种策略的差异:

在这个演示里,你可以:

  1. 在输入框里输入文字,输入越长,图表渲染的数据点越多
  2. 切换三种不同的更新策略(Synchronous、Debounced、Concurrent)
  3. 观察右上角的时钟动画,它是检测 UI 是否卡顿的"晴雨表"

性能对比演示

我们通过三个 GIF 动图来直观对比三种策略的表现:

1. 同步更新(Synchronous)

synchronous.gif

重要说明:图中显示的输入暂停是页面卡顿导致的结果,不是用户主动停止输入。当用户持续输入时,由于同步渲染阻塞了主线程,导致输入框无法及时响应。右上角时钟动画的明显卡顿证明了主线程被渲染任务完全阻塞。这是同步更新的最大问题:即使输入响应即时,但渲染会阻塞用户交互。

特点

  • ✅ 输入响应即时,无延迟
  • ❌ 每次输入都会立即触发完整渲染
  • ❌ 当数据量大时,时钟动画会明显卡顿
  • ❌ 用户输入可能被阻塞,体验不流畅

适用场景:数据量小、渲染简单的场景

2. 防抖更新(Debounced)

debounce.gif

重要说明:图中显示的等待期间是防抖延迟机制的表现(固定1000ms延迟)。防抖的延迟太大,用户输入后需要等1秒才能看到结果更新。虽然避免了频繁渲染,但固定的延迟时间影响了用户体验。用户无法及时看到输入反馈,需要等防抖时间结束。

特点

  • ✅ 减少渲染次数,避免频繁更新
  • ✅ 等待用户停止输入后才更新
  • ❌ 有固定的延迟(1000ms),用户需要等待
  • ❌ 时钟动画在等待期间可能不流畅
  • ❌ 无法利用 React 的并发特性

适用场景:需要减少 API 调用或计算次数的场景

3. 并发更新(Concurrent / useTransition)

concurrent.gif

重要说明:图中显示的流畅表现是并发渲染的效果。并发模式可以及时响应用户输入,无需等待延迟,输入框立即响应。即使在大数据量渲染时,时钟动画始终保持流畅,证明主线程未被阻塞。渲染过程可以被中断,优先处理用户输入,然后继续完成渲染任务。这是并发更新的核心优势:既保证了输入响应性,又完成了复杂渲染。

特点

  • ✅ 输入响应即时,无延迟
  • ✅ 渲染过程可中断,保持 UI 响应性
  • ✅ 时钟动画始终保持流畅
  • ✅ 自动平衡输入响应和渲染性能
  • ✅ 利用 React 18 的并发特性

适用场景:需要保持 UI 响应性的复杂渲染场景

演示代码解析

这个演示应用基于 React 官方的 time-slicing fixture,展示了三种不同的状态更新策略。我们来看看关键代码实现:

三种更新策略

function AppContent({ complexity }: AppProps) {
  const [value, setValue] = useState('');
  const [strategy, setStrategy] = useState<Strategy>('sync');
  const [isPending, startTransition] = useTransition();

  // 防抖处理函数
  const debouncedSetValue = useMemo(
    () =>
      debounce((newValue: string) => {
        setValue(newValue);
      }, 1000),
    []
  );

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      switch (strategy) {
        case 'sync':
          // 策略 1: 同步更新 - 立即触发渲染
          setValue(newValue);
          break;
        case 'debounced':
          // 策略 2: 防抖更新 - 延迟 1000ms 后更新
          debouncedSetValue(newValue);
          break;
        case 'async':
          // 策略 3: 并发更新 - 使用 useTransition
          startTransition(() => {
            setValue(newValue);
          });
          break;
      }
    },
    [strategy, debouncedSetValue, startTransition]
  );

  const data = getStreamData(value, complexity);
  // ...
}

useTransition 使用详解

在上面的代码里,我们看到了 useTransition 的基本使用。我们详细解析一下:

1. useTransition 的基本用法

useTransition 是一个 Hook,它返回一个包含两个元素的数组:

const [isPending, startTransition] = useTransition();
  • isPending:一个布尔值,表示当前有没有正在进行的 transition 更新。当 startTransition 里的更新正在处理时,isPendingtrue;更新完成后变为 false
  • startTransition:一个函数,用来把状态更新标记为"非紧急"的 transition 更新。

2. startTransition 的使用方式

startTransition 接收一个回调函数,在这个回调函数里执行的状态更新会被标记为低优先级:

case 'async':
  // 策略 3: 并发更新 - 使用 useTransition
  startTransition(() => {
    setValue(newValue);
  });
  break;

关键点

  • 所有在 startTransition 回调里调用的 setState 都会被标记为 transition 更新
  • 可以同时更新多个状态:
    startTransition(() => {
      setValue(newValue);
      setFilteredResults(filterResults(newValue));
      setSearchHistory(prev => [...prev, newValue]);
    });
    
  • startTransition 是同步执行的,但里面的状态更新会被异步处理
  • 不要在 startTransition 里执行副作用(比如 API 调用、DOM 操作等),只用来更新状态

3. isPending 的实际应用

isPending 可以用来向用户提供视觉反馈,表示应用正在处理 transition 更新。在演示代码里,我们用 isPending 来改变输入框的透明度:

<input
  className={`p-3 sm:p-4 text-xl sm:text-3xl w-full block bg-white text-black rounded ${inputColorClass} ${
    isPending ? 'opacity-70' : ''
  }`}
  placeholder="longer input → more components"
  onChange={handleChange}
/>

isPendingtrue 时,输入框会变成半透明(opacity-70),给用户一个视觉提示,表明后台正在处理更新。

关键组件说明

1. Charts 组件 用 Victory 图表库渲染大量数据点。根据输入长度动态生成数据复杂度:

function getStreamData(input: string, complexity: number): StreamData {
  const cacheKey = `${input}-${complexity}`;
  if (cachedData.has(cacheKey)) {
    return cachedData.get(cacheKey)!;
  }
  const multiplier = input.length !== 0 ? input.length : 1;
  const data = range(5).map(() =>
    range(complexity * multiplier).map((j: number) => ({
      x: j,
      y: random(0, 255),
    }))
  );
  cachedData.set(cacheKey, data);
  return data;
}

2. Clock 组件 一个实时 SVG 动画时钟,用来检测 UI 是否卡顿。如果主线程被阻塞,时钟动画会明显掉帧。

3. 数据生成策略

  • 输入长度越长,生成的数据点越多
  • 用缓存机制避免重复计算
  • 复杂度参数控制基础数据量

React 源码深度解析

我们用了很多次 useTransition,也看到了它的效果。现在来看看 React 源码里它是怎么实现的。

useTransition 入口函数

useTransition 的入口在 packages/react/src/ReactHooks.js 文件中:

export function useTransition(): [
  boolean,
  (callback: () => void, options?: StartTransitionOptions) => void,
] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useTransition();
}

很简单,就是通过 resolveDispatcher() 拿到 dispatcher,然后调用 dispatcher.useTransition()

这个 dispatcher 是什么呢?React 会根据当前是首次渲染还是更新渲染,给你不同的 dispatcher。首次渲染的时候,dispatcher 里的 useTransition 会调用 mountTransition;更新渲染的时候,会调用 updateTransition

有同学说,为什么要这样设计?因为 React 需要区分首次渲染和更新渲染。首次渲染的时候,需要创建新的 Hook 对象;更新渲染的时候,需要复用之前的 Hook 对象。

mountTransition:首次渲染

组件首次渲染时,会调用 mountTransition。我们来看看它的实现:

packages/react-reconciler/src/ReactFiberHooks.js 中:

function mountTransition(): [
  boolean,
  (callback: () => void, options?: StartTransitionOptions) => void,
] {
  const stateHook = mountStateImpl((false: Thenable<boolean> | boolean));
  // The `start` method never changes.
  const start = startTransition.bind(
    null,
    currentlyRenderingFiber,
    stateHook.queue,
    true,
    false,
  );
  const hook = mountWorkInProgressHook();
  hook.memoizedState = start;
  return [false, start];
}

这段代码做了几件事:

  1. mountStateImpl 创建一个内部状态,初始值是 false。这个状态用来表示当前是不是 pending 状态。
  2. 通过 bind 创建一个 start 函数,绑定了当前 fiber 和 state queue。这个 start 函数就是 startTransition,但已经绑定了必要的上下文。
  3. start 函数存到 hook 的 memoizedState 里,这样下次更新的时候还能拿到同一个函数。
  4. 返回 [false, start],初始状态不是 pending。

updateTransition:更新渲染

组件更新渲染时,会调用 updateTransition

packages/react-reconciler/src/ReactFiberHooks.js 中:

function updateTransition(): [
  boolean,
  (callback: () => void, options?: StartTransitionOptions) => void,
] {
  const [booleanOrThenable] = updateState(false);
  const hook = updateWorkInProgressHook();
  const start = hook.memoizedState;
  const isPending =
    typeof booleanOrThenable === 'boolean'
      ? booleanOrThenable
      : // This will suspend until the async action scope has finished.
        useThenable(booleanOrThenable);
  return [isPending, start];
}

这里做了几件事:

  1. 调用 updateState(false) 获取当前的 pending 状态。这个状态在 startTransition 执行的时候会被更新。
  2. 从 hook 的 memoizedState 里拿到之前存的 start 函数。这个函数在首次渲染的时候就创建好了,之后不会变。
  3. 判断 isPending。如果状态是 boolean,直接返回;如果是 Promise(比如异步 action),就用 useThenable 等待它完成。
  4. 返回 [isPending, start]

体会到 useTransition 的设计了么?它本质上就是 useState + startTransition。用 useState 来管理 pending 状态,用 startTransition 来标记更新为低优先级。

startTransition 函数实现

startTransition 的实现在 packages/react/src/ReactStartTransition.js 文件中。这个函数的核心作用是设置一个全局的 transition 上下文,让 React 知道当前正在执行 transition 更新。

export function startTransition(
  scope: () => void,
  options?: StartTransitionOptions,
): void {
  const prevTransition = ReactSharedInternals.T;
  const currentTransition: Transition = ({}: any);
  if (enableViewTransition) {
    currentTransition.types =
      prevTransition !== null
        ? // If we're a nested transition, we should use the same set as the parent
          // since we're conceptually always joined into the same entangled transition.
          // In practice, this only matters if we add transition types in the inner
          // without setting state. In that case, the inner transition can finish
          // without waiting for the outer.
          prevTransition.types
        : null;
  }
  if (enableGestureTransition) {
    currentTransition.gesture = null;
  }
  if (enableTransitionTracing) {
    currentTransition.name =
      options !== undefined && options.name !== undefined ? options.name : null;
    currentTransition.startTime = -1; // TODO: This should read the timestamp.
  }
  if (__DEV__) {
    currentTransition._updatedFibers = new Set();
  }
  ReactSharedInternals.T = currentTransition;

  try {
    const returnValue = scope();
    const onStartTransitionFinish = ReactSharedInternals.S;
    if (onStartTransitionFinish !== null) {
      onStartTransitionFinish(currentTransition, returnValue);
    }
    if (
      typeof returnValue === 'object' &&
      returnValue !== null &&
      typeof returnValue.then === 'function'
    ) {
      if (__DEV__) {
        // Keep track of the number of async transitions still running so we can warn.
        ReactSharedInternals.asyncTransitions++;
        returnValue.then(releaseAsyncTransition, releaseAsyncTransition);
      }
      returnValue.then(noop, reportGlobalError);
    }
  } catch (error) {
    reportGlobalError(error);
  } finally {
    warnAboutTransitionSubscriptions(prevTransition, currentTransition);
    if (prevTransition !== null && currentTransition.types !== null) {
      // If we created a new types set in the inner transition, we transfer it to the parent
      // since they should share the same set. They're conceptually entangled.
      if (__DEV__) {
        if (
          prevTransition.types !== null &&
          prevTransition.types !== currentTransition.types
        ) {
          // Just assert that assumption holds that we're not overriding anything.
          console.error(
            'We expected inner Transitions to have transferred the outer types set and ' +
              'that you cannot add to the outer Transition while inside the inner.' +
              'This is a bug in React.',
          );
        }
      }
      prevTransition.types = currentTransition.types;
    }
    ReactSharedInternals.T = prevTransition;
  }
}

这段代码的逻辑很简单:

  1. 保存之前的 transition:先把当前的 ReactSharedInternals.T 存起来,因为可能已经有 transition 在运行了(支持嵌套)。
  2. 创建新的 transition 对象:初始化一个新的 transition 对象。如果是嵌套的 transition,会继承外层的 types。
  3. 设置全局 transition:把新创建的 transition 赋值给 ReactSharedInternals.T。这样,在 scope 函数里调用的 setState 就能知道当前在 transition 里了。
  4. 执行用户代码:在 try 块里执行你传入的 scope 函数。
  5. 处理异步返回值:如果 scope 返回的是 Promise,会做一些处理,比如在开发模式下跟踪异步 transition 的数量。
  6. 恢复状态:在 finally 块里恢复之前的 transition 状态。这样嵌套的 transition 就能正确恢复。

这个设计还支持嵌套 transition。比如你在一个 startTransition 里又调用了另一个 startTransition,内层的会继承外层的 types,它们会被当作同一个 transition 处理。

优先级调度机制

React 用 Lane 模型来管理更新的优先级。Lane 就是"车道"的意思,不同的更新走不同的车道,优先级高的车道可以先走。

当你在 startTransition 里调用 setState 的时候,React 怎么知道这个更新是低优先级的呢?我们来看看 requestUpdateLane 这个函数:

packages/react-reconciler/src/ReactFiberWorkLoop.js 中:

export function requestUpdateLane(fiber: Fiber): Lane {
  // Special cases
  const mode = fiber.mode;
  if (!disableLegacyMode && (mode & ConcurrentMode) === NoMode) {
    return (SyncLane: Lane);
  } else if (
    (executionContext & RenderContext) !== NoContext &&
    workInProgressRootRenderLanes !== NoLanes
  ) {
    // This is a render phase update. These are not officially supported. The
    // old behavior is to give this the same "thread" (lanes) as
    // whatever is currently rendering. So if you call `setState` on a component
    // that happens later in the same render, it will flush. Ideally, we want to
    // remove the special case and treat them as if they came from an
    // interleaved event. Regardless, this pattern is not officially supported.
    // This behavior is only a fallback. The flag only exists until we can roll
    // out the setState warning, since existing code might accidentally rely on
    // the current behavior.
    return pickArbitraryLane(workInProgressRootRenderLanes);
  }

  const transition = requestCurrentTransition();
  if (transition !== null) {
    if (enableGestureTransition) {
      if (transition.gesture) {
        throw new Error(
          'Cannot setState on regular state inside a startGestureTransition. ' +
            'Gestures can only update the useOptimistic() hook. There should be no ' +
            'side-effects associated with starting a Gesture until its Action is ' +
            'invoked. Move side-effects to the Action instead.',
        );
      }
    }
    if (__DEV__) {
      if (!transition._updatedFibers) {
        transition._updatedFibers = new Set();
      }
      transition._updatedFibers.add(fiber);
    }

    return requestTransitionLane(transition);
  }

  return eventPriorityToLane(resolveUpdatePriority());
}

这个函数的工作流程很简单:

  1. 先检查 fiber 的模式,如果不是并发模式,直接返回同步 lane。
  2. 检查是不是在渲染阶段更新(这个不推荐,但 React 还是支持了)。
  3. 关键步骤:调用 requestCurrentTransition() 检查当前有没有 active transition。这个函数会去读 ReactSharedInternals.T,也就是 startTransition 设置的那个全局变量。
  4. 如果有 transition,调用 requestTransitionLane() 返回 transition lane(低优先级)。
  5. 否则,用 eventPriorityToLane() 返回默认的 event priority lane(高优先级)。

所以,当你在 startTransition 里调用 setState 的时候,React 会检查到当前有 active transition,然后给你分配一个低优先级的 lane。

requestTransitionLane

requestTransitionLane 负责分配 transition lane。我们来看看它的实现:

packages/react-reconciler/src/ReactFiberRootScheduler.js 中:

export function requestTransitionLane(
  // This argument isn't used, it's only here to encourage the caller to
  // check that it's inside a transition before calling this function.
  // TODO: Make this non-nullable. Requires a tweak to useOptimistic.
  transition: Transition | null,
): Lane {
  // The algorithm for assigning an update to a lane should be stable for all
  // updates at the same priority within the same event. To do this, the
  // inputs to the algorithm must be the same.
  //
  // The trick we use is to cache the first of each of these inputs within an
  // event. Then reset the cached values once we can be sure the event is
  // over. Our heuristic for that is whenever we enter a concurrent work loop.
  if (currentEventTransitionLane === NoLane) {
    // All transitions within the same event are assigned the same lane.
    const actionScopeLane = peekEntangledActionLane();
    currentEventTransitionLane =
      actionScopeLane !== NoLane
        ? // We're inside an async action scope. Reuse the same lane.
          actionScopeLane
        : // We may or may not be inside an async action scope. If we are, this
          // is the first update in that scope. Either way, we need to get a
          // fresh transition lane.
          claimNextTransitionUpdateLane();
  }
  return currentEventTransitionLane;
}

这个函数做了几件事:

  1. 事件级别的 lane 缓存:同一个事件里的所有 transition 更新共享同一个 lane。比如你在一个事件处理函数里调用了多个 startTransition,它们会用同一个 lane。
  2. 异步 action scope 支持:如果你在异步 action scope 里(比如 Server Action),会复用相同的 lane。
  3. lane 分配:如果没有缓存的 lane,就用 claimNextTransitionUpdateLane() 分配一个新的 transition lane。

Lane 优先级系统

Lane 是用位运算实现的,这样判断和分配都很快。我们来看看 transition lane 是怎么分配的:

packages/react-reconciler/src/ReactFiberLane.js 中:

export function isTransitionLane(lane: Lane): boolean {
  return (lane & TransitionLanes) !== NoLanes;
}

export function claimNextTransitionUpdateLane(): Lane {
  // Cycle through the lanes, assigning each new transition to the next lane.
  // In most cases, this means every transition gets its own lane, until we
  // run out of lanes and cycle back to the beginning.
  const lane = nextTransitionUpdateLane;
  nextTransitionUpdateLane <<= 1;
  if ((nextTransitionUpdateLane & TransitionUpdateLanes) === NoLanes) {
    nextTransitionUpdateLane = TransitionLane1;
  }
  return lane;
}

claimNextTransitionUpdateLane 的逻辑很简单:

  1. 取当前的 nextTransitionUpdateLane 作为要返回的 lane。
  2. nextTransitionUpdateLane 左移一位(相当于乘以 2),这样下次就能分配下一个 lane。
  3. 如果左移后超出了 transition lanes 的范围,就循环回到第一个 transition lane。

这样,每个新的 transition 都会得到自己的 lane,直到 lanes 用尽,然后循环使用。

Lane 的优先级规则是:Transition lanes 的优先级低于同步 lanes(SyncLane)和默认 lanes(DefaultLane)。所以当有高优先级的更新(比如用户输入)时,React 会中断 transition 的渲染,先处理高优先级的更新,然后再回来继续渲染 transition。

工作原理流程图

让我们通过流程图来理解 useTransition 的完整工作流程:

flowchart TD
    A[用户调用 startTransition] --> B[设置 ReactSharedInternals.T]
    B --> C[执行 scope 函数]
    C --> D[调用 setState]
    D --> E[requestUpdateLane]
    E --> F{检查是否有 active transition?}
    F -->|是| G[requestTransitionLane]
    F -->|否| H[eventPriorityToLane]
    G --> I[分配 Transition Lane]
    I --> J[标记更新为低优先级]
    J --> K[React 调度器处理]
    K --> L{有更高优先级更新?}
    L -->|是| M[中断 transition 渲染]
    L -->|否| N[继续渲染 transition 更新]
    M --> O[处理高优先级更新]
    O --> P[恢复 transition 渲染]
    N --> Q[完成渲染]
    P --> Q
    Q --> R[恢复 ReactSharedInternals.T]
    
    style I fill:#e1f5ff
    style J fill:#e1f5ff
    style M fill:#fff4e6
    style O fill:#fff4e6

流程说明

  1. 用户调用 startTransition,设置全局 transition 上下文
  2. 在 scope 中调用 setState 触发更新
  3. React 通过 requestUpdateLane 检查是否有 active transition
  4. 如果有,分配 transition lane(低优先级)
  5. React 调度器可以中断 transition 渲染来处理更高优先级的更新
  6. 高优先级更新完成后,恢复 transition 渲染
  7. 最终恢复 transition 上下文

最佳实践

何时使用 useTransition

useTransition 特别适合以下场景:

  1. 搜索和筛选

    const [isPending, startTransition] = useTransition();
    const [query, setQuery] = useState('');
    
    const handleSearch = (value: string) => {
      setQuery(value); // 高优先级:立即更新输入框
      startTransition(() => {
        setFilteredResults(filterResults(value)); // 低优先级:延迟更新结果
      });
    };
    
  2. 标签切换

    const [isPending, startTransition] = useTransition();
    const [activeTab, setActiveTab] = useState('home');
    
    const handleTabChange = (tab: string) => {
      startTransition(() => {
        setActiveTab(tab); // 标签内容渲染可以延迟
      });
    };
    
  3. 列表渲染

    const [isPending, startTransition] = useTransition();
    const [items, setItems] = useState([]);
    
    const loadMoreItems = () => {
      startTransition(() => {
        setItems(prev => [...prev, ...newItems]); // 大量列表项渲染
      });
    };
    

与防抖/节流的区别

特性 useTransition 防抖/节流
延迟机制 基于优先级调度 基于时间延迟
输入响应 即时响应 有固定延迟
渲染控制 React 自动管理 手动控制
中断能力 支持中断和恢复 不支持
适用场景 复杂渲染场景 减少计算/请求

关键区别

  • useTransition 不会延迟用户输入,而是让 React 智能地调度渲染
  • 防抖/节流会固定延迟,可能影响用户体验
  • useTransition 利用 React 的并发特性,可以中断和恢复渲染

注意事项和限制

  1. 不要用于紧急更新

    // ❌ 错误:紧急更新不应该用 useTransition
    startTransition(() => {
      setError(error); // 错误信息应该立即显示
    });
    
    // ✅ 正确:非紧急的 UI 更新
    startTransition(() => {
      setSearchResults(results); // 搜索结果可以延迟显示
    });
    
  2. isPending 的使用

    const [isPending, startTransition] = useTransition();
    
    return (
      <div>
        <input onChange={handleChange} />
        {isPending && <Spinner />} {/* 显示加载状态 */}
      </div>
    );
    
  3. 避免在 transition 中进行副作用

    // ❌ 错误:副作用应该在 transition 外
    startTransition(() => {
      setState(newState);
      document.title = 'Updated'; // 副作用
    });
    
    // ✅ 正确:只更新状态
    startTransition(() => {
      setState(newState);
    });
    document.title = 'Updated'; // 副作用在 transition 外
    
  4. 与 Suspense 配合使用

    <Suspense fallback={<Loading />}>
      <SearchResults query={query} />
    </Suspense>
    

总结

useTransition 是 React 18 并发特性的核心 API 之一,它通过以下机制实现了优秀的性能优化:

  1. 优先级调度:将非紧急更新标记为低优先级,让 React 优先处理用户交互
  2. 可中断渲染:支持中断和恢复,保持 UI 响应性
  3. 智能平衡:自动平衡输入响应和渲染性能

通过本文的源码分析,我们了解到:

  • useTransition 通过内部状态管理 pending 状态
  • startTransition 通过设置全局上下文标记更新为低优先级
  • React 调度器通过 Lane 模型管理不同优先级的更新
  • Transition lanes 的优先级低于同步和默认 lanes

在实际开发中,我们应该:

  • 将非紧急的 UI 更新包装在 startTransition
  • 使用 isPending 提供加载反馈
  • 避免在 transition 中进行副作用
  • 理解与防抖/节流的区别,选择合适的技术

React 18 的并发特性为构建高性能、响应迅速的用户界面提供了强大的工具。useTransition 作为其中的重要组成部分,值得我们深入理解和合理使用。

参考资料

我对防抖(Debounce)的一点理解与实践:从基础到立即执行

我对防抖(Debounce)的一点理解与实践

这篇文章主要是我在项目中使用防抖过程中的一些总结,只代表个人理解,如果有不严谨或可以优化的地方,欢迎指出和讨论。


一、防抖的概念

防抖(Debounce) ,简单来说就是:

在短时间内多次触发同一个函数时,只让它在“合适的时机”执行一次。

常见的两种形式:

  • 尾触发:停止触发一段时间后才执行
  • 立即执行:第一次触发立刻执行,随后一段时间内不再执行

防抖本身并不复杂,真正复杂的地方在于:什么时候该用哪一种,以及实现细节是否可靠。


二、为什么要做防抖(重点)

在实际项目中,高频触发几乎无处不在:

  • 用户快速点击按钮
  • 表单多次提交
  • 输入框实时搜索

如果不加控制,往往会带来一些问题:

  • 接口被重复调用
  • 产生重复副作用(多次提交、多次弹窗)
  • 状态错乱,难以维护

防抖解决的核心问题是:

函数触发频率过高,而这些触发中,只有一部分是真正“有意义”的。

通过防抖,我们可以在函数入口处统一控制执行频率,而不是在函数内部到处加判断。


2.1 除了防抖,还有其它方案吗?(简单带过)

实际开发中,也经常能看到一些方案:

  • loading 状态控制

  • 页面多个按钮,loading按钮过多,然后二次封装按钮组件

接下来先按照我个人的理解,来说一下还是防抖。


三、基础版本防抖实现

3.1 最基础的防抖写法

function debounce(func, wait = 200) {
  let timeout = null

  return function (...args) {
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      func.apply(this, args)
    }, wait)
  }
}

这个版本属于最经典的尾触发防抖

  • 多次触发只会执行最后一次

3.2 普通函数与箭头函数的区别

防抖实现中经常会看到两种写法:

const content = this

setTimeout(function () {
  func.apply(content, args)
}, wait)

以及:

setTimeout(() => {
  func.apply(this, args)
}, wait)

这两种写法的核心区别在于 this 的绑定机制不同

  • 普通函数的 this 是运行时动态绑定的,由函数的调用方式决定,在 setTimeout 等场景中容易发生 this 丢失。
  • 箭头函数不会创建自己的 this,它的 this 在定义时就已经确定,始终指向外层作用域的 this,因此非常适合用于定时器和回调函数中。

因此在防抖中,如果使用普通函数,往往需要额外保存 this;
而使用箭头函数,可以让代码更简洁。然后具体的情况需要具体分析

这里不展开细说 this 的规则。 而且里面还涉及到了apply,call,bind等知识


四、立即执行版防抖

4.1 为什么需要立即执行版

普通防抖有一个明显特点:

  • 第一次触发不会立即执行

在一些场景下,这并不是我们想要的行为,例如:

  • 提交按钮
  • 登录、支付等关键操作

这类场景下,更合理的预期是:

第一次点击立刻执行,但在短时间内禁止再次触发。

这就是立即执行版防抖存在的意义。


4.2 立即执行版完整实现

function debounce(func, wait = 200, immediate = false) {
  let timeout = null

  return function (...args) {
    // 是否需要立即执行(第一次触发)
    const callNow = immediate && !timeout

    // 清除之前的定时器
    if (timeout) clearTimeout(timeout)

    // 设置新的定时器,用于冷却期结束
    timeout = setTimeout(() => {
      // 冷却结束,重置状态
      timeout = null

      // 非立即执行模式,走尾触发
      if (!immediate) {
        func.apply(this, args)
      }
    }, wait)

    // 立即执行(只会执行一次)
    if (callNow) {
      func.apply(this, args)
    }
  }
}

4.3 这一版的核心思路

在这个实现中:

  • timeout 不只是一个定时器
  • 它同时承担了 “是否处于冷却期” 的状态标记
const callNow = immediate && !timeout
  • !timeout 表示当前不在冷却期
  • 只允许第一次触发立即执行

当定时器结束后:

timeout = null

表示冷却期结束,允许下一次立即执行。


五、结合源码理解实现逻辑

在理解了立即执行版防抖的实现后,再回看 Underscore.js 的 _.debounce 源码,其实可以发现:核心思想完全一致,只是写法更偏工程化。

_.debounce = function(func, wait, immediate) {
  var timeout, result;

  var later = function(context, args) {
    timeout = null;
    if (args) result = func.apply(context, args);
  };

  var debounced = function(...args) {
    if (timeout) clearTimeout(timeout);

    if (immediate) {
      var callNow = !timeout;
      timeout = setTimeout(later, wait);
      if (callNow) result = func.apply(this, args);
    } else {
      timeout = setTimeout(() => later(this, args), wait);
    }

    return result;
  };

  return debounced;
};

timeout 是防抖的核心状态

timeout 不只是定时器 ID,更是是否处于冷却期的状态标识

  • timeout === null:不在冷却期
  • timeout !== null:正在防抖中

立即执行模式正是通过 !timeout 来判断“是否第一次触发”。


为什么定时器里要 timeout = null

timeout = null;

这一步表示冷却期结束,为下一次立即执行创造条件。
如果不重置,immediate 只会生效一次。


立即执行的关键逻辑

var callNow = !timeout;
timeout = setTimeout(later, wait);
if (callNow) func.apply(this, args);

这三行完成了三件事:

  1. 判断是否第一次触发
  2. 立刻进入冷却期
  3. 只在第一次触发时立即执行

后续触发只会刷新定时器,不会重复执行。


为什么源码不用 this,而是传 context

later 是普通函数,this 不可靠。
因此 Underscore 选择 显式传递 context,保证 this 指向稳定,这是典型的库级写法。


核心结论

防抖并不依赖复杂 API,本质只有两点:

  • 定时器
  • 状态控制(是否处于冷却期)

立即执行与否,本质区别只是:

函数是在冷却期开始时执行,还是在冷却期结束时执行。

总结

防抖本身并不难,真正容易出问题的是:

  • 使用场景选错
  • this 指向理解不清
  • 状态与执行职责混在一起

这篇文章更多是我个人在项目中的一些理解与总结,
如果你在实践中有不同的经验或看法,也非常欢迎交流。

RequireJS 详解

RequireJS 详解

RequireJS 是一个基于 AMD(Asynchronous Module Definition,异步模块定义) 规范的 JavaScript 模块加载器,主要解决浏览器端模块化开发的依赖管理、异步加载问题,避免传统 <script> 标签加载的阻塞和依赖混乱问题。

一、核心优势

  1. 异步加载:模块按需异步加载,避免阻塞页面渲染;
  2. 依赖管理:明确声明模块依赖,自动按顺序加载依赖模块;
  3. 模块化封装:隔离模块作用域,避免全局变量污染;
  4. 跨环境兼容:支持浏览器端,也可配合工具(如 r.js)打包为生产环境的单文件。

二、快速上手

1. 引入 RequireJS

下载 RequireJS(官网),或通过 CDN 引入,在 HTML 中通过 <script> 标签加载,指定入口模块(data-main):

<!-- 引入 require.js,并指定入口模块为 main.js -->
<script src="https://cdn.jsdelivr.net/npm/requirejs@2.3.6/require.js" data-main="js/main"></script>
  • data-main:指定应用的入口模块(后缀 .js 可省略);
  • RequireJS 会自动加载 main.js,并以它为起点管理所有模块。

2. 定义模块

RequireJS 中模块通过 define() 定义,分三种场景:

(1)无依赖模块
// js/modules/hello.js
define(function() {
  return {
    sayHello: function(name) {
      return `Hello, ${name}!`;
    }
  };
});
(2)有依赖模块

依赖模块通过数组声明,回调函数的参数与依赖一一对应:

// js/modules/user.js
define(['./hello'], function(hello) { // 依赖同目录的 hello.js
  return {
    greet: function(username) {
      return hello.sayHello(username);
    }
  };
});
(3)命名模块(不推荐,RequireJS 会自动根据文件路径生成模块名)
// 显式指定模块名(一般用于第三方库)
define('myModule', ['jquery'], function($) {
  return {
    init: function() {
      $('body').css('background', '#f5f5f5');
    }
  };
});

3. 加载模块

通过 require() 加载模块并执行逻辑,通常在入口模块 main.js 中使用:

// js/main.js
// 配置模块路径(可选,推荐)
require.config({
  // 基础路径:所有模块的根路径
  baseUrl: 'js',
  // 路径映射:简化长路径/第三方库别名
  paths: {
    'jquery': 'https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min', // CDN 加载
    'user': 'modules/user', // 本地模块
    'hello': 'modules/hello'
  },
  // 非 AMD 模块适配(如一些全局变量式的库)
  shim: {
    'underscore': {
      exports: '_' // 暴露全局变量 _ 作为模块导出
    }
  }
});

// 加载并使用模块
require(['jquery', 'user'], function($, user) {
  $(document).ready(function() {
    const greetMsg = user.greet('RequireJS');
    $('body').html(`<h1>${greetMsg}</h1>`);
  });
});

三、核心 API 详解

1. define():定义模块

语法:

// 无依赖
define(function() { /* 模块逻辑,return 导出内容 */ });

// 有依赖
define([依赖1, 依赖2], function(模块1, 模块2) { /* 逻辑 */ });

// 命名模块
define('模块名', [依赖], function() { /* 逻辑 */ });

2. require.config():全局配置

核心配置项:

配置项 说明 示例
baseUrl 模块加载的基础路径 baseUrl: 'js/lib'
paths 模块路径映射(可省略 .js,支持 CDN) paths: { jquery: 'jquery-3.7.1' }
shim 适配非 AMD 模块(暴露变量、声明依赖) shim: { underscore: { exports: '_' } }
map 不同环境加载不同版本模块 map: { '*': { 'jquery': 'jquery-2' } }
waitSeconds 模块加载超时时间(默认 7 秒) waitSeconds: 10

3. require():加载模块并执行

语法:

require([依赖1, 依赖2], function(模块1, 模块2) {
  // 模块加载完成后的逻辑
}, function(err) {
  // 加载失败的回调(可选)
  console.error('模块加载失败:', err);
});

四、常见场景

1. 加载第三方非 AMD 模块

很多传统库(如 Underscore、Backbone)不是 AMD 模块,需通过 shim 适配:

require.config({
  paths: {
    underscore: 'lib/underscore',
    backbone: 'lib/backbone'
  },
  shim: {
    underscore: {
      exports: '_' // 暴露全局变量 _
    },
    backbone: {
      deps: ['underscore', 'jquery'], // 声明依赖
      exports: 'Backbone' // 暴露 Backbone
    }
  }
});

// 使用
require(['backbone'], function(Backbone) {
  console.log(Backbone);
});

2. 动态加载模块

通过 require() 动态加载模块(如用户交互后):

$('#loadModule').click(function() {
  // 动态加载 hello 模块
  require(['modules/hello'], function(hello) {
    alert(hello.sayHello('动态加载'));
  });
});

3. 模块打包(生产环境)

开发阶段模块分散,生产环境需打包为单文件,使用官方工具 r.js

  1. 安装 r.js:npm install -g requirejs
  2. 创建打包配置文件 build.js
    ({
      baseUrl: 'js',
      name: 'main', // 入口模块
      out: 'js/build/main.min.js', // 输出文件
      optimize: 'uglify2' // 压缩方式(uglify2/closure/none)
    })
    
  3. 执行打包:r.js -o build.js

五、注意事项

  1. 模块路径问题baseUrl 是相对 HTML 文件的路径,而非配置文件;
  2. 避免全局变量:模块内通过 return 导出,不要随意定义全局变量;
  3. 循环依赖:AMD 支持循环依赖,但需通过 require 回调获取依赖(而非参数):
    // a.js
    define(['b'], function(b) {
      return {
        foo: function() {
          return b.bar();
        }
      };
    });
    
    // b.js
    define(['a'], function(a) {
      return {
        bar: function() {
          // 循环依赖时,通过 require 实时获取 a
          return require('a').foo() + ' bar';
        }
      };
    });
    
  4. 生产环境优化:务必用 r.js 打包,减少 HTTP 请求;
  5. ES6 模块兼容:RequireJS 也支持加载 ES6 模块(需配置 packages),但现代项目更推荐 ES6 Module + Webpack/Rollup。

六、与 ES6 Module 的区别

特性 RequireJS(AMD) ES6 Module
加载方式 异步加载(运行时加载) 静态加载(编译时确定依赖)
语法 define/require import/export
作用域 函数作用域 块级作用域
浏览器支持 需加载器(RequireJS) 现代浏览器原生支持(需 <script type="module">
动态加载 原生支持 require() 需配合 import() 动态导入

注:现代前端项目(React/Vue/Angular)已普遍使用 ES6 Module + 构建工具(Webpack/Vite),但 RequireJS 仍适用于老项目维护、非构建型简单应用。

七、总结

RequireJS 是浏览器端模块化开发的经典解决方案,核心是通过 AMD 规范实现异步模块加载和依赖管理。其优势是轻量、无需构建工具即可使用,适合中小型项目或老项目维护;缺点是语法相对繁琐,现代项目更推荐 ES6 Module + 构建工具。

如果需要具体场景的代码示例(如循环依赖处理、打包优化),可以进一步说明!

从零掌握 React JSX:为什么它让前端开发像搭积木一样简单?

从零掌握 React JSX:为什么它让前端开发像搭积木一样简单?

大家好,今天带大家深入聊聊 React 的核心灵魂——JSX。我们会结合真实代码示例,一步步拆解 JSX 的本质、组件化开发、状态管理,以及那些容易踩坑的地方。

React 为什么这么火?因为它把前端开发从“拼 HTML + CSS + JS”的手工活,变成了“搭积木式”的组件化工程。JSX 就是那把神奇的“胶水”,让 JavaScript 里直接写 HTML-like 代码成为可能。

5609728adb9d921c5649719c8cbf0517.jpg

JSX 是什么?XML in JS 的魔法

想象一下,你在 JavaScript 代码里直接写 HTML 标签,这听起来多酷?这就是 JSX(JavaScript XML)的核心。它不是字符串,也不是真正的 HTML,而是一种语法扩展,看起来像 XML,但最终会被编译成纯 JavaScript。

为什么需要 JSX?传统前端开发,HTML、CSS、JS 三分离,逻辑和视图混在一起时很容易乱套。React 说:不,我们把一切都放进 JS 里!这样,UI 描述和逻辑紧密耦合,代码更易维护。

来看个简单对比:

  • 不使用 JSX(纯 createElement):

    const element = createElement('h2', null, 'JSX 是 React 中用于描述用户界面的语法扩展');
    
  • 使用 JSX(语法糖):

    const element = <h2>JSX 是 React 中用于描述用户界面的语法扩展</h2>;
    

明显后者更直观、可读性更高!JSX 本质上是 React.createElement 的语法糖,Babel 会帮我们编译成后者。

:Babel 是一个开源的 JavaScript 编译器,更准确地说,是一个 转译器。它的主要作用是:把现代 JavaScript 代码(ES2015+,也就是 ES6 及更高版本)转换成向后兼容的旧版 JavaScript 代码,让这些代码能在老浏览器或旧环境中正常运行。

底层逻辑:JSX 被 Babel 转译后,生成 Virtual DOM 对象树。React 用这个虚拟树对比真实 DOM,只更新变化部分,这就是 React 高性能的秘密——Diff 算法 + 批量更新。

React vs Vue:为什么 React 更“激进”?

Vue 和 React 都是现代前端框架的代表,都支持响应式、数据绑定和组件化。但 React 更纯粹、更激进。

  • Vue:模板、脚本、样式三分离(单文件组件 .vue),上手友好,双向绑定 v-model 超级方便。适合快速原型开发。
  • React:一切皆 JS!JSX 把模板塞进 JS,单向数据流(props down, events up),逻辑更明确,但学习曲线陡峭。

React 的激进在于:它不提供“开箱即用”的模板语法,而是让你用完整的 JavaScript 能力构建 UI。你可以用 if、map、变量等原生 JS 控制渲染,而 Vue 模板需要指令(如 v-if、v-for)。

为什么很多人说 React 入门门槛高?因为它强制你思考“组件树”和“状态流”,而不是靠模板魔法。但一旦上手,你会发现它在大型项目中更可控、更灵活。Facebook、Netflix 都在用 React,就是因为组件化让代码像乐高积木一样可复用

组件化开发:从 DOM 树到组件树

传统前端靠 DOM 操作,审查元素是层层 div。React 说:不,我们用组件树代替 DOM 树!

组件是 React 的基本单位,每个组件是一个函数(现代 React 推荐函数组件),返回 JSX 描述 UI。

来看一个模拟掘金首页的例子:

function JuejinHeader() {
  return (
    <header>
      <h1>掘金的首页</h1>
    </header>
  );
}

const Articles = () => <main>Articles</main>;

function App() {
  return (
    <div>
      <JuejinHeader />
      <main>
        <Articles />
        <aside>{/* 侧边栏组件 */}</aside>
      </main>
    </div>
  );
}

这里,App 是根组件,组合了子组件。就像包工头分工:Header 负责头部,Articles 负责文章列表。页面就是这些组件搭起来的!

image.png

这张图的核心就是:把复杂 UI 拆分成组件树,每个组件专注自己的事,通过组合构建整个页面。

关键点:组件复用

  • 你会注意到 FancyText 出现了两次(一个直接在 App 下,一个在 InspirationGenerator 下)。
  • 这就是在强调:同一个组件可以被多个父组件多次渲染和复用!这正是 React 组件化开发的强大之处——写一次,到处用,像乐高积木一样组合。

为什么函数做组件? 因为函数纯净、无副作用,能完美封装 UI + 逻辑 + 状态。类组件(旧方式)有 this 绑定问题,函数组件 + Hooks 解决了这一切。

底层逻辑:React 渲染时,会递归调用每个组件的 render 函数,最终生成一棵完整的 Virtual DOM 树。也就是说每个组件渲染生成 Virtual DOM 片段,React 合并成一棵大树。更新时,只重渲染变化的组件子树。

useState:让函数组件拥有“记忆”

组件需要交互?就需要状态!useState 是 Hooks 的入门王牌。

import { useState } from 'react';

function App() {
  const [name, setName] = useState('vue'); // 初始值 'vue'
  const [todos, setTodos] = useState([
    { id: 1, title: '学习 React', done: false },
    { id: 2, title: '学习 Node', done: false }
  ]);
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  const toggleLogin = () => setIsLoggedIn(!isLoggedIn);

  setTimeout(() => setName('react'), 3000); // 3秒后自动更新

  return (
    <>
      <h1>Hello <span className="title">{name}</span></h1>
      {todos.length > 0 ? (
        <ul>
          {todos.map(todo => (
            <li key={todo.id}>{todo.title}</li>
          ))}
        </ul>
      ) : (
        <div>暂无待办事项</div>
      )}
      {isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
      <button onClick={toggleLogin}>
        {isLoggedIn ? '退出登录' : '登录'}
      </button>
    </>
  );
}

image.png

useState 返回 [状态值, 更新函数]。调用更新函数会触发重渲染,React 记住最新状态。

函数组件 + Hooks,代码更简洁、复用性更强。常见 Hooks:

  • useState:管理状态
  • useEffect:处理副作用(数据获取、订阅等)
  • useContext、useReducer、useRef 等

易错提醒

setState 是异步的!多个 setState 可能批处理,不要直接依赖旧值。
// 错:可能加1多次只加1
setCount(count + 1);

// 对:函数式更新
setCount(prev => prev + 1);
  • 在同一个事件(如 onClick)里,React 不会立即更新状态,而是把所有 setCount 调用收集到一个队列里。

  • 所有 setCount 执行时,看到的 count 都是当前渲染的“快照值” (这里是 0)。

  • 等事件结束,React 一次性处理队列:两次都是 “0 + 1 = 1”,最后覆盖成同一个值 1,只重渲染一次。

对象状态更新不会自动合并,用展开运算符:

错的例子:直接替换对象,会丢失属性

假设初始状态是一个对象:

const [person, setPerson] = useState({
  name: 'Alice',
  age: 30,
  city: 'Beijing'
});

如果你想只改 age:

// 错!直接传新对象
setPerson({ age: 35 });

结果:新状态变成 { age: 35 },name 和 city 全没了!因为 React 直接用你传的对象替换了整个状态。

正确的做法:用展开运算符(...prev)手动合并

jsx

// 对!函数式更新 + 展开运算符
setPerson(prev => ({ ...prev, age: 35 }));

这里发生了什么?

  • prev 是当前最新的状态对象({ name: 'Alice', age: 30, city: 'Beijing' })
  • { ...prev }:用 ES6 展开运算符把 prev 的所有属性复制到一个新对象里 → { name: 'Alice', age: 30, city: 'Beijing' }
  • age: 35:覆盖 age 属性
  • 最终返回新对象:{ name: 'Alice', age: 35, city: 'Beijing' }

完美!只改了 age,其他属性保留了。

JSX 常见坑与最佳实践

JSX 强大,但也有陷阱:

  1. class → className:class 是 JS 关键字,必须用 className。

    <div className="title">错误会报错!</div>
    
  2. 最外层必须单根元素

JSX 的 return 必须返回一个元素(或 null),不能直接返回多个并列元素。

错的:

return (
  <h1>标题</h1>
  <p>段落</p>  // 报错!Adjacent JSX elements must be wrapped...
);

因为 JSX 最终转译成 React.createElement 调用,而函数返回值只能是一个表达式。

正确做法:用 Fragment <> </> 包裹(不渲染多余 DOM)

return (
  <>  {/* 短语法 */}
    <h1>标题</h1>
    <p>段落</p>
  </>
);

// 或
return (
  <React.Fragment>
    <h1>标题</h1>
    <p>段落</p>
  </React.Fragment>
);

3. 表达式用 {}:插值、条件、三元、map 都用大括号。 jsx {condition ? <A /> : <B />}

  1. key 必加:列表渲染 map 时,加唯一 key,帮助 React 高效 Diff。

    {todos.map(todo => <li key={todo.id}>...</li>)}
    

    缺 key 会警告,性能差。

  2. 事件用 camelCase:onClick,不是 onclick。

  3. 自闭合标签:单标签必须闭合,如 <img />

根组件挂载:从 main.jsx 看 React 启动

import { createRoot } from 'react-dom/client';
import App from './App.jsx';

createRoot(document.getElementById('root')).render(
  <App />
);

1. 这段代码在干啥?一步步拆解

  • document.getElementById('root') :找到 HTML 文件里的挂载点。通常 index.html 有个

    ,React 会把整个应用塞进去,接管这个 div 里的所有内容。

  • createRoot(...) :创建 React 的“根”(Root)。它返回一个 Root 对象,这个对象负责管理整个组件树和 DOM 更新。

  • .render() :告诉 React:“嘿,从现在开始渲染 App 组件吧!”React 会从 App 开始递归渲染组件树,生成 Virtual DOM,最终 commit 到真实 DOM。

整个过程:创建根 → 初始渲染 → 接管 DOM。应用启动后,React 就完全掌控了 #root 里的 UI。

总结:为什么选择 React 和 JSX?

JSX 让 React 成为“全栈 JS”的代表:逻辑、视图、状态全在 JS 里。组件化让你像建筑师一样设计页面,useState 等 Hooks 让函数组件强大无比。

相比 Vue,React 更适合大型、复杂应用(生态丰富,TypeScript 支持一流)。但 Vue 上手更快,适合中小项目。

学 React,不是学语法,而是学“声明式编程”和“组件思维”。掌握 JSX,你就掌握了 React 的半壁江山。

告别“CV工程师”:手把手教你设计一套 B 端低代码 DSL

本文内容引用 哲玄-大前端全栈实践

1. 咱们的痛点:为什么天天在写重复代码?

兄弟们,做 B 端后台开发的日常是不是这样的? 早上来了,产品经理说:“加个用户管理页面。” 你想了想:

  1. 写个 Router 路由。
  2. 画个 Search Bar(搜索栏),里面放 Input 和 Select。
  3. 画个 Table(表格),搞定分页逻辑。
  4. 搞个 Dialog(弹窗),写表单验证。
  5. 调 API,绑定数据……

下午,产品经理又来了:“再加个订单管理页面。” 你一看,这特么跟上午那个页面长得有 90% 是一样的啊! 只是字段从“用户名”变成了“订单号”,接口换了一个而已。

于是,我们变成了毫无感情的 Ctrl+C / Ctrl+V 机器

这套 DSL 的设计初衷就是: 把那 80% 重复的“头部、侧边栏、搜索、表格”抽象成 JSON 配置;剩下的 20% 复杂的逻辑,留给你去挥洒才华。


先附上图和完整的DSL配置 微信图片_20251213192851_15_67.jpg

module.exports = {
  "mode": "dashboard",
  // 头部菜单
  "menu": [{
    // 菜单唯一描述
    "key": "",
    // 菜单name
    "name": "",
    // 菜单类型 group分组 / module
    "menuType": "",
    // 子菜单 当menuType为group时有效
    "subMenu": [{

    }, ...],
    // 菜单行为,当menuType为module时有效。 枚举值:iframe / custom / schema / sider
    "moduleType": "",
    // 当moduleType为sider时有效
    siderConfig:{
      menu:[{},...]
    },
    // 当moduleType为iframe时有效
    iframeConfig: {
      path: "", // iframe地址
    },
    // 当moduleType为custom时有效
    customConfig: {
      path: "" // 自定义路由路径
    },
    // 当moduleType为schema时有效
    schemaConfig: {
      api: "", // 数据源api地址,遵循restful风格
      // json-schema描述
      schema: {
        type: "object",
        properties: {
          key: {
            ...schema, //标准json-schema描述
            type: "string",// 字段类型
            label: "字段名称",// 字段名称
            // 字段在table中的配置
            tableOption:{
              ...elTableColumnConfig, // 标准el-table-column配置
              tofixed: 2, // 数字类型时的小数位数
              visible: true // 是否在table中显示
            },
            // 字段在search-bar中的配置
            searchOption:{
              ...elComponentConfig, // 标准el-component-column组件配置
              comType:'', // 配置组件类型 input/select/dynamicSelect/date-picker/date-range等
              default:'', // 默认值
              // comType为select生效
              enumList:[
                {
                  label:'',
                  value:''
                }
              ],
              // comType为dynamicSelect生效
              api:''
            }
            
          },
          ...
        },
      },
      tableConfig: {
        headerButtons: [
          {
            label:'', // 按钮名称
            eventKey:'', // 按钮事件名
            eventOption:{
               // 按钮事件参数配置
              params:{
                "paramsKey":"schema::fieldKey" // schema:: 开头表示取schema中的字段值
              }
            }, // 按钮事件配置
            ...elButtonConfig, // 标准el-button配置
          }
        ],
        rowButtons: [
          {
            label:'', // 按钮名称
            eventKey:'', // 按钮事件名
            eventOption:{}, // 按钮事件配置
            ...elButtonConfig, // 标准el-button配置
          }
        ]
      }, // table相关配置
      searchConfig: {}, // 搜索相关配置
      components: {}, // 模块组件
    }, ...]
}

2. 宏观架构:像“搭积木”一样组装页面

先看这张架构大图,别被吓到了,其实它就讲了两件事: “怎么配”“怎么染”

2.1 底座:BFF Server 的“继承大法”

看架构图的最底下(红色虚线框区域)。 我们借鉴了面向对象的思想。比如你要做一个“电商后台”:

  • 领域模型(基类) :我们定义好一套所有页面通用的规则。比如,所有表格默认都有“创建时间”,所有搜索栏默认都有“重置”按钮。
  • 项目配置(子类) :具体到“订单管理”页面时,你只需要继承基类,然后说“我要加个订单金额字段”。

好处? 修改基类,一百个页面同时生效,不用一个个文件去改。

2.2 页面骨架:路由分发与组件分工

看中间蓝色的区域,它在工程实现上其实就是一套精心设计的 Vue Router 配置

我们没有写死页面,而是预先定义好了几个“核心容器组件”(View),就像这是几辆不同功能的“车”,JSON 数据就是“乘客”,路由决定了把乘客装进哪辆车里。

来看看这段核心路由代码:

JavaScript

import Dashboard from "./dashboard.vue";
import boot from "@/boot";

// 1. 定义“引擎”列表:这里就是我们的策略库
const componentList = [
  {
    path: "iframe", // 对应 moduleType: iframe
    component: () => import("./complex-view/iframe-view/iframe-view.vue"),
  },
  {
    path: "schema", // 对应 moduleType: schema(核心低代码页)
    component: () => import("./complex-view/schema-view/schema-view.vue"),
  },
  {
    path: "todo",   // 待办/自定义页
    component: () => import("./todo/todo.vue"),
  },
];

// 2. 动态生成扁平路由
const routes = componentList.map((item) => ({
  path: `/view/dashboard/${item.path}`,
  component: item.component,
}));

// 3. 侧边栏布局(Sider Layout)策略
// 如果配置了侧边栏,就让 sider-view 作为父路由,把 componentList 作为子路由嵌套进去
routes.push({
  path: "/view/dashboard/sider",
  component: () => import("./complex-view/sider-view/sider-view.vue"),
  children: componentList,
});

// 4. 侧边栏兜底策略(Wildcard Route)
// 处理多级深层菜单的情况,保证 url 即使很长也能匹配到 sider 布局
routes.push({
  path: "/view/dashboard/sider/:chapters+",
  component: () => import("./complex-view/sider-view/sider-view.vue"),
});

boot(Dashboard, { routes });

这段代码揭示了 DSL 运行的实质流程:

(1) 路由即分发(The Router Dispatcher)

  • 当 URL 匹配到 /view/dashboard/schema 时,Vue Router 自动加载 Schema-View 组件。
  • 当 URL 匹配到 /view/dashboard/iframe 时,加载 Iframe-View 组件。
  • 侧边栏的巧妙处理:代码中专门为 /sider 路径配置了 children,这意味着如果你的页面配置了侧边栏,系统会先渲染 sider-view 框架,再把具体的内容(schema 或 iframe)渲染到 <router-view> 插槽中。

(2) Schema-View 的内部构造

一旦路由命中了 Schema-View,这个组件内部其实又做了一次精细化分工。它不是一个巨大的黑盒,而是由两个核心子面板组成的:

  • 上层:Search-Panel(搜索面板) 它负责接收 JSON 中的 searchOption 配置,动态生成 Input、Select 等表单项。
  • 下层:Table-Panel(表格面板) 它负责接收 JSON 中的 tableOption 配置,负责数据的展示、分页以及行列操作。

总结一下: 路由负责**“选车” (选 Sider 还是 Schema 还是 Iframe),而 Schema-View 负责“装货”**(把 JSON 拆分成搜索配置和表格配置,分发给上下两个面板)。这样一来,结构清晰,维护也非常容易。

3. JSON 核心揭秘:一份配置,掌控全局

接下来我们对着那段 JSON 代码,看看它是怎么指挥前端干活的。

3.1 路由的大脑:menumoduleType

JSON 最外层的 menu 决定了系统的导航结构。这里有个最关键的开关叫 menuTypemoduleType

这也是为了防止“一刀切”。我们不能因为用了低代码,就写不了复杂页面。

  • 如果是标准增删改查:设 moduleType: "schema"。引擎自动干活,你喝咖啡。
  • 如果是超复杂的数据大屏:设 moduleType: "custom"。引擎让路,加载你手写的 Vue 组件。
  • 如果是老系统页面:设 moduleType: "iframe"。直接内嵌完事。

3.2 字段的“单源真理”:最骚的操作在这里

请重点看 JSON 里的 properties 字段。这是整个 DSL 最精华的部分。

以往我们写代码,搜索栏写一遍 <el-input v-model="name">,表格里又写一遍 <el-table-column prop="name">两边是割裂的。

在这个 DSL 里,我们把一个字段(比如 key)的所有属性聚合在一起:

JavaScript

key: {
  label: "字段名称", // 通用名称
  // 1. 告诉表格怎么展示
  tableOption: { 
    visible: true, 
    tofixed: 2 // 假如是数字,自动保留2位小数
  },
  // 2. 告诉搜索栏怎么搜索
  searchOption: { 
    comType: 'select', // 自动渲染成下拉框
    enumList: [...]    // 下拉选项
  }
}

看懂了吗? 你只需要定义一次 key。解析器读取 searchOption 就在上面渲染搜索框,读取 tableOption 就在下面渲染表格列。 改一个字段名,搜索和表格同时更新。 这才叫“不重复造轮子”。

3.3 按钮与事件:schema:: 的魔法

表格肯定要有操作按钮,比如“编辑”、“删除”。 在 headerButtonsrowButtons 里,你可能会疑惑这一行: "paramsKey": "schema::fieldKey"

这是我们约定的一种动态取值语法

  • 场景:点击“删除”按钮,需要调 API 删掉当前行,API 需要 id 参数。
  • 原理:当前端引擎看到 schema:: 开头时,它就知道:“哦,用户不是要传死字符串,而是要我去当前这一行的数据里,把 fieldKey 对应的值取出来传给后端。”

4. 运行流程总结

把图和代码串起来,整个流程是这样的:

  1. 加载:你打开页面,BFF Server 把合并好的 JSON 扔给前端。

  2. 路由:前端 Router 看到 moduleType: "schema",就把任务交给通用模板页。

  3. 渲染

    • Search 引擎 遍历 JSON 里的 properties,把带有 searchOption 的字段挑出来,生成搜索栏。
    • Table 引擎 遍历 properties,把带有 tableOption 的字段挑出来,生成表格列。
  4. 交互:用户点“搜索”,引擎自动收集所有搜索框的值,拼接 API URL,刷新表格数据。


5. 写在最后

这套 DSL 的本质,不是为了炫技,而是为了偷懒(褒义)。

它把我们从繁琐的 DOM 结构和 UI 库 API 中解放出来,让我们只关注业务数据本身。毕竟,作为开发者,我们的价值应该体现在解决复杂的业务逻辑上,而不是比谁写的 <el-table-column> 更多,对吧?

C# Dictionary 入门:用键值对告别低效遍历

一、Dictionary 是什么

Dictionary<TKey, TValue>

  • 一个“键值对”集合
  • 通过 Key 快速查 Value
  • 查找、添加、删除的平均复杂度接近 O(1)
  • 底层是哈希表(hash table)

典型场景:

  • 用户ID(Key) → 用户对象(Value)
  • 商品编码 → 商品信息
  • 配置项名称 → 配置值
  • 状态码 → 描述字符串

命名空间在:

using System.Collections.Generic;

二、如何创建一个 Dictionary

1. 无参构造

var dict = new Dictionary<string, int>();

含义:

  • Key 类型:string
  • Value 类型:int
  • 初始容量默认(会按需要自动扩容)

2. 指定初始容量(推荐在数据量较大时用)

var dict = new Dictionary<string, int>(capacity: 1000);

好处:

  • 减少扩容次数,性能更稳定
  • 适合“我大概知道会放多少条数据”的场景

3.直接初始化一些数据(集合初始化器)

var dict = new Dictionary<string, int>
{
    { "apple", 3 },
    { "banana", 5 },
};

还可以用索引器形式:

var dict = new Dictionary<string, int>
{
    ["apple"] = 3,
    ["banana"] = 5,
};

三、日常要用到的基本操作

var stock = new Dictionary<string, int>();

1. 添加:Add vs 直接用索引器

// 方式1:Add
stock.Add("apple", 10);
stock.Add("banana", 5);

// 方式2:索引器
stock["orange"] = 8;    // orange 不存在时 → 添加
stock["orange"] = 12;   // orange 已存在时 → 覆盖为 12

区别:

  • Add(key, value)
    • 如果 Key 已经存在,会抛 ArgumentException
    • 适合“逻辑上不该有重复 Key,有就是 Bug”的情况
  • stock[key] = value
    • Key 不存在 → 添加
    • Key 已存在 → 覆盖
    • 适合“重复 Key 表示更新”的场景

2. 读取:索引器 vs TryGetValue

// 已经有一些数据
stock["apple"] = 10;

// 方式1:索引器
int appleCount = stock["apple"];  // 如果 apple 不存在会抛 KeyNotFoundException

// 方式2:TryGetValue(推荐)
if (stock.TryGetValue("banana", out int bananaCount))
{
    Console.WriteLine($"banana: {bananaCount}");
}
else
{
    Console.WriteLine("banana 不存在");
}

使用建议:

  • 确定 Key 一定存在 → 可以直接用索引器
  • 不确定 Key 是否存在 → 优先用 TryGetValue,防止异常

3. 修改:直接给索引器赋值即可

// 已有 "apple" → 10
stock["apple"] = 15; // 覆盖为 15

如果你想“在原有值上累加”,可以搭配 TryGetValue

void AddStock(string name, int delta)
{
    stock.TryGetValue(name, out int current); // 不存在时 current=0
    stock[name] = current + delta;
}

// 用法:
AddStock("apple", 5);  // apple: 10 → 15
AddStock("pear", 3);   // pear: 0  → 3(新增)

4. 删除:Remove / Clear

// 删除某个键值对
bool removed = stock.Remove("apple");   // 删除成功返回 true,不存在返回 false

// 清空所有数据
stock.Clear();

四、几个非常重要的属性和方法

1. Count:当前元素个数

Console.WriteLine(stock.Count);

2. KeysValues:获取所有 Key / Value

var keys = stock.Keys;       // ICollection<string>
var values = stock.Values;   // ICollection<int>

stock["apple"] = 33;

foreach (var name in stock.Keys)
{
    Console.WriteLine(name);
}

foreach (var count in stock.Values)
{
    Console.WriteLine(count);
}

注意:

  • Keys / Values 是引用,不是复制品
  • 修改原字典,这两个集合感知得到变化

3. ContainsKey / ContainsValue

bool hasApple = stock.ContainsKey("apple");
bool hasCount10 = stock.ContainsValue(10);

区别与性能:

  • ContainsKey:平均 O(1),很快
  • ContainsValue:需要遍历所有 Value,O(n),大字典慎用

五、如何正确遍历 Dictionary

1. 遍历键值对

foreach (var kv in stock)
{
    Console.WriteLine($"水果:{kv.Key},库存:{kv.Value}");
}

2. 解构写法

foreach (var (name, count) in stock)
{
    Console.WriteLine($"{name} => {count}");
}

3. 只遍历 Key 或只遍历 Value

foreach (var name in stock.Keys)
{
    Console.WriteLine(name);
}

foreach (var count in stock.Values)
{
    Console.WriteLine(count);
}

4. 注意:遍历时不要直接修改字典

下面这种写法在运行时会抛 InvalidOperationException

foreach (var (name, count) in stock)
{
    if (count == 0)
    {
        stock.Remove(name); // 遍历中修改集合 → 异常
    }
}

正确写法之一:先记录要删的 Key,再统一删:

var toRemove = new List<string>();

foreach (var (name, count) in stock)
{
    if (count == 0)
        toRemove.Add(name);
}

foreach (var name in toRemove)
{
    stock.Remove(name);
}

结语

点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文

从一个“不能输负号”的数字输入框说起:Web Component 数字输入组件重构实录

背景

拿到需求时,因为工期还比较宽松,官网开发,我又只做其中一个组件,框架又没有定。

我决定使用原生开发,并封装为Web Component以适配任何框架(如果不能适配,说明框架有问题)。其中就有一个数字输入框带拉杆的,数字输入框和拉杆这两个东西,原生组件都有。

于是在我的要求下,ai很快给我封装了一个还可以的东西,不过后面ui又去掉拉杆了。

临近发布,要合代码了,同事才发现这个输入框有点儿问题!


起点:一段“差不多能用”的代码

这里就不赘述Web Component的开发了,因为确实很简单,看代码就行了。

这是我最初写的 NumInput 组件(为简洁省略部分 CSS):

export class NumInput extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    // ... 模板里用了 <input type="number" steop="0.1">
  }
  _onNumberInput(event) {
    const newValue = this.clamp(event.target.value, this.min, this.max);
    this.setAttribute('value', newValue);
    this._dispatchEvent();
  }
}

功能上:

  • 支持 labelunitminmaxstep
  • 聚焦有高亮效果
  • 值变化会派发 value-changed 事件
  • 还有后缀单位

看起来没问题?直到女同事问我:“为什么我输 - 没反应?” 我压根儿没想过手输,因为这个组件最开始的时候,还是带拉杆的,纯鼠标操作一点儿问题没有。

当然,现在UI变了。


问题一:type="number" 不让你输 “-” 和 “.”

首先,这不是一个bug,嗯。 我仔细研究了一下原生的数字框的机制。

1 实时校验,你每输入一个字符都会校验。 2 只能输入数字相关的,0-9 . -

那么问题来了,为什么她无法输入“-” 和 “.”呢?

实际上,并不是无法输入,只是时机和位置不对。 如果输入框中已经有一个数字1了,这个时候,你就可以在这个数字前面输入一个 “-”,在它后面输入一个“.”,这两种情况(-1和1.)都是合法的。

其余情况都是不合法的,所以无法输入。

结论:type="number"校验过于严苛,鼠标操作足矣,不适合手动输入。


重构第一步:放弃 type="number",拥抱 type="text"

使用text输入框,意味着之前数字框有的功能,我现在也都要也有,这是这个手动输入的校验规则要自定义。

我改成了:

<input type="text" inputmode="decimal" />

inputmode="decimal" 能让移动端弹出带小数点的数字键盘,体验不降反升。

但光改类型不够,得自己控制输入内容。

宽松过滤,只拦非法字符

input 事件中,我只做一件事:

_onTextInput(e) {
  let val = e.target.value;
  val = val.replace(/[^0-9.\-+]/g, ''); // 只留数字、点、正负号
  // 再处理符号位置、小数点数量...
  e.target.value = val;
}

关键原则

输入过程中,只过滤,不校验
允许用户输 -.5-12.,这些“中间状态”必须保留。


重构第二步:什么时候才该“认真”校验?

要保留用户输入的字符,又要在结束后校验,一般可能会想到节流,我觉得太麻烦了,不是指实现节流麻烦,而是节流这个逻辑本身,会一直后延,让js很麻烦。

所以,怎么判断:输入结束了

我定义了两个“结束信号”:

  1. 失焦(blur
  2. 按下回车(Enter) 刚开始没想到这个,直到我输了数字没有反应,习惯性地回车了一下。

在这两个时机,调用同一个函数 _finalizeInput()

_finalizeInput() {
  const raw = this.numberEl.value.trim();
  // 如果是中间状态(如 '-'),不处理
  if (raw === '' || raw === '-' || raw === '.') return;

  let num = parseFloat(raw);
  if (isNaN(num)) {
    // 无效?回退到上次合法值
    this.numberEl.value = this.getAttribute('value') || '';
    return;
  }

  // clamp 到 [min, max]
  num = Math.min(Math.max(num, this.min), this.max);

  // 修正浮点精度(关键!)
  num = this._roundToStepPrecision(num, this.step);

  this.numberEl.value = String(num);
  this.setAttribute('value', num);
  this._dispatchEvent(); // 派发的是数字,不是字符串!
}

问题二:0.1 + 0.2 ≠ 0.3?

  • 0.1 + 0.2 → 显示 0.30000000000000004 JavaScript 的浮点精度问题是老朋友了。
    但用户不关心这些,他们只看到“我 step=0.1,怎么变出一串小数?

解法:按 step 的小数位数四舍五入

_roundToStepPrecision(value, step) {
  if (Number.isInteger(step)) return Math.round(value);
  const decimalPlaces = step.toString().split('.')[1]?.length || 0;
  const factor = 10 ** decimalPlaces;
  return Math.round(value * factor) / factor;
}
  • step=0.1 → 保留 1 位 → 0.300000000000000040.3
  • step=0.01 → 保留 2 位 → 0.13
  • step=1 → 整数 → 4

所有赋值路径(手动输入、上下键、外部设置)都走这个修正,彻底告别脏数字。


监听一下回车作为“确认”。

这里直接不仅走了失焦的逻辑,还主动失焦,避免二次“失焦”。

this.numberEl.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    this._finalizeInput();
    this.numberEl.blur(); // 自动失焦,统一交互
  }
});

其他细节打磨

  • 修复 typosteop="0.1"step="0.1"(别笑,真有人写错)
  • 移除无用代码:原始代码里声明了 rangeEl 但没用,删掉
  • 事件传数字value-changeddetail.valuenumber 类型,不是字符串
  • 外部设置值也修正setAttribute('value', '0.30000000000000004') 会自动转成 0.3

最终效果

✅ 自由输入 -.-12.
✅ 按 ↑/↓ 按 step 精确增减
✅ 按 Enter 或失焦自动校验+修正
✅ 支持 min/max 限制
✅ 移动端弹出数字键盘
✅ 事件传出干净的数字
✅ 完全 Web Component,零依赖


结语

这个组件最终代码比最初长了近一倍,但用户体验提升是质的飞跃

有时候,看似简单的功能,深挖下去全是坑。
但正是这些“小细节”,决定了产品是“能用”还是“好用”。

最后,女同事看了一眼说:“这个输入框终于不抽风了。”
我笑了笑,没告诉她,我让AI改了四版。

(完)


🧩 一文搞懂 HarmonyOS 中的 HAP、HAR 和 HSP:它们到底是什么?怎么用?

Snip20251213_6.png

🌟 开头一句话总结

  • HAP 是你最终安装到手机上的“App 包”;
  • HAR 是可被多个 App 共享的“动态库”(像 npm 包);
  • HSP 是只能被一个 App 内部使用的“静态库”(像私有工具函数集合)。

📦 1. HAP:HarmonyOS Ability Package(能力包)

✅ 它是什么?

HAP 是 鸿蒙应用的安装单元。你可以把它理解为 Android 的 APK 或 iOS 的 IPA。

每个鸿蒙 App 至少包含一个 HAP,通常分为两种:

类型 说明
Entry HAP 主模块,用户点击图标启动的就是它(必须有)
Feature HAP 可选功能模块,按需下载(比如“直播”、“支付”等独立功能)

📁 文件结构示例:

MyApp/
├── entry/          ← Entry HAP
│   ├── src/main/
│   └── module.json5
├── feature_live/   ← Feature HAP(可选)
└── build-profile.json5

💡 关键点:

  • 用户安装的是 .hap 文件(实际是 ZIP 格式)。
  • 一个 App 可以有多个 HAP,但只有一个 Entry。
  • HAP 里包含代码、资源、配置、Ability(页面/服务)等。

🧱 2. HAR:HarmonyOS Archive(共享归档包)

✅ 它是什么?

HAR 是 可复用的共享库,类似 Web 开发中的 npm 包,或 Android 的 AAR。

  • 多个 App 或多个 HAP 都可以引用同一个 HAR
  • 编译后生成 .har 文件。
  • 支持包含 TS/JS 代码、C++ 原生代码、资源文件(图片、字符串等)

🛠️ 什么时候用 HAR?

  • 你有一套 UI 组件库(比如 Design System)要给多个项目用;
  • 封装了网络请求、日志、加密等通用逻辑;
  • 团队协作,需要模块解耦。

📁 创建方式(DevEco Studio):

新建模块 → 选择 “Shared Library” → 生成的就是 HAR。

⚠️ 注意限制:

  • HAR 不能包含 Ability(页面/服务) —— 它只是“工具箱”,不是“应用”。
  • 资源 ID 在不同 HAR 间可能冲突(建议加前缀)。

🔒 3. HSP:HarmonyOS Static Package(静态包)

✅ 它是什么?

HSP 是 仅限当前 App 内部使用的静态库,编译时会直接“合并”进主 HAP。

  • 不会被其他 App 引用;
  • 最终不会生成独立文件,而是“内联”到 HAP 中;
  • 更安全(代码不暴露)、更轻量(无运行时开销)。

🛠️ 什么时候用 HSP?

  • 工具函数、常量、私有业务逻辑,不想对外暴露;
  • 追求极致性能,避免 HAR 的动态加载开销;
  • 模块只在本 App 内使用,无需共享。

📁 创建方式:

新建模块 → 选择 “Static Library” → 生成 HSP。


🔁 对比总结表

特性 HAP HAR HSP
用途 应用安装包 共享库 静态私有库
能否被安装 ✅ 是 ❌ 否 ❌ 否
能否包含页面(Ability) ✅ 是 ❌ 否 ❌ 否
能否被多个 App 共用 ❌ 否 ✅ 是 ❌ 否
编译产物 .hap .har 无独立文件(内联)
创建模板 Empty Ability Shared Library Static Library

🎯 实际开发建议

  1. 主 App 功能 → 用 HAP(Entry + Feature);
  2. 跨项目复用组件/逻辑 → 用 HAR
  3. 仅本项目内部工具 → 用 HSP(更安全高效);
  4. 不要把业务页面放进 HAR/HSP —— 它们只能放“辅助代码”。

🧪 举个例子

假设你在开发一个电商 App:

  • entry → 主 HAP(首页、商品列表)
  • feature_cart → 购物车 HAP(按需加载)
  • common_ui.har → 通用按钮、弹窗组件(多个 App 共用)
  • utils.hsp → 本地加密、时间格式化(仅本 App 用)

这样结构清晰,复用性强,也便于团队分工!


✅ 结语

HAP、HAR、HSP 是鸿蒙模块化开发的三大基石。
理解它们的区别,能帮你写出更规范、可维护、高性能的 HarmonyOS 应用。

📌 记住口诀:
HAP 装得下,HAR 分享它,HSP 私藏吧!

Arco Design 停摆!字节跳动 UI 库凉了?

1. 引言:设计系统的“寒武纪大爆发”与 Arco 的陨落

在 2019 年至 2021 年间,中国前端开发领域经历了一场前所未有的“设计系统”爆发期。伴随着企业级 SaaS 市场的崛起和中后台业务的复杂度攀升,各大互联网巨头纷纷推出了自研的 UI 组件库。这不仅是技术实力的展示,更是企业工程化标准的话语权争夺。在这一背景下,字节跳动推出了 Arco Design,这是一套旨在挑战 Ant Design 霸主地位的“双栈”(React & Vue)企业级设计系统。

Arco Design 在发布之初,凭借其现代化的视觉语言、对 TypeScript 的原生支持以及极具创新性的“Design Lab”设计令牌(Design Token)管理系统,迅速吸引了大量开发者的关注。它被定位为不仅仅是一个组件库,而是一套涵盖设计、开发、工具链的完整解决方案。然而,就在其社区声量达到顶峰后的短短两两年内,这一曾被视为“下一代标准”的项目却陷入了令人费解的沉寂。

截至 2025 年末,GitHub 上的 Issues 堆积如山,关键的基础设施服务(如 IconBox 图标平台)频繁宕机,官方团队的维护活动几乎归零。对于数以万计采用了 Arco Design 的企业和独立开发者而言,这无疑是一场技术选型的灾难。

本文将深入剖析 Arco Design 从辉煌到停摆的全过程。我们将剥开代码的表层,深入字节跳动的组织架构变革、内部团队的博弈(赛马机制)、以及中国互联网大厂特有的“KPI 开源”文化,为您还原整件事情的全貌。

2. 溯源:Arco Design 的诞生背景与技术野心

要理解 Arco Design 为何走向衰败,首先必须理解它诞生时的宏大野心及其背后的组织推手。Arco 并不仅仅是一个简单的 UI 库,它是字节跳动在高速扩张期,为了解决内部极其复杂的国际化与商业化业务需求而孵化的产物。

1.png

2.1 “务实的浪漫主义”:差异化的产品定位

Arco Design 在推出时,鲜明地提出了“务实的浪漫主义”这一设计哲学。这一口号的提出,实际上是为了在市场上与阿里巴巴的 Ant Design 进行差异化竞争。

  • Ant Design 的困境:作为行业标准,Ant Design 以“确定性”著称,其风格克制、理性,甚至略显单调。虽然极其适合金融和后台管理系统,但在需要更强品牌表达力和 C 端体验感的场景下显得力不从心。
  • Arco 的切入点:字节跳动的产品基因(如抖音、TikTok)强调视觉冲击力和用户体验的流畅性。Arco 试图在中后台系统中注入这种基因,主张在解决业务问题(务实)的同时,允许设计师发挥更多的想象力(浪漫)。

这种定位在技术层面体现为对 主题定制(Theming) 的极致追求。Arco Design 并没有像传统库那样仅仅提供几个 Less 变量,而是构建了一个庞大的“Design Lab”平台,允许用户在网页端通过可视化界面细粒度地调整成千上万个 Design Token,并一键生成代码。这种“设计即代码”的早期尝试,是 Arco 最核心的竞争力之一。

2.2 组织架构:GIP UED 与架构前端的联姻

Arco Design 的官方介绍中明确指出,该系统是由 字节跳动 GIP UED 团队架构前端团队(Infrastructure FrontEnd Team) 联合推出的。这一血统注定了它的命运与“GIP”这个业务单元的兴衰紧密绑定。

2.2.1 GIP 的含义与地位

“GIP” 通常指代 Global Internet Products(全球互联网产品)或与之相关的国际化/商业化业务部门。在字节跳动 2019-2021 年的扩张期,这是一个充满活力的部门,负责探索除了核心 App(抖音/TikTok)之外的各种创新业务,包括海外新闻应用(BuzzVideo)、办公套件、以及各种尝试性的出海产品。

  • UED 的话语权:在这一时期,GIP 部门拥有庞大的设计师团队(UED)。为了统一各条分散业务线的设计语言,UED 团队急需一套属于自己的设计系统,而不是直接沿用外部的 Ant Design。
  • 技术基建的配合:架构前端团队的加入,为 Arco Design 提供了工程化落地的保障。这种“设计+技术”的双驱动模式,使得 Arco 在初期展现出了极高的完成度,不仅有 React 版本,还同步推出了 Vue 版本,甚至包括移动端组件库。

2.3 黄金时代的技术堆栈

在 2021 年左右,Arco Design 的技术选型是极具前瞻性的,这也是它能迅速获得 5.5k Star 的原因之一:

  • 全链路 TypeScript:所有组件均采用 TypeScript 编写,提供了优秀的类型推导体验,解决了当时 Ant Design v4 在某些复杂场景下类型定义不友好的痛点。
  • 双框架并进:@arco-design/web-react 和 @arco-design/web-vue 保持了高度统一的 API 设计和视觉风格。这对于那些技术栈不统一的大型公司极具吸引力,意味着设计规范可以跨框架复用。
  • 生态闭环:除了组件库,Arco 还发布了 arco-cli(脚手架)、Arco Pro(中后台模板)、IconBox(图标管理平台)以及 Material Market(物料市场)。这表明团队不仅是在做一个库,而是在构建一个类似 Salesforce Lightning 或 SAP Fiori 的企业级生态。

然而,正是这种庞大的生态铺设,为日后的维护埋下了巨大的隐患。当背后的组织架构发生震荡时,维持如此庞大的产品矩阵所需的资源将变得不可持续。

3. 停摆的证据:基于数据与现象的法医式分析

尽管字节跳动从未发布过一份正式的“Arco Design 停止维护声明”,但通过对代码仓库、社区反馈以及基础设施状态的深入分析,我们可以断定该项目已进入实质性的“脑死亡”状态。

3.1 代码仓库的“心跳停止”

对 GitHub 仓库 arco-design/arco-design (React) 和 arco-design/arco-design-vue (Vue) 的提交记录分析显示,活跃度在 2023 年底至 2024 年初出现了断崖式下跌。

3.png

3.1.1 提交频率分析

虽然 React 版本的最新 Release 版本号为 2.66.8(截至文章撰写时),但这更多是惯性维护。

  • 核心贡献者的离场:早期的高频贡献者(如 sHow8e、jadelike-wine 等)在 2024 年后的活跃度显著降低。许多提交变成了依赖项升级(Dependabot)或极其微小的文档修复,缺乏实质性的功能迭代。
  • Vue 版本的停滞:Vue 版本的状态更为糟糕。最近的提交多集中在构建工具迁移(如迁移到 pnpm)或很久以前的 Bug 修复。核心组件的 Feature Request 长期无人响应。

3.1.2 积重难返的 Issue 列表

Issue 面板是衡量开源项目生命力的体温计。目前,Arco Design 仓库中积累了超过 330 个 Open Issue。

  • 严重的 Bug 无人修复:例如 Issue #3091 “tree-select 组件在虚拟列表状态下搜索无法选中最后一个” 和 Issue #3089 “table 组件的 default-expand-all-rows 属性设置不生效”。这些都是影响生产环境使用的核心组件 Bug,却长期处于 Open 状态。
  • 社区的绝望呐喊:Issue #3090 直接以 “又一个没人维护的 UI 库” 为题,表达了社区用户的愤怒与失望。更有用户在 Discussion 中直言 “这个是不是 KPI 项目啊,现在维护更新好像都越来越少了”。这种负面情绪的蔓延,通常是一个项目走向终结的社会学信号。

3.2 基础设施的崩塌:IconBox 事件

如果说代码更新变慢还可以解释为“功能稳定”,那么基础设施的故障则是项目被放弃的直接证据。

  • IconBox 无法发布:Issue #3092 指出 “IconBox 无法发布包了”。IconBox 是 Arco 生态中用于管理和分发自定义图标的 SaaS 服务。这类服务需要后端服务器、数据库以及运维支持。
  • 含义解读:当一个大厂开源项目的配套 SaaS 服务出现故障且无人修复时,这不仅仅是开发人员没时间的问题,而是意味着服务器的预算可能已经被切断,或者负责运维该服务的团队(GIP 相关的基建团队)已经被解散。这是项目“断供”的最强物理证据。

3.3 文档站点的维护降级

Arco Design 的文档站点虽然目前仍可访问,但其内容更新已经明显滞后。例如,关于 React 18/19 的并发特性支持、最新的 SSR 实践指南等现代前端话题,在文档中鲜有提及。与竞争对手 Ant Design 紧跟 React 官方版本发布的节奏相比,Arco 的文档显得停留在 2022 年的时光胶囊中。

4. 深层归因:组织架构变革下的牺牲品

Arco Design 的陨落,本质上不是技术失败,而是组织架构变革的牺牲品。要理解这一点,我们需要将视线从 GitHub 移向字节跳动的办公大楼,审视这家巨头在过去三年中发生的剧烈动荡。

2.png

4.1 “去肥增瘦”战略与 GIP 的解体

2022 年至 2024 年,字节跳动 CEO 梁汝波多次强调“去肥增瘦”战略,旨在削减低效业务,聚焦核心增长点。这一战略直接冲击了 Arco Design 的母体——GIP 部门。

4.1.1 战略投资部的解散与业务收缩

2022 年初,字节跳动解散了战略投资部,并将原有的投资业务线员工分流。这一动作标志着公司从无边界扩张转向防御性收缩。紧接着,教育(大力教育)、游戏(朝夕光年)以及各类边缘化的国际化尝试业务(GIP 的核心腹地)遭遇了毁灭性的裁员。

4.1.2 GIP 团队的消失

在多轮裁员中,GIP 及其相关的商业化技术团队是重灾区。

  • 人员流失:Arco Design 的核心维护者作为 GIP UED 和架构前端的一员,极有可能在这些轮次的“组织优化”中离职,或者被转岗到核心业务(如抖音电商、AI 模型 Doubao)以保住职位。
  • 业务目标转移:留下来的人员也面临着 KPI 的重置。当业务线都在为生存而战,或者全力以赴投入 AI 军备竞赛时,维护一个无法直接带来营收的开源 UI 库,显然不再是绩效考核中的加分项,甚至是负担。

4.2 内部赛马机制:Arco Design vs. Semi Design

字节跳动素以“APP 工厂”和“内部赛马”文化著称。这种文化不仅存在于 C 端产品中,也渗透到了技术基建领域。Arco Design 的停摆,很大程度上是因为它在与内部竞争对手 Semi Design 的博弈中败下阵来。

4.2.1 Semi Design 的崛起

Semi Design 是由 抖音前端团队MED 产品设计团队 联合推出的设计系统。

  • 出身显赫:与 GIP 这个边缘化的“探索型”部门不同,Semi Design 背靠的是字节跳动的“现金牛”——抖音。抖音前端团队拥有极其充裕的资源和稳固的业务地位。
  • 业务渗透率:Semi Design 官方宣称支持了公司内部“近千个平台产品”,服务 10 万+ 用户。它深度嵌入在抖音的内容生产、审核、运营后台中。这些业务是字节跳动的生命线,因此 Semi Design 被视为“核心资产”。

4.2.2 为什么 Arco 输了?

在资源收缩期,公司高层显然不需要维护两套功能高度重叠的企业级 UI 库。选择保留哪一个,不仅看技术优劣,更看业务绑定深度。

  • 技术路线之争:Semi Design 在 D2C(Design-to-Code)领域走得更远,提供了强大的 Figma 插件,能直接将设计稿转为 React 代码。这种极其强调效率的工具链,更符合字节跳动“大力出奇迹”的工程文化。
  • 归属权:Arco 属于 GIP,GIP 被裁撤或缩编;Semi 属于抖音,抖音如日中天。这几乎是一场没有悬念的战役。当 GIP 团队分崩离析,Arco 自然就成了没人认领的“孤儿”。

4.3 中国大厂的“KPI 开源”陷阱

Arco Design 的命运也折射出中国互联网大厂普遍存在的“KPI 开源”现象。

  • 晋升阶梯:在阿里的 P7/P8 或字节的 2-2/3-1 晋升答辩中,主导一个“行业领先”的开源项目是极具说服力的业绩。因此,很多工程师或团队 Leader 会发起此类项目,投入巨大资源进行推广(刷 Star、做精美官网)。
  • 晋升后的遗弃:一旦发起人成功晋升、转岗或离职,该项目的“剩余价值”就被榨干了。接手的新人往往不愿意维护“前人的功劳簿”,更愿意另起炉灶做一个新的项目来证明自己。
  • Arco 的轨迹:Arco 的高调发布(2021年)恰逢互联网泡沫顶峰。随着 2022-2024 年行业进入寒冬,晋升通道收窄,维护开源项目的 ROI(投入产出比)变得极低,导致项目被遗弃。

5. 社区自救的幻象:为何没有强有力的 Fork?

面对官方的停摆,用户自然会问:既然代码是开源的(MIT 协议),为什么没有人 Fork 出来继续维护?调查显示,虽然存在一些零星的 Fork,但并未形成气候。

5.png

5.1 Fork 的现状调查

通过对 GitHub 和 Gitee 的检索,我们发现了一些 Fork 版本,但并未找到具备生产力的社区继任者。

  • vrx-arco:这是一个名为 vrx-arco/arco-design-pro 的仓库,声称是 "aro-design-vue 的部分功能扩展"。然而,这更像是一个补丁集,而不是一个完整的 Fork。它主要解决特定开发者的个人需求,缺乏长期维护的路线图。
  • imoty_studio/arco-design-designer:这是一个基于 Arco 的表单设计器,并非组件库本身的 Fork。
  • 被动 Fork:GitHub 显示 Arco Design 有 713 个 Fork。经抽样检查,绝大多数是开发者为了阅读源码或修复单一 Bug 而进行的“快照式 Fork”,并没有持续的代码提交。

5.2 为什么难以 Fork?

维护一个像 Arco Design 这样的大型组件库,其门槛远超普通开发者的想象。

  1. Monorepo 构建复杂度:Arco 采用了 Lerna + pnpm 的 Monorepo 架构,包含 React 库、Vue 库、CLI 工具、图标库等多个 Package。其构建脚本极其复杂,往往依赖于字节内部的某些环境配置或私有源。外部开发者即使拉下来代码,要跑通完整的 Build、Test、Doc 生成流程都非常困难。
  2. 生态维护成本:Arco 的核心优势在于 Design Lab 和 IconBox 等配套 SaaS 服务。Fork 代码容易,但 Fork 整个后端服务是不可能的。失去了 Design Lab 的 Arco,就像失去了灵魂的空壳,吸引力大减。
  3. 技术栈锁定:Arco 的一些底层实现可能为了适配字节内部的微前端框架或构建工具(如 Modern.js)做了特定优化,这增加了通用化的难度。

因此,社区更倾向于迁移,而不是接盘

6. 用户生存指南:现状评估与迁移策略

对于目前仍在使用 Arco Design 的团队,局势十分严峻。随着 React 19 的临近和 Vue 3 生态的演进,Arco 将面临越来越多的兼容性问题。

6.1 风险评估表

风险维度 风险等级 具体表现
安全性 🔴 高危 依赖的第三方包(如 lodash, async-validator 等)若爆出漏洞,Arco 不会发版修复,需用户手动通过 resolutions 强行覆盖。
框架兼容性 🔴 高危 React 19 可能会废弃某些 Arco 内部使用的旧生命周期或模式;Vue 3.5+ 的新特性无法享受。
浏览器兼容性 🟠 中等 新版 Chrome/Safari 的样式渲染变更可能导致 UI 错位,无人修复。
基础设施 ⚫ 已崩溃 IconBox 无法上传新图标,Design Lab 可能随时下线,导致主题无法更新。

6.png

6.2 迁移路径推荐

方案 A:迁移至 Semi Design(推荐指数:⭐⭐⭐⭐)

如果你是因为喜欢字节系的设计风格而选择 Arco,那么 Semi Design 是最自然的替代者。

  • 优势:同为字节出品,设计语言的命名规范和逻辑有相似之处。Semi 目前维护活跃,背靠抖音,拥有强大的 D2C 工具链。
  • 劣势:API 并非 100% 兼容,仍需重构大量代码。且 Semi 主要是 React 优先,Vue 生态支持相对较弱(主要靠社区适配)。

7.png

方案 B:迁移至 Ant Design v5/v6(推荐指数:⭐⭐⭐⭐⭐)

如果你追求极致的稳定和长期的维护保障,Ant Design 是不二之选。

  • 优势:行业标准,庞大的社区,Ant Group 背书。v5 版本引入了 CSS-in-JS,在定制能力上已经大幅追赶 Arco 的 Design Lab。
  • 劣势:设计风格偏保守,需要设计师重新调整 UI 规范。

方案 C:本地魔改(推荐指数:⭐)

如果项目庞大无法迁移,唯一的出路是将 @arco-design/web-react 源码下载到本地 packages 目录,作为私有组件库维护。

  • 策略:放弃官方更新,仅修复阻塞性 Bug。这需要团队内有资深的前端架构师能够理解 Arco 的源码。

4.png

7. 结语与启示

Arco Design 的故事是现代软件工程史上的一个典型悲剧。它证明了在企业级开源领域,康威定律(Conway's Law) 依然是铁律——软件的架构和命运取决于开发它的组织架构。

当 GIP 部门意气风发时,Arco 是那颗最耀眼的星,承载着“务实浪漫主义”的理想;当组织收缩、业务调整时,它便成了由于缺乏商业造血能力而被迅速遗弃的资产。对于技术决策者而言,Arco Design 的教训是惨痛的:在进行技术选型时,不能仅看 README 上的 Star 数或官网的精美程度,更要审视项目背后的组织生命力维护动机

8.png

目前来看,Arco Design 并没有复活的迹象,社区也没有出现强有力的接棒者。这套组件库正在数字化浪潮的沙滩上,慢慢风化成一座无人问津的丰碑。

做后台系统别再只会单体架构了,微前端才是更优解

在后台系统开发领域,传统的单体架构已经无法满足现代企业的复杂需求。本文将深入探讨为什么微前端架构是后台系统的更优选择,并通过一个完整的开源项目案例,展示如何构建高性能、可扩展的微前端后台系统。

前言:单体架构的那些坑

说实话,做后台系统开发这么多年,单体架构的痛点真是深有体会:

  • 代码耦合严重:多个业务模块混杂在一起,修改一个功能可能影响到其他模块
  • 代码无法隔离:所有代码都在一个仓库中,无法进行物理隔离,权限管理困难
  • 构建速度缓慢:随着业务增长,项目体积越来越大,构建时间从几分钟到十几分钟不等
  • 技术栈锁定:整个项目被限制在单一技术栈,无法灵活选择最适合的技术方案
  • 团队协作困难:多人同时开发时,代码冲突频发,发布需要协调所有模块
  • 部署风险高:任何小改动都需要重新部署整个应用,风险巨大

这些问题的根源在于传统的单体架构模式。在单体架构中,所有的业务功能都被打包在一个巨大的代码库中,就像一个臃肿的巨人,行动迟缓且容易摔倒。

微前端:一种可行的解决方案

微前端(Micro Frontends)把前端应用拆分成多个小型、独立的部分。每个部分都能独立开发、测试、部署,最后组合成一个完整的应用。

这样做的好处很明显:

  • 独立部署:改一个模块不用重新发布整个系统
  • 团队自治:每个团队管好自己的模块就行
  • 渐进迁移:不用一次性重写,可以慢慢来
  • 故障隔离:一个模块崩了不会拖垮整个应用

一个实际案例:PbstarAdmin

为了让大家看看微前端在后台系统中怎么用,我分享一下最近做的一个项目 —— PbstarAdmin

这个项目用到了腾讯的 wujie 微前端框架,解决了一些实际开发中的痛点。

代码隔离这块是怎么做的?

PbstarAdmin 用了一个比较实用的办法:Git子模块 + Rsbuild构建 双重隔离。

Git子模块隔离

简单说就是把代码分成两类:

  • 内部子应用:放在主仓库的 apps/ 目录下,适合核心业务,改起来方便
  • 外部子应用:用Git子模块管理,完全独立的仓库,适合第三方模块或者需要权限控制的代码

Rsbuild构建隔离

每个子应用都有自己的构建配置:

  • 独立的构建配置和输出目录
  • 子应用之间没有依赖耦合
  • 可以独立部署和版本管理

这样做的好处是实实在在的:不同团队负责不同模块,互不干扰;出问题也容易定位。

项目特色

PbstarAdmin 这个项目主要解决了几个实际问题:

  • 微前端架构:用腾讯 wujie 框架,支持动态加载子应用
  • 模块化设计:pnpm monorepo 管理,支持内外部子应用
  • 组件复用:共享组件库,统一别名引用
  • 工程化工具:CLI 工具链简化开发流程
  • 高性能构建:基于 Rsbuild,支持多环境配置

技术选型

技术选型比较务实,都是现在主流的方案:

  • Vue 3: Composition API 开发体验不错
  • Pinia:状态管理比 Vuex 简洁
  • Element Plus:组件库成熟稳定
  • Rsbuild:基于 Rspack,构建速度很快
  • pnpm:monorepo 管理很方便
  • wujie:腾讯的微前端方案,相对成熟

架构设计

项目结构

整个项目结构比较清晰:

pbstar-admin/
├── main/                      # 主应用(基座)
├── apps/                      # 子应用目录
│   ├── app-common/            # 公共子应用模块
│   ├── system/                # 系统管理应用
│   ├── example/               # 示例应用
│   ├── equipment/             # 设备管理应用(外部子应用)
│   └── apps.json              # 子应用配置
├── components/                # 共享组件库
├── assets/                    # 共享资源
└── tools/                     # 工具模块(CLI)

微应用配置

apps/apps.json 中配置各个微应用的信息:

[
  {
    "key": "system",
    "devPort": 8801,
    "proUrl": "http://pbstar-admin-system.pbstar.cn/"
  },
  {
    "key": "example",
    "devPort": 8802,
    "proUrl": "http://pbstar-admin-example.pbstar.cn/"
  },
  {
    "key": "equipment",
    "devPort": 8803,
    "proUrl": "http://pbstar-admin-equipment.pbstar.cn/"
  }
]

主应用核心代码

主应用负责整体布局、导航菜单管理和微应用加载:

// main/src/stores/apps.js
export const useAppsStore = defineStore("apps", () => {
  const myApps = ref([]); // 存储用户的应用
  const appId = ref(0); // 存储当前激活的应用

  const setApps = (apps) => {
    myApps.value = apps.map((item) => {
      return {
        id: item.id,
        key: item.key,
        name: item.name,
        icon: item.icon,
        group: item.group,
        navs: [],
        navsTree: [],
      };
    });
  };

  const setAppId = async ({ id, key }) => {
    let aId = 0;
    if (id) {
      aId = id;
    } else if (key) {
      const app = myApps.value.find((item) => item.key === key);
      if (app) aId = app.id;
    }
    if (aId) {
      const navRes = await request.get({
        url: "/main/getMyNavListByAppId",
        data: { appId: aId },
      });
      if (navRes.code !== 200) {
        ElMessage.error("获取应用导航失败!请稍后重试");
        return false;
      }
      setAppNavs(aId, navRes.data);
    }
    appId.value = aId;
    return true;
  };

  return {
    appId,
    setApps,
    setAppId,
    getApp,
    getApps,
    hasAppNav,
  };
});

导航菜单管理

// main/src/components/layout/layout.js
export function useNavMenu() {
  const router = useRouter();
  const route = useRoute();
  const appsStore = useAppsStore();

  const activeIndex = ref("1");
  const list = ref([]);
  const listTree = ref([]);

  const updateNavData = () => {
    if (appsStore.appId) {
      const app = appsStore.getApp();
      if (!app) return;
      list.value = app.navs;
      listTree.value = app.navsTree;
    } else {
      list.value = [
        {
          id: 1,
          name: "首页",
          url: "/admin/pHome",
          icon: "el-icon-house",
        },
      ];
      listTree.value = list.value;
    }
  };

  const selectNav = (val) => {
    activeIndex.value = val;
    const url = list.value.find((item) => item.id.toString() === val)?.url;
    if (url) {
      router.push(url);
    }
  };

  return {
    listTree,
    activeIndex,
    selectNav,
    updateNavData,
    updateActiveIndex,
  };
}

构建配置

使用 Rsbuild 进行高性能构建配置:

// rsbuild.config.mjs
export default defineConfig({
  plugins: [pluginVue(), pluginSass(), distZipPlugin()],
  output: { legalComments: "none" },
  resolve: {
    alias: {
      "@Pcomponents": "./components",
      "@Passets": "./assets",
    },
  },
  server: {
    proxy: {
      "/api": {
        target: import.meta.env.PUBLIC_API_BASE_URL,
        pathRewrite: { "^/api": "" },
        changeOrigin: true,
      },
    },
  },
  environments: {
    main: mainConfig,
    ...Object.fromEntries(apps.map((app) => [app.key, createAppConfig(app)])),
  },
});

CLI 工具开发

提供完整的 CLI 工具链,简化开发流程:

// tools/cli/dev.mjs
const list = ["main", ...apps.map((item) => item.key)];

program
  .version("1.0.0")
  .description("启动应用模块")
  .action(async () => {
    try {
      const answers = await inquirer.prompt([
        {
          type: "list",
          name: "appKey",
          message: "请选择要启动的应用模块:",
          choices: list,
        },
      ]);
      const { appKey } = answers;
      // 构建启动命令
      let command = "";
      if (appKey === "main") {
        command = "rsbuild dev --environment main --port 8800 --open";
      } else {
        const app = apps.find((item) => item.key === appKey);
        command = `rsbuild dev --environment ${appKey} --port ${app.devPort}`;
      }
      execSync(command, { stdio: "inherit", cwd: "../" });
    } catch (err) {
      console.error(chalk.red("Error:"), err);
      process.exit(1);
    }
  });

代码隔离的终极解决方案

传统方案的局限性

之前用过一些微前端方案,发现隔离做得并不好:

  • 代码都在一起:所有子应用代码混在一个仓库,权限控制很麻烦
  • 依赖经常冲突:这个子应用要Vue3,那个要Vue2,构建时各种问题
  • 构建互相影响:一个子应用构建失败了,整个项目都跑不起来
  • 版本管理混乱:没法单独给某个业务模块打版本标签

PbstarAdmin的双重隔离机制

PbstarAdmin 用了 Git子模块 + Rsbuild构建 的双重隔离,算是把代码隔离做到了物理层面。

1. Git子模块隔离

# .gitmodules 配置,其实就是普通的git子模块
[submodule "apps/equipment"]
path = apps/equipment
url = https://github.com/pbstar/pbstar-admin-quipment.git

内部子应用(in类型)

  • 代码放在主仓库里,适合核心业务
  • 团队协作方便,代码复用容易
  • 构建起来也快

外部子应用(out类型)

  • 用Git子模块管理,完全独立的仓库
  • 适合业务团队或者需要保密的模块
  • 版本控制完全独立

2. Rsbuild构建隔离

// rsbuild.config.mjs - 给每个子应用单独的配置
const createAppConfig = (app) => {
  const basePath = `./apps/${app.key}`;
  return {
    source: {
      entry: { index: `${basePath}/src/main.js` },
    },
    output: {
      distPath: { root: `./build/dist/${app.key}` },
    },
    resolve: {
      alias: {
        "@": basePath + "/src",
      },
    },
    plugins: [
      checkUniqueKeyPlugin({
        checkPath: `${basePath}/src`,
        checkKeys: ["btnkey"],
      }),
    ],
  };
};

子应用创建流程

// tools/cli/create.mjs - 智能创建子应用
const answers = await inquirer.prompt([
  {
    type: "list",
    name: "appType",
    message: "子应用类型:",
    choices: ["in", "out"],
  },
  {
    type: "input",
    name: "appKey",
    message: "子应用Key:",
    validate: (input) => {
      if (!/^[a-z0-9-]+$/.test(input)) {
        return "子应用Key只能包含小写字母、数字和连字符";
      }
      return true;
    },
  },
]);

// 外部子应用就加个git子模块
if (appType === "out" && gitUrl) {
  execSync(`git submodule add ${gitUrl} apps/${appKey}`, {
    cwd: path.join(__dirname, "../../"),
    stdio: "inherit",
  });
}

代码隔离的实际效果

用了双重隔离后,确实比传统单体架构方便不少:

权限管理

  • 仓库级别权限:外部子应用可以单独设置Git权限
  • 代码审查隔离:每个子应用可以有自己的Code Review流程
  • 敏感代码保护:核心业务代码可以放在内部子应用中

依赖管理

// 每个子应用可以有自己的依赖,不会冲突
{
  "name": "system-subapp",
  "dependencies": {
    "vue": "^3.5.18",
    "element-plus": "^2.10.7",
    // 子应用特定的依赖
    "echarts": "^5.4.0"
  }
}

// 另一个子应用可以用不同版本
{
  "name": "equipment-subapp",
  "dependencies": {
    "vue": "^3.5.18",
    "element-plus": "^2.8.0",
    "echarts": "^4.9.0"  // 版本不一样,但不会冲突
  }
}

独立部署

# 用ptools构建指定子应用(推荐)
pnpm run build
# 选择要构建的子应用,比如equipment

Ptools:CLI工具链

PbstarAdmin 还有个特色是 Ptools - 一套CLI工具链,把复杂的构建流程都封装起来了。

Ptools的核心命令

# 启动开发环境 - 会让你选择子应用
pnpm run dev

# 构建指定子应用 - 交互式选择
pnpm run build

# 创建新的子应用 - 引导式创建
pnpm run create

# 添加依赖包 - 精确到具体工程
pnpm run add

# 移除依赖包 - 清理依赖
pnpm run remove

为什么用Ptools而不是直接敲命令

直接敲命令的问题

# 要记住复杂的命令和参数
rsbuild build --environment equipment --port 8803

# 容易敲错,还得手动指定端口和环境
rsbuild dev --environment system --port 8801

Ptools的交互方式

// tools/cli/build.mjs - 构建命令其实就是帮你选一下
const list = ["main", ...apps.map((item) => item.key)];

program
  .version("1.0.0")
  .description("构建应用模块")
  .action(async () => {
    const answers = await inquirer.prompt([
      {
        type: "list",
        name: "appKey",
        message: "请选择要构建的应用模块:",
        choices: list, // 自动读取所有可用模块
      },
    ]);
    const { appKey } = answers;
    // 自动构建正确的环境和配置
    const command = `rsbuild build --environment ${appKey}`;
    execSync(command, { stdio: "inherit", cwd: "../" });
  });

Ptools的好处

  1. 不用记配置:开发者不用了解底层的Rsbuild配置
  2. 不会选错:自动发现可用的子应用,避免手打错误
  3. 统一入口:所有操作都通过统一的CLI,比较好记
  4. 减少出错:内置参数验证和错误处理
  5. 流程统一:确保团队成员用相同的流程

依赖管理

// tools/cli/add.mjs - 添加依赖包
const answers = await inquirer.prompt([
  {
    type: "list",
    name: "appKey",
    message: "请选择要添加依赖包的工程:",
    choices: [
      "全局工程",
      "assets",
      "components",
      "tools",
      "main",
      ...apps.map((item) => item.key),
    ],
  },
  {
    type: "input",
    name: "packageName",
    message: "请输入要添加的依赖包名称:",
  },
  {
    type: "list",
    name: "packageType",
    message: "请选择要添加的依赖包类型:",
    choices: ["dependencies", "devDependencies"],
  },
]);

通过Ptools,PbstarAdmin把从创建到构建的流程都标准化了。

故障隔离

// 子应用A构建出错了,不会影响子应用B
const appConfigs = {
  system: createAppConfig({ key: "system" }), // ✅ 正常构建
  equipment: createAppConfig({ key: "equipment" }), // ❌ 构建失败
  example: createAppConfig({ key: "example" }), // ✅ 不受影响
};

实际使用效果

开发体验

  • 独立开发:每个团队可以独立开发自己的微应用,互不干扰
  • 构建速度:单个微应用构建比整个项目快很多
  • 热更新:修改一个微应用不会影响其他应用,热更新很快

部署运维

  • 独立部署:每个微应用可以独立部署,降低风险
  • 灰度发布:支持微应用级别的灰度发布
  • 故障隔离:单个微应用出错不会影响整个系统

团队协作

  • 团队自治:每个团队负责自己的微应用,职责清晰
  • 技术选型自由:不同团队可以选择最适合的技术栈
  • 并行开发:多个团队可以并行开发,提高效率

快速开始

想要体验这个微前端后台系统?只需要简单的几步:

# 克隆项目
git clone https://github.com/pbstar/pbstar-admin.git

# 进入项目目录
cd pbstar-admin

# 克隆外部子应用仓库(可选)
git submodule update --init

# 安装依赖
pnpm install

# 使用Ptools启动开发环境(推荐方式)
pnpm run dev
# 交互式选择要启动的子应用

# 使用Ptools构建项目
pnpm run build
# 选择要构建的子应用

# 创建新的子应用
pnpm run create

# 添加依赖包
pnpm run add

# 移除依赖包
pnpm run remove

Ptools使用示例

# 开发环境 - 交互式选择子应用
$ pnpm run dev
? 请选择要启动的应用模块: (Use arrow keys)
❯ main
  system
  example
  equipment

# 构建指定子应用
$ pnpm run build
? 请选择要构建的应用模块: (Use arrow keys)
❯ main
  system
  example
  equipment

# 添加依赖包到指定工程
$ pnpm run add
? 请选择要添加依赖包的工程: (Use arrow keys)
❯ 全局工程
  assets
  components
  tools
  main
  system
  example
  equipment
? 请输入要添加的依赖包名称: axios
? 请选择要添加的依赖包类型: (Use arrow keys)
❯ dependencies
  devDependencies

总结

单体架构就像一搜巨大的航空母舰,虽然功能强大,但转向困难,维护成本高。而微前端架构就像一支现代化的舰队,每艘舰艇都有自己的使命,既能独立作战,又能协同配合。

通过 PbstarAdmin 的实践,我发现微前端在后台系统中的优势确实很明显:

  • 开发效率:构建速度快了很多,热更新基本是秒级
  • 部署运维:可以独立部署,不用每次都全量发布
  • 团队协作:各团队负责自己的模块,冲突少了很多
  • 系统稳定性:一个模块出问题不会拖垮整个系统

当然,微前端也有它的复杂性,比如通信机制、状态同步等问题。但总的来说,对于大型后台系统,微前端是一个值得考虑的方向。

如果你也在做后台系统,建议可以试试看微前端的思路,或许能解决你当前遇到的一些痛点。

相关资料

项目地址和文档都整理在这里了,有兴趣的可以看看:


💡 这里是初辰,一个有理想的切图仔!

🎉 如果本文对你有帮助,别忘了点赞、收藏、评论哦!

给项目点个Star,支持开源精神,让更多人发现这个优秀的微前端解决方案!

斐波那契数列:从递归到缓存优化的极致拆解

斐波那契数列:从递归到缓存优化的极致拆解

斐波那契数列是算法入门的经典案例,也是理解「递归」「缓存优化」「闭包」核心思想的绝佳载体。本文会从最基础的递归解法入手,逐步拆解重复计算的痛点,再通过哈希缓存、闭包缓存等方式优化,带你吃透斐波那契数列的解题思路。

一、斐波那契数列的定义

先明确斐波那契数列的核心规则:

  • 起始项:f(0) = 0f(1) = 1
  • 递推公式:f(n) = f(n-1) + f(n-2)(n ≥ 2);
  • 数列示例:0, 1, 1, 2, 3, 5, 8, 13, 21...

简单来说,从0和1开始,后续每一项都等于前两项之和。

二、基础递归解法:思路简单但效率拉胯

1. 递归核心思想

递归的本质是「大问题拆解为小问题」:计算 f(n) 时,先拆解为计算 f(n-1)f(n-2),直到拆解到 f(0)f(1) 这个「递归终止条件」,再逐层返回结果。

2. 代码实现

// 基础递归版斐波那契
function fib(n) {
  // 递归退出条件:触底到0或1,直接返回
  if (n <= 1) return n;
  // 递推公式:拆分为两个子问题
  return fib(n - 1) + fib(n - 2);
}

console.log(fib(10)); // 55(小数值正常)
console.log(fib(100)); // 卡死(重复计算导致超时)

3. 核心问题分析

(1)重复计算严重

fib(5) 为例,拆解过程如下:

fib(5) = fib(4) + fib(3)
fib(4) = fib(3) + fib(2)
fib(3) = fib(2) + fib(1)
fib(2) = fib(1) + fib(0)

可以看到:fib(3) 被计算了2次,fib(2) 被计算了3次,fib(1) 被计算了5次。随着n增大,重复计算呈指数级增长。

(2)时间复杂度爆炸
  • 时间复杂度:O(2ⁿ),指数级复杂度,n=40时计算时间就会明显增加,n=100直接卡死;
  • 空间复杂度:O(n),递归调用栈的深度等于n,极端情况下会触发「栈溢出」。
(3)调用栈溢出风险

递归依赖函数调用栈存储上下文,当n过大时(比如n=10000),会超出JS引擎的调用栈限制,抛出 Maximum call stack size exceeded 错误。

三、优化1:哈希缓存(空间换时间)

1. 优化思路

既然重复计算是核心问题,我们可以用「哈希表(对象)」缓存已经计算过的结果:

  • 计算前先查缓存,存在则直接返回;
  • 计算后将结果存入缓存,避免重复计算。

这是典型的「空间换时间」策略,用少量内存开销换取时间复杂度的大幅降低。

2. 代码实现

// 缓存对象:存储已计算的斐波那契值
const cache = {};

function fib(n) {
  // 1. 优先查缓存,存在则直接返回
  if (n in cache) {
    return cache[n];
  }
  // 2. 递归终止条件
  if (n <= 1) {
    cache[n] = n; // 存入缓存
    return n;
  }
  // 3. 计算并缓存结果
  const result = fib(n - 1) + fib(n - 2);
  cache[n] = result;
  return result;
}

console.log(fib(100)); // 顺利输出:354224848179261915075

3. 优化效果分析

  • 时间复杂度:O(n),每个n只计算一次,后续直接取缓存;
  • 空间复杂度:O(n),缓存对象存储n个值 + 递归调用栈深度n;
  • 核心改进:彻底解决重复计算问题,n=100也能快速计算。

4. 小问题

缓存对象 cache 暴露在全局作用域中,容易被意外修改,破坏了函数逻辑的独立性。

四、优化2:闭包封装缓存(更优雅的空间换时间)

1. 优化思路

用「立即执行函数(IIFE)」创建闭包,将缓存对象封装在函数内部,避免全局污染:

  • IIFE 立即执行,创建独立的作用域;
  • 内部定义缓存对象(自由变量),返回一个计算斐波那契的函数;
  • 返回的函数可以访问闭包中的缓存对象,且外部无法修改。

2. 代码实现

// IIFE 创建闭包,封装缓存
const fib = (function() {
  // 闭包中的缓存:仅内部可访问,避免全局污染
  const cache = {};
  
  // 返回实际的计算函数
  return function(n) {
    if (n in cache) {
      return cache[n];
    }
    if (n <= 1) {
      cache[n] = n;
      return n;
    }
    // 注意:此处调用的是外部的fib(即返回的这个函数)
    cache[n] = fib(n - 1) + fib(n - 2);
    return cache[n];
  }
})();

console.log(fib(100)); // 依然快速输出结果
console.log(cache); // undefined(外部无法访问缓存,更安全)

3. 核心优势

  • 缓存私有化:闭包中的 cache 仅被返回的 fib 函数访问,避免全局污染和意外修改;
  • 代码更优雅:把缓存和计算的逻辑打包在一起,就像把相关工具放进同一个工具箱,用起来方便还不杂乱;
  • 性能不变:时间复杂度仍为O(n),空间复杂度仍为O(n)。

五、补充:递归 vs 迭代(拓展思路)

除了缓存优化递归,还可以用「迭代」彻底避免递归调用栈问题:

// 迭代版斐波那契(空间复杂度可优化至O(1))
function fib(n) {
  if (n <= 1) return n;
  let prev = 0, curr = 1;
  for (let i = 2; i <= n; i++) {
    const next = prev + curr;
    prev = curr;
    curr = next;
  }
  return curr;
}
  • 时间复杂度:O(n);
  • 空间复杂度:O(1),仅用三个变量存储状态,无递归栈和缓存开销。

六、核心知识点总结

1. 递归的适用场景

递归适合解决「可拆分为相似子问题、有明确终止条件、符合树形结构」的问题,但必须注意:

  • 避免重复计算(用缓存优化);
  • 防止栈溢出(n过大时优先用迭代)。

2. 缓存优化的核心思想

「空间换时间」是算法优化的常用策略,核心是存储已计算的结果,避免重复劳动,常见载体包括:

  • 哈希表(对象/Map);
  • 数组;
  • 闭包私有化缓存。

3. IIFE + 闭包的价值

  • IIFE:立即执行函数,创建独立作用域,避免全局污染;
  • 闭包:让内部函数访问外部作用域的变量(如cache),且变量不会被垃圾回收,持续有效。

4. 各版本对比

版本 时间复杂度 空间复杂度 优点 缺点
基础递归 O(2ⁿ) O(n) 思路简单 重复计算、易栈溢出
哈希缓存 O(n) O(n) 解决重复计算 缓存全局暴露
闭包缓存 O(n) O(n) 缓存私有化、代码优雅 仍有递归栈开销
迭代 O(n) O(1) 性能最优、无栈溢出 思路稍绕

七、总结

斐波那契数列的优化过程,是算法思维从「简单实现」到「高效优雅」的典型体现:

  1. 基础递归:满足「能跑」,但存在重复计算和栈溢出问题;
  2. 哈希缓存:解决重复计算,时间复杂度从O(2ⁿ)降到O(n);
  3. 闭包缓存:在缓存的基础上优化代码结构,实现缓存私有化;
  4. 迭代优化:彻底摆脱递归栈,空间复杂度降到O(1)。

斐波那契看似简单,却是理解算法优化的绝佳入口。从朴素递归的指数爆炸,到缓存记忆化的时间换空间,再到闭包封装的工程优雅,最后迭代实现极致效率——每一步都体现了“用合适工具解决合适问题”的编程智慧。

JSX 基本语法与 React 组件化思想

JSX 基本语法与 React 组件化思想

在现代前端开发中,React 框架凭借其独特的 JSX 语法和组件化思想占据了重要地位。本文将结合实际代码示例,详细介绍 JSX 语法特性、组件化思想以及基本使用方法。

什么是 JSX?

JSX(JavaScript XML)是 React 中用于描述用户界面的语法扩展,它允许我们在 JavaScript 代码中直接编写类似 HTML 的标记,实现了 "在 JS 中写 HTML" 的开发体验。

// JSX语法示例
const element = <h2>JSX 是 React 中用于描述用户界面的语法扩展</h2>

这看似是 HTML,实则是 JavaScript 的语法糖。JSX 会被 Babel 等工具编译为普通的 JavaScript 函数调用:

// 编译后的JavaScript
const element2 = createElement('h2', null, 'JSX 是 React 中用于描述用户界面的语法扩展')

两者效果完全一致,但 JSX 的可读性和开发效率明显更高。

React 组件化思想

React 的核心思想之一是组件化,即将 UI 拆分为独立、可复用的部分,每个部分都可以单独维护。

组件的定义方式

在 React 中,组件可以通过函数来定义,返回 JSX 的函数就是一个组件:

// 函数组件定义示例
function JuejinHeader() {
  return (
    <div>
      <header>
        <h1>JueJin首页</h1>
      </header>
    </div>
  )
}

// 箭头函数形式的组件
const Ariticles = () => {
  return (
    <div>
      Articles
    </div>
  )
}

组件组合与嵌套

组件可以像搭积木一样组合使用,形成组件树:

function App() {
  return (
    <div>
      {/* 头部组件 */}
      <JuejinHeader />
      <main>
        {/* 文章列表组件 */}
        <Ariticles />
        <aside>
          {/* 侧边栏组件 */}
          <Checkin />
          <TopArticles />
        </aside>
      </main>
    </div>
  )
}

这种组合方式让我们可以将复杂页面拆分为多个简单组件,提高代码的可维护性和复用性。

JSX 基本语法规则

  1. 表达式插入:使用{}在 JSX 中插入 JavaScript 表达式
// 数据绑定示例
const [name, setName] = useState("vue");

// 在JSX中使用表达式
return (
  <h1>Hello <span className="title">{name}!</span></h1>
)
  1. 条件渲染:通过逻辑与运算符或三元表达式实现
// 条件渲染示例
{isLoggedIn ? <div>已登录</div> : <div>未登录</div>}

// 按钮文本根据状态变化
<button onClick={toggleLogin}>
  {isLoggedIn ? "退出登录" : "登录"}
</button>
  1. 列表渲染:使用数组的 map 方法渲染列表,需提供唯一 key
// 列表渲染示例
{todos.length > 0 ? (
  <ul>
    {todos.map((todo) => (
      <li key={todo.id}>
        {todo.title}
      </li>
    ))}
  </ul>
) : (
  <div>暂无待办事项</div>
)}
  1. 样式处理:class 属性需使用 className(因为 class 是 JavaScript 关键字)
// CSS类名使用className
<span className="title">{name}!</span>
/* 对应的CSS样式 */
.title{
  color: red;
}
  1. 根元素限制:JSX 最外层必须有一个根元素,或使用片段<>
// 使用片段避免多余的div嵌套
return (
  <>  
    <h1>标题</h1>
    <p>内容</p>
  </>
)
  1. 事件处理:使用驼峰命名法,如 onClick
// 事件处理示例
const toggleLogin = () => {
 setIsLoggedIn(!isLoggedIn);
}

<button onClick={toggleLogin}>切换登录状态</button>

响应式数据与 JSX

React 通过 useState 钩子实现响应式数据,当状态变化时,JSX 会自动重新渲染:

// 响应式数据示例
const [name, setName] = useState("vue");

// 3秒后更新状态,视图会自动更新
setTimeout(() => {
  setName("react");
}, 3000);

当 name 状态从 "vue" 变为 "react" 时,使用{name}的地方会自动更新,无需手动操作 DOM。

总结

JSX 和组件化是 React 的两大核心特性:

  • JSX 提供了一种直观、高效的方式描述 UI,将 HTML 和 JavaScript 无缝结合
  • 组件化思想将 UI 拆分为独立可复用的单元,使复杂应用的开发和维护变得简单
  • 通过状态管理实现响应式更新,让开发者专注于数据逻辑而非 DOM 操作

这种开发模式使得 React 在构建大型应用时具有明显优势,也是现代前端开发的重要思想。

❌