普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月31日掘金 前端

祝大家 2026 年新年快乐,代码无 bug,需求一次过

作者 前端Hardy
2025年12月31日 18:05
新年将至,你是否想为亲友制作一个特别的新年祝福页面?今天我们就来一起拆解一个精美的 2026 新年祝福页面的完整实现过程。这个页面包含了加载动画、动态倒计时、雪花特效、悬浮祝福卡片等炫酷效果,完全使用

这应该是前端转后端最简单的办法了,不买服务器、不配 Nginx,也能写服务端接口,腾讯云云函数全栈实践

2025年12月31日 17:32

前言:后端开发真的太累了

作为一个想做独立开发的前端或全栈工程师,每当你想写个小项目(比如工具箱、记账本、个人博客)时,热情往往在配置后端的瞬间熄灭:

  1. 买服务器:几百块一年,性能还一般。
  2. 配环境:SSH 连上去,装 Node、PM2、Nginx,防火墙配置。
  3. 搞域名:买域名、备案(最劝退的一步)、配置 HTTPS 证书。
  4. 写接口:纠结 RESTful 规范,/api/v1/add 还是 /add?参数放 Body 还是 Query?

我就想写个 简单 的接口,至于这么折腾吗?

今天,我要给你安利一套 “零成本”、“免运维”、“免域名” 的全栈接口方案: 腾讯云 SCF (云函数) + 函数 URL + rpc-server-scf

你只需要写纯粹的 JS 函数,它就能自动变成 HTTP 接口,不用管服务器,甚至不用管 Nginx!


🚀 什么是 rpc-server-scf?

它是 js-rpc 生态中专为 腾讯云云函数 (SCF) 设计的服务端框架。

它的核心理念是:把云函数变成一个 RPC 服务器。 你不需要关心 HTTP 请求的报文结构,不需要关心 API 网关的参数透传。你只需要在 api/ 目录下写函数,前端就能直接调用。


🛠️ 后端实战:3步搭建云端 RPC

我们将使用腾讯云 SCF 的 “函数 URL” 功能,它会直接分配给你一个公网 HTTPS 地址,让你彻底告别 API 网关配置和域名备案。

第一步:准备代码

在本地创建一个文件夹,初始化 npm:

mkdir my-scf-rpc
cd my-scf-rpc
npm init -y
npm install rpc-server-scf

第二步:编写业务逻辑 (api/math.js)

我们不需要写路由,只需要在 api 目录下创建文件。文件名就是模块名。

api/math.js

module.exports = {
  // 加法
  async add(a, b) {
    return a + b;
  },
  
  // 乘法
  async multiply(a, b) {
    return a * b;
  },

  // 稍微复杂点的逻辑
  async calculate(params) {
    // 这里可以连数据库、Redis,或者做任何后端逻辑
    const { x, y, op } = params;
    if (op === 'minus') return x - y;
    return 0;
  }
}

index.js (入口文件)

一行代码,启动 RPC 服务:

const { createRpcServer } = require('rpc-server-scf');

// 导出 main_handler 给云函数调用
exports.main_handler = createRpcServer();

这就写完了! 没有 Express,没有 app.listen,代码极其干净。

第三步:部署到腾讯云 SCF(开启“白嫖”模式)

  1. 登录 腾讯云云函数控制台

  2. 点击 “新建” -> 选择 “从头开始”

    • 函数名称:随便填,比如 rpc-demo
    • 运行环境:Nodejs 16.13 或以上。
  3. 上传代码

    • 将你的项目文件夹(包含 node_modules)压缩成 zip 包上传,或者直接在在线编辑器里创建文件并安装依赖。
  4. 🔥 关键步骤:启用访问服务 (函数 URL)

    • 在“访问服务”或“触发器”配置中,找到 【函数 URL】
    • 点击 “启用”
    • 鉴权方式:选择 “不校验 (PUBLIC)”(为了演示方便,生产环境可在代码里用中间件鉴权)。
    • 你将获得一个长这样的地址:https://你的函数ID.scf.tencentcs.com

    (如图所示,不用买域名,直接送你一个 HTTPS 的公网地址!)


⚡️ 前端如何调用?

现在你的后端已经跑在云端了。前端(小程序、UniApp、Web)怎么调?

我们使用配套的客户端 rpc-client-request(专为小程序/UniApp/HTTP场景设计)。

安装

npm install rpc-client-request

调用代码

在小程序或 UniApp 中:

import { create } from 'rpc-client-request';

// 填入刚才腾讯云分配给你的“函数 URL”
const rpc = create({
  url: 'https://service-xxxx.scf.tencentcs.com/release/rpc-demo' 
});

// 业务调用
async function test() {
  try {
    // 像调用本地函数一样!
    // 自动发请求给 api/math.js 的 add 方法
    const sum = await rpc.math.add(10, 20);
    
    console.log('计算结果:', sum); // 30
    
  } catch (err) {
    console.error('调用出错', err);
  }
}

完事! 你没有写 wx.request,没有拼接 URL,没有处理 POST 参数,一切就像在写本地代码。


🌟 为什么这套方案是“独立开发者”的神器?

1. 真正的“零运维” (Serverless)

你不需要维护服务器进程,不需要配置 Nginx 反向代理,不需要担心服务器挂掉。腾讯云帮你托管,有请求自动唤醒,没请求自动休眠。

2. 免域名,自带 HTTPS

利用 SCF 的 “函数 URL” 功能,你省去了购买域名、备案(通常需要半个月)、申请 SSL 证书的所有繁琐流程。起步阶段直接用官方链接,省时省力。

3. “白嫖”级成本

腾讯云 SCF 有免费额度(或者非常低廉的按量付费)。对于个人项目、测试项目或者低频工具类应用,成本几乎为 0。只有当你的业务真的做大了,才需要支付少量费用。

4. 极致的开发体验

  • 后端:只写业务函数,文件即路由。
  • 前端:RPC 调用,像调本地方法一样顺滑。
  • 全栈:你可以把精力 100% 放在业务逻辑上,而不是浪费在 HTTP 协议的翻译工作中。

🔗 总结与资源

如果你厌倦了传统的后端开发流程,想快速上线一个全栈应用,SCF + 函数 URL + js-rpc 绝对是你现在的最佳选择。

  • 项目 GitHub: github.com/myliweihao/…
  • 服务端 SDK: npm install rpc-server-scf
  • 小程序客户端: npm install rpc-client-request

别再让“服务器运维”阻挡你改变世界的创意了,现在就开始吧!

2025 提效别再卷了:当我把 AI 当“团队”,工作真的顺了

作者 优弧
2025年12月31日 17:27

image.png 哈喽朋友们,我是优弧!

前阵子我有个很真实的崩溃瞬间:凌晨两点,需求还在飞,事儿多到永远做不完,我在工位上跟AI来回对话——聊着聊着我突然发现,我根本不是在“提效”,我是在“聊天”

你们有没有这种感觉:AI明明很强,但你用起来像在打乒乓球——我发一句,它回一段;我再补一句,它又换个方向跑;最后上下文越堆越长,人越看越累,效率还不如自己硬写。

后来我想通了一个点(扎心但很管用):很多人2025年还在卷 提示词 ,其实真正的提效不在提示词,而在“工作方式”

把AI当聊天框,你就只能得到聊天;把AI当团队,你才会得到交付。

先说清楚:这篇会聊到我正在用的一些工具和设备(其中包括我最近入手的明基 RD280U编程显示器),在软件和硬件层面都是付费用户,这些事情会让我的Vibe coding更快,更稳,也欢迎大家与我讨论分享。

所以这篇我就按“软件工作流 → 硬件底盘”的顺序来讲:软件上怎么把AI当团队把活交付出来,硬件上 RD280U 怎么把多窗口和长时间盯屏这两件事稳住——最后你会发现,提效变顺很多时候不是更用力,而是摩擦更少。

01 提效第一原则:先别堆工具,先把“摩擦”拆掉

很多人讲提效,上来就:

  • 装一堆插件
  • 换一堆模型
  • 背一堆提示词“咒语”

结果是:工具越多,切换越多;切换越多,注意力越碎;注意力越碎,越想摆烂。

真正浪费时间的,往往是你每天都在重复的“摩擦动作”:

  • 在窗口之间来回切
  • 在对话框里反复补上下文
  • 滚动条滚到怀疑人生(滚着滚着就丢上下文)
  • 打字打到手指发热(还容易错别字)

所以我现在的2025提效链路,核心只干一件事:把摩擦从高频动作里剔掉

02 把AI当“团队”:靠谱模型各司其职,别指望一个模型包打天下!

我现在的心态很明确:

模型不是“神”,模型是“员工”。员工要分工,不分工就会内耗。

你可以按手里能用的模型替换,但我自己这套分工是按“谁擅长干哪类活”来分的:

  • 前端界面/视觉稿:用更擅长UI生成的模型快速出原型(推荐Gemini 3 Pro)
  • 规划执行文档:用更擅长结构化的模型把任务拆开、把Done写清楚(推荐Codex)
  • 落地写代码/修Bug:用更擅长工程实现、能长期协作的模型跟进落地(推荐Claude Code)

你会发现:当你让模型做它擅长的事,你就不会频繁“返工重问”。这就是最朴素的提效。

03 放弃手动输入:语音 + Prompt 纠错,省下来的不是时间,是意志力!

这一条我真心推荐:别再手动敲那么多字了, 原因很现实:

  • 打字很慢
  • 打字很累
  • 打字会降低你“把信息讲完整”的欲望

我现在基本是: 想到什么先语音讲出来(越口语越好),再让AI按固定Prompt做三件事:

  • 纠错错别字
  • 补齐逻辑结构
  • 输出成可执行的任务清单

你会突然发现:你不是在“写”,你是在“说清楚”。 而“说清楚”本来就是人类最擅长的事。

04 尽早接受“文档驱动”:别等Bug爆了才发现你连需求都没定

我以前也不爱写文档:觉得麻烦、浪费时间。 后来我被现实教育了:不写文档,最后一定会用加班还债。 我现在会尽量用一种“工程化的spec框架”来约束自己(思路类似 spec-kit):

  • 目标是什么(Done 的定义)
  • 输入输出是什么(接口、数据、边界)
  • 不做什么(明确排除项)
  • 风险在哪里(依赖、性能、兼容)

这套东西的价值在于: 你写给AI看,AI更不容易跑偏;你写给自己看,自己也不容易忘;你写给同事看,同事少骂两句。提效的终点不是“更快写代码”,而是“更少返工”。

05 你以为是 AI 不够强?很多时候是你“看不清、看不多”

说到这里,就绕不开硬件:

我觉得2025年很多人提效卡住,是因为屏幕太小/比例不对/护眼不行,导致你看两小时就开始烦躁,然后效率雪崩。

我自己之前是典型症状:

  • 一边IDE一边AI面板,再加终端,屏幕直接挤爆
  • 看日志/看长函数疯狂滚动,滚着滚着就丢上下文
  • 晚上加班开深色主题,还是刺眼、眼干、头疼

后来我换成 明基 RD280U,体验变化很“朴素”,但很关键:

1)3:2 的方屏比例:上下文更完整,脑子更不容易断片

RD280U 是 28.2英寸3840×25603:2。 单看参数不刺激,但实际写代码的时候非常直观:同一屏能看到更多代码行,尤其适合“左中右三栏 + 底部终端”的AI编程布局。

我最明显的感受,是“思路更连续”。以前用 16:9,Code Review 碰到长函数,或者你在日志里追一段异常堆栈,经常滚一下才能看到 if/else 对称结构的另一半;滚动的那一下,其实就是打断。换成 3:2 后,很多时候能在一屏里把前因后果收尾,注意力不容易断片。

体感上,我在IDE里大概能多看到 6-8 行左右的代码(取决于字体/行高)。这在看调用链、对比两份文件、读长日志/SQL 输出的时候最值:不是“多看几行”那么简单,而是你不用频繁在脑子里缓存上下文。

缺点也很现实:它主要是 60Hz 的定位,不是电竞向;另外如果你强需求竖屏,要留意不同型号支架的能力。

2)抗反射 + 夜间方案:减少“看屏的痛苦”,才有持续输出

这一块我觉得特别适合“白天办公室 + 晚上回家接着干”的人。

白天最烦的其实不是亮,而是反光:下午阳光斜射、顶灯一照,你就会下意识调整角度、或者把亮度拉爆去“硬刚”。抗反射面板把眩光压下来之后,你不用再跟环境光对抗,视线更稳,心态也更稳。

可以看到下图是两块屏幕在同一位置、同一角度模拟强光照射的炫光效果(左图明基、右图其他)。

到了晚上,另一个典型痛点是“屏幕亮、环境黑”的反差。我的用法很简单:把亮度压到一个更低的舒适区,同时开夜间相关方案(比如低亮度夜间模式 + 背部的 MoonHalo 环形补光),让环境光更柔和。最直接的变化是:写到后半夜,眼睛干涩来得更慢,注意力也更不容易飘。

当然,任何护眼方案都不是让你无限熬夜的借口;背光也不等同于台灯,需要的话还是得补环境光。

3)编程模式:别把“看代码”当成普通显示器的默认任务

很多显示器谈护眼,更多是“降低刺激”;但程序员真正的痛点是:我们看的不是图片,是结构化文本,是语法高亮,是一堆高频对比的小信息。 RD280U的“编程模式”之所以值得单独拎出来讲,是因为它解决的不是“看得见”,而是“看得清、看得久、看得不烦”。

它的实现方式也很“工程化”:不是在系统里套个滤镜,而是在硬件侧提供专门的色彩模式,去适配深色/浅色两种常见主题,并把“层次感”做出来——关键字、字符串、注释、报错提示之间更容易区分,但又不是靠硬拉亮度去刺激眼睛。再加上一键切换,能把“我又要去调一堆参数”的折腾感直接砍掉。

我自己的体验是:深色编程模式下,红色报错提示、橙色警告、灰色注释的“分层”更明显;盯久了眼睛那种“被迫用力识别”的感觉会少很多,整体是更松弛的。

怎么开启/切换编程模式有很多种方式。我推荐选一个“你最顺手、最不费脑”的方式固定下来。

最简单的方式是触摸280U显示器下方的专属编码键,即可快速切换编程模式。

当然你也可以直接用显示器OSD菜单,路径一般是 菜单色彩模式 → 选 编程(深色)/编程(浅色)

同时你还可以装 Display Pilot 2,在系统里点一下就切,甚至可以按时间自动切(白天浅色、晚上深色)。

至于怎么按个人偏好微调,我的原则是:先保证“舒服”和“清晰”,再追求“好看” 。顺序上通常是:先根据IDE主题选深色或浅色当底座;再把亮度降到“看久不累”;然后小幅调对比度和锐利度,让字和背景更分层、字边缘更清楚但不假锐;最后按环境把色温/低蓝光往中性或偏暖挪一点。如果你用 Display Pilot 2,把这套参数存成预设并绑定快捷切换,会省掉每天重复操作的摩擦。

如果你调完还觉得“不够清楚”,我的经验是:先检查IDE字体、行高、抗锯齿(这是经常被忽略但影响巨大的三件事),别一上来就把显示器亮度拉爆。

4)KVM + USB-C 一线通:减少桌面乱线,减少切换成本

这项功能对“需要在两台设备间切换”的人特别爽:比如白天公司电脑在内网写业务,晚上切回个人电脑做开源/学习;或者一台跑本地服务,一台开资料/会议。

正确连线后,键盘鼠标直接插在显示器上,通过切换信号源实现KVM切换;再加上 USB-C 把视频+数据+供电合一(最高 90W),桌面线材会从“盘丝洞”变成“清爽模式”。它的价值不是省几秒,而是你不会因为嫌麻烦就一直拖着不切设备——很多人不是不想切,是不想拔线、配对、重连。

需要注意的是,KVM 很吃“正确的线材与连接方式”;

90W 的反向充电对大多数笔记本都是够用的,还省去了一根充电线,桌面会更整洁。

5)Display Pilot 2:把“每天重复设置”交给自动化

我以前也觉得显示器软件是鸡肋,直到我真的每天要开一堆窗口:IDE、终端、浏览器文档、API工具、AI面板、日志……这时候你会发现,真正累人的不是某一次拖拽,而是“一天几十次对齐窗口”的高频摩擦。

Display Pilot 2 的桌面分区有点像给屏幕加了“吸附磁铁”,窗口拖过去就自动贴好;场景切换则更像给显示器加了日程表,到点自动切到更适合当下的模式。它不一定会让你惊呼,但会让你少抱怨、少被小动作打断。

它的缺点也很朴素:要装软件,偶尔系统升级后得等适配更新;但整体属于“能用且有用”的那类配套。

06 最后一个提效建议:锻炼身体,保护眼睛,别把自己当机器人

这条听起来像鸡汤,但我认真讲:它是我2025最硬核的提效工具。

你再会用AI、再会写Prompt、再懂框架,如果你:

  • 眼睛不行
  • 颈椎不行
  • 精神不行

那你的输出上限就会被身体锁死……我现在给自己定了很粗暴的规则:

  • 连续专注 45-60 分钟就起身走动
  • 晚上尽量把环境光补起来(别在黑暗里怼屏)
  • 有条件就上更舒服的显示器(别跟眼睛过不去)

提效不是把你卷到报废,是让你更长久地输出。

07 总结:这条“更软”的提效链路,你可以直接抄

如果你只想带走一个可执行版本,我给你压缩成 7 句话:

  1. 模型分工:别一个模型硬扛所有活
  2. 语音输入:别手动敲一大堆上下文
  3. spec框架:先定目标、边界、Done,再开干
  4. 少滚动少切换:能同屏就同屏,能自动就自动
  5. 屏幕要对:比例、可读性、护眼,都是生产力底座
  6. 工具要服务流程:别为了工具而工具
  7. 身体要顶得住:眼睛和颈椎是你最值钱的资产

如果你跟我一样,天天跟代码、日志、AI面板打交道,想把“摩擦”降下来,明基 RD280U 这类编程向显示器确实值得你认真看一眼(我自己就是用上之后不太想回去了)。

lecen:一个更好的开源可视化系统搭建项目--页面设计器(表单设计器)--全低代码|所见即所得|利用可视化设计器构建你的应用系统-做一个懂你的人

作者 晴虹
2025年12月31日 17:12
设计器采用拖拽式可视化编辑方式,分为三大功能区:视图绘制区域(放置页面组件)、组件面板(提供UI组件库)和属性面板(配置组件属性和事件)。整个设计过程无需本地开发环境,直接在网页中完成页面构建和实时

2026最新React技术栈梳理,全栈必备

作者 怕浪猫
2025年12月31日 17:09

前言

2025年的React生态持续迭代,从核心框架的编译器革新到生态工具的性能优化,都带来了诸多实用特性。对于前端开发者而言,精准把握最新技术栈选型,是提升开发效率、构建高性能应用的关键。本文将从「核心基础」「路由方案」「状态管理」「工程化工具」「性能优化」「全栈拓展」六大模块,系统梳理2025年React技术栈的核心内容,每个模块均附上官方链接与实用资源,助力大家快速上手实践。

一、核心基础:React 19 关键特性

React 19作为2025年的主流版本,最大亮点是内置编译器的稳定发布与服务器组件的全面普及,彻底改变了传统开发与优化模式。

1. 核心升级点

  • 自动性能优化:编译器可自动处理useMemouseCallback等手动优化逻辑,开发者无需关注底层优化细节,代码量减少30%以上。示例:无需手动缓存回调,编译器自动识别依赖并优化
  • 新Hooks增强:新增useFormStatus(统一管理表单提交状态)、useOptimistic(乐观更新提升交互体验)、useActionState(集中处理API状态),简化复杂交互逻辑实现。
  • 服务器组件(RSC) :在服务器端预渲染组件逻辑,流式传输到客户端,首屏加载时间平均缩短30%,尤其适配低带宽环境。

2. 学习资源

官方文档:React 官方文档(英文) | React 中文文档;升级指南:React 19 官方发布公告

二、路由方案:从React Router到全场景适配

路由是SPA应用的核心,2025年React生态的路由方案呈现「功能集成化」「状态一体化」趋势,主流选择仍是React Router,同时Router5等方案在复杂场景中崭露头角。

1. 主流方案:React Router 7+

最新版本的React Router强化了与服务器状态的协同,通过Loaders、Actions机制实现前后端数据同步,减少传统缓存方案的冗余性。核心特性:

  • 嵌套路由与Outlet:通过<Outlet>组件实现布局复用,无需重复编写导航栏、页脚等公共组件,适配复杂页面结构。
  • 路由懒加载:结合React.lazy<Suspense>实现组件按需加载,搭配Loading占位UI提升用户体验,示例代码:
import { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));

function App() {
  return (
    <Router>
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
        </Routes>
      </Suspense>
    </Router>
  );
}

路由状态一体化:通过useNavigationuseFetcher钩子直接获取导航状态、表单数据,无需额外维护网络相关状态,避免状态冗余。

2. 进阶方案:Router5

针对复杂应用的路由管理需求,Router5提出「路由即状态」理念,将路由纳入统一状态管理,实现视图与路由的解耦,支持精准更新与灵活订阅机制。适合中大型团队协作的复杂应用,官方文档:Router5 官方文档

3. 实用资源

React Router 官方文档:React Router 7+ 英文文档;实战教程:React 路由实战:构建GitHub仓库管理应用

三、状态管理:轻量化与集中化并存

2025年React状态管理趋势:中小型项目首选轻量化方案,大型项目倾向简洁的集中式管理,传统复杂方案逐渐被替代。

1. 轻量化方案:useContext + useReducer

React原生方案,无需引入第三方依赖,通过上下文实现跨层级状态共享,搭配reducer处理复杂状态逻辑,适合中小型项目。优势是学习成本低、代码简洁,示例:创建全局状态上下文。

import { createContext, useReducer, useContext } from 'react';

// 1. 创建上下文
const GlobalContext = createContext();

// 2. 定义reducer
function repoReducer(state, action) {
  switch (action.type) {
    case 'FETCH_SUCCESS':
      return { ...state, data: action.payload, loading: false };
    case 'FETCH_ERROR':
      return { ...state, error: action.error, loading: false };
    default:
      return state;
  }
}

// 3. 提供状态
export function GlobalProvider({ children }) {
  const [state, dispatch] = useReducer(repoReducer, {
    data: [],
    loading: true,
    error: null
  });
  return (
    <GlobalContext.Provider value={{ state, dispatch }}>
      {children}
    </GlobalContext.Provider>
  );
}

// 4. 消费状态
export function useGlobalState() {
  return useContext(GlobalContext);
}

2. 集中式方案:Zustand vs Redux

  • Zustand:轻量级集中式状态管理库,API简洁,无需冗余的action定义,支持中间件与状态持久化,适合中大型项目快速开发。官方文档:Zustand 官方文档
  • Redux:经典集中式状态管理方案,通过单一状态树与中间件(redux-thunk、redux-saga)处理复杂数据流,适合需要严格分层、多人协作的大型项目。最新版本简化了API,学习成本降低。官方文档:Redux 官方文档

3. 服务端状态管理:React Query/Apollo

专注于服务器数据同步,实现数据获取、缓存、更新一体化,减少重复代码。React Query适用于REST API,Apollo适用于GraphQL。官方链接:TanStack Query(React Query升级版本) | Apollo Client

四、工程化工具:高效开发与构建

工程化是React项目规模化开发的基础,2025年主流工具聚焦「快速构建」「类型安全」「自动化部署」,Vite逐渐替代Webpack成为中小型项目首选。

1. 构建工具:Vite vs Webpack

工具 优势 适用场景 官方链接
Vite 冷启动快、热更新高效、配置简洁 中小型React项目、快速迭代的创业项目 Vite 官方文档
Webpack 生态完善、定制化能力强、支持复杂构建流程 大型企业级项目、需要深度定制构建逻辑的项目 Webpack 官方文档

2. 类型检查:TypeScript

70%的React项目已采用TypeScript,静态类型检查可降低40%的Bug率,提升团队协作效率。核心优势:类型提示、代码补全、编译时错误检查。配置指南:React + TypeScript 官方指南

3. 代码规范与测试

五、性能优化:从编译到运行时

2025年React性能优化已实现「全周期覆盖」,从编译阶段的自动优化到运行时的精准控制,无需开发者手动编写大量优化代码。

1. 编译阶段:React编译器

自动识别不必要的重渲染,缓存计算结果与回调函数,替代手动useMemouseCallback,开发者只需专注业务逻辑。

2. 运行时优化

  • 长列表优化:使用React Window/React Virtualized实现列表虚拟化,仅渲染可视区域元素,减少DOM节点数量。官方链接:React Window
  • 资源加载优化:React 19支持后台异步加载图像、脚本等资源,结合流式渲染(Streaming SSR),避免阻塞主线程。
  • 缓存控制:在React Router的Loaders中配置Cache-Control头,利用浏览器原生缓存减少重复请求。

3. 性能监测:Lighthouse

谷歌开源的性能监测工具,可检测首屏加载时间、交互响应速度等指标,提供优化建议。使用指南:Lighthouse 官方文档

六、全栈拓展:React + Next.js

全栈化是React开发的重要趋势,Next.js作为React的全栈框架,2025年最新版本(Next.js 15)全面支持React 19,提供混合渲染、AI优化、边缘函数等特性,无需独立后端即可构建全栈应用。

核心特性

  • 混合渲染:支持SSR(服务器端渲染)、SSG(静态生成)、ISR(增量静态生成),适配不同内容场景。
  • AI驱动优化:内置AI工具链,支持自动代码优化、Figma设计一键转React代码。
  • 边缘函数:在边缘节点运行后端逻辑,降低延迟,支持地理位置感知。

学习资源

官方文档:Next.js 15 官方文档;实战项目:从React到Next.js 全栈实战

七、2025 React技术栈选型建议

根据项目规模与场景,推荐以下选型方案,帮助大家快速落地:

  • 小型项目/个人项目:React 19 + Vite + React Router 7 + useContext+useReducer
  • 中型项目/创业项目:React 19 + TypeScript + Vite + React Router 7 + Zustand + React Query
  • 大型企业级项目:React 19 + TypeScript + Webpack/Next.js 15 + Redux/Zustand + Apollo + 完整测试体系

总结

2025年的React技术栈呈现「简洁化」「高效化」「全栈化」的趋势,核心框架的编译器革新降低了优化成本,生态工具的集成化提升了开发效率。作为前端开发者,无需追逐所有新技术,只需根据项目需求选择合适的方案,聚焦业务逻辑实现。希望本文的梳理能帮助大家理清React技术栈的脉络,快速上手最新特性。如果有补充或疑问,欢迎在评论区交流~

表格组件封装详解(含完整代码)

作者 婷婷婷婷
2025年12月31日 17:02

表格组件封装详解(含完整代码)

本文详细解析 布局容器 + 动态搜索栏 + 智能表格 三组件的封装逻辑、实现细节与标准用法,附带完整可运行代码。


一、整体架构与协作关系

🧩 组件职责划分

组件 职责 关键能力
LayoutContainer.vue 布局骨架 统一结构、操作按钮(刷新/显隐列/折叠搜索)
DynamicSearchBar.vue 动态表单 根据配置生成 input/select/date 等控件
SmartTable.vue 数据展示 自动请求、分页、字典转换、时间格式化

🔗 数据流图

[LayoutContainer]
   │
   ├─ #search[DynamicSearchBar] ←→ params (v-model)
   │                │
   │                └── tableRef.getList() ←──┐
   │                                          │
   └─ #default[SmartTable] ←───────────────┘
                   ↑
             columns (响应式数组)
             tableRef (expose 方法)

关键设计

  • columns共享状态:LayoutContainer 修改 .hide → SmartTable 自动响应
  • tableRef方法通道:SearchBar 和 LayoutContainer 通过它触发表格刷新

二、智能表格组件(SmartTable.vue)

💡 封装目标

  • 自动处理分页、排序、加载状态
  • 支持字典、时间、链接等常见字段类型
  • 提供插槽覆盖默认渲染

📄 完整代码

<!-- SmartTable.vue -->
<template>
  <div class="smart-table">
    <el-table
      ref="tableRef"
      v-loading="loading"
      :data="tableData"
      v-bind="mergedConfig.table"
      @sort-change="handleSortChange"
    >
      <!-- 遍历 columns 渲染列 -->
      <template v-for="column in visibleColumns" :key="column.prop">
        <!-- selection / index 列 -->
        <el-table-column
          v-if="column.type === 'selection'"
          type="selection"
          :width="column.width || 55"
        />
        <el-table-column
          v-else-if="column.type === 'index'"
          type="index"
          :label="column.label"
          :width="column.width || 60"
        />

        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :sortable="column.sortable || false"
          :show-overflow-tooltip="true"
        >
          <template #default="{ row }">
            <!-- 插槽优先 -->
            <slot
              :name="column.slot"
              :row="row"
              :column="column"
              v-if="column.slot"
            />
            <!-- 字典标签 -->
            <dict-tag
              v-else-if="column.dict"
              :value="row[column.prop]"
              :dict-key="column.dict"
            />
            <!-- 时间格式化 -->
            <span v-else-if="column.date">
              {{ formatDate(row[column.prop], column.dateFormat) }}
            </span>
            <!-- 链接 -->
            <el-link
              v-else-if="column.link"
              type="primary"
              @click="handleLinkClick(column, row)"
            >
              {{ row[column.prop] }}
            </el-link>
            <!-- 默认文本 -->
            <span v-else>{{ row[column.prop] }}</span>
          </template>

          <!-- 表头提示 -->
          <template #header>
            <span>{{ column.label }}</span>
            <el-tooltip
              v-if="column.tip"
              :content="column.tip.content"
              placement="top"
            >
              <i class="el-icon-question" style="margin-left: 4px; color: #999"></i>
            </el-tooltip>
          </template>
        </el-table-column>
      </template>

      <!-- 空状态 -->
      <template #empty>
        <div class="no-data">
          <img src="@/assets/images/no-data.png" alt="无数据" />
          <p>暂无数据</p>
        </div>
      </template>
    </el-table>

    <!-- 分页 -->
    <el-pagination
      v-if="!mergedConfig.notPagination && total > 0"
      v-model:current-page="queryParams.pageNum"
      v-model:page-size="queryParams.pageSize"
      :total="total"
      layout="total, sizes, prev, pager, next, jumper"
      @size-change="handleSizeChange"
      @current-change="getList"
      v-bind="mergedConfig.pagination"
    />
  </div>
</template>

<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { parseTime } from '@/utils'

// Props
const props = defineProps({
  columns: {
    type: Array,
    required: true
  },
  func: {
    type: Function,
    required: true
  },
  params: {
    type: Object,
    default: () => ({})
  },
  config: {
    type: Object,
    default: () => ({})
  },
  events: {
    type: Object,
    default: () => ({})
  }
})

// Expose
const tableRef = ref(null)
defineExpose({
  getList,
  resetQuery,
  reload
})

// 响应式数据
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const queryParams = reactive({
  pageNum: 1,
  pageSize: 10,
  ...props.params
})

// 合并配置
const mergedConfig = computed(() => {
  return {
    table: {
      border: true,
      stripe: true,
      ...props.config.table
    },
    pagination: {
      background: true,
      pageSizes: [10, 20, 50, 100],
      ...props.config.pagination
    },
    sort: props.config.sort ?? false,
    notPagination: props.config.notPagination ?? false,
    autoPagination: props.config.autoPagination ?? false,
    initResquest: props.config.initResquest ?? true
  }
})

// 可见列(过滤 hide = true 的列)
const visibleColumns = computed(() => {
  return props.columns.filter(col => !col.hide)
})

// 格式化时间
function formatDate(value, format = '{y}-{m}-{d}') {
  if (!value) return ''
  return parseTime(value, format)
}

// 获取数据
async function getList() {
  try {
    loading.value = true

    // 触发 formatParams 事件
    let finalParams = { ...queryParams }
    if (props.events?.formatParams) {
      finalParams = props.events.formatParams(finalParams) || finalParams
    }

    const res = await props.func(finalParams)

    // 触发 formatData 事件
    let finalData = res
    if (props.events?.formatData) {
      finalData = props.events.formatData(res) || res
    }

    // 处理分页数据
    if (mergedConfig.value.autoPagination) {
      // 前端分页
      tableData.value = finalData.data || []
      total.value = tableData.value.length
    } else {
      // 后端分页
      tableData.value = finalData.data?.rows || []
      total.value = finalData.data?.total || 0
    }
  } catch (error) {
    console.error('表格请求失败:', error)
    tableData.value = []
    total.value = 0
  } finally {
    loading.value = false
  }
}

// 重置查询
function resetQuery() {
  queryParams.pageNum = 1
  getList()
}

// 强制重绘
function reload() {
  tableRef.value?.doLayout()
}

// 排序变更
function handleSortChange({ prop, order }) {
  if (mergedConfig.value.sort) {
    const sort = order ? { prop, order: order === 'ascending' ? 'asc' : 'desc' } : null
    if (props.events?.onSortChange) {
      props.events.onSortChange(queryParams, sort)
    }
    getList()
  }
}

// 分页大小变更
function handleSizeChange(val) {
  queryParams.pageSize = val
  getList()
}

// 链接点击
function handleLinkClick(column, row) {
  if (props.events?.onLinkClick) {
    props.events.onLinkClick(column, row)
  } else if (column.link?.name) {
    // 路由跳转
    router.push({
      name: column.link.name,
      params: typeof column.link.params === 'function'
        ? column.link.params(row)
        : column.link.params
    })
  }
}

// 初始化
onMounted(() => {
  if (mergedConfig.value.initResquest) {
    getList()
  }
})

// 监听外部 params 变更
watch(() => props.params, (newVal) => {
  Object.assign(queryParams, newVal)
}, { deep: true })
</script>

<style scoped>
.smart-table {
  width: 100%;
}
.no-data {
  text-align: center;
  padding: 40px 0;
}
.no-data img {
  width: 120px;
  opacity: 0.6;
}
</style>

三、动态搜索栏组件(DynamicSearchBar.vue)

💡 封装目标

  • 根据配置动态生成不同类型的输入控件
  • 自动绑定参数,支持回车查询
  • 超过3项自动折叠

📄 完整代码

<!-- DynamicSearchBar.vue -->
<template>
  <el-form
    ref="formRef"
    :model="localParams"
    :inline="true"
    label-width="80px"
    size="small"
  >
    <!-- 显示项 -->
    <el-form-item
      v-for="(item, index) in displayedItems"
      :key="item.prop"
      :label="item.label"
      v-has-permi="item.permi"
    >
      <!-- input -->
      <el-input
        v-if="item.component.is === 'input'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @keyup.enter="handleQuery"
        clearable
      />
      <!-- select -->
      <el-select
        v-else-if="item.component.is === 'select'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @change="handleQuery"
        clearable
      >
        <el-option
          v-for="opt in item.component.options"
          :key="opt.value"
          :label="opt.label"
          :value="opt.value"
        />
      </el-select>
      <!-- date-picker -->
      <el-date-picker
        v-else-if="item.component.is === 'date-picker'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @change="handleQuery"
      />
      <!-- tree-select -->
      <el-tree-select
        v-else-if="item.component.is === 'tree-select'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @change="handleQuery"
      />
    </el-form-item>

    <!-- 操作按钮 -->
    <el-form-item>
      <el-button type="primary" @click="handleQuery">查询</el-button>
      <el-button @click="handleReset">重置</el-button>
      <el-button
        v-if="items.length > 3"
        type="text"
        @click="toggleExpand"
      >
        {{ isExpanded ? '收起' : '展开' }}<i :class="`el-icon-arrow-${isExpanded ? 'up' : 'down'}`"></i>
      </el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive, computed, watch } from 'vue'
const props = defineProps({
  items: {
    type: Array,
    required: true
  },
  params: {
    type: Object,
    required: true
  },
  config: {
    type: Object,
    default: () => ({})
  },
  tableRef: {
    type: Object,
    default: () => ({})
  }
})

// 响应式数据
const localParams = reactive({})
const isExpanded = ref(false)

// 计算显示项(折叠逻辑)
const displayedItems = computed(() => {
  if (isExpanded.value || props.items.length <= 3) {
    return props.items
  }
  return props.items.slice(0, 3)
})

// 同步外部 params
watch(() => props.params, (newVal) => {
  Object.assign(localParams, newVal)
}, { immediate: true, deep: true })

// 同步到外部
watch(localParams, (newVal) => {
  Object.assign(props.params, newVal)
}, { deep: true })

// 查询
function handleQuery() {
  if (props.tableRef?.getList) {
    props.tableRef.getList()
  }
  // 触发事件
  emit('query', { ...localParams })
}

// 重置
function handleReset() {
  // 重置为初始值
  for (const key in localParams) {
    localParams[key] = ''
  }
  handleQuery()
  emit('reset')
}

// 切换展开
function toggleExpand() {
  isExpanded.value = !isExpanded.value
}

// 权限指令(示例)
const vHasPermi = {
  mounted(el, binding) {
    const { value } = binding
    if (value && !checkPermission(value)) {
      el.style.display = 'none'
    }
  }
}

// 模拟权限检查
function checkPermission(permi) {
  // 实际项目中从 store 或全局状态获取用户权限
  const userPermi = ['user:query', 'role:edit'] // 示例
  if (Array.isArray(permi)) {
    return permi.some(p => userPermi.includes(p))
  }
  return userPermi.includes(permi)
}

const emit = defineEmits(['query', 'reset'])
</script>

四、布局容器组件(LayoutContainer.vue)

💡 封装目标

  • 提供标准化布局结构
  • 集成常用操作(刷新/显隐列/折叠搜索)
  • 控制内容区高度自适应

📄 完整代码

<!-- LayoutContainer.vue -->
<template>
  <div class="layout-container">
    <!-- 搜索区 -->
    <div class="search-area" v-if="$slots.search" v-show="store.search">
      <slot name="search"></slot>
    </div>

    <!-- 内容区 -->
    <div :class="['content-area', config.fullContent ? 'full' : '']">
      <!-- 操作栏 -->
      <div class="action-bar" v-if="config.actions.show">
        <div class="left-actions">
          <slot name="actions-data"></slot>
        </div>
        <div class="right-actions" v-if="config.actions.table.show">
          <!-- 折叠搜索 -->
          <el-tooltip content="隐藏搜索" placement="top">
            <el-button
              circle
              @click="store.search = !store.search"
              v-show="config.actions.table.search"
            >
              <i class="el-icon-search"></i>
            </el-button>
          </el-tooltip>

          <!-- 刷新 -->
          <el-tooltip content="刷新" placement="top">
            <el-button
              circle
              v-show="config.actions.table.refresh"
              @click="handleRefresh"
            >
              <i class="el-icon-refresh"></i>
            </el-button>
          </el-tooltip>

          <!-- 显隐列 -->
          <el-tooltip content="显隐列" placement="top">
            <el-dropdown
              trigger="click"
              :hide-on-click="false"
              v-show="config.actions.table.columns"
              popper-class="column-toggle-popper"
            >
              <el-button circle>
                <i class="el-icon-menu"></i>
              </el-button>
              <template #dropdown>
                <el-dropdown-menu>
                  <el-dropdown-item
                    v-for="col in props.columns"
                    :key="col.prop"
                  >
                    <el-checkbox
                      v-if="col.type !== 'selection'"
                      :model-value="!col.hide"
                      @update:model-value="(val) => toggleColumn(col, val)"
                    >
                      {{ col.label }}
                    </el-checkbox>
                  </el-dropdown-item>
                </el-dropdown-menu>
              </template>
            </el-dropdown>
          </el-tooltip>
        </div>
      </div>

      <!-- 主体内容 -->
      <div class="main-content" v-if="$slots.default">
        <slot></slot>
      </div>
    </div>
  </div>
</template>

<script setup>
import { reactive, computed } from 'vue'
import { merge } from 'lodash-es'

// 默认配置
const DEFAULT_CONFIG = {
  fullContent: true,
  actions: {
    show: true,
    table: {
      show: true,
      search: true,
      refresh: true,
      columns: true
    }
  }
}

const props = defineProps({
  columns: {
    type: Array,
    default: () => []
  },
  config: {
    type: Object,
    default: () => ({})
  },
  tableRef: {
    type: Object,
    default: () => ({})
  }
})

const config = computed(() => {
  return merge({}, DEFAULT_CONFIG, props.config)
})

const store = reactive({
  search: true
})

// 刷新
function handleRefresh() {
  if (props.tableRef?.getList) {
    props.tableRef.getList()
  }
}

// 切换列显隐
function toggleColumn(column, visible) {
  column.hide = !visible
}
</script>

<style scoped>
.layout-container {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}

.search-area {
  padding: 15px;
  background: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  margin-bottom: 16px;
}

.content-area {
  background: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  flex: none;
}

.content-area.full {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.action-bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px 0;
  gap: 12px;
}

.left-actions,
.right-actions {
  display: flex;
  align-items: center;
  gap: 12px;
}

.main-content {
  flex: 1;
  padding: 16px;
  overflow: auto;
}
</style>

<style>
/* 全局样式(非 scoped) */
.column-toggle-popper .el-dropdown-menu__item {
  line-height: 32px;
  padding: 0 16px;
}
</style>

五、标准使用示例

📄 父组件(业务页面)

<template>
  <layout-container 
    :columns="columns" 
    :config="wrapConfig" 
    :table-ref="tableRef"
  >
    <!-- 搜索区 -->
    <template #search>
      <dynamic-search-bar
        :items="searchItems"
        :params="queryParams"
        :table-ref="tableRef"
      />
    </template>

    <!-- 左侧操作 -->
    <template #actions-data>
      <el-button type="primary" @click="handleAdd">新增用户</el-button>
    </template>

    <!-- 表格 -->
    <smart-table
      ref="tableRef"
      :columns="columns"
      :func="getUserList"
      :params="queryParams"
      :config="tableConfig"
      :events="tableEvents"
    >
      <!-- 自定义操作列 -->
      <template #operation="{ row }">
        <el-button size="small" @click="handleEdit(row)">编辑</el-button>
        <el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
      </template>
    </smart-table>
  </layout-container>
</template>

<script setup>
import { reactive, ref } from 'vue'
import { getUserListAPI } from '@/api/user'

// 查询参数
const queryParams = reactive({
  username: '',
  status: '',
  createTime: []
})

// 表格列
const columns = reactive([
  { label: '用户名', prop: 'username' },
  { label: '状态', prop: 'status', dict: 'sys_normal_disable' },
  { label: '创建时间', prop: 'createTime', date: true },
  { label: '操作', prop: 'operation', slot: 'operation', width: 180 }
])

// 配置
const wrapConfig = {
  fullContent: true
}

const searchItems = [
  { label: '用户名', prop: 'username', component: { is: 'input', placeholder: '请输入' } },
  { 
    label: '状态', 
    prop: 'status',
    component: { 
      is: 'select',
      options: [
        { value: '1', label: '启用' },
        { value: '0', label: '禁用' }
      ]
    }
  },
  {
    label: '创建时间',
    prop: 'createTime',
    component: { is: 'date-picker', type: 'daterange', rangeSeparator: '-' }
  }
]

const tableConfig = {
  sort: true
}

const tableEvents = {
  formatParams(params) {
    // 处理日期范围
    if (params.createTime?.length) {
      params.beginTime = params.createTime[0]
      params.endTime = params.createTime[1]
      delete params.createTime
    }
    return params
  }
}

const tableRef = ref(null)

// API 方法
async function getUserList(params) {
  const res = await getUserListAPI(params)
  return { data: { rows: res.list, total: res.total } }
}

// 操作方法
function handleAdd() { /* ... */ }
function handleEdit(row) { /* ... */ }
function handleDelete(row) { /* ... */ }
</script>

六、关键设计总结

✅ 为什么这样设计?

问题 解决方案 优势
每页重复写表格结构 SmartTable 封装 减少 70% 模板代码
搜索表单千奇百怪 DynamicSearchBar 配置驱动 统一体验,快速开发
刷新/显隐列位置不一 LayoutContainer 标准化 全系统交互一致
列显隐状态难管理 直接修改 columns.hide 无需 emit,天然响应式
业务逻辑耦合 UI events 解耦 + slot 覆盖 高内聚低耦合

⚠️ 使用注意事项

  1. columns 必须是响应式对象

    // ✅ 正确
    const columns = reactive([...])
    
    // ❌ 错误
    :columns="[{ label: 'ID', prop: 'id' }]"
    
  2. 不要在组件内部写业务 API

    • 所有数据请求通过 func prop 传入
    • 参数处理通过 events.formatParams
  3. 复杂 UI 用插槽覆盖

    • 操作列 → slot
    • 自定义单元格 → slot
  4. 权限控制统一接入

    • 搜索项权限 → v-hasPermi
    • 按钮权限 → 父组件控制

在这里插入图片描述

💬 结语:这套组件体系已在多个大型后台项目中验证,显著提升开发效率与代码质量。核心思想是 “配置驱动 UI,事件解耦逻辑,插槽覆盖特例”,在规范性与灵活性之间取得平衡。

Tauri (24)——窗口在隐藏期间自动收起导致了位置漂移

2025年12月31日 17:01

背景

在桌面端应用中,我们为 SearchChat 设计了一种「紧凑模式」:

  • 正常状态下窗口高度较大
  • 当一段时间无操作后,窗口会自动收起到紧凑高度(例如 84px)
  • 收起动作由 compactModeAutoCollapseDelay 控制,比如 5 秒后触发

整体体验在大多数情况下是正常的,但在一次使用中发现了一个非常隐蔽却影响体验的问题


问题现象

问题只会出现在一个特定时序下:

  1. 窗口处于紧凑模式的 “延迟收起倒计时” 中(例如还剩 2~3 秒)
  2. 用户通过快捷键 主动隐藏窗口
  3. 延迟计时器仍然在后台触发
  4. 计时结束后,执行了 setWindowSize 收起逻辑
  5. 用户再次用快捷键唤起窗口

结果是:

窗口位置发生了漂移,不再出现在隐藏前的位置。

这个问题在某些平台或窗口管理器上尤为明显。


问题根因分析

拆开来看,核心原因其实并不复杂:

  • 延迟收起逻辑是一个 纯前端的定时器
  • 窗口被隐藏后,计时器并不会自动停止
  • 计时器触发时,仍然会调用 setWindowSize
  • 某些平台在「窗口不可见」状态下修改窗口尺寸时,会重新计算窗口位置
  • 这个重算过程不是我们可控的

因此,真正的问题不是“收起”本身,而是:

在窗口不可见时发生了尺寸变化,导致系统偷偷帮我们改了位置。


核心设计目标

我们希望做到一件事:

即使窗口在隐藏状态下被触发了尺寸变更,也要保证它在再次显示时,仍然回到隐藏前的位置。

并且要满足几个约束:

  • 不侵入现有窗口尺寸策略
  • 不依赖平台特性 hack
  • 能正确处理高 DPI 场景
  • 修改范围尽量小

解决思路(前端侧)

整体方案分为两步。

一、在窗口失焦 / 隐藏时,记录当前位置

当窗口即将被隐藏时,我们可以认为此刻的位置是“用户认可的位置”。

SearchChat 中:

  • 通过 useTauriFocusonBlur 回调
  • 调用 outerPosition() 获取当前窗口位置
  • 将结果保存到 windowPositionRef

关键点在于:

  • outerPosition() 返回的是 physical position
  • 这个坐标不受 DPI / scale factor 影响
const pos = await window.outerPosition()
windowPositionRef.current = { x: pos.x, y: pos.y }

代码位置:

src/components/SearchChat/index.tsx:113-119

二、延迟收起触发时,如果窗口不可见,强制恢复位置

在自动收起的定时器中:

  1. 正常执行 setWindowSize

  2. 紧接着判断窗口当前是否可见

  3. 如果窗口是隐藏状态,并且我们之前记录过位置:

    • 主动把窗口位置设回去

伪代码逻辑如下:

await platformAdapter.setWindowSize(width, height)

if (!(await window.isVisible()) && windowPositionRef.current) {
  const { x, y } = windowPositionRef.current
  await platformAdapter.setWindowPhysicalPosition(x, y)
}

代码位置:

src/components/SearchChat/index.tsx:158-179

这样即使系统在隐藏期间偷偷“动了手脚”,也会被我们立刻纠正。


为什么要用 Physical Position

这里有一个非常容易踩坑的点:DPI 缩放

  • outerPosition() 返回的是 physical position
  • 项目中原有的 setWindowPosition(x, y) 使用的是 logical position
  • 如果存的是 physical,却用 logical 去设,高 DPI 下会产生明显偏移

因此,我们补充了一个明确的 API:

setWindowPhysicalPosition

Tauri 实现

import { PhysicalPosition, getCurrentWebviewWindow } from '@tauri-apps/api/window'

const win = getCurrentWebviewWindow()
await win.setPosition(new PhysicalPosition(x, y))

代码位置:

src/utils/tauriAdapter.ts:85-89

Web 实现(占位)

Web 模式下不需要真实移动窗口,只保留日志即可:

src/utils/webAdapter.ts:88-90

最终效果

这个方案带来的收益非常明确:

  • ✅ 修复隐藏期间自动收起导致的窗口位置漂移
  • ✅ 正确处理高 DPI 场景,避免 logical / physical 混用
  • ✅ 改动范围小,只在 SearchChat 的定时收起路径兜底
  • ✅ 不影响其他窗口尺寸或动画策略

手动验证步骤

建议按以下流程验证:

  1. 设置 compactModeAutoCollapseDelay = 5
  2. 打开窗口,确保满足进入紧凑模式的条件
  3. 在 5 秒倒计时期间,使用快捷键隐藏窗口
  4. 等待超过 5 秒
  5. 再次用快捷键唤起窗口

预期结果:

窗口应出现在隐藏前的位置,不应发生任何跳动或漂移。


小结

这个问题本质上不是 “窗口 API 用错了”,而是 多个合理行为在特定时序下叠加,暴露出的系统边界问题

解决它的关键,不是阻止自动收起,而是:

尊重用户最后一次看到的窗口状态,并在必要时为系统行为兜底。

这类问题在桌面端应用中非常常见,也非常容易被忽略,希望这次的整理能对你有所帮助。

从一行好奇的代码说起:Vue怎么没有React的props.children

作者 雲墨款哥
2025年12月31日 16:37

引言

最近在学习 React 的过程中,我发现了一个有趣的特性:父子组件间的 props.children 传递。这让我不禁思考:Vue 中是否也有类似的功能?

React 中的 Children 传递

基础示例

先来看一个简单的父子组件示例:

父组件 App.tsx

import Son from './study/Son.tsx';

function App() {
  return (
    <>
     <Son message={'this is a reactApp'}>
      <span>this is a span</span>
     </Son>
    </>
  )
}

export default App

子组件 Son.tsx

function Son(props: any) {
    console.log(props);
    
    return <div>{props.message}</div>;
}
export default Son;

这是如何工作的?

通过控制台的输出内容可以看到,在 React 中,当你在组件标签内部放置内容时,这些内容会作为 props.children 传递给子组件。这是一个非常强大的特性,它允许组件作为容器来包裹其他组件或元素。

3ae12ef1-f807-487c-ab57-7bf1482f1838.png

所以,在子组件中,只要使用props.children就可以把它显示出来。:

Vue 中的类似功能

在我的认知中,Vue 似乎不能这样写?实际上,Vue 也有类似的功能,但语法不同:

Vue 3 中的插槽 (Slots)

<!-- 父组件 -->
<template>
  <Son message="this is a vue app">
    <span>this is a span</span>
  </Son>
</template>

<!-- 子组件 Son.vue -->
<template>
  <div>
    <div>{{ message }}</div>
    <slot></slot> <!-- 这里会渲染父组件传递的内容 -->
  </div>
</template>

<script setup>
defineProps(['message'])
</script>

Vue 使用 <slot> 元素来接收父组件传递的内容,而 React 使用 props.children。两者语法不同,概念相似,但也有一些区别的。

props.children 与 slot 的本质差异

React 的逻辑:数据驱动,责任自负

在 React 中,当父组件这样写:

<Son>
  <span>我是内容</span>
</Son>

实际上被转译成:

React.createElement(Son, {
  children: React.createElement('span', null, '我是内容')
})

关键点

  1. 数据已经送达:子组件无论如何都能在 props.children 中访问到这个 <span> 元素
  2. 处理权完全在子组件:子组件可以选择渲染、修改、忽略,甚至把它放到别的地方
  3. 无法"拒绝接收" :这份数据(React 元素引用)已经作为参数传递给了子组件函数
// 子组件可以有各种处理方式
function Son(props) {
  // 1. 正常渲染
  // return <div>{props.children}</div>
  
  // 2. 包装后再渲染
  // return <div className="wrapper">{props.children}</div>
  
  // 3. 有条件地渲染
  // return props.show ? props.children : null
  
  // 4. 拆分处理
  // const childrenArray = React.Children.toArray(props.children)
  // return childrenArray.map(child => <div className="item">{child}</div>)
  
  // 5. 完全忽略
  return <div>我不需要 children</div>
}

Vue 的逻辑:模板驱动,需要显式声明

现象观察

父组件

<template>
  <Son message="Hello">
    <span class="important">我是重要内容!</span>
  </Son>
</template>

子组件

<!-- 情况1:有 slot 签收 -->
<template>
  <div>
    <div>{{ message }}</div>
    <slot></slot> <!-- span 会被渲染 -->
  </div>
</template>

<!-- 情况2:没有 slot 签收 -->
<template>
  <div>
    <div>{{ message }}</div>
    <!-- 没有 slot,span 就像不存在一样 -->
  </div>
</template>

在 Vue 中:

  1. 编译时处理:Vue 在编译阶段处理插槽内容
  2. 需要显式签收:只有在子组件模板中写了 <slot>,内容才会被渲染
  3. 未签收就丢弃:如果没有 <slot>,内容在渲染阶段就被丢弃了

代码背后的逻辑

// Vue 在编译时决定是否包含插槽内容
// 如果子组件没有 slot,父组件的内容就不会被包含在渲染函数中
  • 插槽是模板特性:slot 是模板语法的一部分
  • 编译时决策:在编译阶段决定是否包含内容
  • 更安全的默认行为:防止意外渲染

React 的优势与风险

优势

  1. 灵活性极高:可以任意操作 children
  2. 模式多样:支持 render props、HOC 等模式
  3. 运行时控制:可以在运行时动态决定如何处理
// React 的灵活模式
function Toggle({ children }) {
  const [isOn, setIsOn] = useState(false);
  
  return (
    <>
      <button onClick={() => setIsOn(!isOn)}>
        Toggle
      </button>
      {/* 运行时决定渲染哪个 child */}
      {isOn ? children : null}
    </>
  );
}

风险

  1. 可能浪费资源:即使不渲染,children 也被创建和传递
  2. 需要更多注意:必须显式处理,否则可能意外遗漏

Vue 的优势与局限

优势

  1. 性能优化:未使用的插槽内容不会被包含
  2. 更安全:不会意外渲染未声明的内容
  3. 模板清晰:在模板中明确显示插槽位置
<!-- Vue 3 组合式 API 中 -->
<script setup>
// 可以访问 slots
import { useSlots } from 'vue'

const slots = useSlots()
// 检查是否有某个插槽内容
const hasHeader = slots.header
</script>

局限

  1. 灵活性较低:操作插槽内容不如 React 灵活
  2. 模板限制:必须在模板中声明插槽位置

Axios 常用配置及使用

作者 28256_
2025年12月31日 16:27

Axios配置详解

{
常用实例配置项
// `baseURL` 将自动加在 `url` 前面,除非 `url` 是一个绝对 URL。
// 它可以通过设置一个 `baseURL` 便于为 axios 实例的方法传递相对 URL
baseURL: 'https://some-domain.com/api/',
// `timeout` 指定请求超时前的毫秒数。 
// 如果请求耗时超过 `timeout`,则请求将被中止。
timeout: 1000, // default is `0` (no timeout)
// `withCredentials`用于指示跨域访问控制请求是否携带凭证
// 请求需要携带token时,需要设置为true
withCredentials: false, // default
常用请求配置项
url: '/user',
method: 'get', // default
headers: {'X-Requested-With': 'XMLHttpRequest'},
// `responseType` 表示服务器将返回的数据类型
// 选项包括:'arraybuffer', 'document', 'json', 'text', 'stream'
// browser only: 'blob'
responseType: 'json', // default
// `params` 是即将与请求一起发送的 URL 参数
// 一般用于get请求携带参数
// 也可post请求时,在url上拼接参数
params: { ID: 12345 },
// `data` 是作为请求主体被发送的数据
// 只适用于这些请求方法 'PUT', 'POST', 和 'PATCH'
data: { firstName: 'Fred' },
不常用配置项
  // `transformRequest` 允许在向服务器发送前,修改请求数据
  // 只能用在 'PUT', 'POST' 和 'PATCH' 这几个请求方法
  // 后面数组中的函数必须返回一个字符串,或 ArrayBuffer,或 Stream
  transformRequest: [function (data, headers) {
    // 对 data 进行任意转换处理
    return data;
  }],

  // `transformResponse` 在传递给 then/catch 前,允许修改响应数据
  transformResponse: [function (data) {
    // 对 data 进行任意转换处理
    return data;
  }],
}

Axios用法

基本用法

axios(config)

axios({
  method: 'post',
  url: '/user/12345',
  data: {
    firstName: 'Fred',
    lastName: 'Flintstone'
  },
  ...
});

通过别名使用

axios.request(config)

使用起来更简单,方便书写,减少字段的重复书写。
通过别名使用时urlmethoddata 这些字段名可忽略不写。
header之类的需要指明字段名

// 忽略了method url params 等字段
axios.get('/user?ID=12345')
// 也可以是
axios.get('/user', {
    params: {
      ID: 12345
    }
  })
// 忽略了method url data 等字段
axios.post('/user', {
  firstName: 'Fred',
  lastName: 'Flintstone',
})

针对get请求可以简化为下面这种。method默认就是get

axios('/user/12345'); 

Axios如何取消请求

Axios注意事项

  1. 后端返回的长整形数据过长,会导致精度丢失,出现变为0的情况
    原因: axios在处理HTTP响应时,默认使用JSON.parse()解析数据,但JavaScript的number类型安全整数范围有限(最大安全值为2^53 - 1,约16位十进制数),超出时会导致精度丢失,常见于后端返回的长整型ID(如雪花算法生成的19位ID)。‌

Effect 执行时机与事件循环交错关系

作者 day1
2025年12月31日 16:25

1.引言

在 React 并发渲染架构中,副作用(Effect)的执行时机并非孤立存在,而是与浏览器事件循环的微任务、宏任务队列深度耦合。useEffect 作为最核心的副作用 Hook,其异步执行特性、passive effect 的特殊调度逻辑,一直是开发者理解 React 底层原理的难点。本文将从 React 源码出发,先通过核心源码片段梳理更新调度的关键链路,再逐层拆解 Effect 与事件循环的交错关系,揭示 passive effect 的调度模型。

2. 事件循环基础:微任务与宏任务的执行机制

要理解 React Effect 的调度时机,首先要掌握浏览器事件循环(Event Loop)的核心。这是 React 实现异步、非阻塞渲染,并与宿主环境(浏览器)高效交互的底层基础。熟悉事件循环的可以跳过这个章节。

之前写过一篇事件循环的文章,也可以看一下。

2.1 事件循环的运行流程:一次“循环”的生命周期

浏览器的事件循环机制确保了 JavaScript 在单线程模型下依然能高效处理异步操作。其核心可以概括为“一次只做一件事,但会按顺序排队”。一轮具体的事件循环(Tick)可以分解为以下步骤:

  1. 执行同步代码:从宏任务队列中取出一个最老的任务(例如一个 script 标签里的代码),将其放入调用栈(Call Stack)并执行,直到调用栈清空。
  2. 执行所有微任务:在同步代码执行完毕后,立即检查微任务队列(Microtask Queue)。如果队列不为空,则依次取出所有微任务并执行,直到微任务队列被完全清空。
  3. (可选)UI 渲染:在微任务队列清空后,浏览器有一次机会来更新 UI,包括执行重绘(Repaint)和回流(Reflow)。这个步骤不是每轮循环都必然发生,浏览器会根据刷新率、页面性能等因素自行决定。
  4. 进入下一轮循环:返回第一步,准备从宏任务队列中取出下一个任务。

这个“执行一个宏任务 -> 清空所有微任务 -> 可能的 UI 渲染”的循环,构成了事件循环的核心。

8-1.svg

2.2 微任务与宏任务的核心差异与示例

微任务和宏任务最关键的区别在于它们的执行时机优先级

  1. 微任务 (Microtask):

特点:优先级极高,在当前同步任务执行结束后、UI 渲染之前立即执行。如果微任务执行过程中又产生了新的微任务,它们会被添加到队列末尾,并在同一轮事件循环中被“插队”执行,直到队列完全清空。 常见示例: - Promise.then()Promise.catch()Promise.finally() - MutationObserver:用于监听 DOM 树的变化。 - queueMicrotask():一个专门用于手动向微任务队列添加任务的现代 API。 - process.nextTick() (Node.js 环境)

  1. 宏任务 (Macrotask):

特点:优先级较低,被放入宏任务队列中排队等待。每轮事件循环只执行队列中的一个宏任务。 常见示例: - 整体的 <script> 代码块 - setTimeout()setInterval() - MessageChannel:React Scheduler 用它来模拟 setImmediate,以实现无延迟的宏任务调度。 - I/O 操作、网络请求回调 - 用户交互事件(如 clickscroll)的回调函数

代码示例:执行顺序的直观体现

console.log("1. 同步代码开始");

setTimeout(() => {
  console.log("5. 宏任务:setTimeout");
}, 0);

Promise.resolve().then(() => {
  console.log("3. 微任务:Promise.then 1");
  Promise.resolve().then(() => {
    console.log("4. 微任务:Promise.then 2");
  });
});

console.log("2. 同步代码结束");

// 预期输出:
// 1. 同步代码开始
// 2. 同步代码结束
// 3. 微任务:Promise.then 1
// 4. 微任务:Promise.then 2
// 5. 宏任务:setTimeout

这个例子清晰地展示了:同步代码最先执行,随后所有微任务被清空,最后才轮到下一轮事件循环中的宏任务。

3、passive effect 调度模型:useEffect 的异步执行本质

我们已经知道,React 的渲染工作(Render Phase)可以在宏任务中被切分执行,而提交工作(Commit Phase)是同步的。那么,useEffect 的回调,也就是 Passive Effect,它到底在哪个阶段执行?

答案是:在 Commit 阶段之后,通过 Scheduler 调度的一个独立的宏任务中异步执行。

这正是 useEffect “非阻塞”特性的核心。它确保了 Effect 的执行不会推迟浏览器的绘制,从而为用户提供更流畅的体验。

3.1 从 Commit 结束到 Passive Effect 调度

整个流程可以分解为以下几个关键步骤:

  1. Commit 阶段同步执行: 当 React 完成 Render 阶段后,会进入 commitRoot 函数。在这个阶段,React 会同步执行所有 DOM 操作(增、删、改)和 useLayoutEffect 的回调。这个过程是不可中断的,因为它需要保证 DOM 的一致性。

  2. 检查并标记 Passive Effect: 在 commitRoot 的末尾,React 会检查本次更新中是否存在 Passive Effect。如果 root.passiveEffects 标记为 true,说明有 useEffect 或其销毁函数需要被执行。

  3. 调度 flushPassiveEffects: 如果存在 Passive Effect,React 并不会立即执行它们。相反,它会调用 Scheduler_scheduleCallback,将一个名为 flushPassiveEffects 的函数作为一个低优先级IdleSchedulerPriority)的新任务交给 Scheduler。

    // c:\Users\48150\Desktop\新建文件夹 (8)\react-app\src\react\packages\react-reconciler\src\ReactFiberWorkLoop.js (简化)
    function commitRoot(root) {
      // ...
      // 1. 同步执行 DOM Mutations 和 useLayoutEffect
      // ...
    
      const rootHasPassiveEffects = root.passiveEffects;
      if (rootHasPassiveEffects) {
        // 2. 如果存在 passive effects,将其作为一个新的低优先级任务调度
        const callback = flushPassiveEffects.bind(null, root);
        Scheduler_scheduleCallback(IdleSchedulerPriority, callback);
      }
    
      // ...
      // 3. 提交阶段结束
    }
    
  4. 通过宏任务异步执行: Scheduler 接收到这个新任务后,会像处理其他更新一样,通过 MessageChannel 安排一个新的宏任务。这意味着 flushPassiveEffects 函数的执行将发生在下一轮或更晚的事件循环中。

8-2.png

3.2flushPassiveEffects 的工作

当浏览的那个宏任务时,flushPassiveEffects 函数才真正被调用。它的工作很简单,分为两步:

  1. 执行销毁函数:首先,遍器执行到 Scheduler 安排历所有在上一次渲染中注册的 useEffect,并执行它们的销毁函数(cleanup,即 useEffect 返回的那个函数)。
  2. 执行回调函数:然后,遍历本次渲染中新注册的 useEffect,并执行它们的回调函数。

3.3 总结:为什么是异步的?

通过这个“提交 -> 调度 -> 异步执行”的模型,React 实现了 useEffect 的非阻塞特性:

  • 保证渲染不被阻塞:DOM 更新和页面绘制(发生在当前事件循环的渲染阶段)与 useEffect 的执行被完全分离。即使用户在 useEffect 中编写了非常耗时的代码,也不会影响页面的首次呈现。
  • 获取最新的状态:由于 useEffect 在渲染和提交之后执行,它可以确保访问到最新的 DOM 状态和 React state。

这个设计是 React 并发模式的基石之一,它将副作用的执行从关键的渲染路径中剥离,从而在根本上提升了应用的响应性能。

4.从源码中理解一次完整更新代码执行流程

4.1 更新产生与入队

当组件中调用 setState 时,React 会创建一个更新对象,为其分配一个 Lane(优先级车道),并将其入队。关键入口是 scheduleUpdateOnFiber,它负责将更新与 Fiber 关联,并确保根(root)被标记为待处理。

// c:\Users\48150\Desktop\新建文件夹 (8)\react-app\src\react\packages\react-reconciler\src\ReactFiberWorkLoop.js:848
export function scheduleUpdateOnFiber(
  root: FiberRoot,
  fiber: Fiber,
  lane: Lane
) {
  // ...
  markRootUpdated(root, lane);
  // ...
  ensureRootIsScheduled(root); // 核心调度函数
}

ensureRootIsScheduled 的核心职责是:判断本次更新的优先级,并决定是以同步还是并发的方式来安排一次调度。

// c:\Users\48150\Desktop\新建文件夹 (8)\react-app\src\react\packages\react-reconciler\src\ReactFiberRootScheduler.js:109
function ensureRootIsScheduled(root: FiberRoot) {
  // ...
  const newCallbackPriority = getHighestPriorityLane(nextLanes);
  // ...
  if (newCallbackPriority === SyncLane) {
    // 同步更新路径
  } else {
    // 并发更新路径
    const schedulerPriorityLevel =
      lanePriorityToSchedulerPriority(newCallbackPriority);
    newCallbackNode = scheduleCallback(
      schedulerPriorityLevel,
      performConcurrentWorkOnRoot.bind(null, root)
    );
  }
  // ...
}
  • 同步路径 (SyncLane): 对于 flushSync 或老版 React 中的同步更新,它会调用 scheduleSyncCallbackperformSyncWorkOnRoot 函数推入一个同步队列,并安排一个微任务 (scheduleMicrotask) 来冲刷这个队列。这意味着渲染会在当前事件循环的微任务阶段被同步执行,不会等待下一个宏任务。

  • 并发路径 (其他 Lane): 对于普通更新,它会调用 Scheduler 的 scheduleCallback 方法,将 performConcurrentWorkOnRoot 函数作为一个任务交给 Scheduler 去处理。

4.2 Scheduler 的宏任务调度与 workLoop

当 Reconciler 通过 scheduleCallback 请求一次并发调度时,流程进入 Scheduler 模块。

第一步:创建任务并入队

scheduleCallback 根据 Reconciler 传递的优先级,计算出任务的过期时间 expirationTime,然后创建一个 task 对象,并将其推入 taskQueue 这个最小堆中。

// c:\Users\48150\Desktop\新建文件夹 (8)\react-app\src\react\packages\scheduler\src\forks\Scheduler.js:331
function unstable_scheduleCallback(priorityLevel, callback, options) {
  // ... 计算 expirationTime
  var newTask = {
    id: taskIdCounter++,
    callback, // 这里的 callback 就是 performConcurrentWorkOnRoot
    priorityLevel,
    // ...
    expirationTime,
  };

  push(taskQueue, newTask); // 入队(最小堆)

  // 如果当前没有调度在进行,则请求一个宏任务
  if (!isHostCallbackScheduled && !isPerformingWork) {
    isHostCallbackScheduled = true;
    requestHostCallback(flushWork);
  }
  return newTask;
}

第二步:通过 MessageChannel 安排宏任务

requestHostCallback 的作用是安排一个宏任务来执行 flushWork 函数。它优先使用 MessageChannel,因为 MessageChannel 的回调属于宏任务,且没有 setTimeout(0) 的 4ms 延迟限制,可以尽快执行。

// c:\Users\48150\Desktop\新建文件夹 (8)\react-app\src\react\packages\scheduler\src\forks\Scheduler.js:532
if (typeof MessageChannel !== "undefined") {
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
}

function requestHostCallback() {
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}

注意:requestHostCallback 最终会调用 schedulePerformWorkUntilDeadline,其 onmessage 的处理函数是 performWorkUntilDeadlineperformWorkUntilDeadline 内部会调用 flushWorkflushWork 内部再调用 workLoop

第三步:workLoop 执行任务

当浏览器空闲下来,执行 MessageChannel 安排的宏任务时,workLoop 函数启动。它的核心逻辑是:

  1. taskQueue(最小堆)的堆顶 peek 一个任务。这个任务是当前所有任务中最紧急expirationTime 最小)的。
  2. 检查这个任务是否过期,以及当前帧是否还有剩余时间 (shouldYieldToHost)。
  3. 如果可以执行,就执行任务的 callback,这个 callback 正是 Reconciler 传来的 performConcurrentWorkOnRoot
  4. 执行完毕后,如果任务返回了一个新的函数(表示工作未完成),则将其放回队列。否则,将任务从队列中 pop 出。
  5. 循环此过程,直到时间切片用完或队列为空。
// c:\Users\48150\Desktop\新建文件夹 (8)\react-app\src\react\packages\scheduler\src\forks\Scheduler.js:188
function workLoop(initialTime: number) {
  let currentTime = initialTime;
  currentTask = peek(taskQueue); // 从最小堆顶部取出最高优先级的任务

  while (currentTask !== null) {
    if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
      // 时间切片用完,让出主线程
      break;
    }
    const callback = currentTask.callback;
    if (typeof callback === "function") {
      // ...
      const continuationCallback = callback(didUserCallbackTimeout); // 执行任务
      // ...
      if (continuationCallback === null) {
        pop(taskQueue); // 任务完成,出队
      }
    }
    // ...
    currentTask = peek(taskQueue); // 取下一个任务
  }
  // ...
}

至此,一次并发更新的调度流程完成,performConcurrentWorkOnRoot 被成功调用,正式进入了可中断的渲染阶段。

4.3 Commit 阶段与 Passive Effect 的重新调度

当渲染阶段(performConcurrentWorkOnRoot)成功完成,并且 React 决定将更新提交到屏幕上时,就进入了不可中断的 Commit 阶段。这个阶段的核心函数是 commitRoot

commitRoot 负责执行所有真实的 DOM 操作和调用生命周期方法,它分为几个同步执行的子阶段,例如:

  • Mutation Effects:执行 DOM 的增、删、改。
  • Layout Effects:同步执行 useLayoutEffect 的回调。

在所有这些同步工作都完成后,commitRoot 并不会立即执行 useEffect 的回调。相反,它会检查是否存在 Passive 标记的 effect,如果存在,它会将一个名为 flushPassiveEffects 的函数重新调度给 Scheduler。

// c:\Users\48150\Desktop\新建文件夹 (8)\react-app\src\react\packages\react-reconciler\src\ReactFiberWorkLoop.js (simplified)
function commitRoot(root) {
  // ...
  // 执行 Mutation 和 Layout effects (同步)
  // ...

  const rootHasPassiveEffects = root.passiveEffects;
  if (rootHasPassiveEffects) {
    // 如果存在 passive effects,将其作为一个新的低优先级任务调度
    const callback = flushPassiveEffects.bind(null, root);
    Scheduler_scheduleCallback(IdleSchedulerPriority, callback);
  }

  // ...
  // 提交阶段结束
}

// flushPassiveEffects 的大致实现
function flushPassiveEffects(root) {
  // 1. 调用 commitHookPassiveUnmountEffects (执行 useEffect 的销毁函数)
  // 2. 调用 commitHookPassiveMountEffects (执行 useEffect 的回调函数)
}

核心逻辑闭环:

  1. Effect 的异步调度commitRoot 在同步完成 DOM 更新和 useLayoutEffect 后,并没有直接执行 useEffect。它调用 Scheduler_scheduleCallback,将 flushPassiveEffects 包装成一个空闲优先级IdleSchedulerPriority)的任务,放回 Scheduler 的任务队列。
  2. 宏任务执行:Scheduler 会在未来的某个时间点(通常是在浏览器完成当前帧的绘制、主线程进入空闲状态后)的新宏任务中,执行这个低优先级的 flushPassiveEffects 任务。
  3. 最终回调flushPassiveEffects 函数内部才会真正去执行 useEffect 的销毁和挂载回调。

这种设计确保了 useEffect 中的代码(如网络请求、日志记录)不会影响到用户感知的渲染性能。

这个“提交 -> 重新调度 -> 异步执行”的闭环,说明passive effect(即 useEffect)正是通过与事件循环(宏任务)的交错执行,才实现了其不阻塞浏览器渲染的异步特性。从 setState 开始的更新,经过 Reconciler 和 Scheduler 的协同工作,最终在 Commit 阶段通过一次新的调度,完成了 useEffect 的异步回调,形成了一个完整的调度模型。

5.总结

React 的 Effect 调度模型是其并发渲染架构的核心组成部分,它通过 Lane 优先级模型、Scheduler 时间切片、passive effect 异步调度,实现了与浏览器事件循环的深度协同,解决了传统同步渲染中主线程阻塞、用户体验差的问题。

从底层原理来看,React 以 “用户体验为核心”,通过精细化的任务优先级管理与协作式调度,将更新任务与副作用在事件循环的微任务、宏任务队列中合理编排,既保证了高优先级更新的即时性,又确保了低优先级副作用的非阻塞执行。

设计并实现一个 MCP Server

作者 ycbing
2025年12月31日 16:14

设计并实现一个 MCP Server 其实比你想象的要简单,特别是 Anthropic 最近推出了简化版的 SDK。

实现一个 MCP Server 主要分为三个阶段:设计(定义能力) -> 编码(Python/TS实现) -> 配置(连接到客户端)

我们将以一个 Python 版本的简单示例——“一个能查询股票并记录笔记的助手”——来带你走完全流程。

第一阶段:设计 (Design Phase)

在写代码之前,你需要明确你的 Server 要通过 MCP 协议暴露什么能力给 AI。

  1. 确定工具 (Tools): AI 需要主动执行什么动作?

    • 示例: get_stock_price(symbol) —— 输入股票代码,返回价格。
  2. 确定资源 (Resources): AI 需要被动读取什么数据?

    • 示例: daily_note://today —— 读取今天的笔记内容。
  3. 确定提示词 (Prompts): 是否需要预设模板?

    • 示例: summarize_stock —— 一个帮助分析股票数据的预设 Prompt。

第二阶段:编码实现 (Implementation Phase)

我们将使用 Python 的 mcp 官方库。为了最快上手,我们使用 FastMCP(这是 SDK 中用于快速构建的高级接口)。

1. 环境准备

你需要安装 Python 和 mcp 库:

pip install mcp

2. 编写代码 (server.py)

创建一个 server.py 文件,写入以下代码:

from mcp.server.fastmcp import FastMCP

# 1. 初始化 Server 名称
mcp = FastMCP("StockHelper")

# 模拟的数据库数据
stock_data = {
    "AAPL": 180.50,
    "GOOGL": 140.20,
    "MSFT": 410.00
}

# 2. 定义工具 (Tool) - AI 会调用这个函数
@mcp.tool()
def get_stock_price(symbol: str) -> str:
    """
    获取指定股票代码的当前价格。
    Args:
        symbol: 股票代码 (例如 AAPL, MSFT)
    """
    # 实际开发中这里会调用真实的 API (如 Yahoo Finance)
    price = stock_data.get(symbol.upper())
    if price:
        return f"{symbol.upper()} 当前价格是 ${price}"
    else:
        return f"未找到股票代码 {symbol}"

# 3. 定义资源 (Resource) - AI 可以直接读取这个 URI
@mcp.resource("note://daily_log")
def get_daily_log() -> str:
    """获取今天的交易日志"""
    return "今日日志:关注科技股动向,特别是 AI 板块的波动。"

# 4. 运行服务
if __name__ == "__main__":
    mcp.run()

第三阶段:连接与测试 (Integration Phase)

代码写好了,现在需要把这个 Server 挂载到宿主应用(Host)上,最常用的宿主是 Claude Desktop

1. 修改配置文件

找到 Claude Desktop 的配置文件:

  • MacOS: ~/Library/Application Support/Claude/claude_desktop_config.json

  • Windows: %APPDATA%\Claude\claude_desktop_config.json

用文本编辑器打开它,添加你的 Server 配置(注意修改 python 的路径和文件路径):

{
  "mcpServers": {
    "stock-helper": {
      "command": "python", 
      "args": ["/绝对路径/path/to/your/server.py"]
    }
  }
}

注意:建议使用绝对路径,或者使用 uv / venv 环境里的 python 可执行文件路径。

2. 重启 Claude 并测试

  1. 完全关闭并重启 Claude Desktop。

  2. 你会看到输入框旁边多了一个“插头”或工具图标,点击可以看到 stock-helper 已连接。

  3. 开始对话:

  • 你: “帮我查一下 AAPL 的价格。”

  • Claude: (内部调用 get_stock_price("AAPL")) -> “AAPL 当前价格是 $180.50”。

  • 你: “看看今天的日志里写了什么?”

  • Claude: (内部读取 note://daily_log) -> “今日日志显示要关注科技股动向..."

第四阶段:调试与优化 (Debugging & Best Practices)

在实际开发中,你不可能一次就写对,这里有两个关键工具和原则:

1. 使用 MCP Inspector (调试神器)

Anthropic 提供了一个网页版调试器,不用重启 Claude 就能测代码。

npx @modelcontextprotocol/inspector python server.py

这会启动一个本地网页,你可以在上面手动点击你的 Tool,查看返回值是否符合预期。

2. 关键设计原则

  • 详细的 Docstring: 注意看上面代码里的 """文档字符串"""。LLM 是通过阅读这些文字来理解如何使用你的工具的。如果你写得不清楚,AI 就不知道该传什么参数。

  • 错误处理: 你的函数不要直接抛出 Python 异常(Crash),而是应该返回一个清晰的错误信息字符串(如 Error: Connection timeout),这样 AI 可以看到错误并尝试自我修正。

  • 安全性: 因为是本地执行,千万小心不要暴露类似 execute_shell_command 这样危险的工具,否则 AI 被提示词注入攻击后可能会删除你电脑的文件。

面试官: “ 说一下怎么做到前端图片尺寸的响应式适配 ”

作者 千寻girling
2025年12月31日 15:26

前端开发中,图片的尺寸适配是响应式设计的核心部分之一,需要结合图片类型、容器场景、设备特性来选择方案。以下是常见的图片尺寸策略和多窗口适配方法:

一、先明确:前端常用的图片尺寸场景

不同场景下,图片的 “合适尺寸” 差异很大:

场景 建议尺寸范围 示例
图标 / 小图标 24×24 ~ 128×128(2 倍图) 按钮图标、头像缩略图
列表缩略图 300×200 ~ 600×400(2 倍图) 商品列表、文章封面缩略图
详情页主图 800×600 ~ 1920×1080(2 倍图) 商品详情图、Banner 图
背景图 1920×1080 ~ 3840×2160 全屏背景、页面 Banner
移动端适配图 750×1334(2 倍图)、1242×2208(3 倍图) 移动端页面元素图

二、多窗口适配的核心方法

1. 基础适配:max-width: 100%(通用)

最常用的适配方式,让图片不超过容器宽度,自动缩放高度:

img {
  max-width: 100%; /* 图片宽度不超过父容器 */
  height: auto;    /* 高度自动按比例缩放,避免变形 */
}

✅ 适用场景:大部分内联图片、列表图、详情图。

2. 背景图适配:background-size

针对背景图,通过 CSS 属性控制缩放逻辑:

.bg-img {
  width: 100%;
  height: 300px;
  background: url("bg.jpg") center/cover no-repeat; 
  /* 或单独设置: */
  background-size: cover; /* 覆盖容器,可能裁剪 */
  /* background-size: contain; 完整显示,可能留白 */
}
  • cover:优先覆盖容器,保持比例(常用全屏背景);
  • contain:优先完整显示,保持比例(常用图标背景)。

3. 响应式图片:srcset + sizes(精准加载)

让浏览器根据设备尺寸 / 像素比,自动选择合适的图片(减少加载体积):

<img 
  src="img-800.jpg"  <!-- 默认图 -->
  srcset="
    img-400.jpg 400w,  <!-- 400px宽的图 -->
    img-800.jpg 800w,  <!-- 800px宽的图 -->
    img-1200.jpg 1200w <!-- 1200px宽的图 -->
  "
  sizes="(max-width: 600px) 400px, 800px" <!-- 告诉浏览器容器宽度 -->
  alt="响应式图片"
>

✅ 适用场景:对加载性能要求高的大图(如 Banner、详情主图)。

4. 移动端高清图:2 倍图 / 3 倍图

针对 Retina 屏,提供高分辨率图,避免模糊:

<!-- 方法1:srcset 按像素比适配 -->
<img 
  src="img@2x.png" 
  srcset="
    img@1x.png 1x,  <!-- 普通屏 -->
    img@2x.png 2x,  <!-- Retina屏 -->
    img@3x.png 3x   <!-- 超高清屏 -->
  "
  alt="高清图"
>

<!-- 方法2:CSS 背景图(针对图标) -->
.icon {
  background: url("icon@2x.png") no-repeat;
  background-size: 24px 24px; /* 实际显示尺寸是24×24,图片是48×48 */
  width: 24px;
  height: 24px;
}

5. 容器限制:object-fit(控制图片在容器内的显示方式)

当图片宽高比与容器不一致时,避免变形:

.img-container {
  width: 300px;
  height: 300px;
  overflow: hidden;
}
.img-container img {
  width: 100%;
  height: 100%;
  object-fit: cover; /* 覆盖容器,裁剪多余部分(常用头像、卡片图) */
  /* object-fit: contain; 完整显示,留白 */
  /* object-fit: fill; 拉伸变形(不推荐) */
}

6. 媒体查询:针对特定窗口尺寸切换图片

强制在不同屏幕下使用不同图片(适合差异较大的场景):

/* 移动端用小图 */
@media (max-width: 768px) {
  .banner {
    background-image: url("banner-mobile.jpg");
  }
}
/* 桌面端用大图 */
@media (min-width: 769px) {
  .banner {
    background-image: url("banner-desktop.jpg");
  }
}

三、总结适配思路

  1. 优先用 max-width: 100% + height: auto:覆盖 80% 的基础场景;
  2. 背景图用 background-size: cover/contain
  3. 大图用 srcset + sizes:兼顾性能和清晰度;
  4. 固定容器用 object-fit:避免图片变形;
  5. 移动端用 2 倍 / 3 倍图:保证高清显示。

Electron + Vue 3 + Node.js 的跨平台桌面应用示例项目

2025年12月31日 15:26

Electron + Vue + Node.js 原生模块学习示例

项目简介

这是一个结合 Electron + Vue 3 + Node.js 的跨平台桌面应用示例项目,用于学习和展示 Node.js 原生模块在 Electron 环境中的使用。

技术栈

  • Electron: ^39.2.7 - 跨平台桌面应用开发框架
  • Vue: ^3.5.24 - 渐进式 JavaScript 框架
  • Vite: ^7.2.4 - 下一代前端构建工具
  • Node.js: >= 20.0.0 (推荐使用 LTS 版本)

环境要求

Node.js 版本

推荐使用 Node.js 20.x LTS 版本,因为 Electron 39 基于 Node.js 20.x 构建。

你可以通过以下命令检查当前 Node.js 版本:

node -v

如果需要管理多个 Node.js 版本,推荐使用:

  • nvm (Windows/Linux/macOS)
  • n (macOS/Linux)

项目结构

.
├── electron/              # Electron 主进程代码
│   ├── main.js           # 主进程入口文件
│   └── preload.js        # 预加载脚本
├── src/                  # Vue 渲染进程代码
│   ├── assets/           # 静态资源
│   ├── App.vue           # 根组件
│   ├── main.js           # 渲染进程入口
│   └── style.css         # 全局样式
├── public/               # 静态资源
├── dist-electron/        # Electron 构建产物
├── dist/                 # Vue 构建产物
├── dist-installer/       # 安装包产物
├── electron-builder.json # Electron 构建配置
├── package.json          # 项目依赖和脚本
└── vite.config.js        # Vite 配置

安装依赖

npm install

开发模式

启动开发服务器:

npm run dev
# 或
npm run electron:dev

这将同时启动 Vite 开发服务器和 Electron 应用。

打包构建

构建生产版本

npm run build
# 或
npm run electron:build

打包配置

项目使用 electron-builder 进行打包,配置文件为 electron-builder.json

打包产物
  • Windows: 生成 .exe 安装包和绿色版文件夹
  • macOS: 生成 .dmg 镜像文件
  • Linux: 生成 .deb.rpm 等格式
自定义打包配置

你可以在 electron-builder.json 中修改打包配置,例如:

  • 应用名称
  • 图标
  • 打包格式
  • 依赖项

项目功能

  • 展示 Vue 3 在 Electron 中的基本使用
  • 演示 Node.js 原生模块的调用
  • 提供完整的开发和构建流程
  • 包含预加载脚本示例

常见问题

1. 安装依赖失败

如果安装依赖时遇到网络问题,可以尝试使用淘宝镜像:

npm install --registry=https://registry.npmmirror.com

2. 打包失败

  • 确保 Node.js 版本符合要求
  • 检查是否有足够的磁盘空间
  • 查看控制台错误信息

3. 应用启动失败

  • 检查端口是否被占用
  • 查看开发者工具中的错误信息

许可证

MIT License

作者

Developer

项目地址 gitee.com/niehuizhe/e…

nextjs学习3:动态路由、路由组、平行路由

2025年12月31日 14:50

动态路由(Dynamic Routes)

有的时候,你并不能提前知道路由的地址,就比如根据 URL 中的 id 参数展示该 id 对应的文章内容,文章那么多,我们不可能一一定义路由,这个时候就需要用到动态路由。

[folderName]

使用动态路由,你需要将文件夹的名字用方括号括住,比如 [id][slug]。这个路由的名字会作为 params prop 传给布局、页面、路由处理程序等。

举个例子,我们在 app/blog 目录下新建一个名为 [slug] 的文件夹,在该文件夹新建一个 page.js 文件,代码如下:


// app/blog/[slug]/page.js
export default function Page({ params }) {
  return <div>My Post: {params.slug}</div>
}

当你访问 /blog/a的时候,params 的值为 { slug: 'a' }; 当你访问 /blog/yayu的时候,params 的值为 { slug: 'yayu' }

 [...folderName]

在命名文件夹的时候,如果你在方括号内添加省略号,比如 [...folderName],这表示捕获所有后面所有的路由片段。

也就是说,app/shop/[...slug]/page.js会匹配 /shop/clothes,也会匹配 /shop/clothes/tops/shop/clothes/tops/t-shirts等等。

// app/shop/[...slug]/page.js
export default function Page({ params }) {
  return <div>My Shop: {JSON.stringify(params)}</div>
}

当你访问 /shop/a的时候,params 的值为 { slug: ['a'] }

当你访问 /shop/a/b的时候,params 的值为 { slug: ['a', 'b'] }

当你访问 /shop/a/b/c的时候,params 的值为 { slug: ['a', 'b', 'c'] }

路由组(Route groups)

在 app目录下,文件夹名称通常会被映射到 URL 中,但你可以将文件夹标记为路由组,阻止文件夹名称被映射到 URL 中。

使用路由组,你可以将路由和项目文件按照逻辑进行分组,但不会影响 URL 路径结构。路由组可用于比如:

  1. 按站点、意图、团队等将路由分组
  2. 在同一层级中创建多个布局,甚至是创建多个根布局

那么该如何标记呢?

把文件夹用括号括住就可以了,就比如 (dashboard)

按逻辑分组

将路由按逻辑分组,但不影响 URL 路径:

image.png

你会发现,最终的 URL 中省略了带括号的文件夹(上图中的(marketing)(shop))。

创建不同布局

借助路由组,即便在同一层级,也可以创建不同的布局:

image.png

在这个例子中,/account 、/cart/checkout 都在同一层级。但是 /account和 /cart使用的是 /app/(shop)/layout.js布局和app/layout.js布局,/checkout使用的是 app/layout.js

平行路由(Parallel Routes):vue中的插槽

平行路由可以使你在同一个布局中同时或者有条件的渲染一个或者多个页面(类似于 Vue 的插槽功能)。

本质是一种控制页面渲染区域的机制,允许在同一个 URL 下同时渲染多个独立的路由组件

实现多区域独立渲染,比如侧边栏、主内容区、弹窗等区域各自对应不同的路由,且互不干扰。

用途 1:条件渲染

image.png

平行路由的使用方式是将文件夹以 @作为开头进行命名,比如在上图中就定义了两个插槽 @team 和 @analytics

插槽会作为 props 传给共享的父布局。在上图中,app/layout.js 从 props 中获取了 @team 和 @analytics 两个插槽的内容,并将其与 children 并行渲染:

// app/layout.js
// 这里我们用了 ES6 的解构,写法更简洁一点
export default function Layout({ children, team, analytics }) {
  return (
    <>
      {children}
      {team}
      {analytics}
    </>
  )
}

注:从这张图也可以看出,children prop 其实就是一个隐式的插槽,/app/page.js相当于 app/@children/page.js

除了让它们同时展示,你也可以根据条件判断展示:

image.png

在这个例子中,先在布局中获取用户的登录状态,如果登录,显示 dashboard 页面,没有登录,显示 login 页面。这样做的一大好处就在于代码完全分离。

用途 2:独立路由处理

平行路由可以让你为每个路由定义独立的错误处理和加载界面:

image.png

用途 3:子导航

注意我们描述 team 和 analytics 时依然用的是“页面”这个说法,因为它们就像书写正常的页面一样使用 page.js。

除此之外,它们也能像正常的页面一样,添加子页面,比如我们在 @analytics 下添加两个子页面:/page-views and /visitors

image.png

平行路由跟路由组一样,不会影响 URL,所以 /@analytics/page-views/page.js 对应的地址是 /page-views/@analytics/visitors/page.js 对应的地址是 /visitors,你可以导航至这些路由:

// app/layout.js
import Link from "next/link";

export default function RootLayout({ children, analytics }) {
  return (
    <html>
      <body>
        <nav>
          <Link href="/">Home</Link>
          <br />
          <Link href="/page-views">Page Views</Link>
          <br />
          <Link href="/visitors">Visitors</Link>
        </nav>
        <h1>root layout</h1>
        {analytics}
        {children}
      </body>
    </html>
  );
}

当导航至这些子页面的时候,子页面的内容会取代 /@analytics/page.js 以 props 的形式注入到布局中

这也就是说,每个插槽都可以有自己独立的导航和状态管理,就像一个小型应用一样。这种特性适合于构建复杂的应用如 dashboard。

路由组和平行路由的区别

  1. 路由组是文件层面的组织工具,核心解决结构混乱分组布局复用问题,不改变渲染逻辑;
  2. 平行路由渲染层面的控制机制,核心解决 多区域并行渲染 问题,允许同一个 URL 下渲染多个独立的路由组件。

调试时卡半天?原来127.0.0.1和localhost的区别这么大!

2025年12月31日 14:38

大家好,我是李司凌,一名曾工作在一线大厂的前端程序员,如今工作在外企的前端软件工程师。

前言

作为前端开发者,你是否也遇到过这样的困惑:明明都是指向本机,为什么有时候 localhost:3000 能正常访问项目,换成 127.0.0.1:3000 就直接报错?配置数据库连接时,有的教程写 localhost,有的写 127.0.0.1,到底该选哪一个才不会踩坑?

大部分时候,这两个地址的表现似乎完全一致,但偶尔出现的微妙差异,却能让我们在调试时卡上半天。其实,这背后藏着网络协议分层的底层逻辑 —— 一个是网络层的固定回环 IP,一个是应用层的约定主机名。

今天这篇文章,就带你彻底搞懂 127.0.0.1 和 localhost 的核心区别,拆解实际开发中的高频踩坑场景,还会分享一套分场景的选择指南,让你从此不再为 “本机访问地址” 发愁。

刚开始做 Web 开发的同学,大概率都遇到过这样的困惑:

  • 用 localhost:3000 能顺畅访问项目,换成 127.0.0.1:3000 就直接报错;
  • 配置数据库连接时,有的教程写 localhost,有的写 127.0.0.1,看似都指向本机,却不知道该选哪一个;
  • 大部分时候两者效果一致,但偶尔出现的微妙差异,让人摸不着头脑,排错时无从下手。

其实,只要搞懂网络协议的底层逻辑,就会发现它们代表着两个完全不同的概念层次,而那些 “偶发问题”,本质上都是这个底层差异带来的连锁反应。今天就一次性把这个知识点讲透,帮你彻底避开踩坑。

一、表面等价,本质不同:一个是 “具体地址”,一个是 “代称”

先看两个日常使用场景,大部分时候它们的效果确实一致:

bash

运行

# 命令行请求,结果基本相同
curl http://localhost:8080/api
curl http://127.0.0.1:8080/api

plaintext

# 浏览器访问,页面均能正常加载
http://localhost:3000
http://127.0.0.1:3000

但从本质上看,两者天差地别,用一个通俗的比喻就能理解:

  • 127.0.0.1 就像 “北京市朝阳区某某街道 123 号”(具体的网络层 IP 地址),直接指向目标位置,无需额外查询;
  • localhost 就像 “小明家”(应用层的主机名 / 域名),需要先查 “地址簿”(DNS/hosts 文件),才能找到对应的具体地址。

1. 127.0.0.1:网络层的固定回环地址

127.0.0.1 是 IPv4 协议中预留的回环地址,有几个核心特性:

  • 整个 127.0.0.0/8 网段都被保留为回环地址,127.0.0.1 是最常用的一个;
  • 发送到这个地址的数据包,不会离开本机,也不会经过物理网卡和外部网络设备,直接在本机网络栈内部循环处理;
  • 地址固定,无需任何解析步骤,直接与网络接口建立连接。

2. localhost:应用层的约定俗成主机名

localhost 是一个无官方强制要求,但全网通用的本机主机名,核心特性如下:

  • 本身不具备网络寻址能力,必须通过 “DNS 服务器查询” 或 “本地 hosts 文件映射”,才能解析为具体的 IP 地址;
  • 通常默认解析为 127.0.0.1(IPv4)或 ::1(IPv6),但这个映射可以被手动修改;
  • 由 RFC 1123 正式规范其特殊地位,现代操作系统都会在 hosts 文件中预配置它与回环地址的映射。

3. 核心差异总结表

对比维度 127.0.0.1 localhost
概念层次 网络层 IPv4 地址 应用层 主机名 / 域名
解析过程 无需解析,直接连接 需通过 hosts/DNS 解析
配置灵活性 固定不变,无法修改 可手动配置指向其他 IP
协议支持 仅支持 IPv4 可解析为 IPv4(127.0.0.1)或 IPv6(::1)
访问速度 更快(跳过解析步骤) 稍慢(增加解析开销)
可靠性 更高(无解析失败风险) 较低(可能因解析异常失效)

二、解析流程揭秘:为什么两者偶尔会 “一能用一不能用”

1. 访问 localhost的完整解析流程

当你在浏览器 / 命令行输入 localhost:3000 时,系统会按以下顺序查找对应 IP:

  1. 检查浏览器本地缓存,是否有近期的localhost解析记录;
  2. 若缓存无记录,检查本机 hosts 文件(Windows:C:\Windows\System32\drivers\etc\hosts;Linux/Mac:/etc/hosts);
  3. 若 hosts 文件中找到localhost对应的 IP,直接使用该 IP 建立连接;
  4. 若 hosts 文件无记录,向配置的 DNS 服务器发送查询请求;
  5. 若 DNS 服务器返回解析结果,使用该 IP 建立连接;
  6. 若 DNS 服务器也无法解析,返回连接失败。

2. 访问 127.0.0.1 的流程

当你输入 127.0.0.1:3000 时,流程会被大幅简化:

  1. 系统直接识别这是一个合法的 IPv4 回环地址;
  2. 无需任何解析步骤,直接在本机网络栈内建立连接,处理数据包。

正是这个解析流程的差异,导致了各种 “偶发问题”,下面看几个高频场景。

三、实际开发中的 3 大踩坑场景,附解决方案

场景 1:hosts 文件被修改(恶意篡改 / 配置失误)

问题现象

127.0.0.1:3000 能正常访问,localhost:3000 无法连接或跳转到陌生地址。

问题根源

hosts 文件中的localhost映射被修改,例如:

bash

运行

# 被恶意软件/错误配置修改后的 /etc/hosts
127.0.0.1    localhost
192.168.1.100    localhost  # 额外添加的错误映射

此时localhost会优先解析为 192.168.1.100,而非本机回环地址。

解决方案

  1. 打开本机 hosts 文件,查询localhost映射记录:

    bash

    运行

    # Linux/Mac 终端执行
    cat /etc/hosts | grep localhost
    
    # Windows 终端(管理员权限)执行
    findstr "localhost" C:\Windows\System32\drivers\etc\hosts
    
  2. 删除错误的映射记录,保留正确配置:

    bash

    运行

    127.0.0.1       localhost
    ::1             localhost  # 可选,支持 IPv6
    

场景 2:IPv6 环境下,应用仅监听 IPv4

问题现象

127.0.0.1:3000 能正常访问,localhost:3000 连接超时 / 拒绝。

问题根源

现代操作系统会同时配置 IPv4 和 IPv6 映射,localhost可能优先解析为 IPv6 地址 ::1,而应用程序仅监听了 IPv4 的 127.0.0.1,导致协议不匹配。

解决方案

  1. 强制使用 IPv4 解析 localhost(以 curl 为例):

    bash

    运行

    curl -4 http://localhost:3000
    
  2. 直接使用 127.0.0.1 访问,规避 IPv6 解析问题;

  3. 修改应用配置,同时监听 IPv4 和 IPv6 地址:

    javascript

    运行

    // Node.js 示例:同时监听 IPv4 和 IPv6
    app.listen(3000, '::', () => {
      console.log('Server running on both IPv4 and IPv6');
    });
    

场景 3:容器环境(Docker)中,服务绑定 127.0.0.1

问题现象

容器内部访问 127.0.0.1:3000 正常,容器外部(主机)无法访问,localhost行为也不稳定。

问题根源

容器有独立的网络命名空间,127.0.0.1 对应容器自身的回环地址,而非主机的回环地址。服务绑定 127.0.0.1 时,仅能被容器内部访问,无法穿透到容器外部。

解决方案

  1. 修改应用配置,绑定 0.0.0.0(监听容器所有网络接口):

    javascript

    运行

    // server.js 修正前
    app.listen(3000, '127.0.0.1', () => {
      console.log('Server running on 127.0.0.1:3000');
    });
    
    // server.js 修正后
    app.listen(3000, '0.0.0.0', () => {
      console.log('Server running on 0.0.0.0:3000');
    });
    
  2. Docker 启动 / 编排时,正确映射端口:

    yaml

    # docker-compose.yml 示例
    services:
      app:
        build: .
        ports:
          - "3000:3000"  # 主机端口:容器端口
        environment:
          - HOST=0.0.0.0
    

四、历史背景:为什么会同时存在这两种 “本机访问方式”

1. 127.0.0.1 回环地址的设计初衷(1970 年代)

TCP/IP 协议设计初期,就考虑到了 “本机进程间通信” 和 “本机调试网络程序” 的需求:

  • 预留 127.0.0.0/8 网段作为回环地址,避免与公网 IP 冲突;
  • 让数据包在本机网络栈内闭环处理,不经过物理网卡、路由器等外部设备,既提升效率,又保证调试安全性;
  • 提供一种标准化的本机通信方式,让不同程序之间的本机交互有统一的地址规范。

2.localhost 主机名的约定形成

  • 早期 Unix 系统率先使用 localhost 作为本机的默认主机名,方便用户记忆和使用(无需记住冗长的 IP 地址);
  • 1989 年 RFC 1123 发布,正式规范 localhost 为 “本机回环主机名”,禁止将其用于公网域名解析;
  • 随着操作系统的普及,localhost 与 127.0.0.1 的映射被预配置到 hosts 文件中,成为全网通用的约定。

3. IPv6 时代的变化

IPv4 向 IPv6 过渡后,回环地址和主机名的映射也得到了扩展:

  • IPv4 回环地址:127.0.0.1 ↔ localhost
  • IPv6 回环地址:::1 ↔ localhost
  • 现代系统默认支持双协议栈,localhost 的解析优先级可能因系统配置不同而变化,这也让两者的差异更加凸显。

五、开发 / 部署最佳实践:该选 127.0.0.1 还是localhost

1. 服务器监听配置:优先绑定 0.0.0.0/::,避免访问限制

配置方式 效果 适用场景
app.listen(3000, '127.0.0.1') 仅监听本机 IPv4 接口,外部无法访问 本地单机调试(无外部访问需求)
app.listen(3000, 'localhost') 监听本机 IPv4/IPv6 接口(视解析而定) 本地开发环境(直观易懂)
app.listen(3000, '0.0.0.0') 监听所有 IPv4 网络接口,外部可访问 容器部署、局域网共享服务
app.listen(3000, '::') 监听所有 IPv4/IPv6 网络接口 现代多协议环境部署

2. 数据库连接配置:分环境选择,兼顾可靠性和便捷性

  • 生产环境:优先使用 127.0.0.1避免 DNS 解析失败、hosts 文件被篡改等风险,提升连接稳定性和访问速度,示例:

    javascript

    运行

    // 生产环境数据库配置
    const prodDbConfig = {
      host: '127.0.0.1', // 拒绝解析开销,避免潜在风险
      port: 3306,
      database: 'prod_myapp',
      user: 'prod_user',
      password: process.env.DB_PASSWORD
    };
    
  • 开发 / 测试环境:可以使用localhost更直观易懂,方便团队成员理解和调试,无需记忆 IP 地址,示例:

    javascript

    运行

    // 开发环境数据库配置
    const devDbConfig = {
      host: 'localhost', // 直观易懂,便于团队协作
      port: 3306,
      database: 'dev_myapp',
      user: 'root',
      password: '123456'
    };
    

3. 容器化应用:核心原则是 “避免绑定容器内回环地址”

  1. 应用服务必须绑定 0.0.0.0 或 ::,确保容器外部可访问;
  2. 数据库等辅助服务,可通过 127.0.0.1 限制仅容器内部访问;
  3. 编排文件(docker-compose.yml)中,明确端口映射和环境变量配置。

4. 黄金选择法则

  1. 追求稳定性 / 性能 / 安全性(生产环境):选 127.0.0.1
  2. 追求便捷性 / 直观性(开发环境):选 localhost
  3. 外部访问 / 容器部署:选 0.0.0.0(IPv4)或 ::(双协议);
  4. 遇到连接异常:优先用 127.0.0.1 排查是否为解析问题。

六、总结

  1. 127.0.0.1 是 IPv4 回环地址,无需解析直接连接;localhost 是主机名,需通过 hosts/DNS 解析为回环地址,这是两者的核心差异;
  2. 两者的 “偶发不兼容”,本质是解析异常、协议不匹配、网络命名空间隔离导致的;
  3. 开发中遵循 “生产用 127.0.0.1 保稳定,开发用 localhost 提效率,部署用 0.0.0.0 开访问” 的原则,可大幅减少踩坑。

理解这两个概念的底层差异,不仅能解决日常开发中的连接问题,更能帮你建立起 “网络分层” 的思维模式,对后续学习容器网络、分布式服务等知识点也有极大帮助。

这些看似基础的网络知识点,恰恰是前端开发中 “隐形的坑”—— 平时用着没问题,一旦出问题就很难定位。如果这篇文章帮你理清了思路、避开了踩坑,别忘了点赞 + 收藏,把它收入你的开发避坑手册,下次遇到类似问题就能快速解决!

我会持续分享前端开发中 “知其然,更知其所以然” 的底层干货,从网络基础到工程化实践,帮你夯实技术功底、提升开发效率。关注我,后续更多优质技术内容,第一时间推送给你!

nextjs学习2:app router

2025年12月31日 14:27

本文是基于 nextjs v14 版本进行展示。

最快捷的创建 Next.js 项目的方式是使用 create-next-app脚手架,你只需要运行:

npx create-next-app@14

推荐使用 tailwindcss ,这也是 Next.js 推荐的 CSS 方案,很多 example 都会用它。

运行项目

查看项目根目录 package.json 文件的代码:

  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  }

开发的时候使用 npm run dev。部署的时候先使用 npm run build 构建生产代码,再执行 npm run start 运行生产项目。运行 npm run lint 则会执行 ESLint 语法检查。

next build

执行 next build 将会创建项目的生产优化版本:

image.png

从上图可以看出,构建时会输出每条路由的信息,比如 Size 和 First Load JS。注意这些值指的都是 gzip 压缩后的大小。其中 First Load JS 会用绿色、黄色、红色表示,绿色表示高性能,黄色或红色表示需要优化。

这里要解释一下 Size 和 First Load JS 的含义。

正常我们开发的 Next.js 项目,其页面表现类似于单页应用,即路由跳转(我们称之为“导航”)的时候,页面不会刷新,而会加载目标路由所需的资源然后展示,所以:

加载目标路由一共所需的 JS 大小 = 每个路由都需要依赖的 JS 大小(shared by all) + 目标路由单独依赖的 JS 大小

其中:

  • 加载目标路由一共所需的 JS 大小就是 First Load JS
  • 目标路由单独依赖的 JS 大小就是 Size
  • 每个路由都需要依赖的 JS 大小就是图中单独列出来的 First load JS shared by all

以上图中的 / 路由地址为例,87.6 kB(First Load JS)= 533 B(Size) + 87.1 kB(First load JS shared by all)

next start

生产模式下,使用 next start运行程序。不过要先执行 next build构建出生产代码。运行的时候,跟开发模式相同,程序默认开启在 http://localhost:3000。如果你想更改端口号:

npx next start -p 4000

app Router

app router 的目录结构类似于:

src/
└── app
    ├── page.js 
    ├── layout.js
    ├── template.js
    ├── loading.js
    ├── error.js
    └── not-found.js
    ├── about
    │   └── page.js
    └── more
        └── page.js

定义路由(Routes)

首先是定义路由,文件夹被用来定义路由。

每个文件夹都代表一个对应到 URL 片段的路由片段。创建嵌套的路由,只需要创建嵌套的文件夹。举个例子,下图的 app/dashboard/settings目录对应的路由地址就是 /dashboard/settings

image.png

定义页面(Pages)

那如何保证这个路由可以被访问呢?你需要创建一个特殊的名为 page.js 的文件。至于为什么叫 page.js呢?除了 page 有“页面”这个含义之外,你可以理解为这是一种约定或者规范。

image.png

  • app/page.js 对应路由 /
  • app/dashboard/page.js 对应路由 /dashboard
  • app/dashboard/settings/page.js 对应路由/dashboard/settings
  • analytics 目录下因为没有 page.js 文件,所以没有对应的路由。这个文件可以被用于存放组件、样式表、图片或者其他文件。

那 page.js 的代码该如何写呢?最常见的是展示 UI,比如:

// app/page.js
export default function Page() {
  return <h1>Hello, Next.js!</h1>
}

 定义布局(Layouts)

布局是指多个页面共享的 UI。

在导航的时候,布局会保留状态、保持可交互性并且不会重新渲染,比如用来实现后台管理系统的侧边导航栏。

定义一个布局,你需要新建一个名为 layout.js的文件,该文件默认导出一个 React 组件,该组件应接收一个 children prop,chidren 表示子布局(如果有的话)或者子页面。

举个例子,我们新建目录和文件如下图所示:

image.png

相关代码如下:

// app/dashboard/layout.js
export default function DashboardLayout({
  children,
}) {
  return (
    <section>
      <nav>nav</nav>
      {children}
    </section>
  )
}

// app/dashboard/page.js
export default function Page() {
  return <h1>Hello, Dashboard!</h1>
}

同一文件夹下如果有 layout.js 和 page.js,page 会作为 children 参数传入 layout。换句话说,layout 会包裹同层级的 page。

定义加载界面(Loading UI)

dashboard 目录下我们新建一个 loading.js

image.png

loading.js的代码如下:

// app/dashboard/loading.js
export default function DashboardLoading() {
  return <>Loading dashboard...</>
}

同级的 page.js 代码如下:

// app/dashboard/page.js
async function getData() {
  await new Promise((resolve) => setTimeout(resolve, 3000))
  return {
    message: 'Hello, Dashboard!',
  }
}

# 一定要是async定义的组件,不然loading.js不会生效
export default async function DashboardPage(props) {
  const { message } = await getData()
  return <h1>{message}</h1>
}

不再需要其他的代码,loading 的效果就实现了。

就是这么简单。其关键在于 page.js导出了一个 async 函数

loading.js 的实现原理是将 page.js和下面的 children 用 <Suspense> 包裹。因为page.js导出一个 async 函数,Suspense 得以捕获数据加载的 promise,借此实现了 loading 组件的关闭。

image.png

定义 404 页面

顾名思义,当该路由不存在的时候展示的内容。

Next.js 项目默认的 not-found 效果如下:

image.png

如果你要替换这个效果,只需要在 app 目录下新建一个 not-found.js,代码示例如下:

❌
❌