普通视图

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

前端面试常见的 10 个场景题

2026年3月3日 09:18

大家好,我是双越。wangEditor 作者,前百度 滴滴 资深前端工程师,慕课网金牌讲师,PMP,前端面试派 作者。

我正致力于两个项目的开发和升级,感兴趣的可以私信我,加入项目小组。

  • 划水AI Node 全栈 AIGC 知识库,包括 AI 写作、多人协同编辑。复杂业务,真实上线。
  • 智语 AI Agent 智能体项目。一个智能面试官,可以优化简历、模拟面试、解答题目等。

本文介绍前端面试常见的场景题。需要面试的同学欢迎可先点赞、收藏,以后慢慢学习。

如何设计实现一个准确的前端倒计时

这个问题的核心是:单纯用 setInterval 倒计时是不准时不可靠的setInterval(fn, 1000) 并不保证每 1000ms 准时执行一次。

JS 是单线程的,当遇到大量计算、页面渲染、长任务等,setInterval 会被延迟执行。页面切到后台,定时器会被浏览器降频。本该 1 秒减一次,结果 1.2 秒甚至 2 秒才执行一次 → 倒计时变慢。

设计要点:

  • 计时要以时间戳为基准(使用 Date.now 计时),而不是递减秒数
  • setInterval 只作为刷新工具。

代码示例

const endTime = Date.now() + 60 * 1000; // 1分钟倒计时

const timer = setInterval(() => {
  const now = Date.now();
  const remain = endTime - now;

  if (remain <= 0) {
    clearInterval(timer);
    console.log("倒计时结束");
    return;
  }

  console.log(Math.floor(remain / 1000) + "秒");
}, 1000);

总之,一个准确的前端倒计时应该以时间戳差值为核心,而不是依赖 setInterval 的次数;定时器只负责刷新 UI,每次通过 目标时间 - 当前时间 重新计算剩余时间,才能保证在卡顿、切后台等场景下依然准确。

如何设计实现一个精准的支付秒杀倒计时

这个问题的核心是:前端倒计时必须和服务器时间一致,不能靠本地时间瞎算。

设计要点

  • 以服务器时间为准。禁止用 new Date() 直接作为倒计时依据(用户电脑时间不准)。必须先获取一次服务端当前时间。
  • 只计算时间差,不依赖本地绝对时间。计算公式 剩余时间 = 活动开始时间 - 服务器时间
  • 防止前端篡改计时,即真正是否可支付由后端控制,前端倒计时只是展示。篡改了也支付不了。

代码示例

const diff = serverTime - Date.now();

setInterval(() => {
  const remain = startTime - (Date.now() + diff);
  updateUI(remain);
}, 1000);

一个 Web 管理系统,使用越来越慢,如何排查

这个问题的核心是:慢在哪里,要先定位瓶颈,再针对性优化。一般分网络、前端、后端三个方向进行定位。

先定位问题,用 Chrome DevTools:

  • Network:看接口是否变慢(TTFB、响应时间)
  • Performance:看是否卡在 JS 执行或渲染
  • Memory:是否有内存泄漏(页面越用越卡)

前端常见的问题有

  • 页面组件越来越多,重复渲染 / 状态管理混乱
  • 大列表一次性渲染(上千条数据)
  • 事件监听未释放,导致内存泄漏
  • 打包体积变大,首屏加载慢

对应的解决方案有:

  • 虚拟列表(只渲染可视区域)
  • 减少不必要的 re-render(memo、拆组件)
  • 检查未销毁的定时器、监听器
  • 按需加载(懒加载模块)

接口慢常见的问题有

  • 接口响应时间变长
  • 一次请求返回数据过多
  • 串行请求过多

对应的解决方案有

  • 分页 / 按需加载数据
  • 合并接口 or 并行请求
  • 开启 gzip / CDN / 缓存

后端接口返回几万条数据 前端表格如何去展示处理

这个问题考察的是:大数据量渲染性能 + 用户体验 + 架构设计能力

设计的关键点是

  • 不一次性渲染几万条数据(会卡死浏览器)
  • 分批加载 + 按需渲染
  • 保证滚动和操作流畅

解决方案有

  • 后端分页返回(如果可以的话,但面试时一般规定后端一次性返回)
  • 前端使用虚拟表格,每次只渲染几十个 DOM
  • 如有数据处理,使用 web worker 解决,防止阻塞主线程
  • 减少 DOM 嵌套和复杂度

H5 瀑布流展示商品信息,低端安卓机和网络不稳定,如何优化?

针对低端安卓和弱网用户,可以从 图片压缩 + 懒加载 + 虚拟列表 + 降级策略 + 容错体验 入手,减少资源体积、降低渲染压力,保证页面能“快加载、不白屏、可用性优先”。

图片资源优化(关键)

  • 使用 WebP / AVIF,多尺寸图片(srcset),低端机优先加载小图
  • 首屏用 低清图占位(LQIP / blur) ,滚动再加载高清图
  • 图片压缩 + CDN
  • 避免一次性加载大量图片

网络加载优化

  • 懒加载(IntersectionObserver) ,只加载可视区域图片
  • 分批请求(分页 / 分段加载),不要一次拉全量数据
  • 请求失败自动重试 + 超时兜底
  • 弱网模式:降低图片质量或数量

网页渲染优化

  • 使用 虚拟列表 / 虚拟瀑布流,只渲染屏幕内的 DOM
  • 避免频繁重排重绘(少用复杂阴影、动画)
  • 使用 transformopacity 做动画,避免 top/left

交互体验优化

  • 骨架屏 / loading 占位,避免白屏
  • 图片加载失败显示默认图
  • 滚动时不阻塞主线程(避免大 JS 计算)

容错和降级方案

  • 低端机或弱网:自动切换 简化模式(少图 / 小图 / 低清图)
  • 关闭复杂动画、特效
  • 监控卡顿和加载失败(埋点)

设计一个“单选框组件”,选项里面可能是图片、文字等,该如何设计。

如果只包含图片、文本这两个,是比较好设计的,做 if-else 判断显示即可。但如果有其他自定义类型,就需要用到 <slot>

<template>
  <RadioGroup v-model="value">
    <!-- 文本选项 -->
    <RadioItem value="text">
      <span>文本选项</span>
    </RadioItem>

    <!-- 图片选项 -->
    <RadioItem value="image">
      <img src="https://via.placeholder.com/80" />
      <p>图片选项</p>
    </RadioItem>

    <!-- 自定义 slot(复杂内容) -->
    <RadioItem value="custom">
      <div>
        <h3>自定义内容</h3>
        <p>可以放任意组件</p>
        <button>按钮</button>
      </div>
    </RadioItem>
  </RadioGroup>
</template>

定义两个组件 RadioGroupRadioItemRadioGroup 管理选中的数据

<template>
  <div class="radio-group">
    <slot />
  </div>
</template>

<script setup>
import { provide } from "vue";

const props = defineProps({
  modelValue: [String, Number]
});
const emit = defineEmits(["update:modelValue"]);

provide("radioValue", props);
provide("radioChange", (val) => {
  emit("update:modelValue", val);
});
</script>

RadioItem 负责各类数据的 UI 渲染,监听 change 事件来修改 value

<template>
  <div
    class="radio-item"
    :class="{ active: isChecked }"
    role="radio"
    :aria-checked="isChecked"
    @click="select"
  >
    <slot />
  </div>
</template>

<script setup>
import { inject, computed } from "vue";

const props = defineProps({
  value: [String, Number]
});

const radioValue = inject("radioValue");
const radioChange = inject("radioChange");

const isChecked = computed(() => radioValue.modelValue === props.value);

const select = () => {
  radioChange(props.value);
};
</script>

把单选框设计成 RadioGroup + RadioItem 的组合组件,用数据驱动选项,通过 slot 支持图片和文字等自定义内容,使用受控模式管理选中状态,并兼顾可访问性和性能。

如何排查网页白屏问题

白屏问题本质:页面没渲染出来或 JS 报错中断了渲染。排查要有顺序,从外到内、从简单到复杂。

先快速定位问题方向

  • 看有没有 JS 报错(语法错误、接口报错、资源 404)。
  • 看 HTML、JS、CSS 文件是否加载成功?核心接口是否返回 500 / 超时?
  • 看 DOM 是否渲染出来?还是 body 是空的?

如果是 JS 报错了,就需要

  • try/catch 关键逻辑
  • 接入全局错误监控(window.onerrorunhandledrejection

如果是 HTML、JS、CSS 文件加载失败,就检查 CDN 是否配置错误?这一般不会是程序问题。

如果核心接口返回 500 / 超时,那就在前端做容错方案,例如展示“获取数据失败,请刷新重试”

还可以加 ErrorBoundary 容错组件,来最大范围的概括各类组件渲染报错,给用户提示友好信息。

总之,先看控制台和网络请求,确认是 JS 报错、资源加载失败还是接口问题;再定位到具体代码。工程上通过错误监控、兜底 UI 和自动化监控来预防和快速发现白屏问题。

让你启动一个新项目,你将如何开始这个项目?

第一,要明确需求,先和产品、设计、后端对齐,搞清楚几个核心问题:

  • 做什么:后台管理系统、C端页面、还是小程序?
  • 面向谁:用户量多大、对性能/SEO 有没有要求?
  • 工期多久:赶进度就用成熟方案,不搞花活

第二,技术选型,要按公司团队情况选择,不要盲目求新

  • 语言 JS TS
  • 框架 Vue React Nextjs 等
  • UI 组件库 AntD Element 等
  • 构建工具 Vite

第三,工程化搭建,环境搭好,后续才能高效协作

  • 代码规范 ESLint + Prettier,保证风格统一
  • 配置 CI/CD 流程(GitHub Actions / Jenkins)。
  • 配置环境变量、打包优化(Tree Shaking、Code Splitting)和性能监控(Lighthouse / Sentry)。

第四,架构设计

  • 代码目录结构
  • Vuex Redux 等前端状态数据结构
  • API 接口规范
  • 请求封装:Axios 统一封装,处理 token、错误码、loading
  • 权限控制:路由守卫 + 按钮级权限指令提前想好
src/
├── api/        # 所有接口,按模块拆分
├── components/ # 通用组件(Button、Modal...)
├── views/      # 页面级组件
├── hooks/      # 复用逻辑(useUser、useTable...)
├── stores/     # 状态管理(Pinia / Zustand)
├── router/     # 路由配置 + 权限守卫
└── utils/      # 工具函数

如何实现前端线上监控 前端线上报错如何排查

三个主要步骤:采集、上报、分析

采集什么?

  • JS错误:window.onerrortry-catch捕获
  • 资源加载失败:window.addEventListener('error')监听资源
  • 接口请求:重写XMLHttpRequestfetch
  • 性能数据:Performance API获取FP、FCP、LCP等
  • 用户行为:点击路径、路由变化

怎么上报?

  • 封装成固定数据结构(错误信息、环境、用户、时间戳)
  • 使用Navigator.sendBeacon(页面关闭时也能发)
  • 图片打点(new Image().src)做简单上报
  • 批量压缩上报,减少请求次数

数据存储和分析

  • 后端可用 ElasticSearch/Kafka/数据库 保存日志
  • 提供 错误聚合、告警、统计报表,快速定位问题

前端问题如何排查

  • 前端报错日志分类、聚合,找出发生概率比较大的
  • 使用 source map 将压缩代码映射回原始源代码
  • 根据堆栈和出错代码判断逻辑或环境问题
  • 在本地开发环境复现问题,并修复问题

一百万个人同时抢一个商品,如何判断谁是第一个?

这个问题的关键不在于前端,而在于后端,前端只是发起请求和展示结果。所以这个问题一般会考察全栈岗位或者高级前端岗位,需要有一定后段能力的。

后端实现这个功能,需要满足两点:

  • 支持高并发,因为有一百万人同时抢购
  • 要能准确识别第一个人,响应要快

常见的解决方案是 后端原子操作 ,这个方案最简单可靠,容易支持高并发

  • 所有请求打到后端
  • 用 Redis / 数据库做原子判断
SETNX product_lock userId

第一个写入的 user 就是赢家,其他人直接返回失败。前端只负责发起请求和展示结果。

鸿蒙-List和Grid拖拽排序:仿微信小程序删除效果

作者 Huang兄
2026年3月3日 08:56

前言

今天来实现一下拖拽排序功能。对于鸿蒙中的控件来说,我们可以通过将draggable属性设置为true,并在onDragStart等接口中实现数据传输相关内容来实现拖拽能力,但对于 List 和 Grid 来讲,有几个特殊的用法。

List 的拖拽排序

准确来讲,应该是List + ForEach/LazyForEach/Repeat 生成的ListItem组件才会生效。 我们可以通过ForEach/LazyForEach/RepeatonMove回调来完成拖拽排序

List({ space: 20 }) {
  ForEach(this.numberData, (item: string) => {
    ListItem(){
      Text(`${item}`)
        .width('100%')
        .height(80)
        .textAlign(TextAlign.Center)
        .backgroundColor(Color.White)
        .fontColor(Color.Black)
    } .borderRadius(8)

  }, (item: number) => item.toString())
    .onMove((from: number, to: number) => {
      let tmp = this.numberData.splice(from, 1);
      this.numberData.splice(to, 0, tmp[0]);
    })
}.width('100%').height(500)

这里需要注意下在onMove会调用中处理一下数据,让数据和实际展示内容一致。

看下效果:

list_drag.gif
可以看到,能实现基本的拖拽排序,也可以触发滑动,但无法拖拽出 List 组件的范围。

Grid

由于onMove只能在父组件是List的情况下有效果,在Grid组件中,我们可以使用onItemDragStartonItemDrop回调来实现相同的效果。 相比于onMove回调,onItemDragStartonItemDrop回调给了更多的参数,我们可以做更多的效果,并且还可以将GridItem拖拽到Grid组件的范围之外。缺点就是无法自动触发Grid的滑动。

先看下怎么做拖拽排序:

  1. Grid 设置editMode属性为 true,这样可以拖拽Grid组件内部GridItem。
  2. Grid 设置supportAnimation属性为 true,这样在拖拽的时候会有动画效果,不会太生硬。
  3. 重写onItemDragStart回调,该方法在开始拖拽网格元素时触发。返回void表示不能拖拽。但是需要注意:由于拖拽检测也需要长按,且事件处理机制优先触发子组件事件,GridItem上绑定LongPressGesture时无法触发拖拽。如有长按和拖拽同时使用的需求可以使用通用拖拽事件。
  4. 重写onItemDrop,处理数据。注意:不重写该方法时无法触发拖拽动效。
  5. ForEachLazyForEachRepeat都可以使用

下面看下使用ForEach代码实现:

@Builder
// 拖拽过程样式
pixelMapBuilder(text:string) {
  Column() {
    Text(text)
      .fontSize(16)
      .backgroundColor(0xF9CF93)
      .width(80)
      .height(80)
      .textAlign(TextAlign.Center)
      .borderRadius(8)
  }
}
//数据
data: number[] = [0,1,2,34,5,6,7,8,9,10,11,12,13,14,15]
//Grid
Grid() {
  LazyForEach(this.data, (day: string) => {
    GridItem() {
      Text(day)
        .fontSize(16)
        .backgroundColor(0xF9CF93)
        .width('100%')
        .aspectRatio(1)
        .textAlign(TextAlign.Center)
        .borderRadius(8)
    }
  }, (day: string) => day)
}
.width('100%')
.height(300)
.columnsTemplate('1fr 1fr 1fr 1fr')
.columnsGap(10)
.rowsGap(10)
.backgroundColor(Color.Orange)
.editMode(true)
.supportAnimation(true)
.onItemDragStart((event: ItemDragInfo, itemIndex: number) => { // 第一次拖拽此事件绑定的组件时,触发回调。
  console.error('开始拖拽')
  return this.pixelMapBuilder(`${this.data[itemIndex]}` ); // 设置拖拽过程中显示的图片。
})
.onItemDrop((event: ItemDragInfo, itemIndex: number, insertIndex: number, isSuccess: boolean) => {
  console.error( `onItemDrop`)
  if(isSuccess){
    let tmp = this.data.splice(itemIndex, 1);
    this.data.splice(insertIndex, 0, tmp[0]);
  }
})

这里需要注意的是onItemDrop回调中的isSuccess,当该参数为false时,表示松开拖拽时拖拽的项目落在了Grid组件范围之外。如果为true,则处理一下数据。

grid_drag.gif

拖拽删除

既然 Grid 可以 GridItem可以拖拽出 Grid 的范围,并且在 onItemDrop的时候可以拿到坐标信息,我们就可以做一个丐版的微信小程序删除效果了。

grid_drag_delete.gif

我们需要注意的是:当删除区域位于 Grid 组件范围之外的情况下,我们只能通过onItemDrop回调来判断结束拖拽位置的坐标,因为onItemDragMove方法在GridItem拖拽出Grid区域之后就不再回调了。

实现起来也挺简单的

  1. 计算删除组件的坐标
  2. onItemDrop中判断结束拖拽的时候,是否在删除组件的范围内,在的话就删除数据。
@State data: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
private deleteViewHeight: number = 100 //这里固定删除控件的高度
@State deleteViewOffset: number = this.deleteViewHeight //默认不展示删除控件,在`onItemDragStart`回调时再展示
@State screenHeight: number = 0
@State deleteViewRawPositionY: number = 0
@State deleteViewTop: number = 0
@State statusBarHeight: number = 0
@State bottomNavBar: number = 0

计算我们需要的数据

aboutToAppear(): void {
  this.screenHeight = this.getUIContext().px2vp(display.getDefaultDisplaySync().height)
  console.error(`DraggedGridPage:screenHeight -> ${this.screenHeight}`)
  window.getLastWindow(this.getUIContext().getHostContext()).then((win) => {
    this.bottomNavBar = this.getUIContext().px2vp(win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR).bottomRect.height)
    console.error(`DraggedGridPage bottomNavBar-> ${this.bottomNavBar}`)
    this.statusBarHeight =
      this.getUIContext().px2vp(win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).topRect.height)
    console.error(`DraggedGridPage:statusBarHeight-> ${this.getUIContext().px2vp(this.statusBarHeight)}`)
    this.deleteViewTop = this.screenHeight - this.statusBarHeight - this.deleteViewHeight
    console.error(`DraggedGridPage:deleteViewTop-> ${this.deleteViewTop}`)
  })
}

布局逻辑

build() {
    Column() {
      Blank().height(200).width(0)
      Grid(this.scroller) {}
      .onItemDragStart((event: ItemDragInfo, itemIndex: number) => {
        this.text = this.data[itemIndex].toString();
        this.getUIContext().animateTo({
          duration: 1000,
          curve: curves.interpolatingSpring(0, 1, 400, 38)
        }, () => {
          this.deleteViewOffset = 0
        })
        return this.pixelMapBuilder(); // 设置拖拽过程中显示的图片。
      })

      .onItemDrop((event: ItemDragInfo, itemIndex: number, insertIndex: number,
        isSuccess: boolean) => {
        let top = this.deleteViewTop + 80// 80是 GridItem 的高度
        console.error(`onItemDrop: isSuccess->${isSuccess} y->${event.y}     top-> ${top}`)
        if (isSuccess) {
          let tmp = this.data.splice(itemIndex, 1);
          this.data.splice(insertIndex, 0, tmp[0]);
        } else {
          if (event.y > top) {
            console.error(`item 进入删除区域,删除第 ${itemIndex} 个`)
            this.data.splice(itemIndex, 1)
            console.error(`删除后的数据 ${JSON.stringify(this.data)}`)
          } else {
            console.error(`item没有进入删除区域`)
          }
        }
        this.getUIContext().animateTo({
          duration: 1000,
          curve: curves.interpolatingSpring(0, 1, 400, 38)
        }, () => {
          this.deleteViewOffset = this.deleteViewHeight
        })
      })

      Text('删除')
        .fontColor(Color.White)
        .backgroundColor(Color.Red)
        .width('100%')
        .height(this.deleteViewHeight)
        .textAlign(TextAlign.Center)
        .position({ bottom: -this.bottomNavBar - this.deleteViewOffset })
        .onAreaChange((oldValue, newValue) => {
          this.deleteViewRawPositionY = newValue.position.y as number
        })
    }
    .width('100%')
    .height('100%')
    .backgroundColor(Color.Pink)
    .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
  }

这样我们就实现了丐版的微信小程序删除效果。 如果想要完全复制:比如在拖拽进入删除组件时有个震动效果,可以参考示例16(实现GridItem自定义拖拽)

代码

github:github.com/huangyuanlo…

gitcode:gitcode.com/huangyuan_x…

每日一题-找出第 N 个二进制字符串中的第 K 位🟡

2026年3月3日 00:00

给你两个正整数 nk,二进制字符串  Sn 的形成规则如下:

  • S1 = "0"
  • i > 1 时,Si = Si-1 + "1" + reverse(invert(Si-1))

其中 + 表示串联操作,reverse(x) 返回反转 x 后得到的字符串,而 invert(x) 则会翻转 x 中的每一位(0 变为 1,而 1 变为 0)。

例如,符合上述描述的序列的前 4 个字符串依次是:

  • S= "0"
  • S= "011"
  • S= "0111001"
  • S4 = "011100110110001"

请你返回  Snk 位字符 ,题目数据保证 k 一定在 Sn 长度范围以内。

 

示例 1:

输入:n = 3, k = 1
输出:"0"
解释:S3 为 "0111001",其第 1 位为 "0" 。

示例 2:

输入:n = 4, k = 11
输出:"1"
解释:S4 为 "011100110110001",其第 11 位为 "1" 。

示例 3:

输入:n = 1, k = 1
输出:"0"

示例 4:

输入:n = 2, k = 3
输出:"1"

 

提示:

  • 1 <= n <= 20
  • 1 <= k <= 2n - 1

AI辅助开发的基础概念

作者 牛奶
2026年3月2日 23:55

AI辅助开发的基础概念

这是系列第二篇,上一篇聊完"为什么学",我们来看看 AI 辅助开发到底帮我们做了什么,需要掌握哪些概念,以及怎么用好 AI。


上一篇文章,我们聊了为什么前端必须学AI。

简单说就是:AI不会取代前端,但它会重新定义前端。

既然要学,接下来一个很自然的问题就是:到底学什么?怎么学?

市面上关于AI的资料,多的让人头皮发麻。

一会儿有人说要学Prompt工程,一会儿有人说要学LangChain,一会儿又有人说要学向量数据库。一顿操作下来,还没开始就已经晕了。

这篇文章,我帮你把AI辅助开发这件事 全面梳理一遍。不求深,但求全。让你知道整个图景是什么样的,遇到什么该学什么。


原文地址

墨渊书肆/AI辅助开发的基础概念


先理解三个问题

在开始之前,我们先回答三个最基本的问题:

  1. AI能帮我做什么? — 知道它的能力边界
  2. 需要掌握哪些概念? — 理解背后的原理
  3. 怎么用好AI? — 具体的操作方法

这三个问题对应AI辅助开发的三个层次。接下来我们一层一层来讲。


第一层:AI能帮你做什么

这是最实在的东西——AI到底能帮我们干什么活。

1. 写代码

根据你的描述生成代码。

"帮我写一个React组件,功能是:用户可以选择日期范围,支持禁用特定日期,暗色模式适配"

AI能帮你写:

  • 完整的组件(React、Vue)
  • 工具函数
  • API接口
  • 样式代码(CSS、Tailwind)
  • TypeScript类型定义
  • 测试用例

2. 读代码

帮你理解你不懂的代码。

"这个组件的数据流是怎么走的?为什么要用useMemo?"

AI能帮你:

  • 解释代码逻辑
  • 梳理复杂代码
  • 找出潜在问题
  • 理解项目结构

3. 查文档

以前遇到问题,你先去 Google 搜,然后看 Stack Overflow,最后才去翻文档。

现在直接问AI:

"Next.js 15怎么做密码重置?"
"Vercel AI SDK怎么实现流式响应?"

AI直接从文档里给你准确的答案。

4. 修Bug

遇到报错了,直接问AI:

"这个报错是什么意思?TypeError: Cannot read properties of undefined (reading 'map')"

AI会告诉你:错误原因是什么、最可能出在哪个地方、怎么修复。

5. 代码Review

让AI帮你审查代码:

"请审查以下代码,指出:1. 潜在安全问题 2. 性能问题 3. 代码规范问题"

它会从多个角度帮你分析一遍。

6. 重构代码

觉得某段代码写得烂,但不知道怎么改?

"请帮我重构以下代码,要求:1. 使用TypeScript类型 2. 提取可复用逻辑 3. 增加错误处理"

AI会给一个全新的版本,你可以参考它的思路。

7. 写测试

写测试很枯燥,但很重要。

"请为以下函数编写单元测试,覆盖:正常情况、空输入、错误输入"

AI生成测试代码,你再根据需要调整。

8. 帮你想名字

我经常让AI帮我给变量、函数起名字。

"我有一个函数,接受用户ID,返回用户名、邮箱、头像、最后登录时间。请帮我想一个合适的函数名"

AI会给三四个建议,通常都比较规范,符合命名习惯。


第二层:需要掌握哪些概念

知道AI能帮你做什么了,接下来你需要理解一些核心概念。这样你才能更好地和AI配合。

1. 大模型(LLM)

LLM,Large Language Model,大语言模型。

你可以把大模型理解成一个见过海量代码的超级程序员。它看过互联网上几乎所有的开源代码、文档、教程,所以它知道你想要什么。

GPTClaudeDeepSeek通义——这些都是大模型。

它不是真的"智能",而是见过太多了,知道概率最高的答案是什么

作为前端开发者,你不需要会训练模型,你只需要知道怎么调用它们

2. Token

Token 是 AI 处理信息的基本单位

简单理解:AI不是按"字"或"词"来处理文字的,而是按 Token。一个Token可能是半个单词、一个单词、或者一个标点符号。

为什么这个概念重要?因为:

  • API按Token收费:输入和输出都算 Token
  • 上下文长度限制:你给 AI 的上下文不能超过它能处理的Token数

举个例子:Claude 3.5 支持 200K Token 的上下文,意味着你可以一次性把整个项目的代码都丢给它(但这样做很贵,而且AI可能会"忘记"中间部分)。

3. Agent

Agent(智能体)是AI领域最重要的概念之一。

你可以把Agent理解成一个能自己执行任务的AI。不像普通的聊天AI只能"问一句答一句",Agent可以:

  • 自己规划步骤
  • 自己调用工具
  • 自己检查结果
  • 自主完成复杂任务

这就是为什么很多人说"AI不是替代程序员,而是替代不会用AI的程序员"——因为Agent已经可以自主完成很多开发任务了。

4. MCP(Model Context Protocol)

MCP是这两年AI辅助开发领域最重要的新东西。

你可以把它理解成AI的"USB接口"

以前AI只能跟你聊天,它不知道你电脑里有什么、你的项目是什么样子。MCP出现后,AI可以:

  • 读取你电脑上的文件
  • 执行终端命令
  • 访问你的代码仓库
  • 调用各种第三方服务

这就是为什么现在的AI编程工具突然变得超级强大——因为它们不只是"聊天"了,它们真的能"干活"了。

5. Prompt

Prompt就是你给AI说的话。「帮我写个登录功能」就是一个Prompt。

Prompt不是越长越好,而是越精确越好

好的Prompt应该包含:

  • 上下文:你用的技术栈是什么
  • 具体需求:你想要什么功能
  • 约束条件:有什么特殊要求
  • 期望结果:你想要的输出格式

6. 幻觉

AI有时候会"编故事"——生成一些看似正确但实际上是错误的内容。这就是所谓的"幻觉"

为什么会产生幻觉?因为AI本质上是在"预测"下一个最可能的词,而不是真的"理解"事实。

这意味着:

  • AI给出的答案不一定是对的
  • 你要有能力判断答案对不对
  • 重要的代码要自己验证

第三层:怎么用好AI辅助开发

知道能做什么、也理解概念了,接下来就是具体怎么用。

1. 搞清楚什么时候用什么模式

大部分AI编程工具都有两种模式:

对话模式(Chat):你问一句,它答一句。

我一般用来:

  • 问具体问题:「这个报错是什么意思?」
  • 查知识点:「PostgreSQL的索引类型有哪些?」
  • 解释代码:「这个函数做了什么?」

任务模式(Agent):你描述一个任务,它自己去分析和执行。

我一般用来:

  • 帮我重构整个模块:「把这个登录从JWT改成Session」
  • 帮我修bug:「登录一直返回401,帮我看看是什么原因」
  • 帮我实现一个功能:「帮我实现用户注册功能,包含表单验证、数据库存储」

简单说:小问题用对话,大任务用Agent。

2. 喂上下文是有技巧的

AI最强的地方是它能理解你的整个项目。但有时候它也会犯傻——给你一些牛头不对马嘴的回答。

这时候,你得学会喂上下文

我犯过的错误:

「怎么优化这个查询?」

AI回了半天,什么加索引、分页、缓存讲了一套,我根本不知道它说的是什么,因为我连我的表结构都没告诉它。

后来我学乖了:

「我的Prisma查询是这样的:prisma const users = await prisma.user.findMany() 数据量大概10万条,现在查询要3秒,请问怎么优化?」

这次AI直接告诉我:1. 加索引 2. 用select只查需要的字段 3. 考虑分页。

我的习惯是:至少告诉AI三件事

  1. 技术栈:我用的技术是什么(Next.js + Prisma + PostgreSQL)
  2. 当前代码:现在代码长什么样(贴上代码)
  3. 问题:遇到了什么问题(查询慢、报错、不知道怎么做)

3. 选中代码让AI帮你改

选中一段代码,让AI帮你修改。这是一个核心技巧。

比如我选中一个函数,这样用:

「请帮我添加错误处理和类型定义」

AI直接在原代码基础上帮我改好了,我只需要确认一下就行。

比让它生成一段新代码然后我再替换,效率高很多。

再举几个我常用的场景:

  • 选中一段面条式代码:「请帮我重构这段代码」
  • 选中一个API接口:「请帮我添加参数校验」
  • 选中一个组件:「请把这个组件改成响应式」

4. 用@引用代替复制粘贴

大部分AI编程工具支持用@符号引用特定内容:

  • @File :引用当前打开的文件
  • @components/UserCard.tsx :直接引用某个文件
  • @Folder :引用整个文件夹
  • @Docs :引用官方文档
  • @Search :搜索项目内的代码

最常用的场景:

@components/UserCard.tsx 请帮我在这个组件里添加一个编辑用户信息的功能

AI直接读取文件内容,在正确的位置帮我添加代码。

@Docs 请帮我查一下Next.js的metadata怎么用来做SEO

AI直接读官方文档,给我准确的答案。

用@引用比复制粘贴代码更省Token,而且AI能更准确地理解你的需求。

5. 一次性把需求说清楚

让AI一次性完成所有需求,别分开问:

  • ❌ 「先帮我写HTML」「再帮我写样式」「再加个交互」
  • ✅ 「帮我写一个登录组件,包含表单验证、错误提示、暗色模式支持」

一次说清楚,AI能更好地理解你的整体需求,生成的代码也更连贯。

6. 设置好项目规范

我在每个项目都会设置规范文件。

设置好之后,AI每次生成代码都会自动遵循这些规范。

举个例子:我不用每次都说「API错误要返回success和error字段」,AI自己就知道。

规范内容包括:

  • 技术栈和版本
  • 目录结构
  • 代码规范(命名、格式)
  • 常用的工具函数

7. 积累自己的Prompt模板

AI 的输出质量很大程度上取决于你的 Prompt,好的 Prompt 能让AI 生成更准确、更符合你需求的代码。

在平时的开发中,可以把一些常用的场景总结成模板,方便后续快速调用。


工作流程:AI怎么融入日常开发

知道有什么工具了,接下来我们看看AI是怎么融入日常开发工作的。

我总结了一个我自己常用的工作流程:

1. 需求分析阶段

  • 解释需求文档里的技术术语
  • 给出技术选型的建议
  • 评估实现难度和时间

2. 编码实现阶段

  • 根据描述生成代码
  • 解释你不熟悉的API
  • 帮你写测试用例
  • 优化现有代码

3. 代码审查阶段

  • 检查代码安全性
  • 找出潜在性能问题
  • 审查代码规范
  • 解释复杂逻辑

4. Bug修复阶段

  • 分析报错信息
  • 定位问题原因
  • 给出修复建议

5. 文档输出阶段

  • 从代码生成注释
  • 生成README文档
  • 写API文档

写在最后

这篇文章帮你把AI辅助开发这件事,从概念到工具,从流程到趋势,全面梳理了一遍。

你不需要记住所有细节,你只需要知道:

  1. AI能帮你做什么:写代码、读代码、查文档、修Bug、代码Review、重构、写测试
  2. 需要理解什么概念:大模型、Token、Agent、MCP、Prompt、幻觉
  3. 怎么用好AI:喂上下文、选对模式、用@引用、一次性说清楚需求

知道了能做什么、懂了概念,剩下的就是多练习。

AI辅助开发不是玄学,就是一个工具。用多了,你会发现:会问问题的人,效率比不会问的人高十倍。


篇预告

下一篇,我会讲讲《2026年大模型怎么选?前端人实用对比》。

市面上有那么多大模型,GPT、Claude、DeepSeek、通义千问......到底该用哪个?不同的场景该怎么选?怎么才能低成本使用?

我会从实际开发的角度,给你一个清晰的选型建议。

感兴趣的话,下一篇见。

从递归到 O(1) 数学公式(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2026年2月26日 08:55

方法一:递归 / 迭代

我们需要确定第 $k$ 个字符位于 $S_n$ 的左半、正中间还是右半。为此,首先要知道 $S_n$ 的长度。

用 $|s|$ 表示字符串 $s$ 的长度。根据题意,$|S_1| = 1$,$|S_n| = 2|S_{n-1}| + 1$,所以有

$$
|S_n| + 1 = 2(|S_{n-1}| + 1)
$$

所以 ${|S_n| + 1}$ 是个首项为 $2$,公比为 $2$ 的等比数列,得

$$
|S_n| = 2^n - 1
$$

所以 $|S_{n-1}| = 2^{n-1} - 1$,这说明 $S_n$ 的左半是第 $1$ 个字符到第 $2^{n-1}-1$ 个字符,正中间是第 $2^{n-1}$ 个字符,右半是第 $2^{n-1} + 1$ 个字符到第 $2^n-1$ 个字符。

分类讨论:

  • 如果 $k < 2^{n-1}$,那么第 $k$ 个字符位于 $S_n$ 的左半,问题变成 $S_{n-1}$ 的第 $k$ 个字符。这可以递归解决。
  • 如果 $k > 2^{n-1}$,那么第 $k$ 个字符位于 $S_n$ 的右半,问题变成 $S_{n-1}$ 反转后的第 $k-2^{n-1}$ 个字符,即反转前的第 $2^{n-1}-(k-2^{n-1}) = 2^n-k$ 个字符(比如 $k=2^n-1$ 对应反转前的第 $1$ 个字符)。这个字符再翻转,即为 $S_n$ 的第 $k$ 个字符。这也可以递归解决。

递归边界:

  • 如果 $n=1$,那么返回 $S_1$ 唯一的字符 $\texttt{0}$。
  • 如果 $k = 2^{n-1}$,那么返回 $S_n$ 正中间的字符 $\texttt{1}$。

递归写法

class Solution:
    def findKthBit(self, n: int, k: int) -> str:
        if n == 1:
            return '0'
        if k == 1 << (n - 1):
            return '1'
        if k < 1 << (n - 1):
            return self.findKthBit(n - 1, k)
        res = self.findKthBit(n - 1, (1 << n) - k)
        return '0' if res == '1' else '1'
class Solution {
    public char findKthBit(int n, int k) {
        if (n == 1) {
            return '0';
        }
        if (k == 1 << (n - 1)) {
            return '1';
        }
        if (k < 1 << (n - 1)) {
            return findKthBit(n - 1, k);
        }
        char res = findKthBit(n - 1, (1 << n) - k);
        return (char) (res ^ 1);
    }
}
class Solution {
public:
    char findKthBit(int n, int k) {
        if (n == 1) {
            return '0';
        }
        if (k == 1 << (n - 1)) {
            return '1';
        }
        if (k < 1 << (n - 1)) {
            return findKthBit(n - 1, k);
        }
        return findKthBit(n - 1, (1 << n) - k) ^ 1;
    }
};
char findKthBit(int n, int k) {
    if (n == 1) {
        return '0';
    }
    if (k == 1 << (n - 1)) {
        return '1';
    }
    if (k < 1 << (n - 1)) {
        return findKthBit(n - 1, k);
    }
    return findKthBit(n - 1, (1 << n) - k) ^ 1;
}
func findKthBit(n, k int) byte {
if n == 1 {
return '0'
}
if k == 1<<(n-1) {
return '1'
}
if k < 1<<(n-1) {
return findKthBit(n-1, k)
}
return findKthBit(n-1, 1<<n-k) ^ 1
}
var findKthBit = function(n, k) {
    if (n === 1) {
        return '0';
    }
    if (k === 1 << (n - 1)) {
        return '1';
    }
    if (k < 1 << (n - 1)) {
        return findKthBit(n - 1, k);
    }
    return findKthBit(n - 1, (1 << n) - k) === '1' ? '0' : '1';
};
impl Solution {
    pub fn find_kth_bit(n: i32, k: i32) -> char {
        if n == 1 {
            return '0';
        }
        if k == 1 << (n - 1) {
            return '1';
        }
        if k < 1 << (n - 1) {
            return Self::find_kth_bit(n - 1, k);
        }
        (Self::find_kth_bit(n - 1, (1 << n) - k) as u8 ^ 1) as _
    }
}

迭代写法

class Solution:
    def findKthBit(self, n: int, k: int) -> str:
        rev = 0  # 翻转次数的奇偶性
        while True:
            if n == 1:
                return '1' if rev else '0'
            if k == 1 << (n - 1):
                return '0' if rev else '1'
            if k > 1 << (n - 1):
                k = (1 << n) - k
                rev ^= 1
            n -= 1
class Solution {
    public char findKthBit(int n, int k) {
        int rev = 0; // 翻转次数的奇偶性
        while (true) {
            if (n == 1) {
                return (char) ('0' ^ rev);
            }
            if (k == 1 << (n - 1)) {
                return (char) ('1' ^ rev);
            }
            if (k > 1 << (n - 1)) {
                k = (1 << n) - k;
                rev ^= 1;
            }
            n--;
        }
    }
}
class Solution {
public:
    char findKthBit(int n, int k) {
        int rev = 0; // 翻转次数的奇偶性
        while (true) {
            if (n == 1) {
                return '0' ^ rev;
            }
            if (k == 1 << (n - 1)) {
                return '1' ^ rev;
            }
            if (k > 1 << (n - 1)) {
                k = (1 << n) - k;
                rev ^= 1;
            }
            n--;
        }
    }
};
char findKthBit(int n, int k) {
    int rev = 0; // 翻转次数的奇偶性
    while (true) {
        if (n == 1) {
            return '0' ^ rev;
        }
        if (k == 1 << (n - 1)) {
            return '1' ^ rev;
        }
        if (k > 1 << (n - 1)) {
            k = (1 << n) - k;
            rev ^= 1;
        }
        n--;
    }
}
func findKthBit(n, k int) byte {
rev := byte(0) // 翻转次数的奇偶性
for {
if n == 1 {
return '0' ^ rev
}
if k == 1<<(n-1) {
return '1' ^ rev
}
if k > 1<<(n-1) {
k = 1<<n - k
rev ^= 1
}
n--
}
}
var findKthBit = function(n, k) {
    let rev = 0; // 翻转次数的奇偶性
    while (true) {
        if (n === 1) {
            return rev ? '1' : '0';
        }
        if (k === 1 << (n - 1)) {
            return rev ? '0' : '1';
        }
        if (k > 1 << (n - 1)) {
            k = (1 << n) - k;
            rev ^= 1;
        }
        n--;
    }
};
impl Solution {
    pub fn find_kth_bit(mut n: i32, mut k: i32) -> char {
        let mut rev = 0; // 翻转次数的奇偶性
        loop {
            if n == 1 {
                return (b'0' ^ rev) as _;
            }
            if k == 1 << (n - 1) {
                return (b'1' ^ rev) as _;
            }
            if k > 1 << (n - 1) {
                k = (1 << n) - k;
                rev ^= 1;
            }
            n -= 1;
        }
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$。
  • 空间复杂度:$\mathcal{O}(1)$。

方法二:数学公式

奇数位

$S_4 = \texttt{011100110110001}$,只看奇数位(下标从 $1$ 开始)的字符,是 $\texttt{01010101}$,这是一个 $\texttt{01}$ 交替序列,为什么?

只看奇数位:

  • $S_1 = \texttt{0}$。
  • $S_2$ 把 $\texttt{0}$ 反转再翻转,得到 $\texttt{1}$,拼起来是 $\texttt{01}$。
  • $S_3$ 把 $\texttt{01}$ 反转再翻转,得到 $\texttt{01}$,拼起来是 $\texttt{0101}$。
  • $S_4$ 把 $\texttt{0101}$ 反转再翻转,得到 $\texttt{0101}$,拼起来是 $\texttt{01010101}$。

一般地,由于 $\texttt{01}$ 交替序列反转再翻转,结果不变,所以从 $S_{i-1}$ 到 $S_i\ (i\ge 3)$,其中奇数位相当于复制了一份自身,拼在了自身后面,得到的仍然是 $\texttt{01}$ 交替序列。

所以,当 $k$ 是奇数时,可以立刻得出答案:

  • 设 $k' = \dfrac{k-1}{2}$。这会把 $k=1,3,5,7,\ldots$ 变成 $k'=0,1,2,3,\ldots$
  • 如果 $k'$ 是偶数,那么答案是 $\texttt{0}$。
  • 如果 $k'$ 是奇数,那么答案是 $\texttt{1}$。
  • 一般地,答案为 $k'\bmod 2$ 对应的字符。

偶数位

奇数位的字符,都发源于 $S_1 = \texttt{0}$。

偶数位的字符呢?都发源于 $S_i\ (i\ge 2)$ 正中间的那个 $\texttt{1}$,即位置为 $2,4,8,16,\ldots$ 的字符 $\texttt{1}$。

根据方法一的结论,$S_{n-1}$ 的第 $k$ 个字符,反转后,是 $S_n$ 的第 $2^n-k$ 个字符。

$2^n-k$ 有什么性质?

比如二进制 $10000 - 100 = 1100$,去掉末尾的两个 $0$,相当于 $100 - 1 = 11$,结果最低位一定是 $1$,所以 $100$ 和 $1100$ 的尾零个数相同。一般地,$k$ 和 $2^n-k$ 的尾零个数是相同的,这是个不变量!我们可以根据 $k$ 的尾零个数,找到 $k$ 发源于哪个 $S_i$ 正中间的 $\texttt{1}$。

以 $S_2$ 的中间字符(第 $2$ 个字符)为例:

  • 我们把 $S_2$ 的第 $2$ 个字符反转到了 $S_3$ 的第 $8-2=6$ 个字符。把 $\texttt{1}$ 反转再翻转,得到 $\texttt{0}$,拼起来是 $\texttt{10}$。
  • 我们把 $S_3$ 的第 $2,6$ 个字符反转到了 $S_4$ 的第 $14,10$ 个字符。把 $\texttt{10}$ 反转再翻转,得到 $\texttt{10}$,拼起来是 $\texttt{1010}$。注意 $2,6,10,14$ 的二进制尾零个数都是 $1$,且这些位置上的字符拼起来是一个 $\texttt{10}$ 交替序列。

一般地,设 $t$ 为 $k$ 去掉尾零后的值,即 $k = t\cdot 2^x$ 且 $t$ 是奇数。比如 $k=2,6,10,14,\ldots$ 对应着 $t=1,3,5,7,\ldots$

  • 设 $t' = \dfrac{t-1}{2}$。这会把 $t=1,3,5,7,\ldots$ 变成 $t'=0,1,2,3,\ldots$
  • 如果 $t'$ 是偶数,那么答案是 $\texttt{1}$。
  • 如果 $t'$ 是奇数,那么答案是 $\texttt{0}$。
  • 一般地,答案为 $1 - t'\bmod 2$ 对应的字符。

如何去掉 $k$ 的尾零?把 $k$ 除以其 $\text{lowbit}$ 即可。关于 $\text{lowbit}$ 的原理,请看 从集合论到位运算,常见位运算技巧分类总结

class Solution:
    def findKthBit(self, _, k: int) -> str:
        if k % 2:
            return str(k // 2 % 2)
        k //= k & -k  # 去掉 k 的尾零
        return str(1 - k // 2 % 2)
class Solution {
    public char findKthBit(int n, int k) {
        if (k % 2 > 0) {
            return (char) ('0' + k / 2 % 2);
        }
        k /= k & -k; // 去掉 k 的尾零
        return (char) ('1' - k / 2 % 2);
    }
}
class Solution {
public:
    char findKthBit(int, int k) {
        if (k % 2) {
            return '0' + k / 2 % 2;
        }
        k /= k & -k; // 去掉 k 的尾零
        return '1' - k / 2 % 2;
    }
};
char findKthBit(int, int k) {
    if (k % 2) {
        return '0' + k / 2 % 2;
    }
    k /= k & -k; // 去掉 k 的尾零
    return '1' - k / 2 % 2;
}
func findKthBit(_, k int) byte {
if k%2 > 0 {
return '0' + byte(k/2%2)
}
k /= k & -k // 去掉 k 的尾零
return '1' - byte(k/2%2)
}
var findKthBit = function(_, k) {
    if (k % 2) {
        return (k - 1) / 2 % 2 ? '1' : '0';
    }
    k /= k & -k; // 去掉 k 的尾零
    return (k - 1) / 2 % 2 ? '0' : '1';
};
impl Solution {
    pub fn find_kth_bit(_: i32, mut k: i32) -> char {
        if k % 2 > 0 {
            return (b'0' + k as u8 / 2 % 2) as _;
        }
        k /= k & -k; // 去掉 k 的尾零
        (b'1' - k as u8 / 2 % 2) as _
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(1)$。
  • 空间复杂度:$\mathcal{O}(1)$。

相似题目

见下面回溯题单的「五、其他递归/分治」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

找出第 N 个二进制字符串中的第 K 位

2020年8月20日 20:51

方法一:递归

观察二进制字符串 $S_n$,可以发现,当 $n>1$ 时,$S_n$ 是在 $S_{n-1}$ 的基础上形成的。用 $\text{len}n$ 表示 $S_n$ 的长度,则 $S_n$ 的前 $\text{len}{n-1}$ 个字符与 $S_{n-1}$ 相同。还可以发现,当 $n>1$ 时,$\text{len}n=\text{len}{n-1} \times 2 + 1$,根据 $\text{len}_1=1$ 可知 $\text{len}_n=2^n-1$。

由于 $S_1=``0"$,且对于任意 $n \ge 1$,$S_n$ 的第 $1$ 位字符也一定是 $0'$,因此当 $k=1$ 时,直接返回字符 $0'$。

当 $n>1$ 时,$S_n$ 的长度是 $2^n-1$。$S_n$ 可以分成三个部分,左边 $2^{n-1}-1$ 个字符是 $S_{n-1}$,中间 $1$ 个字符是 $1'$,右边 $2^{n-1}-1$ 个字符是 $S_{n-1}$ 翻转与反转之后的结果。中间的字符 $1'$ 是 $S_n$ 的第 $2^{n-1}$ 位字符,因此如果 $k=2^{n-1}$,直接返回字符 $`1'$。

当 $k \ne 2^{n-1}$ 时,考虑以下两种情况:

  • 如果 $k<2^{n-1}$,则第 $k$ 位字符在 $S_n$ 的前半部分,即第 $k$ 位字符在 $S_{n-1}$ 中,因此在 $S_{n-1}$ 中寻找第 $k$ 位字符;

  • 如果 $k>2^{n-1}$,则第 $k$ 位字符在 $S_n$ 的后半部分,由于后半部分为前半部分进行翻转与反转之后的结果,因此在前半部分寻找第 $2^n-k$ 位字符,将其反转之后即为 $S_n$ 的第 $k$ 位字符。

上述过程可以通过递归实现。

###Java

class Solution {
    public char findKthBit(int n, int k) {
        if (k == 1) {
            return '0';
        }
        int mid = 1 << (n - 1);
        if (k == mid) {
            return '1';
        } else if (k < mid) {
            return findKthBit(n - 1, k);
        } else {
            k = mid * 2 - k;
            return invert(findKthBit(n - 1, k));
        }
    }

    public char invert(char bit) {
        return (char) ('0' + '1' - bit);
    }
}

###JavaScript

const invert = (bit) => bit === '0' ? '1' : '0';

var findKthBit = function(n, k) {
    if (k == 1) {
        return '0';
    }
    const mid = 1 << (n - 1);
    if (k == mid) {
        return '1';
    } else if (k < mid) {
        return findKthBit(n - 1, k);
    } else {
        k = mid * 2 - k;
        return invert(findKthBit(n - 1, k));
    }
};

###C++

class Solution {
public:
    char findKthBit(int n, int k) {
        if (k == 1) {
            return '0';
        }
        int mid = 1 << (n - 1);
        if (k == mid) {
            return '1';
        } else if (k < mid) {
            return findKthBit(n - 1, k);
        } else {
            k = mid * 2 - k;
            return invert(findKthBit(n - 1, k));
        }
    }

    char invert(char bit) {
        return (char) ('0' + '1' - bit);
    }
};

###Python

class Solution:
    def findKthBit(self, n: int, k: int) -> str:
        if k == 1:
            return "0"
        
        mid = 1 << (n - 1)
        if k == mid:
            return "1"
        elif k < mid:
            return self.findKthBit(n - 1, k)
        else:
            k = mid * 2 - k
            return "0" if self.findKthBit(n - 1, k) == "1" else "1"

###C#

public class Solution {
    public char FindKthBit(int n, int k) {
        if (k == 1) {
            return '0';
        }
        int mid = 1 << (n - 1);
        if (k == mid) {
            return '1';
        } else if (k < mid) {
            return FindKthBit(n - 1, k);
        } else {
            k = mid * 2 - k;
            return Invert(FindKthBit(n - 1, k));
        }
    }

    private char Invert(char bit) {
        return (char)('0' + '1' - bit);
    }
}

###Go

func findKthBit(n int, k int) byte {
    if k == 1 {
        return '0'
    }
    mid := 1 << (n - 1)
    if k == mid {
        return '1'
    } else if k < mid {
        return findKthBit(n - 1, k)
    } else {
        k = mid*2 - k
        return invert(findKthBit(n - 1, k))
    }
}

func invert(bit byte) byte {
    if bit == '0' {
        return '1'
    }
    return '0'
}

###C

char invert(char bit) {
    return '0' + '1' - bit;
}

char findKthBit(int n, int k) {
    if (k == 1) {
        return '0';
    }
    int mid = 1 << (n - 1);
    if (k == mid) {
        return '1';
    } else if (k < mid) {
        return findKthBit(n - 1, k);
    } else {
        k = mid * 2 - k;
        return invert(findKthBit(n - 1, k));
    }
}

###TypeScript

function findKthBit(n: number, k: number): string {
    if (k === 1) {
        return '0';
    }
    const mid = 1 << (n - 1);
    if (k === mid) {
        return '1';
    } else if (k < mid) {
        return findKthBit(n - 1, k);
    } else {
        k = mid * 2 - k;
        return invert(findKthBit(n - 1, k));
    }
}

function invert(bit: string): string {
    return bit === '0' ? '1' : '0';
}

###Rust

impl Solution {
    pub fn find_kth_bit(n: i32, k: i32) -> char {
        Self::find_kth_bit_recursive(n, k)
    }
    
    fn find_kth_bit_recursive(n: i32, k: i32) -> char {
        if k == 1 {
            return '0';
        }
        let mid = 1 << (n - 1);
        if k == mid {
            return '1';
        } else if k < mid {
            return Self::find_kth_bit_recursive(n - 1, k);
        } else {
            let new_k = mid * 2 - k;
            return Self::invert(Self::find_kth_bit_recursive(n - 1, new_k));
        }
    }
    
    fn invert(bit: char) -> char {
        if bit == '0' {
            '1'
        } else {
            '0'
        }
    }
}

复杂度分析

  • 时间复杂度:$O(n)$。字符串 $S_n$ 的长度为 $2^n-1$,每次递归调用可以将查找范围缩小一半,因此时间复杂度为 $O(\log 2^n)=O(n)$。

  • 空间复杂度:$O(n)$。空间复杂度主要取决于递归调用产生的栈空间,递归调用层数不会超过 $n$。

递归——双百(logn)

作者 233999
2020年8月9日 12:13

解题思路

递归 将时间复杂度降到logn
力扣.png

代码

###cpp

class Solution {
private:
    char ch_not(char ch) {
        if(ch == '0') { return '1'; }
        else          { return '0'; }
    }
public:
    char findKthBit(int n, int k) {
        if(n == 1) { return '0'; }
        int mid = (1<<(n-1));
        if(k == mid) { return '1'; }
        if(k < mid) { return findKthBit(n-1, k); }
        return ch_not(findKthBit(n-1, (1<<n) - k)); 
    }
};

自定义右键菜单:在项目里实现“选中文字即刻生成新提示”

2026年3月3日 06:42

一个真正丝滑的项目,交互不能只停留在点击按钮。 “选中即触发” (Selection-to-Action)是生产力工具的标配。

实现这个功能看似简单,实则藏着不少关于 Selection API视口坐标计算的深坑。


1. 核心流程:监听、获取、定位

第一步:捕捉用户的“选中时刻”

虽然有 selectionchange 事件,但在做浮窗时,mouseup 通常更稳,因为它能确保是在用户完成拖拽动作后触发。

JavaScript

document.addEventListener('mouseup', handleSelection);

function handleSelection(e) {
  const selection = window.getSelection();
  const selectedText = selection.toString().trim();

  if (selectedText.length > 0) {
    const range = selection.getRangeAt(0);
    // 关键点:获取选中文字在视口中的精确几何位置
    const rect = range.getBoundingClientRect();
    
    showFloatingMenu(rect, selectedText);
  } else {
    hideFloatingMenu();
  }
}

2. 坐标计算:让浮窗“如影随形”

这是最容易翻车的地方。getBoundingClientRect() 返回的是相对于**视口(Viewport)**的坐标。如果你的页面有滚动条,或者容器是 position: relative,直接赋值 top/left 会让浮窗飞到九霄云外。

正确的绝对定位公式:

Left=rect.left+window.scrollX+(rect.width/2)(menuWidth/2)Left = rect.left + window.scrollX + (rect.width / 2) - (menuWidth / 2)

Top=rect.top+window.scrollYmenuHeightoffsetTop = rect.top + window.scrollY - menuHeight - offset

JavaScript

function showFloatingMenu(rect, text) {
  const menu = document.getElementById('floating-menu');
  const offset = 10; // 距离文字上方的间距

  // 计算位置:居中显示在选中文字上方
  const left = rect.left + window.scrollX + (rect.width / 2);
  const top = rect.top + window.scrollY - offset;

  Object.assign(menu.style, {
    display: 'flex',
    left: `${left}px`,
    top: `${top}px`,
    transform: 'translate(-50%, -100%)' // 利用 transform 实现水平对齐
  });

  menu.dataset.selectedText = text; // 暂存文字供后续使用
}

3. 避坑指南

① 避免“点一下就弹”

如果用户只是单纯点击了一下(没有选中任何字),mouseup 也会触发。

  • 解决:除了判断 selectedText.length > 0,还可以记录 mousedown 的位置,如果 mouseup 的位置没变,说明是点击而非选择。

② 浮窗点击冲突:onmousedown 陷阱

当你点击浮窗上的“复制”按钮时,浏览器默认会清除当前页面的文字选中状态,导致 mouseup 再次触发把浮窗关掉。

  • 解决:在浮窗容器上使用 onmousedown={(e) => e.preventDefault()}。这样点击浮窗时,焦点不会离开原来的文字。

③ 边界检测(Viewport Boundary)

如果选中的文字在屏幕最顶端,浮窗会超出屏幕。

  • 对策:判断 rect.top 是否小于浮窗高度。如果是,则将浮窗显示在文字下方

4. 功能实现:一键复制与翻译

JavaScript

// 复制逻辑(复用我们上一篇文中的 safeCopy)
menu.querySelector('.copy-btn').onclick = async () => {
  const text = menu.dataset.selectedText;
  await safeCopy(text);
  showToast('已复制!');
  hideFloatingMenu();
};

// 翻译逻辑:调用 AI 接口
menu.querySelector('.translate-btn').onclick = async () => {
  const text = menu.dataset.selectedText;
  // 直接跳转到 AI 对话框并自动输入 Prompt
  router.push(`/chat?prompt=请翻译以下文字:${text}`);
};

5. 交互进阶:移动端长按适配

在移动端,用户习惯长按选择文字。

  • 方案:现代移动浏览器会自动弹出系统菜单。如果你想覆盖它,需要监听 contextmenu 事件,或者通过 CSS 属性 -webkit-touch-callout: none; 禁用系统菜单,再手写一套长按逻辑(touchstart + setTimeout)。

告别后端转换:高质量批量导出实战

2026年3月3日 06:41

对于 100 条这种量级的数据,虽然内存压力不大,但格式的标准化导出的顺滑度是区分“初级实现”和“资深工具”的关键。

在应用场景下,用户导出数据通常有两个目的:备份/迁移(JSON)或分享/离线审阅(Excel)


1. 方案选择:JSON vs. XLSX

格式 优势 劣势 资深开发建议
JSON 结构化、无损、浏览器原生支持。 对非技术人员不友好。 用于系统备份、数据迁移。
XLSX 可读性极强、支持排序筛选。 需要第三方库、体积较大。 用于周报汇报、团队分享。

2. 导出 JSON:最纯粹的无损备份

这是最简单的方案,不需要任何库,直接利用 BlobURL.createObjectURL

JavaScript

/**
 * 导出为 JSON 文件
 * @param {Array} data - Prompt 数组
 */
function exportToJSON(data) {
  // 1. 序列化,添加 2 个空格缩进方便阅读
  const content = JSON.stringify(data, null, 2);
  
  // 2. 创建 Blob 
  const blob = new Blob([content], { type: 'application/json' });
  
  // 3. 触发下载
  downloadFile(blob, `Prompts_Backup_${new Date().getTime()}.json`);
}

3. 导出 XLSX:专业的表格方案

对于 Excel 导出,SheetJS (xlsx) 是行业标准。作为 8 年老兵,我会建议你使用 动态引入(Dynamic Import) ,因为这个库体积较大(数百 KB),没必要在首屏加载。

实战代码(按需加载型)

JavaScript

async function exportToXLSX(data) {
  // 1. 动态加载 SheetJS
  const XLSX = await import('https://cdn.sheetjs.com/xlsx-0.20.1/package/dist/xlsx.full.min.js');

  // 2. 数据扁平化(如果你的 Prompt 包含嵌套对象,需要先处理)
  const worksheet = XLSX.utils.json_to_sheet(data);
  
  // 3. 创建工作簿
  const workbook = XLSX.utils.book_new();
  XLSX.utils.book_append_sheet(workbook, worksheet, "MyPrompts");

  // 4. 写入并触发下载
  XLSX.writeFile(workbook, `Prompt_Export_${new Date().toLocaleDateString()}.xlsx`);
}

4. 资深开发的“性能与健壮性”补丁

① 统一的下载触发器(避免内存泄漏)

频繁导出时,如果不销毁 ObjectURL,会导致页面内存占用持续攀升。

JavaScript

function downloadFile(blob, fileName) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = fileName;
  document.body.appendChild(a);
  a.click();
  
  // 关键:延迟移除和销毁,确保下载任务已交给浏览器
  setTimeout(() => {
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }, 100);
}

② 数据清洗(避免 Excel 报错)

Excel 对某些特殊字符或过长的单个单元格(超过 32767 字符)会报错。

  • 对策:在 json_to_sheet 之前,遍历数据,对超长 Prompt 进行截断或分块,或者至少给用户一个提示。

③ 导出时的“防抖”与“状态反馈”

100 条数据虽然快,但如果是 10000 条,UI 可能会卡死。

  • 对策:点击导出后,按钮立即进入 Loading 状态,并使用 Web Worker 处理数据序列化,最后再回到主线程触发下载。

5. 加分项

  1. 文件名命名规范:不要只叫 data.json。推荐 [应用名]_[分类]_[日期].xlsx
  2. 表头国际化:如果你的工具面向国际,导出的 Excel 表头(如:标题、内容、创建时间)应该根据当前 UI 语言动态映射。
  3. CSV 降级:如果不想引几十 KB 的 xlsx 库,且数据结构简单,导出 CSV 是最高性能的方案。但要注意加上 BOM (Byte Order Mark) 头(\ufeff),否则 Excel 打开中文会乱码。

JavaScript

// CSV 乱码修正技巧
const csvContent = "\ufeff" + convertToCSV(data);
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });

弃用html2pdf.js,这个html转pdf方案能力是它的几十倍

作者 刘发财
2026年3月3日 02:06

欢迎转载文章

在前端开发中,“把网页变成 PDF”是个老生常谈的需求。无论是生成发票、报告还是简历,用户总希望点一下按钮就能带走一份格式完美的文档。 目前主流的前端html转pdf方案是通过html2canvas将网页渲染成canvas,再通过jsPDF将canvas转换为pdf。代表方案就是 html2pdf.js,npm包周下载量达到了80万,为广大开发者所接受。但是因为它基于html2canvas和jsPDF,会有一些无法解决的问题,比如:

  • 生成速度慢
  • 生成的pdf文件体积大
  • 生成的pdf内容会模糊,打印时无法达到清晰度要求
  • 文字无法被搜索,选中,编辑,因为它生成的pdf是图片式的,而非矢量pdf

而现在,有一种全新的解决思路,完美的解决了这些问题,那就是作者开源的前端pdf生成库dompdf.js,具体的实现和说明可以查看我上一篇文章 https://juejin.cn/post/7583912637470769203

在线体验

dompdfjs.lisky.com.cn

Git 仓库地址 (欢迎 Star⭐⭐⭐)

github.com/lmn1919/dom…

gitee.com/liu-facai/d…

dompdf.js的大致原理

1.解析 html 页面,生成一个包含节点位置信息,样式,层级,内容等信息的 DOM 树。

2.递归 DOM 树,根据节点据顶部的高度和生成页面规格的高度,将节点分配到不同的页面。

3.调用 jspdf.js 的 api,将节点绘制到 PDF 文件上。

可以看出,dompdf.js 跳过了html转图片的步骤,直接将 DOM 树转换为矢量 PDF 文件,避免了图片转换导致的模糊问题,同时也解决了文字无法被搜索,选中,编辑的问题。

下面,我们从pdf生成速度,生成质量,生成数量等方面对两种方案进行对比

测试的内容为生成包含文本和表格的pdf文件

1.文件生成速度对比

同样的内容,dompdf.js 生成速度更快,耗时基本上只有 html2pdf.js 的 1/2。

微信截图_20260303012415.png

2.文件体积对比

dompdf.js 生成的 pdf 文件体积更小,同样的内容页数,dompdf.js 生成的 pdf 文件体积是 html2pdf.js 的 1/5左右。

微信截图_20260303012435.png

3.清晰度对比

在放大到500%后,html2pdf.js 生成的 pdf 文字会出现明显的锯齿,而 dompdf.js 生成的 pdf 文字则完全没有压力。

html2pdf.png

html2pdf.js生成的pdf文件,放大后会有锯齿

微信截图_20260303013333.png

dompdf.js生成的矢量文件,不会出现模糊的情况

4.生成数量对比

html2pdf.js在30页左右,由于canvas高度限制,就会出现空白页,而 dompdf.js 轻松可以生成数百上千页的pdf。

微信截图_20260303014415.png

html2pdf.js生成的pdf文件,内容过多会出现空白页

微信截图_20260303015837.png

dompdf.js轻松可以生成数百上千页pdf

总结

通过上述对比可以看出,dompdf.js 在各项指标上都完胜传统的 html2pdf.js 方案。它不仅解决了 html2canvas 带来的模糊、体积大、无法选中文字等痛点,还大幅提升了生成速度和页面承载能力。

对于需要高质量、可编辑、且对性能有要求的前端 PDF 生成场景,dompdf.js 无疑是目前更优的选择。

如果你也被前端生成 PDF 的各种坑所困扰,不妨试一试这个库,希望能够帮助到你!

别忘了去 GitHub 点个 Star 支持一下作者哦!⭐⭐⭐

GitHub: github.com/lmn1919/dom…

2026年大模型怎么选?前端人实用对比

作者 牛奶
2026年3月2日 23:57

2026年大模型怎么选?前端人实用对比

这是系列第三篇。02篇我们聊完基础概念,这篇来看看怎么选对大模型和开发工具。


你有没有过这样的经历?

打开一个AI编程工具,纠结半天该选哪个模型。有人说Claude最强,有人说GPT好用,还有人说免费的DeepSeek足够用了。你花了半小时研究,最后还是随便选了一个。

结果用起来才发现:这个模型写代码总是漏这漏那,那个模型响应太慢,还有一个模型连中文都理解不好。

如果你有这样的经历,说明你和我一样,曾经被困在「选择困难」里。

这篇文章,我帮你把这件事彻底讲清楚。


原文地址

墨渊书肆/2026年大模型怎么选?前端人实用对比


先说结论

不想看长文的记住这几点:

  • 免费首选:Trae国内版(完全免费,Claude 3.5)
  • 想要最强:Cursor Pro($20/月,Claude Opus 4.6)
  • 性价比之选:Windsurf($15/月,Claude Sonnet)
  • 国产之光:智谱GLM-5(开源最强,逼近Claude)
  • 开源白嫖:OpenCode + 免费API

核心问题:模型到底差在哪?

选工具之前,先搞明白一个问题:这些模型都能写代码,到底差在哪?

根据2026年2月Coding Arena的真实投票数据(17万+开发者票选),核心差异就三点:

复杂任务处理能力

面对一个模糊的需求,顶级模型会先问清楚再做,差的模型会直接开写,然后写错。

面对跨文件重构,顶级模型能理解整个代码库的结构,差的模型只能看到当前文件。

举一个我自己的例子:

有一次我要重构一个React项目的老组件,大概3000行代码。我分别用了三个模型:

  • Claude Opus 4.5:先问我「这个组件的数据流是什么」「有没有单元测试」「目标是用Class还是Function」,然后才开始写
  • GPT-5.1:直接开始写,写到一半发现数据结构不对,又从头改了一遍
  • DeepSeek V3.2:写倒是能写,但细节处理不完善,后面我自己修了半小时

这就是差距。复杂任务面前,顶级模型不是在「写代码」,而是在「解决问题」。

思考深度

Thinking模式(推理模式)比普通模式平均强5-10%。

但Thinking模式响应慢3-8秒。

简单任务不需要Thinking,复杂任务必须开。

我的经验是:

  • 写个简单函数、开个API接口 → 普通模式就够了
  • 面对复杂需求、跨文件重构、疑难Bug → 必须开Thinking

上下文理解

有的模型看 3000 行代码就开始「失忆」,给你的代码前后矛盾。

有的模型能理解200K token,整个项目丢进去都不是问题。

前端项目越大,上下文能力越重要。特别是你要让AI帮你理解一个老项目的时候。


2026年模型排名(基于Coding Arena)

这是2026年2月的真实排名,17万开发者投票得出。数据来源:Arena.ai

第一梯队:最强王者

排名 模型 得分 适合场景
1 Claude Opus 4.6 1560 通用最强,新版无需Thinking
2 Claude Opus 4.6 Thinking 1553 架构设计、复杂重构
3 Claude Sonnet 4.6 1531 性价比最高的顶级模型

为什么强:这三兄弟是 Anthropic 家的,特点是「想清楚了再写代码」。当你面对一个复杂需求,它们会先分析问题、考虑边界情况、规划实现方案,然后才动手。

第二梯队:实用之选

排名 模型 得分 适合场景
5 GPT 5.1 High 1471 快速原型、速度优先
7 Gemini 3.1 Pro PreView 1461 多语言切换、前后端通吃
8 GLM-5 1451 开源最强,200K上下文

为什么实用:GPT 5.1 High在速度上有优势,适合快速迭代;Gemini 3.1 Pro在多语言支持上表现出色,适合全栈开发者;GLM-5虽然是国产模型,但表现已经逼近国际顶级水平,特别是在中文场景下。

第三梯队:国产新势力

排名 模型 得分 适合场景
12 kimi k2.5 thinking 1436 长文本处理、中文对话、文档分析
13 minimax m2.5 1436 多模态理解、长文本总结
17 qwen3.5 1396 阿里生态、中文优化、高性价比

为什么值得关注:国产模型正在快速追赶国际顶级水平。Kimi在长上下文和多轮对话上有优势,MiniMax在多模态领域表现出色,Qwen3.5背靠阿里云生态,性价比极高。对于国内开发者,这三个模型是很好的替代选择,特别是中文场景下体验不输国际大厂。


开发工具到底选哪个?

对于前端开发者,工具比模型更重要。因为工具已经把模型封装好了,还加了文件管理、终端操作这些功能。

1. Cursor(推荐给不差钱的)

价格:$20/月

包含模型:Claude Opus 4.6 + GPT-5.1 High + Gemini 3.1 系列

优点

  • 目前集成度最高的AI IDE
  • Tab补全、Ctrl+I提问、Ctrl+K改代码,三种模式无缝切换
  • Agent模式可以自己跑命令、改文件
  • 理解项目结构,能跨文件分析

缺点

  • 贵,$20/月
  • 国内访问不稳定

适合:预算充足,追求最强体验

我的建议:如果你只能选一个,选 Cursor。它的体验是目前最好的,特别是Agent模式,真的能帮你减少很多机械劳动。


2. Trae(国内免费首选)

价格:国内版完全免费

包含模型:Claude 3.5 Sonnet + 豆包

亮点功能

  • 国内直达:无需翻墙,直接访问
  • 中文优化:对中文Prompt理解更准确
  • 智能补全:类似Cursor的Tab补全
  • Agent模式:支持自动执行开发任务

优点

  • 免费!国内直达,不用翻墙
  • 中文体验最好
  • Claude 3.5 Sonnet足够强
  • 界面简洁,上手快

缺点

  • 相比 Cursor,集成度稍低
  • Agent 能力不如 Cursor
  • 插件生态不如 Cursor 丰富

适合:国内用户,预算0元,日常开发

我的建议:国内开发者的福音。免费且够用,夫复何求?如果你之前没用过AI编程工具,从Trae开始是最省心的选择。


3. Windsurf(性价比之选)

价格:$15/月

包含模型:Claude Sonnet系列

亮点功能

  • Flow模式:类似Cursor的Agent模式,可以自动执行多步骤任务
  • Cascade:新一代AI编程架构,任务拆解能力更强
  • 上下文保持:长时间对话中保持项目上下文

优点

  • 比Cursor便宜$5
  • 能力接近Cursor
  • Flow模式也能自动执行任务
  • 对Mac/Windows/Linux支持都很完善

缺点

  • 略逊于Cursor(主要在Agent的智能化程度)
  • 生态没Cursor成熟(插件少一些)
  • 中文优化不如Trae

适合:预算有限,但想要好体验

我的建议:如果$20觉得贵,Windsurf是完美的替代品。能力足够,价钱友好。特别是Cascade模式发布后,整体体验提升明显。


4. Google Antigravity(AI原生开发平台)

价格:免费(目前)

包含模型:Gemini 3 Pro / Flash

亮点功能

  • Agent Manager:可以同时管理多个AI Agent协同工作
  • 浏览器自动化:支持浏览器内的自动化任务执行
  • Workspace概念:支持创建多个独立的工作空间
  • Google生态集成:深度整合Google Cloud和开发工具链

优点

  • Google原生,AI Agent能力强大
  • Agent Manager可以同时管理多个AI协同工作
  • 支持浏览器内自动化任务
  • 免费!目前对开发者免费开放
  • Gemini 3在多模态理解上优势明显

缺点

  • 2025年11月才发布,还比较新
  • 生态还在建设中(插件少、功能在快速迭代)
  • 国内访问可能不稳定

适合:喜欢Google生态,想尝试最新AI编程方式的开发者

我的建议:这是Google在AI编程领域的大招。虽然还年轻,但Google的投入力度很大,未来值得关注。特别是它的「Agent Manager」概念很有意思——你可以同时让多个AI帮你干活。如果你是Google全家桶用户,强烈建议试试。


5. OpenCode(开源白嫖)

价格:完全免费(开源项目)

支持模型:75+模型,包括Claude、GPT、Gemini、DeepSeek、MiniMax M2.5

亮点功能

  • MCP扩展:支持Model Context Protocol,可以扩展各种功能
  • 灵活配置:可以自定义模型参数、API端点
  • 隐私优先:所有数据本地处理,不上传云端
  • 多模型切换:同一个对话中随时切换不同模型

优点

  • 完全免费
  • 灵活,想用啥模型用啥模型
  • 隐私优先,数据本地处理
  • 支持MCP扩展
  • 社区活跃,插件丰富
  • 支持MiniMax M2.5免费模型,国内访问稳定

缺点

  • 终端操作,有学习成本
  • 没有图形界面(纯命令行)
  • 需要自己配置API Key
  • 没有内置的代码编辑器功能

适合:开发者,有技术背景,想自己掌控

使用技巧

  • 配合VS Code的Dev Container使用效果更好
  • 推荐使用MiniMax M2.5免费模型,国内直达,无需翻墙
  • 适合需要高度定制化的专业开发者

我的建议:如果你愿意折腾,OpenCode + MiniMax M2.5是性价比最高的组合。完全免费,工具免费+模型免费,夫复何求?适合有一定技术基础、喜欢折腾的开发者。


6. Z Code(智谱官方)

价格:免费/付费

包含模型:GLM-5系列

亮点功能

  • AutoDev模式:自动完成整个开发流程(写代码→执行→测试→提交)
  • 200K超长上下文:可以一次性理解整个大型项目
  • 多模态支持:支持图片、代码、文档等多种输入形式
  • 国产化部署:支持私有化部署,适合企业用户

优点

  • 智谱官方,GLM-5体验最完整
  • 自动完成整个开发流程(写代码、执行、测试、提交)
  • 200K超长上下文
  • 中文理解能力极强
  • 国内访问稳定

缺点

  • 刚发布,生态还在建设中
  • 插件和第三方集成不如Cursor丰富
  • Agent能力还在持续优化中

适合:想体验国产最强模型、喜欢尝鲜的开发者

我的建议:GLM-5确实强,但配套工具还需要时间完善。适合想支持国产的朋友。特别是200K上下文对于大型项目非常友好,如果你需要处理大型老项目,Z Code值得一试。


预算方案推荐

预算0元:Trae + OpenCode

  • 日常开发:Trae国内版
  • 查问题:OpenCode + MiniMax M2.5免费模型
  • 尝鲜:Z Code(GLM-5)

效果:80%的日常开发够用,国产模型崛起


预算15元/月:Windsurf Pro

  • 工具:Windsurf Pro($15/月)
  • 模型:Claude Sonnet

效果:比Cursor便宜,能力足够


预算20元/月:Cursor Pro

  • 工具:Cursor Pro($20/月)
  • 模型:Claude Opus 4.6

效果:目前前端开发最强组合


想要国产最强:Z Code + GLM-5

  • 工具:Z Code(免费)
  • 模型:GLM-5(开源最强)

效果:支持国产,能力逼近Claude


我的建议

  1. 先用起来:别纠结,Trae直接下载先用
  2. 从免费开始:觉得不够再升级
  3. 按需付费:每个工具都有免费额度,先试试
  4. 组合使用:不同场景用不同工具
  5. 关注国产:GLM-5的崛起值得关注

写在最后

AI工具更新快,这篇写的是2026年2月的格局。

有一点特别想说的是:这两年国产模型的进步速度超出了所有人的预期。从2024年的「能用」,到2025年的「够用」,再到2026年的「逼近最强」——GLM-5、Kimi K2.5这些国产模型正在快速追赶。

作为前端开发者,这是最好的时代。我们有更多的选择,也有更大的空间。

下篇我们聊《Prompt怎么写才有效》——同样工具不同人用,效果差十倍。

感兴趣下篇见。

前端人为什么要学AI?

作者 牛奶
2026年3月2日 23:50

前端人为什么要学AI?

系列开篇,写给想要真正掌握未来的前端开发者。


你有没有过这样的经历?

写一个登录表单,花了半小时调样式。产品说交互要改一下,你又花了半小时。类似的功能做了无数遍,感觉自己就是个「Ctrl+C / Ctrl+V」工程师。

遇到一个复杂的正则表达式或者是算法题,对着Google搜了半小时,结果复制过来的代码自己都看不懂,最后只能硬着头皮问同事。

接手别人的代码,看着满满一屏幕的useEffectuseState,完全不知道数据是怎么流的,想改又不敢改。

如果你有过类似的经历,说明你和我一样,曾经被困在某种「技术舒适区」里。

前端会React,会写样式,会调API,但面对一些「重复性的工作」和「棘手的问题」,总是要花大量时间。

我想聊聊这件事。


原文地址

墨渊书肆/前端人为什么要学AI?


前端这件事,也被误解了很多年

一提到「前端工程师」,很多人脑海里浮现的是这样一个形象:每天跟样式打交道,调调组件,写写页面,看起来没什么技术含量。

这种理解,该过时了。

现在的Web应用越来越复杂。前端不再只是「画界面」,而是要处理复杂的交互、状态管理、性能优化、工程化建设。ReactVueNext.js......框架越来越强大,需要学的越来越多。

但问题是:

  • 前端的工作边界在扩大:以前只管页面,现在要做SSR、做SEO、做动画、做可视化......一个人要学的东西越来越多
  • 重复劳动越来越多:同样的组件改改参数就是一个新的,同样的交互换换逻辑又要重新写
  • 沟通成本越来越高:和产品经理、设计师、后端工程师来来回回确认需求,代码反而没写多少

我们变成了「高级CV工程师」——不是Copy Vector,是Copy and Paste。

这不是前端的问题,这是整个行业的痛点。


AI来了,情况不一样了

2023年开始,AI的爆发让一切变得不同。

以前我们需要自己写的代码,现在AI可以帮我们写。以前我们需要自己查的文档,现在AI可以直接读给我们听。以前我们需要自己调试的bug,现在AI可以直接帮我们定位。

但我发现一个有趣的现象:很多前端开发者对AI的态度是两个极端——

要么觉得AI没用,「生成的代码一堆bug还得我自己改」;要么觉得AI太厉害,「迟早要取代我」。

这两种观点,都不对。

AI不会取代前端,但它会重新定义「前端」这个岗位。

就像计算器没有取代数学家,但数学家必须会用计算器。AI工具不会取代前端开发者,但前端开发者必须会用AI。


AI到底能帮前端做什么?

说几个我自己的真实经历。

1. 写代码更快了

以前我要写一个日期选择器组件,从头写到尾要半小时。现在我告诉AI我的需求——「需要一个支持范围选择、禁用特定日期、暗色模式的主题适配」——它能给我一个可以直接用的版本,我只需要根据业务需求微调。

这不是「替代」,是「放大」。我原本半小时只能做一个组件,现在十分钟做出来,剩下二十分钟可以去喝杯咖啡。

2. 读代码更快了

接手别人的项目,最头疼的就是看不懂代码。现在我可以直接把代码丢给AI,让它帮我解释:「这个组件的数据流是怎么走的?为什么要用useMemo?」

它不仅能解释代码,还能帮我梳理逻辑,告诉我哪里可能有性能问题。

3. 查文档更快了

以前遇到问题,我先去Google搜,然后看Stack Overflow,最后实在不行才去翻文档。

现在我直接问AI:「Next.js 15怎么做密码重置?」它能直接给我答案,虽然不一定完全准确,但足够让我快速上手。

4. 做项目更有底气了

以前做一个带AI功能的项目,光是调研要用什么API、怎么接入、怎么管理上下文,就能劝退一半的人。

现在这些都有现成的方案。Vercel AI SDK几分钟就能搭一个聊天界面,LangChain帮我管理AI的工作流,我只需要专注于业务逻辑。


但AI不是万能的

我知道有人要问了:AI这么厉害,那我们还学什么?

这是个好问题。

我用了一年多AI辅助开发,发现它有几个明显的短板:

  • 第一,AI不懂你的业务

你告诉AI「帮我写个用户列表」,它能给你写。但你的产品里用户列表要显示会员等级、要按活跃时间排序、要支持导出Excel——这些AI不知道。

你得自己把需求翻译成AI能理解的形式。

  • 第二,AI会犯错

AI生成的代码有bug是常态,不是例外。它能帮你写70%的代码,剩下30%你得自己改、自己调。

如果你没有判断代码对不对的能力,AI帮你的可能还没有坑你的多。

  • 第三,AI不知道什么是「好」

代码能跑和代码好是两回事。AI可以写出能跑的代码,但不一定符合性能要求、安全规范、可维护性标准。

这些都需要你有一定的技术判断力。

所以,AI时代更需要学习,只是学习的内容变了。

以前我们学的是「怎么实现」,以后我们学的是「怎么整合」。

以前我们学的是「这个API怎么用」,以后我们学的是「这个需求怎么拆」。

以前我们学的是「怎么写代码」,以后我们学的是「怎么用AI写代码」。


前端学AI,有什么优势?

说了这么多,你可能会问:为什么是前端先学AI?而不是后端、不是移动端?

我的答案是:前端天然离用户最近,天然是AI落地的最佳场景。

你想做一个智能助手,第一个要做的就是界面。一个聊天窗口、一个语音按钮、一个输入框——这些是前端最擅长的。

你想做一个AI生成图片的应用,第一个要做的还是界面。用户上传图片、选择风格、预览结果——这些也是前端最擅长的。

而且前端开发者有几个天然优势:

  • 对交互敏感:我们知道什么是好的用户体验AI生成的内容需要什么样的交互来呈现
  • 对视觉敏感:我们知道怎么把AI生成的内容美化、适配不同的屏幕
  • 对技术敏感:我们天天跟API打交道,接入AI服务对我们来说轻车熟路

2026年了,如果前端还只把自己定位在「画界面」,那确实危险。但如果前端把自己定位在「用户与AI的桥梁」,那前景无限。


这个系列想带你做什么

市面上不缺AI教程。Prompt工程大模型原理LangChain实战——这种内容一搜一大把。

但我发现很多前端开发者看完这些教程,还是不知道怎么做。

因为大部分教程要么太偏理论(全是数学公式),要么太偏后端(全是Python代码),跟前端开发者的日常工作没关系。

这个系列我想带你做的事情很简单:从零开始,让AI真正成为你的开发助手。

不是demo,不是练习,而是真实的、能用到日常工作中的技能。

我会分成这几个阶段:

  • 阶段零:认知重建

    先理解AI到底能帮我们什么(就是这篇)。

  • 阶段一:Prompt工程与AI应用基础

    真正开始用AI工具。学怎么写有效的Prompt,怎么让AI帮我们写代码、查文档、修bug。

  • 阶段二:AI功能接入与网页开发

    开始做项目。把AI功能接入到自己的网页里,做出能展示的Demo。

  • 阶段三:AI原理与进阶应用

    从「会用」到「理解」。不求能自己训练模型,但求知道AI为什么有时候聪明有时候犯傻。

  • 阶段四:本地部署与生产实践

    接近实际生产。LangChain、本地模型、浏览器端运行——怎么让AI不依赖云服务也能跑。

在这个过程中,你会看到我踩过的坑,做过的错误决策,总结出的经验。我不是为了告诉你「这个技术怎么用」,而是告诉你「这个AI能力该怎么学」。


写在最后

回到开头的问题。

你是不是经常感觉写了很多代码,但真正用到的时候还是那些老东西?

这很正常。

技术本身不是目的,解决问题才是。

2026年了,AI可以帮你写代码,但不能帮你判断什么是好的代码。能做到这一点的人,永远有市场。

而这,就是我们这个系列要一起做的事情。

下一篇文章,我会讲讲《AI辅助开发的基础概念》介绍一些向量、Token、大模型的基本概念,以及前端视角怎么理解这些概念。

感兴趣的话,下一篇见。

昨天 — 2026年3月2日技术

🎉OpenTiny NEXT-SDK 重磅发布:四步把你的前端应用变成智能应用!

2026年3月2日 21:25

AI Agent 时代,人们已经不满足只是与 AI 进行问答交互,而是希望 AI 能直接帮人干活。

目前 AI 帮人干活的场景越来越丰富,最常见的就是 AI 帮人写代码、做视频、做 PPT、做设计稿。

你有没有想过 AI 能帮人操作网页?

这就是 OpenTiny NEXT-SDK 做的事情。

1 简介

OpenTiny NEXT‑SDK 是一套面向前端智能应用的开发工具包,核心是基于 MCP(Model Context Protocol) 协议,让前端应用快速接入 AI Agent,实现前端界面可被智能体直接操控的能力。

OpenTiny NEXT‑SDK 可以帮助开发者:

  • 把普通前端应用快速改造为 MCP Server,对外暴露界面操作能力

  • 让 AI Agent(WebAgent)通过标准 MCP 协议读取界面、调用功能、执行操作

  • 快速集成 AI 对话组件(如 TinyRobot),构建智能交互前端

2 项目优势

NEXT‑SDK 基于 MCP 协议实现,将 MCP 的能力扩展到了 Web 端,让 Web 应用也能被 AI 操控,以下是项目优势:

  • 扩大 MCP 工具范围:为 Agent 智能体提供更多的 MCP 工具,实现当前现有的本地/云服务 MCP 工具所不具备的能力,即操控前端应用的能力。这种能力比 RPA 方案(Browser Use / Computer Use)更快(可通过后面的演示视频感受 AI 操作的效率)、更准更经济(消耗更少 Token)

  • 完全兼容 MCP 生态:所有的前端应用都采用标准的 MCP 协议声明 MCP Server,并且基于标准的 MCP 通讯方式进行连接,比如 Streamable HTTP,意味着能完全融入现有的 MCP 生态,兼容现有乃至未来的 MCP Host 应用

  • 支持智能体交互范式:当前的前端应用主要还是人机交互,即人手动操作前端界面上的 UI 组件。引入 OpenTiny NEXT-SDK 之后,Agent 智能体可以借助 MCP 工具读取前端界面的信息、调用前端界面的功能,配合生成式 UI 实现新的智能体交互范式

  • 多样的前端智能化方案:不仅支持 Web 应用的前端智能化改造,还全面覆盖 AI 应用(对话框)的多端部署场景——无论是浏览器扩展、Web 页面集成,还是各终端内置的 AI 助手,均可直接或间接调用前端应用中的 MCP 工具

3 演示动画

我们一起来看一个演示动画(无剪辑、无加速,AI 操作页面的真实速度),直观感受下 NEXT-SDK 的能力吧!

AI创建用户.gif

接入 NEXT-SDK 的前端应用,右下角会出现一个机器人图标,点击这个图标会从侧边弹出 AI 对话框,我们可以使用自然语言与 AI 对话,让 AI 帮我们操作前端应用。

比如我们可以输入以下内容:

帮我创建以下用户,用户信息如下:
邮箱:zhangsan@sina.com
密码:Abc123456
用户名:zhangsan

这时 AI 会调用页面中定义的名为 add-user 的 MCP 工具,帮我们创建 zhangsan 这个用户。

我们提供了一个 Playground 代码演练场,你可以在线体验 NEXT-SDK 的能力。

NEXT-SDK Playground:playground.opentiny.design/next-sdk

4 快速接入

使用 OpenTiny NEXT-SDK,只需要以下四步,就可以把你的前端应用变成智能应用。

第一步:安装依赖


npm install @opentiny/next-sdk

第二步:创建 MCP Client

在 Web 应用的主入口(比如:Vue 项目的 App.vue 文件)定义 WebMcpClient。


import { onMounted, provide } from 'vue'
import { WebMcpClient, createMessageChannelPairTransport } from '@opentiny/next-sdk'

onMounted(async () => {
  // 创建通信通道
  const [serverTransport, clientTransport] = createMessageChannelPairTransport()
  provide('serverTransport', serverTransport)

  // 创建 MCP Client
  const client = new WebMcpClient()
  await client.connect(clientTransport)
  // 这个 sessionId 是 Web 应用与 WebAgent 服务建立连接后,由 WebAgent 服务生成的,用来唯一标识被操控的 Web 应用(被控端)
  const { sessionId } = await client.connect({
    agent: true,
    url: 'https://agent.opentiny.design/api/v1/webmcp-trial/mcp'
  })
})

第三步:创建 MCP Server

在 Web 应用的子页面(比如:views/page1.vue)中定义 WebMcpServer,每个页面可以定义自己的 WebMcpServer,页面切换时,MCP Client 会与当前页面的 MCP Server 建立连接,并丢弃与之前页面的连接。


import { onMounted, inject } from 'vue'
import { WebMcpServer, z } from '@opentiny/next-sdk'

onMounted(async () => {
  const serverTransport = inject('serverTransport')
  // 创建 MCP Server
  const server = new WebMcpServer({
    name: 'mcp-server-page1',
    version: '1.0.0'
  })

  // 定义 MCP 工具
  server.registerTool(
    'demo-tool',
    {
      title: '演示工具',
      description: '一个简单工具',
      inputSchema: { foo: z.string() }
    },
    async (params) => {
      console.log('params:', params)
      return { content: [{ type: 'text', text: `收到: ${params.foo}` }] }
    }
  )

  await server.connect(serverTransport)
})

完成!现在你的前端应用已经变成智能应用,可以被 AI 操控了,你可以通过各类 MCP Host 来操控智能应用。

第四步:添加 AI 遥控器

我们提供了一个开箱即用的 AI 对话框组件,支持 PC 端和移动端,就像一个遥控器,可以通过对话方式操控你的前端应用。

安装遥控器组件:


npm install @opentiny/next-remoter

在 Vue 项目中使用:


<script setup lang="ts">
import { TinyRemoter } from '@opentiny/next-remoter'
import '@opentiny/next-remoter/dist/style.css'

// 使用第二步获取的 sessionId
const sessionId = 'your-session-id'
</script>

<template>
  <tiny-remoter 
    :session-id="sessionId" 
    title="我的智能助手"
  />
</template>

遥控器会在你的应用右下角显示一个图标,悬浮后可以选择:

  • 弹出 AI 对话框:在应用侧边打开 AI 对话界面

  • 显示二维码:手机扫码后打开移动端遥控器

不管是 PC 端还是移动端,都可以通过自然语言对话的方式让 AI 帮你操作应用,极大提升工作效率!

如果你想了解更多 NEXT-SDK 的用法,请参考 NEXT-SDK 官网文档:docs.opentiny.design/next-sdk

5 立即行动

在 AI 技术快速迭代的今天,前端智能化不再是“高端需求”,而是提升产品竞争力、提升操作效率的核心能力和必选项。

OpenTiny NEXT-SDK 让前端 AI 集成,从“复杂踩坑”到“5分钟上手”,让你的应用瞬间拥有 AI 能力,领跑行业智能化创新!

立即行动,解锁前端智能化新可能:

  • 执行 npm install @opentiny/next-sdk 安装 OpenTiny NEXT-SDK,5分钟上手实操,快速体验 AI 操控效果

  • 前往 OpenTiny NEXT-SDK 官网:opentiny.design/next-sdk,查看详细的项目介绍、API 文档和进阶用法

  • 访问 OpenTiny NEXT-SDK 代码演练场:playground.opentiny.design/next-sdk,在线体验 AI 自动操作前端应用

  • 添加 OpenTiny 微信小助手:opentiny-official,加入 OpenTiny 技术交流群,获取一对一集成指导,解决实操难题,与同行交流 AI 前端集成经验

如果你有任何问题,欢迎在评论区留言交流!

ArcGIS Pro 中的 notebook 初识

作者 GIS之路
2026年3月2日 20:13

^ 关注我,带你一起学GIS ^

notebook中文翻译为笔记本,既然是笔记本,那就具有添加、修改、删除、保存等功能。ArcGIS Pro中的 notebook其实也是这意思。

区别就是ArcGIS Notebooks是一个基于JupyterLab构建的开源 web 应用程序 ,可用于创建和共享包含实时 Python 代码、可视化效果和叙事文本的文档(名为 Notebooks)。

将 ArcGIS Notebooks 集成到 ArcGIS Pro 后,可以执行分析并在地理环境中立即查看结果,与新兴数据进行交互,记录并自动化工作流,以及将其保存以供稍后使用或共享。ArcGIS Notebooks 用途包括数据清理和转换、数值模拟、统计建模、计算机学习、管理任务等。

并且ArcGIS Pro 中的所有 Python 功能均可通过 ArcGIS Notebooks 使用,其中包括核心 Python 功能、Python 标准库、ArcPyArcGIS API for Python 以及ArcGIS Pro 所随附的众多第三方库,例如 NumPy 和 pandas

ArcGIS Pro 可以使用 ArcGIS Pro 包管理器通过开源库进行扩展。

当开源Jupyter NotebooksArcGIS Pro 应用程序中本地运行时,Esri集成 Jupyter Notebook 体验也可用于ArcGIS OnlineArcGIS Enterprise门户。

1. ArcGIS Notebooks 使用

1.1. 创建一个新的笔记本。

方式一:

点击插入选项卡,在工程窗口中选择New Notebook下拉菜单,然后点击New Notebook。或者存在保存过的笔记本的话,也可以通过Add and Open Notebook打开。

方式二:

点击分析选项卡,选择Python下拉菜单,点击Python Notebook

打开notebook笔记本窗口显示如下,由标题栏、工具栏和代码区组成,主要包括保存、新建、剪切、复制、运行等工具。

1.2. 运行 Python 代码

在单元格中输入代码后,点击三角形按钮运行代码。

也可以通过按住[CTRL+ENTER]运行选定行,代码显示如下。可通过在每一行后按 Enter 键,在单个单元格内添加多行代码。 如果您习惯于在 Python 窗口或 Python 编辑器的交互式窗口中运行代码,这可能会与您的习惯不符,因为在上述两个窗口中按 Enter 键的结果是运行代码行。

2. 查看ArcGIS Notebooks

已添加到工程中的ArcGIS Notebooks将在目录窗格的 Notebooks 文件夹下列出。 使用 ArcGIS Pro 创建的 Notebook 会自动添加到您的工程中。

要将现有的笔记本添加到工程中,请右键单击Notebooks文件夹,然后选择添加笔记本,或者单击插入功能区上添加笔记本按钮旁边的下拉箭头,然后选择添加笔记本。

3. 查看代码帮助

ArcGIS Notebooks中输入代码后,可通过按下tab键打开帮助窗口查看具体方法或者属性,具有代码提示和代码补全功能。

显示列表后,还可以输入内容进行再次过滤。 从列表中选择合适的方法后,按 Enter 键即可使用该方法。

Python工具、模块、函数、类和关键字都会存储可提供有关其使用信息的文档。 通过按Shift+Tab 可以激活指针处的文档。以下是针对缓冲区工具显示的文档:

或者,也可以使用内置Python help方法访问帮助文档。以下是针对 arcpy.analysis.Clip显示的帮助文档:

4. 参考资料

  • ArcGIS Pro 中的 notebook:https://pro.arcgis.com/zh-cn/pro-app/latest/arcpy/get-started/pro-notebooks.htm
  • ArcGIS Pro 提取分析工具:https://pro.arcgis.com/zh-cn/pro-app/latest/tool-reference/analysis/clip.htm

GIS之路-开发示例数据下载,请在公众号后台回复:vector

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集(全)

GDAL 开发合集(全)

GIS 影像数据源介绍

GeoJSON 数据源介绍

GIS 名词解释

ArcPy,一个基于 Python 的 GIS 开发库简介

GIS 开发库 Turf 介绍

GIS 开发库 GeoTools 介绍

GIS 开发库 GDAL 介绍

地图网站大全

从微信指数看当前GIS框架的趋势

Landsat 卫星数据介绍

OGC:开放地理空间联盟简介

中国地图 GeoJSON 数据集网站介绍

看完就懂 useSyncExternalStore

作者 ssshooter
2026年3月2日 19:42

功能

React 引入 useSyncExternalStore 也很长一段时间了,但是存在感还不太强。简而言之,它专门用来搞定那些不受 React 内部生命周期控制的外部数据源

过去最大的问题其实是 React 渲染时的 「撕裂」,这是 React 为了优化页面响应速度引入的并发渲染机制带来的副作用。

简单来说就是 React 为了防止在渲染时长时间无法响应用户输入,把渲染过程拆分成多个可中断的小任务,这就能小任务的间隙中插入用户响应,从而模拟出「并发」的感觉。更完整的前因后果可以参考《React 的设计哲学》

在 React 并发渲染机制下,如果用普通的 useEffect 去同步外部数据,可能会出现渲染进行到一半时数据突然发生变化,导致同一份页面中,一半的组件拿着老数据,另一半拿着新数据的灵异现象(但是实际上出现这个问题的几率其实非常小,大家都忽略了,这就导致了 useSyncExternalStore 的存在感很低)。使用 useSyncExternalStore 后,如果在渲染过程中快照发生变化,React 会丢弃当前渲染并重新开始,从而保证同一次提交中的所有组件看到的是同一个版本的数据。

使用场景

订阅浏览器 API

拿监听网络状态来说。不使用这个 Hook 之前,我们通常得在组件里写个包含完整挂载和清理逻辑的 useEffect 去监听 onlineoffline 事件。

function subscribe(callback) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}

function getSnapshot() {
  return navigator.onLine;
}

// 组件里直接这么用
const isOnline = useSyncExternalStore(subscribe, getSnapshot);

监听媒体查询(Media Queries)响应式布局也是同样的套路:

const query = window.matchMedia("(max-width: 600px)");

function subscribe(callback) {
  query.addEventListener("change", callback);
  return () => query.removeEventListener("change", callback);
}

const isMobile = useSyncExternalStore(subscribe, () => query.matches);

轻量级全局状态

如果你接手了一个极小的项目,不想引入 Redux 或 Zustand 这样繁琐的包,但又迫切需要在几个跨层级的组件间共享某部分状态。这时候你可以直接手搓一个简易的 Store:

// 丢在 React 外面的状态中心
let internalState = { count: 0 };
const listeners = new Set();

const store = {
  increment() {
    internalState = { count: internalState.count + 1 };
    listeners.forEach((l) => l());
  },
  subscribe(callback) {
    listeners.add(callback);
    return () => listeners.delete(callback);
  },
  getSnapshot() {
    return internalState;
  },
};

// 任何组件里都可以直接同步获取状态
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);

注意:useSyncExternalStore 内部用 Object.is 比较前后快照,如果 getSnapshot 在数据未变的情况下每次都返回新对象,会导致无限循环重渲染。

只要把这段代码看懂,你就掌握了 Zustand 这种现代状态管理库的核心原理

竞品 API

useEffect + setState

曾经大家都习惯在 useEffect 里监听外部变化,如果变了,再跑一下 setState 触发更新。

这就又到了日常批判 useEffect 的时候了。

useEffect 带来重复渲染和闪烁问题。如果你的外部状态和页面初始计算的状态不对齐,页面渲染就会经历「旧值 -> 闪烁 -> 新值」这三步。而 useSyncExternalStore 在渲染中途就能直接取走最新的正确值。

另外,在处理服务端渲染时,用副作用很容易抛出水合(Hydration)错误,因为服务端和客户端首次生成的 HTML 大概率因为外部数据对不上。useSyncExternalStore 为此专门开了一个叫 getServerSnapshot 的参数,让你传能兜底服务端的静态快照。

Context

很多人滥用 Context 做全局状态,但如果是频繁变动的数据,Context 的广播机制简直是一场灾难。只要 Provider 提供的值发生了变动,它底下所有的子组件也会跟着无脑重跑 Render,除非你给每个组件层级套一层 React.memo(当然现在有 compiler,但也不是毫无代价)。

相比之下,useSyncExternalStore 实现了高精度的按需订阅——只有从 Store 取出的快照真的有了变化,关联的组件才会再次渲染。在这里还是顺便强调一下,没事别用 Context。

总结

要判断何时使用 useSyncExternalStore 其实很简单,只要你的数据依然在 React 的生命周期里流转(例如表单实时输入、控制弹窗开闭的布尔值),那就老老实实用回你的 useStateuseReducer

一旦数据满足游离于 React 管理之外、会随时间变化、且你要让 UI 能自动响应这种变化这三个条件,就毫不犹豫上 useSyncExternalStore。日常写前端页面也许碰不到几次,但之后你要是去造底层 Hook 库,或者需要硬啃第三方库内部暴露出的状态时,useSyncExternalStore 绝对好使~

相关链接

干掉 Virtual DOM?尤雨溪开始"强推" Vapor Mode?

作者 前端Hardy
2026年3月2日 18:14

上周 Code Review,我看到同事写了这样一段代码:

const state = reactive({
  user: null,
  loading: false,
  error: '',
  list: []
});

// 后面又单独定义
const currentPage = ref(1);
const pageSize = ref(10);

乍看没问题,但一运行**——页面卡顿、watch 失效、调试器里数据对不上……**

问题出在哪?
不是逻辑错,而是响应式对象的“组合方式”错了

今天,我就用3 条黄金法则 + 2 个实战模板,帮你彻底搞懂 Vue 3 响应式怎么写才高效、安全、可维护。

法则 1:简单值用 ref,复杂对象用 reactive —— 但别混用!

很多教程说:“primitive 用 ref,object 用 reactive”,这没错,但忽略了“解构陷阱”。

错误示范:

const { user, loading } = reactive({ user: null, loading: false });
// 解构后失去响应性!

正确做法:

// 方案 A:全部用 ref(推荐新手)
const user = ref(null);
const loading = ref(false);

// 方案 B:用 toRefs 保持响应性
const state = reactive({ user: null, loading: false });
const { user, loading } = toRefs(state); // ✅ 响应式保留

经验公式:

  • 如果你要频繁解构 or 传递单个属性 → 优先用 ref
  • 如果是完整状态模块(如表单、列表配置)→ 用 reactive + toRefs

法则 2:别把 ref 套进 reactive,除非你真的需要

见过这种写法吗?

const state = reactive({
  count: ref(0), // ❌ 不要!
  name: 'Vue'
});

这会导致:

  • 访问时必须写 state.count.value(破坏一致性)
  • 模板中虽然自动 unwrap,但逻辑层混乱
  • 容易引发“value 嵌套地狱”

正确做法:统一层级

// 要么全 ref
const count = ref(0);
const name = ref('Vue');

// 要么全 reactive(count 直接是 number)
const state = reactive({
  count: 0,
  name: 'Vue'
});

小技巧:在 setup() 返回时,用 ...toRefs(state) 一键暴露所有属性。

法则 3:大型组件,用“状态模块化”代替巨型 reactive

当组件状态超过 5 个字段,别堆在一个 reactive 里!

反面教材:

const state = reactive({
  // 用户信息
  userId, userName, userAvatar,
  // 分页
  page, size, total,
  // 搜索条件
  keyword, status, dateRange,
  // UI 状态
  showDrawer, loading, errorMsg...
});

推荐拆分:

// 按功能拆成多个小状态块
const userState = reactive({ id: '', name: '', avatar: '' });
const pagination = reactive({ page: 1, size: 10, total: 0 });
const uiState = reactive({ loading: false, drawerVisible: false });

// 或封装成 composable
const { userState } = useUserStore();
const { pagination, fetchList } = usePagination();

这样不仅逻辑清晰,还天然支持 逻辑复用(比如分页逻辑抽成 usePagination)。

实战模板:两种主流写法对比

模板 A:全 ref 风格(适合中小型组件)

export default {
  setup() {
    const loading = ref(false);
    const list = ref([]);
    const keyword = ref('');

    const search = async () => {
      loading.value = true;
      list.value = await api.search(keyword.value);
      loading.value = false;
    };

    return { loading, list, keyword, search };
  }
}

优点:直观、无解构风险、TS 类型推导友好
注意:返回时别漏写 .value

模板 B:reactive + toRefs(适合状态密集型组件)

export default {
  setup() {
    const state = reactive({
      loading: false,
      list: [] as Item[],
      keyword: ''
    });

    const search = async () => {
      state.loading = true;
      state.list = await api.search(state.keyword);
      state.loading = false;
    };

    return { ...toRefs(state), search };
  }
}

优点:状态聚合、减少变量声明、模板中直接用 list
注意:内部操作用 state.xxx,别解构!

高阶建议:结合 更清爽

如果你用 Vue 3.3+,直接上 :

import { ref } from 'vue'

const loading = ref(false)
const list = ref([])
const keyword = ref('')

const search = async () => {
  loading.value = true
  list.value = await api.search(keyword.value)
  loading.value = false
}

没有 return,没有 setup(),变量自动暴露——这才是 Vue 3 的终极舒适区。

最后说两句

Vue 3 的响应式系统很强大,但自由也意味着责任。
用对了,代码清爽如诗;用错了,bug 隐蔽如鬼。

记住三句话:

  1. 简单用 ref,复杂用 reactive
  2. 别混用,别嵌套,别解构裸对象
  3. 大组件,拆状态,抽 composable

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

使用Ai从零开发智慧水利态势感知大屏(开源)

作者 柳杉
2026年3月2日 18:09

基于 React + autofit.js 打造的全屏自适应数据可视化大屏系统


📌 系统概述

智慧水利态势感知系统 是一套专为水利防汛设计的实时监控与数据可视化平台。系统采用现代化的前端技术栈,结合智能自适应方案,能够在任意分辨率的大屏设备上完美呈现,为水利防汛指挥提供全方位的数据支撑。

🎯 核心特性

  • 全屏自适应:基于 autofit.js 实现任意屏幕完美适配
  • 实时数据监控:天气、降雨、河道水情实时更新
  • 地理信息可视化:河南省地图 + 区域预警标注
  • 交互式图表:ECharts 驱动的多维度数据展示
  • 响应式布局:左中右三栏式科技感界面
  • 动态视觉效果:渐变、光晕、动画营造沉浸体验

🎨 界面布局设计

ScreenShot_2026-03-02_164325_575.png 系统采用经典的三栏式大屏布局,设计分辨率为 1920×1080px

┌─────────────────────────────────────────────────────────┐
│                    智慧水利态势感知系统                     │  ← 头部导航
├──────────┬──────────────────────────┬──────────┤
│          │                          │          │
│  左侧面板 │      中心地图可视化       │  右侧面板  │
│          │                          │          │
│ 实时天气  │    河南省地图 + 预警面板   │ 河道水情  │
│ 降雨监控  │                          │ 水位变化  │
│ 降雨统计  │    底部模式切换控制       │ 趋势图表  │
│          │                          │          │
└──────────┴──────────────────────────┴──────────┘

区域功能划分

区域 宽度占比 主要功能
左侧面板 25% 实时天气情况、实时降雨情况、降雨统计
中心区域 50% 河南省地图、暴风雨预警、模式切换
右侧面板 25% 河道实时水情、水情变化、水位趋势

🛠️ 技术架构

核心技术栈

{
  "前端框架": "React 19.2.3",
  "构建工具": "Vite 7.2.4",
  "UI框架": "Tailwind CSS 4.1.17",
  "图表库": "ECharts 6.0.0 + echarts-for-react",
  "自适应方案": "autofit.js 3.2.8",
  "时间处理": "Day.js 1.11.19",
  "类型支持": "TypeScript 5.9.3",
  "图标库": "lucide-react 0.575.0"
}

项目结构

dashboard-autofit-setup/
├── src/
│   ├── components/          # 组件目录
│   │   ├── ui/             
│   │   │   └── Panel.tsx   # 通用面板容器
│   │   ├── Header.tsx      # 顶部导航栏
│   │   ├── LeftPanel.tsx   # 左侧数据面板
│   │   ├── CenterMap.tsx   # 中心地图区域
│   │   └── RightPanel.tsx  # 右侧数据面板
│   ├── hooks/              # 自定义 Hooks
│   │   ├── useData.ts      # 数据获取 Hook
│   │   └── useTime.ts      # 实时时间 Hook
│   ├── api/                # API 接口层
│   │   ├── index.ts        # API 统一导出
│   │   └── mock/          
│   │       └── data.ts     # Mock 数据
│   ├── utils/              # 工具函数
│   │   └── cn.ts           # 类名合并工具
│   ├── assets/             # 静态资源
│   ├── App.tsx             # 根组件
│   ├── autofit.d.ts        # autofit.js 类型声明
│   └── index.css           # 全局样式
└── package.json

🎯 核心功能详解

1️⃣ 全屏自适应解决方案

技术原理

系统采用 autofit.js 作为核心自适应引擎,通过 CSS3 Transform Scale 实现等比例缩放:

关键配置代码:

// App.tsx
useEffect(() => {
  autofit.init({
    dw: 1920,        // 设计稿宽度
    dh: 1080,        // 设计稿高度
    el: '.dashboard', // 缩放目标元素
    resize: true,     // 监听窗口变化
  });

  return () => {
    autofit.off();
  };
}, []);

CSS 样式支持:

/* index.css */
html, body {
  width: 100%;
  height: 100%;
  margin: 0;
  overflow: hidden;
}

#root {
  display: flex;
  justify-content: center;
  align-items: center;
}

.dashboard {
  transform-origin: center center;
  width: 1920px;
  height: 1080px;
}

工作流程

1. 页面加载 → autofit.js 获取窗口尺寸
2. 计算缩放比例 = min(窗口宽/1920, 窗口高/1080)
3. 对 .dashboard 应用 transform: scale(比例)
4. 监听窗口 resize 事件,动态调整

适配效果

  • ✅ 支持 1366×7684K 任意分辨率
  • ✅ 保持 16:9 宽高比不变形
  • ✅ 自动居中对齐,始终撑满屏幕
  • ✅ 无需编写媒体查询代码

2️⃣ 实时天气与降雨监控

功能模块

左侧面板 - 实时天气情况

<Panel title="实时天气情况">
  {/* 实时时间 + 天气状态 */}
  <div className="flex justify-between">
    <div>
      <Clock /> 实时时间
      {time} {date}
    </div>
    <div>
      <CloudRain /> 实时天气
      {weather.weather} {weather.temp}
    </div>
  </div>
  
  {/* 降雨概率趋势图 */}
  <ReactECharts option={rainProbOption} />
</Panel>

数据展示:

  • 📍 实时时钟(基于 Day.js 每秒更新)
  • 🌡️ 温度范围:17~28°C
  • ☀️ 天气状态:晴/多云/雨
  • 📊 24小时降雨概率曲线图

左侧面板 - 实时降雨情况

<Panel title="实时降雨情况">
  {/* 累计降雨量数据 */}
  <div className="flex space-x-6">
    <div>当日累计降雨量: {rainfall.daily} mm</div>
    <div>近3日累计降雨量: {rainfall.threeDay} mm</div>
  </div>
  
  {/* 逐小时降雨量柱状图 */}
  <ReactECharts option={rainfallHoursOption} />
</Panel>

技术亮点:

  • 📈 ECharts 渐变色柱状图
  • 🔄 自动刷新数据(通过 useData Hook)
  • 🎨 动态高亮当前时段

左侧面板 - 降雨统计

展示各行政区划的降雨数据表格,支持:

  • 📋 当日/三日/当月降雨量对比
  • 🔀 可切换流域、水库维度
  • 📜 虚拟滚动加载(处理大量数据)

3️⃣ 地理信息可视化

河南省地图

数据来源: DataV.GeoAtlas(阿里云地理数据服务)

// CenterMap.tsx
useEffect(() => {
  fetch('https://geo.datav.aliyun.com/areas_v3/bound/410000_full.json')
    .then(res => res.json())
    .then(data => {
      echarts.registerMap('henan', data);
      setGeoJson(data);
    });
}, []);

地图特性:

  • 🗺️ 支持缩放、拖拽交互
  • 📍 标注重点城市降雨量
  • ✨ 特效散点标记高风险区域
  • 🌊 动态波纹效果(effectScatter)

暴风雨预警面板

叠加在地图左下角的实时预警卡片:

<div className="absolute left-[6%] bottom-[10%] panel-bg">
  <div className="title">
    🌧️ 暴风雨预警
    <span className="orange-alert">Ⅲ级橙色预警</span>
  </div>
  
  <div className="content">
    <div>预警区域: 郑州 · 南阳市</div>
    <div>1小时最大雨强: 48mm</div>
    <div>未来3小时累计: 96mm</div>
    <div>风险上升: ▲ 32%</div>
    
    {/* 微型趋势柱状图 */}
    <div className="mini-chart">
      {[18, 26, 32, 40, 48, 38].map(v => (
        <div className="bar" style={{height: `${v/52*22}px`}} />
      ))}
    </div>
    
    <button>预案详情</button>
  </div>
</div>

设计亮点:

  • 🎯 橙色预警级别标识
  • 📊 实时数据大字号突出
  • 📈 渐变柱状图可视化趋势
  • 💡 操作建议 + 预案链接

4️⃣ 河道水情监控

右侧面板 - 河道实时水情

7列数据表格展示各站点详细信息:

站点 实时水位 实时雨量 设防水位 防洪高水位 警戒水位 保证水位
伊洛河 40m 20m 20m 20m 20m 20m
卫河 60m 20m 20m 20m 20m 20m

颜色标识:

  • 🟢 实时水位:青色加粗
  • 🟡 警戒水位:黄色
  • 🔴 保证水位:红色

右侧面板 - 河道水情变化

对比上一时段的水位变化:

{riverChanges.map(item => (
  <div className="grid-cols-4">
    <div>{item.station}</div>
    <div>{item.realtime}</div>
    <div>{item.previous}</div>
    <div>
      {item.trend === 'up' ? 
        <ArrowUp className="text-red-500" /> : 
        <ArrowDown className="text-green-500" />
      }
      {item.change}
    </div>
  </div>
))}

交互体验:

  • ⬆️ 上升趋势:红色箭头
  • ⬇️ 下降趋势:绿色箭头
  • 🔄 Hover 高亮当前行

右侧面板 - 水位实时变化趋势

折线图可视化:

series: [{
  type: 'line',
  smooth: true,
  data: waterTrends.data,
  markLine: {
    data: [
      { yAxis: waterTrends.safe, label: '保证水位' },
      { yAxis: waterTrends.warning, label: '警戒水位' }
    ]
  }
}]

技术细节:

  • 📏 警戒/保证水位标线
  • 🎨 渐变填充区域
  • 🔍 Tooltip 悬浮提示
  • 📊 支持流域切换(下拉选择器)

5️⃣ 自定义 Hooks 设计

useTime - 实时时钟

// hooks/useTime.ts
export function useTime() {
  const [time, setTime] = useState('');
  const [date, setDate] = useState('');

  useEffect(() => {
    const timer = setInterval(() => {
      const now = dayjs();
      setTime(now.format('HH:mm:ss'));
      setDate(now.format('YYYY-MM-DD dddd'));
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return { time, date };
}

应用场景:

  • 头部导航栏右侧时间显示
  • 左侧天气面板实时时钟

useData - 数据获取与缓存

// hooks/useData.ts
export function useData<T>(
  fetcher: () => Promise<T>, 
  initialData: T
) {
  const [data, setData] = useState(initialData);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetcher()
      .then(setData)
      .finally(() => setLoading(false));
  }, []);

  return { data, loading };
}

使用示例:

const { data: weather } = useData(
  () => getWeatherData(), 
  { temp: '', weather: '', rainProb: [] }
);

6️⃣ 响应式图表处理

ECharts 自适应问题

问题: autofit.js 的 transform: scale() 会导致 ECharts 图表内部不感知真实容器尺寸。

解决方案:

// CenterMap.tsx
useEffect(() => {
  const handleResize = () => {
    setTimeout(() => {
      if (chartRef.current) {
        const chart = chartRef.current.getEchartsInstance();
        chart?.resize();
      }
    }, 100);
  };

  window.addEventListener('resize', handleResize);
  handleResize(); // 初始化时调用

  return () => window.removeEventListener('resize', handleResize);
}, [geoJson]);

关键点:

  • ⏱️ 延迟 100ms 确保 transform 完成
  • 📐 手动调用 chart.resize() 更新尺寸
  • 🔄 监听 window resize 事件

🎨 视觉设计系统

色彩方案

:root {
  --color-primary: #00ffcc;        /* 主题青色 */
  --color-primary-dark: #00b38f;   /* 深青色 */
  --color-bg-dark: #020b18;        /* 深蓝黑背景 */
  --color-bg-panel: rgba(2,16,32,0.7); /* 面板半透明 */
  --color-border: #00e5ff;         /* 边框青色 */
}

视觉特效

1. 面板样式

.panel-bg {
  background: linear-gradient(
    180deg, 
    rgba(3,26,45,0.8) 0%, 
    rgba(2,14,25,0.8) 100%
  );
  border: 1px solid rgba(0,229,255,0.3);
  box-shadow: inset 0 0 20px rgba(0,229,255,0.1);
}

2. 渐变文字

.text-gradient {
  background: linear-gradient(180deg, #ffffff 0%, #00e5ff 100%);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}

3. 光晕背景

<div className="absolute inset-0">
  <div className="w-[800px] h-[800px] bg-[radial-gradient(
    circle_at_center,
    rgba(0,229,255,0.1)_0,
    transparent_60%
  )]" />
</div>

📦 部署与优化

构建配置

// package.json
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  }
}

生产构建

# 安装依赖
npm install

# 开发环境运行
npm run dev

# 生产构建(输出到 dist 目录)
npm run build

# 预览生产构建
npm run preview

性能优化建议

  1. 代码分割

    • Vite 自动进行 chunk 分割
    • React 组件懒加载(React.lazy)
  2. 图片资源

    • 使用 WebP 格式
    • 压缩静态资源
  3. ECharts 按需加载

    import { LineChart, BarChart } from 'echarts/charts';
    import { GridComponent } from 'echarts/components';
    echarts.use([LineChart, BarChart, GridComponent]);
    
  4. 数据请求优化

    • 实现请求缓存
    • 增量数据更新
    • WebSocket 实时推送

🚀 扩展方向

功能增强

  • 多屏联动:支持多大屏同步显示
  • 历史数据回放:时间轴拖拽查看历史
  • 告警推送:WebSocket 实时预警通知
  • 3D 地形:Three.js 立体地形可视化
  • AI 预测:机器学习预测降雨趋势

技术升级

  • TypeScript 完善:增强类型安全
  • 单元测试:Vitest 测试覆盖
  • Docker 部署:容器化部署方案
  • 微前端改造:qiankun/Module Federation
  • 性能监控:接入 Sentry/性能埋点

💡 技术亮点总结

技术点 实现方案 优势
屏幕自适应 autofit.js 零配置、高性能、兼容性强
数据可视化 ECharts 功能强大、交互丰富、文档完善
状态管理 React Hooks 轻量级、易维护、TypeScript 友好
样式方案 Tailwind CSS 原子化、响应式、开发效率高
构建工具 Vite 快速启动、热更新、现代化
时间处理 Day.js 轻量级、国际化、链式调用

📚 参考资料


👨‍💻 开发者信息

项目名称: 智慧水利态势感知系统
技术栈: React + TypeScript + Vite + autofit.js
设计分辨率: 1920×1080
开发时间: 2026年


🎉 结语

本系统综合运用了现代前端技术,实现了高性能、强交互、全适配的数据可视化大屏解决方案。通过 autofit.js 自适应引擎,完美解决了传统大屏开发中的分辨率适配难题,为水利防汛指挥提供了强有力的技术支撑。

核心价值:

  • ✅ 开箱即用的自适应方案
  • ✅ 模块化组件设计易于维护
  • ✅ 丰富的视觉效果提升体验
  • ✅ 完整的技术栈可复用性强

希望这套系统能够为智慧水利建设贡献一份力量!🚀


我放在公众号(柳杉前端) 回复 智慧水利态势感知大屏 获取源码

从高阶函数到 Hooks:React 如何减轻开发者的心智负担(含 Demo + ahooks 推荐)

作者 兆子龙
2026年3月2日 17:55

从高阶函数到 Hooks:React 如何减轻开发者的心智负担(含 Demo + ahooks 推荐)

对比 HOC/render props 与 Hooks,用具体 demo 展示「按功能组织、无 this、复用逻辑」的减负效果,并推荐 ahooks 库。


一、高阶函数时代的心智负担

在 Hooks 之前,React 里复用「带状态的逻辑」主要靠两类手段:高阶组件(HOC)render props。二者本质都是「高阶函数」——接收组件或函数,返回增强后的组件或新的渲染方式。它们能解决问题,但会带来明显的心智负担。

1. 嵌套地狱,难以追踪

多个 HOC 叠加时,组件树会变成一层套一层:withAuth(withTheme(withWindowSize(MyPage)))。DevTools 里看到的是一串 WithAuth(WithTheme(WithWindowSize(...)))数据从哪一层来、props 叫什么,都要一层层往上找,调试和阅读成本都很高。

2. this 与生命周期分散逻辑

Class 组件里,this 的绑定(bind 或类字段)是常见坑;同一块逻辑还经常被拆到 componentDidMountcomponentDidUpdate 两处,「根据 A 同步 B」 的代码散落在不同生命周期里,难以按「功能」理解。

3. 命名与透传的样板代码

HOC 要透传 props({...this.props}),还要小心 refdisplayName;render props 则要多写一层函数和命名(如 render={({ x, y }) => ...})。这些都是在解决「逻辑复用」时多出来的心智开销。

下面先用一个具体 demo 对比「HOC 写法」和「自定义 Hook 写法」,直观感受 Hooks 如何减负。


二、Demo 1:窗口尺寸 —— HOC 与 Hook 对比

需求:多个组件需要用到「当前窗口宽高」,并在 resize 时更新。

用 HOC 实现(心智负担大)

// 高阶组件:包装一层 Class,把 width/height 通过 props 注入
function withWindowSize(WrappedComponent) {
    return class WithWindowSize extends React.Component {
        state = { width: window.innerWidth, height: window.innerHeight };
        componentDidMount() {
            this.handler = () => this.setState({
                width: window.innerWidth,
                height: window.innerHeight,
            });
            window.addEventListener('resize', this.handler);
        }
        componentWillUnmount() {
            window.removeEventListener('resize', this.handler);
        }
        render() {
            return (
                <WrappedComponent
                    width={this.state.width}
                    height={this.state.height}
                    {...this.props}
                />
            );
        }
    };
}

// 使用:组件被包一层,DevTools 里多一个 WithWindowSize
const MyPanel = withWindowSize(function MyPanel({ width, height }) {
    return <div>当前宽度:{width}px,高度:{height}px</div>;
});

你要关心:HOC 的 displayNameref 透传(若需要)、以及「数据从哪个 HOC 来」。多个 HOC 叠加时,问题成倍增加。

用自定义 Hook 实现(减负)

// 自定义 Hook:按「一块逻辑」组织,无 Class、无 this
function useWindowSize() {
    const [size, setSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight,
    });
    useEffect(() => {
        const handler = () => setSize({
            width: window.innerWidth,
            height: window.innerHeight,
        });
        window.addEventListener('resize', handler);
        return () => window.removeEventListener('resize', handler);
    }, []);
    return size;
}

// 使用:直接调用,无包装、无嵌套
function MyPanel() {
    const { width, height } = useWindowSize();
    return <div>当前宽度:{width}px,高度:{height}px</div>;
}

减负体现:逻辑集中在 useWindowSize 里,按「功能」一块块组织;组件树扁平,没有多余的包装组件;没有 this,没有生命周期命名,读代码时「用到什么就调什么 Hook」。


三、Demo 2:请求数据 + loading —— 手写 vs ahooks useRequest

需求:请求用户列表,展示 loading、错误和重试。

手写 useEffect(容易漏依赖、重复逻辑)

function UserList() {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        let cancelled = false;
        setLoading(true);
        setError(null);
        fetch('/api/users')
            .then((res) => res.json())
            .then((json) => {
                if (!cancelled) setData(json);
            })
            .catch((e) => {
                if (!cancelled) setError(e);
            })
            .finally(() => {
                if (!cancelled) setLoading(false);
            });
        return () => { cancelled = true; };
    }, []);

    if (loading) return <div>加载中...</div>;
    if (error) return <div>错误:{error.message}</div>;
    return <ul>{data?.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

你要自己处理:竞态取消、loading/error 状态、重试逻辑若再加一层,代码更长、心智负担更大。

用 ahooks 的 useRequest(减负)

import { useRequest } from 'ahooks';

function UserList() {
    const { data, loading, error, refresh } = useRequest(() =>
        fetch('/api/users').then((res) => res.json())
    );

    if (loading) return <div>加载中...</div>;
    if (error) return <div>错误:{error.message} <button onClick={refresh}>重试</button></div>;
    return (
        <ul>
            {data?.map((u) => <li key={u.id}>{u.name}</li>)}
            <button onClick={refresh}>刷新</button>
        </ul>
    );
}

减负体现竞态、loading、error、重试 都由 useRequest 管,你只关心「发什么请求」和「怎么渲染」;代码更短,逻辑更清晰,心智负担明显下降。


四、Demo 3:防抖输入 —— 手写 vs ahooks useDebounce

需求:搜索框输入防抖,仅在实际停顿后再请求。

手写(要管定时器、清理、依赖)

function SearchBox() {
    const [keyword, setKeyword] = useState('');
    const [debouncedKeyword, setDebouncedKeyword] = useState('');

    useEffect(() => {
        const timer = setTimeout(() => setDebouncedKeyword(keyword), 300);
        return () => clearTimeout(timer);
    }, [keyword]);

    useEffect(() => {
        if (!debouncedKeyword) return;
        fetch(`/api/search?q=${debouncedKeyword}`).then(/* ... */);
    }, [debouncedKeyword]);

    return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
}

你要自己保证:防抖时间、清理、以及「防抖后的值」和「请求」的依赖关系正确。

用 ahooks 的 useDebounce(减负)

import { useDebounce } from 'ahooks';

function SearchBox() {
    const [keyword, setKeyword] = useState('');
    const debouncedKeyword = useDebounce(keyword, { wait: 300 });

    useEffect(() => {
        if (!debouncedKeyword) return;
        fetch(`/api/search?q=${debouncedKeyword}`).then(/* ... */);
    }, [debouncedKeyword]);

    return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
}

减负体现:防抖逻辑交给 useDebounce,你只关心「用防抖后的值做什么」;少写定时器、少操心清理,心智负担更小。


五、React 如何用 Hooks 减轻心智负担(小结三点)

  1. 按功能组织,而非按生命周期
    同一块逻辑(如「窗口尺寸」「请求用户」)收拢在一个 Hook 里,相关代码在一起,读起来是「这个组件用了哪些能力」,而不是「mount 里干了啥、update 里又干了啥」。

  2. 无 this,闭包清晰
    函数组件 + Hooks 没有 this,state 和更新函数都来自 useState 等 API,依赖关系写在 Hook 的依赖数组里,减少「this 指向错了」「忘了 bind」这类问题。

  3. 复用即「调用 Hook」
    复用带状态的逻辑不再依赖 HOC 或 render props 的层层包装,直接「调用自定义 Hook」即可,组件树扁平、数据来源一目了然。

在此基础上,用好现成的 Hooks 库(如 ahooks)可以进一步减少「自己管请求、防抖、节流、缓存」的心智负担,把精力放在业务 UI 和交互上。


六、推荐 ahooks:为业务而生的 Hooks 库

ahooks 是阿里开源的 React Hooks 库,目标是做 Hooks 领域的「lodash」——稳定、可长期依赖。它用 TypeScript 编写,提供完整类型,且针对闭包、SSR 等做了处理,适合在真实项目里直接使用。

安装

npm install ahooks
# 或 pnpm add ahooks / yarn add ahooks

常用 Hooks 一览

场景 Hook 作用简述
异步请求 useRequest 自动/手动请求、loading、重试、轮询、缓存
防抖 / 节流 useDebounce / useThrottle 值或函数的防抖/节流
状态与存储 useLocalStorageState 持久化到 localStorage
DOM / 尺寸 useSizeuseScroll 元素尺寸、滚动位置
生命周期相关 useUnmountuseUpdateEffect 仅卸载时执行、仅更新时执行

与本文 demo 的对应关系

  • Demo 2 用了 useRequest,可直接替换手写的 useEffect + fetch,并享受重试、轮询、缓存等能力。
  • Demo 3 用了 useDebounce,把「防抖后的值」从状态和定时器里抽离出来,代码更短、更稳。

更多 API 和用法见官网:ahooks.js.org/zh-CN


总结

  • 高阶函数(HOC/render props) 能复用逻辑,但带来嵌套、this、生命周期分散等心智负担。
  • Hooks 通过「按功能组织、无 this、复用即调 Hook」减轻负担;用 自定义 Hook 替代 HOC,组件树更扁平、数据流更清晰。
  • 文中用 窗口尺寸、请求数据、防抖输入 三个 demo 对比手写/HOC 与 Hook/ahooks 的写法,直观看到 Hooks 的优势。
  • ahooks 提供 useRequestuseDebounce 等常用能力,建议在项目中直接使用,进一步减少重复逻辑与心智负担。

若对你有用,欢迎点赞、收藏;有更好的 Hooks 实践或 ahooks 用法也欢迎在评论区分享。

前端老哥的救命稻草:用 Obsidian 搞定 Claude Code 的「金鱼记忆」

作者 jerrywus
2026年3月2日 17:51

写在前面

前端开发中常见这些问题:

  • 每次写代码都要翻一遍同样的规范
  • 踩过的坑,过段时间又忘了
  • 项目规范写在文档里,但开发时根本想不起来
  • 想沉淀经验,但不知道从哪下手
  • Claude Code 有时候不按规范写(上下文丢失)

这篇文章讲讲我们怎么用 Obsidian + Claude Code 来解决这些问题。

整体方案

三层结构:

Memory 文件(200行左右)→ Smart Context Skill → Obsidian docs/

工作流程很简单:

  1. 你给 Claude Code 一个编程任务
  2. Smart Context 自动触发
  3. 自动查 Obsidian 里的相关规范和踩坑记录
  4. 带着上下文开始写代码

步骤一:创建 Obsidian 文档结构

在项目根目录建 docs/ 文件夹:

docs/
├── 00-索引.md
├── 01-快速开始.md
├── 02-开发规范/
│   ├── index.md
│   ├── API规范.md
│   ├── 组件使用.md
│   ├── 命名约定.md
│   └── 页面开发.md
├── 03-架构设计/
│   ├── index.md
│   ├── 目录结构.md
│   └── 分包策略.md
├── 04-开发笔记/
│   ├── index.md
│   └── 踩坑记录.md
└── 05-Claude相关/
    ├── index.md
    └── 规则文件说明.md

示例:踩坑记录

docs/04-开发笔记/踩坑记录.md

# 踩坑记录

## Taro 相关

### scroll-view 下拉加载不触发
**问题**: @scrolltolower 事件不触发
**原因**: scroll-view 高度未设置
**解决**: 设置 scroll-y 和 height: 100vh

### 内联 SVG 不支持
**问题**: svg 标签不渲染
**解决**: 使用图片 URL 或 IconFont

示例:API 规范

docs/02-开发规范/API规范.md

# API 开发规范

## 函数命名
- query: 查询/获取
- add: 新增
- edit: 编辑
- delete: 删除
- toggle: 切换状态
- do: 执行操作

## 标准模式
1. try/catch 包裹
2. 检查 code === EResponseCode.Succeed
3. 从 context 提取数据
4. catch 中使用 getHttpErrorMessage

步骤二:Memory 文件

这个是claudecode自带的,/memory去开启即可

文件位置:

~/.claude/projects/-项目名-/memory/MEMORY.md

内容首次微调到精简到 50 行以内 (因为后续cc会自动往里面加记忆):

# Project Memory

## 知识库架构

> Memory(200行核心)→ Obsidian docs/(完整知识)

| 层级 | 存储 | 用途 |
|------|------|------|
| Memory | 核心规范摘要 | 始终加载 |
| Obsidian | 完整文档/踩坑记录 | 检索使用 |

## 核心规范

- **页面**: SafeLayout 根容器,列表 graybg/详情 whitebg
- **API**: query/add/edit/delete/toggle/do + try/catch
- **组件**: 优先 src/components/,禁 SVG 用 IconFont

## 常见避坑

1. scroll-view: 设 scroll-y + height
2. 小程序禁 SVG: 用图片 URL
3. NutUI 样式: 查 auto-import
4. ref template 不需 .value

## Obsidian 检索

```bash
obsidian search query=页面开发 # 搜索
grep -r "xxx" docs/ # 失败时才用 Grep
知识 文件
踩坑 docs/04-开发笔记/踩坑记录.md
API docs/02-开发规范/API规范.md
页面 docs/02-开发规范/页面开发.md

触发条件

编程任务自动检索:新增/修复/重构/询问"怎么做"/业务模块

## 步骤三:创建 Smart Context Skill

在项目 `.claude/skills/` 目录下创建:

.claude/skills/smart-context/
└── skill.md

内容:

---
name: smart-context
description: |
  智能上下文增强技能。自动检索本项目 Obsidian 知识库(docs/)中的项目规范、踩坑记录。
  触发条件:(1) 实现新功能/创建页面/添加API (2) 修复bug/解决报错 (3) 重构代码
  (4) 询问"怎么做" (5) 提到业务模块(提货/结算/销售/会员等)。
  优先使用 obsidian-cli 搜索 Obsidian 文档,失败才使用 Grep。
---

# Smart Context - 智能上下文增强

## 核心原则

1. 以 Obsidian 为知识库,obsidian-cli 为检索工具
2. 当用户说"把这个加入知识库"时,优先使用 obsidian-cli 增加

✅ obsidian search query=文档关键词 ❌ grep -r "scroll-view" docs/


## 工作流程

### 第一步:分析任务意图
- 任务类型:新增/修改/修复/查询
- 业务模块:提货/结算/销售/会员
- 技术领域:API/页面/组件

### 第二步:检索 Obsidian
```bash
obsidian search query="{关键词}"

第三步:按需读取

obsidian read path=docs/04-开发笔记/踩坑记录.md

第四步:注入上下文执行

检索关键词

任务 搜索词
创建页面 页面开发、SafeLayout、列表页
添加 API API规范、query、try catch
修复报错 踩坑、{报错关键词}
提货相关 提货、pickup

示例

用户输入:帮我创建一个退款订单列表页面

自动执行:

  1. 分析:创建页面,销售/退款
  2. 搜索:obsidian search query=页面开发
  3. 搜索:obsidian search query=列表页
  4. 读取踩坑记录
  5. 注入上下文,开始实现

注意事项

  • 必须使用 obsidian-cli,禁止 Grep 搜索 docs/
  • 按需读取,不要整个文件加载
  • 实现前先查踩坑记录,避免重复踩坑

编码实测

配置完成后,实际效果长这样:

左边 Claude Code 正在工作,右边 Obsidian 里的搜索结果同步显示。它自动检索到了相关规范,比如页面开发、SafeLayout 这些关键信息。

再看另一个角度:

左边继续从 Obsidian 拉取踩坑记录,右边代码已经写上了。Smart Context 把规范和避坑信息注入上下文,Claude 直接沿着正确方向写,不需要你中途打断去纠正。


步骤四:验证和使用

验证配置

重启 Claude Code,然后测试:

帮我创建一个订单列表页面

观察 Claude 的行为:

  1. 自动触发 Smart Context
  2. 使用 obsidian search 搜索相关文档
  3. 读取关键片段
  4. 注入上下文后开始实现

添加新知识

遇到新踩坑时,直接告诉 Claude:

把这个加入知识库:Taro 项目上传图片时,如果使用本地路径不显示,
需要使用 require() 或者用 COS 托管的图片 URL

Claude 会自动:

  1. 使用 obsidian-cli 找到踩坑记录文件
  2. 追加新的踩坑内容
  3. 可选的,同步更新 Memory 文件

常见问题

Q0: 如何启用obsidian-cli

在obsidian软件设置->关于-> 打开"允许命令行和obsidian交互“, 然后重启cc会话即可。

同时安装一下mcp服务:

mcp-obsidian.org/install/

再安装obsidian skills

请打开:obsidian skills

Q1:为什么要用 obsidian-cli 而不是 Grep?

  • obsidian-cli 是 Obsidian MCP 工具,专门用于搜索和操作 Obsidian 文档,速度极快
  • Grep 搜索会破坏 Obsidian 的双向链接和知识图谱
  • obsidian-cli 支持更智能的搜索

Q2:Memory 文件太长怎么办?

  • 减少memory篇幅,只放核心规范(约 50-200 行)
  • 详细内容通过双向链接指向 Obsidian 文档
  • 用表格和列表,减少段落

Q3:Obsidian 需要手动打开吗?

不需要。Claude Code 通过 obsidian-cli MCP 工具直接操作,Obsidian 可以关闭,只是一个存储软件,实际cc调用效果如下图:

比如我让他修改文档


总结

配置完成后,你得到一个自动化的知识增强系统:

功能 实现方式
记忆增强 Obsidian 持久存储 + Memory 始终加载
规范约束 Smart Context 自动检索
避坑提醒 踩坑记录 + 自动查询
知识更新 自然语言告诉 Claude "加入知识库"

核心思路:让 Claude Code 在每次编程时自动检索相关规范,而不是靠人工记忆。

关于obsidian更多用法,请关注后续写新的文章~

❌
❌