阅读视图

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

尤雨溪力荐!Vue3 专属!100+ 动效组件!

今年 1 月,尤雨溪在推特上亲自安利了一款「Vue 专属动画库」——Motion Vue

它基于 Framer Motion 的语法,让 Vue 3 开发者也能写出丝滑高性能的动画。

然而官网长期缺案例、少更新,一度被贴上“可能烂尾”的标签。

就在大家快要遗忘之际,Motion 官方突然放了个大招

正式把 Vue 纳入第一梯队,与 ReactJavaScript 并列,成为官方支持的三大平台之一;

并且把原来只在 React 生态才有的 100+ 经典动效示例几乎 1:1 迁移到了 Vue 端

除此之外,官方还补齐了「开发者工具链」——VS Code 可视化编辑器LLM 文档AI 生成 CSS 弹性曲线……一次性打包奉上。

Motion 官方支持平台一览

  • JavaScript
  • React
  • Vue 3(新增)

你还可以在 FramerFigmaSquarespaceWordPressWebflow 等平台通过官方指南直接调用 Motion

100+ 动效组件速览(Vue 已可用)

  • Fade In / Out
    最经典的淡入淡出,一行 :initial + :animate 就能启动。

  • Cursor
    是一个为 Vue 打造的创意光标组件。借助它,你可以快速、轻松地构建自定义光标或跟随鼠标的动画效果。

  • Transform
    创建一个运动值,它将一个或多个运动值的输出进行转换后作为新的结果。

  • Ticker
    Ticker 让你能够快速、轻松地构建无限滚动的跑马灯式动画。

    录屏2025-08-14 23.25.36.gif

  • Typewriter
    打字机效果,可配合 splitText 拆字爆炸。

  • Progress
    进度条加载动画。

更多优秀动画案例请移步官网

  • https://examples.motion.dev/vue

开发工具链「三连击」

  • VS Code 插件
    拖拽调曲线实时预览AI 生成弹簧,动画调试从未如此直观。
    bezier-editor

  • LLM 友好文档
    Cursor / Claude / Windsurf 直接 @motion,API 永不落后。

    文档https://llms.motion.dev

  • Vibe Coding
    FramerFigmav0 已内置 Motion 模板,设计师拖拽 → 开发者导出 Vue

    文档https://motion.dev/docs/tools-quick-start

30 秒上手

npm i motion-v
<motion.div
  :initial="{ backgroundColor: 'rgb(0, 255, 0)', opacity: 0 }"
  :whileInView="{ backgroundColor: 'rgb(255, 0, 0)', opacity: 1 }"
/>

Vue 3 动效生态再添一员猛将

特点 推荐指数
Motion Vue 官方支持、100+ 示例、AI 工具链 ⭐⭐⭐⭐⭐
GSAP 老牌全能、复杂时间轴 ⭐⭐⭐⭐
Anime.js 轻量、易上手 ⭐⭐⭐
Inspira UI 纯 Vue 组件、开箱即用 ⭐⭐⭐⭐
Uiverse.io 社区模板丰富 ⭐⭐⭐

完整 10 款 Vue3 动效库横向对比,可戳下方原文
Vue3 生态:10 个最强大的动效组件库!

写在最后

从**「可能烂尾」「官方亲儿子」**,Motion Vue 只用了一次大更新。
组件全、性能好、工具链完善,还有尤雨溪背书——Vue 3 动画选型,真的可以把它放进第一梯队了。

立即体验

  • 文档 + 100+ 示例https://examples.motion.dev/vue
  • GitHub 仓库https://github.com/motiondivision/motion

Vue3 超强“积木”组件!5 分钟搞定可交互 3D 机房蓝图!

过去,我们用 ExcelVisioCAD 画机房平面图:

  • 改一台机位重新截图重新标注 → 再发邮件。
    如今,一个 不到 200 KB 的 Vue3 组件——Grid Plan v2.0——把“画蓝图”卷进了浏览器,还把图纸 直接立了起来

Grid Plan 是什么?

一句话:
Grid Plan 是一个轻量级 Vue3 组件,用来实时绘制并交互式管理“网格蓝图”——从房间、机柜到数据中心,都能秒级可视化。

“如果 2D 是工程师的语言,那么 3D 就是决策者的母语。”
——Graphieros 团队在 v2.0 发布公告里这样说。

亮点 一句话说明
2D/3D 一键切换 同一套数据,实时生成可旋转、缩放的 3D 视图
完全可插拔 8 个插槽 + BEM 类名,样式和 UI 想怎么改就怎么改
事件驱动 增删改选全部通过事件抛回,业务逻辑零侵入
TypeScript 零配置 所有类型开箱即用,IDE 自动补全

30 秒跑通 Demo

  • 克隆 & 启动
git clone https://github.com/graphieros/grid-plan.git
cd grid-plan
pnpm i          # 或 npm / yarn
pnpm dev        # 本地 5173,带完整 3D 示例
  • 浏览器打开

  • 左侧:可拖拽的组件菜单(服务器 / 电源 / 路由器…)

  • 右侧:实时 3D 机房,鼠标旋转、缩放

  • 任意改动 → 2D3D 同步更新

录屏2025-08-17 22.20.00.gif

核心 API 速查表

维度 关键字段 说明
组件类型 availableTypes 可放置的组件类型清单(含图标、颜色等)
已放置 placedItems 已摆放在蓝图上的组件实例(坐标 + 尺寸)
3D 开关 config.showGrid3d 是否启用 3D 视图
布局 config.grid3dPosition 3D 视图位于 2D 网格的上方或下方
事件 @change / @delete / @select / @created 组件增删改选时触发

把 3D 机房嵌进你的后台

全局注册(main.js)

import { GridPlan } from 'grid-plan'
app.component('GridPlan', GridPlan)

业务页面

<template>
  <GridPlan
    :availableTypes="types"
    :placedItems="layout"
    :config="{ showGrid3d: true, grid3dPosition: 'top' }"
    @change="saveLayout"
    @delete="removeDevice"
  >
    <!-- 自定义清单 -->
    <template #inventory="{ item, deleteItem }">
      <DeviceCard :device="item" @remove="deleteItem" />
    </template>
  </GridPlan>
</template>

<script setup lang="ts">
import type { GridPlanItem, GridPlanItemType } from 'grid-plan'

const types = ref<GridPlanItemType[]>([
  { typeId: 1, description: 'Dell R750', color: '#007DB8', icon: 'server' },
  { typeId: 2, description: 'APC UPS',   color: '#FF6F00', icon: 'power' }
])

const layout = ref<GridPlanItem[]>([])   // 从接口拉取
const saveLayout = (item: GridPlanItem) => api.save(item)
const removeDevice = (item: GridPlanItem) => api.delete(item.id)
</script>

录屏2025-08-17 22.21.07.gif

3D 视图能做什么?

功能 体验亮点
实时同步 2D 改尺寸 → 3D 立即拉伸;3D 拖拽 → 2D 坐标实时变
零额外建模 不需要 glTF/obj,任何矩形组件自动生成立方体
性能怪兽 1000+ 方块 60 FPS,WebGL 局部更新
旋转缩放 鼠标拖拽旋转 / 滚轮缩放 / 触控板双指

样式随便换(Tailwind 示例)

Grid Plan 不带任何 CSS,只暴露类名:

.grid-plan-grid-3d {
  @apply bg-slate-900;
  perspective: 1200px;
}
.grid-plan-inventory__body {
  @apply p-4 bg-slate-800 text-white;
}

写在最后

Excel3D,只需一个 <GridPlan> 标签。
下一次领导再催“机房搬迁方案”,别再熬夜截图 PPT——直接把浏览器投上大屏,让服务器在 3D 里“长”出来!

  • Github 地址https://github.com/graphieros/grid-plan
  • 官网地址https://grid-plan.graphieros.com/

尤雨溪力荐!Vue3 生态最强大的 14 个 UI 组件库!

Vue3 官网 的「EcosystemUI Components」菜单里,官方维护了一份精挑细选的组件库清单:全部基于 Vue3TypeScript 优先、活跃维护、社区认可。

Nuxt UI

  • 亮点Nuxt 3/4 官方旗舰,Reka UI + Tailwind CSS,SSR 满分,全量无障碍、RTL & 暗黑模式、Figma 设计稿
  • 使用场景:需要 SSRSEOi18n 的企业级中后台、营销官网
  • GitHub Stars5.2 k
  • GitHubhttps://github.com/nuxt/ui
  • 官网https://ui.nuxt.com

PrimeVue

  • 亮点Styled / Unstyled 双模式,30+ 主题 + 可视化主题工厂,80+ 复杂组件
  • 使用场景BPMERP、数据密集型后台
  • GitHub Stars13.2 k
  • GitHubhttps://github.com/primefaces/primevue
  • 官网https://primevue.org

Quasar

  • 亮点:同一套代码编译成 SPAPWASSR移动端ElectronCapacitorCLI 工程化完善
  • 使用场景:需要同时交付 WebiOSAndroid 的创业项目
  • GitHub Stars26.8 k
  • GitHubhttps://github.com/quasarframework/quasar
  • 官网https://quasar.dev

Vuetify 3

  • 亮点Material You 动态主题,无障碍满分,636 k/周 npm 下载量,生态最成熟
  • 使用场景:政企、SaaS、严格遵循 Material Design
  • GitHub Stars40.7 k
  • GitHubhttps://github.com/vuetifyjs/vuetify
  • 官网https://vuetifyjs.com

Reka UI

  • 亮点Vue 官方支持的 Headless 内核,零样式、100 % 可定制、TypeScript 友好
  • 使用场景:自建 Design System、需要极致可访问性
  • GitHub Stars5.4 k
  • GitHubhttps://github.com/unovue/reka-ui
  • 官网https://reka-ui.com

Shadcn-vue

  • 亮点:复制粘贴即可用,基于 Reka UI + Tailwind CSS,极客最爱
  • 使用场景:追求最小 bundle、只想要用到的组件
  • GitHub Stars8 k
  • GitHubhttps://github.com/radix-vue/shadcn-vue
  • 官网https://www.shadcn-vue.com

Naive UI

  • 亮点TypeScript 极致友好,主题系统丝滑,中文文档、社区活跃
  • 使用场景中文后台仪表盘SaaS
  • GitHub Stars17.6 k
  • GitHubhttps://github.com/tusen-ai/naive-ui
  • 官网https://www.naiveui.com

Volt UI

  • 亮点PrimeVue Headless + Tailwind 原子类,轻量可摇树
  • 使用场景:喜欢 PrimeVue 功能但想完全自定义样式
  • GitHub Stars:-(跟随 PrimeVue 版本)
  • GitHubhttps://github.com/primefaces/primevue/tree/master/apps/volt/volt
  • 官网https://volt.primevue.org/

Daisy UI

  • 亮点Tailwind CSS 插件,一行类名生成组件,452 k/周下载量
  • 使用场景:原型页、营销落地页、快速 MVP
  • GitHub Stars38.4 k
  • GitHubhttps://github.com/saadeghi/daisyui
  • 官网https://daisyui.com

Flowbite Vue

  • 亮点:与 Flowbite Figma 套件 1:1 对齐,27 个常用组件
  • 使用场景:设计-开发协作紧密、像素级还原
  • GitHub Stars8.8 k
  • GitHubhttps://github.com/themesberg/flowbite-vue
  • 官网https://flowbite-vue.com

Element Plus

  • 亮点:饿了么团队维护,Vue2 无缝迁移,中文生态最完善
  • 使用场景:Vue2 老项目升级、后台 CRUD、权限系统
  • GitHub Stars26.4 k
  • GitHubhttps://github.com/element-plus/element-plus
  • 官网https://element-plus.org

Ant Design Vue

  • 亮点Ant Design 完整设计体系,ProComponents 加持复杂中后台
  • 使用场景:阿里系产品、国际化 SaaS
  • GitHub Stars21.1 k
  • GitHubhttps://github.com/vueComponent/ant-design-vue
  • 官网https://antdv.com

Ark UI

  • 亮点:更轻更快的 Headless 组件库,可 tree-shaking
  • 使用场景:与 Reka UI 类似,但包体更小、API 更简化
  • GitHub Stars4.6 k
  • GitHubhttps://github.com/chakra-ui/ark/tree/main/packages/vue
  • 官网https://ark-ui.com

Vuestic UI

  • 亮点Epicmax 出品,提供即装即用的前端组件,配置简单,能够显著加快响应式、高性能 Web 界面的开发速度,42 个业务组件
  • 使用场景后台系统营销落地页
  • GitHub Stars3.6 k
  • GitHubhttps://github.com/epicmaxco/vuestic-ui
  • 官网https://vuestic.dev

🏁 一页看全

场景需求 首推库
SSR / 静态站点 Nuxt UI
跨端(Web+App) Quasar
纯 Material Design Vuetify
Headless 自建设计系统 Reka UI / Ark UI
复制即用 Shadcn-vue
中文生态 & 文档 Naive UI / Element Plus
阿里系 / 国际化 Ant Design Vue
原型 & 快速 MVP Daisy UI

收藏本文,按图索骥,Vue3 项目选型再也不纠结!

【AI解读源码系列】ant design mobile——Divider分割线

前言

笔者目前业务主要围绕ant design mobile组件库来交付H5前端工作。

故此出此专栏来解读每一个组件是如何实现的。

本文基于AI来解读Divider组件。

文档链接:

mobile.ant.design/zh/componen…

源码:

github.com/ant-design/…

设计prompt

解读antd-mobile divider组件,结构化思维输出整个组件实现流程,按步骤提供关键代码解读 

// 附组件代码

实现步骤


一、属性设计与props合并

Step 1:定义参数和默认值

  • 参数支持:
    • contentPosition: 'left' | 'right' | 'center' —— 分割线中内容的位置(居左/居右/居中)
    • direction: 'horizontal' | 'vertical' —— 分割线方向
    • children —— 可作为分割线中显示内容
    • 继承 NativeProps,支持 className、style、aria等原生属性

关键代码:

const defaultProps = {
  contentPosition: 'center',
  direction: 'horizontal',
}

合并props:

const props = mergeProps(defaultProps, p)
  • 保证所有默认配置被应用,用户传入的参数可覆盖默认值。

二、分割线主结构渲染

Step 2:生成className,确定样式与分布

  • 使用 classnames 工具拼接动态类名,实现样式变化:
    • adm-divider // 基础样式
    • adm-divider-horizontaladm-divider-vertical // 方向
    • adm-divider-center/left/right // 内容位置

关键代码:

className={classNames(
  classPrefix,
  `${classPrefix}-${props.direction}`,
  `${classPrefix}-${props.contentPosition}`
)}
  • 外层是 div,如有 children 内容,包裹一层 ${classPrefix}-content

主结构:

return withNativeProps(
  props,
  <div className={...}>
    {props.children && (
      <div className={`${classPrefix}-content`}>{props.children}</div>
    )}
  </div>
)
  • withNativeProps 是增强原生属性工具,保证你传入的 style/className/aria-xxx 都能自动透传到主div

三、实现分割线效果的关键css

Step 3:CSS实现分割线和内容排布

横向分割线 .adm-divider-horizontal

.adm-divider-horizontal {
  display: flex;
  align-items: center;
  // 线样式
  &::before, &::after {
    flex: auto;
    display: block;
    content: '';
    border-style: inherit;
    border-color: inherit;
    border-width: 1px 0 0;
  }
  .adm-divider-content {
    flex: none;
    padding: 0 16px;
  }
  // 内容位置,限制一侧最大宽度
  .adm-divider-left&::before { max-width: 10%; }
  .adm-divider-right&::after { max-width: 10%; }
}
  • 原理是左右两个伪元素画线,中间内容居左/右/中灵活
  • 线用 border-width: 1px 0 0,就是上方细线
  • 内容用 .adm-divider-content 居中,用flex布局约束

纵向分割线 .adm-divider-vertical

.adm-divider-vertical {
  display: inline-block;
  height: 0.9em;
  margin: 0 16px;
  border-left: 1px solid var(--adm-color-border);
}
  • 细竖线,用 border-left 实现
  • 一般用作行内分割,eg:文字间竖分割

四、内容位置排布实现逻辑

Step 4:维持内容位置left/right/center的切换

  • contentPosition="left",before变短,内容贴左,after拉满剩余
  • right,after变短,内容贴右
  • center,左右线长度一致,内容居中

五、NativeProps原生属性增强

Step 5:保证 className/style/aria-xxx/data-xxx 全部透传出来

return withNativeProps(
  props,
  <div ...>{...}</div>
)
  • 这让 divider 能像原生DOM一样自定义风格和可访问性

以上就是笔者基于AI返回的解读信息稍加了一些补充和修改,结合起来看源码提效真是太多了,对于前端本身就是基于视图所完成编码,因此把组件逻辑层交给AI来解读太适合不过了。

希望对大家有所帮助,共同学习源码。

electron-vite_20配置依赖包运行时区外部加载commonjsExternals_vite-plugin-commonjs-externals

Electron 项目中使用 electron-vite(Vite的Electron 构建工具)时的配置文件,告诉 Vite哪些依赖(包)在打包时不用一起打包,而是运行时让Electron去外部加载;

依赖插件vite-plugin-commonjs-externals

1.项目中版本package.json

"devDependencies":{
"vite-plugin-commonjs-externals": "^0.1.4",
}

2.安装

npm i vite-plugin-commonjs-externals -D

3.引入electron.vite.config.ts 文件

import commonjsExternals from 'vite-plugin-commonjs-externals';

4.使用electron.vite.config.ts

const commonjsPackages = [
  'dingrtc-electron-sdk',
] as const;

export default defineConfig({
main:{},
preload:{},
renderer:{
plugins:[
commonjsExternals({ externals: commonjsPackages }),
]
},
});
  1. 编译后路径
// win电脑右键图标=>属性=>打开文件所在的位置;
// mac公司没有mac有的兄弟可以评论区告诉我怎么弄;
resources/app.asar.unpacked/node_modules/dingrtc-electron-sdk

🔥 uView Pro 全新升级来啦!一行配置,解锁 uView Pro 全局 TS 类型提示与校验

一、前言

TypeScript 作为 Vue3 生态的主流选择,其强大的类型推断和智能提示极大提升了开发效率。而 uView Pro 作为 uni-app 生态下新生的 Vue3 组件库,我们一直致力于为开发者带来更高效、更智能的开发体验。

为了让大家在使用 uView Pro 时也能享受到完善的类型提示和校验,所以近期,uView Pro 新版本正式支持 Volar 插件下的全局组件类型提示与类型校验,大大的提升了 TypeScript 项目的开发效率和代码质量。

接下来,本文将详细介绍 uView Pro 如何集成 Volar 类型提示、配置 tsconfig.json、适配不同项目结构(CLI 与 HBuilderX),并结合实际代码示例,帮助大家快速上手并了解提示和校验原理。

1.gif

二、Volar 与 TypeScript 类型提示

1. 什么是 Volar?

VolarVue 官方推荐的 VSCode 插件,专为 Vue3 + TypeScript 项目设计,提供了更强大的类型推断、智能提示、错误校验和 IDE 体验。相比 VeturVolar<script setup>TSX、全局类型等支持更完善。

2. 为什么要全局类型提示?

Vue3 + TS 项目中,组件库的全局类型提示可以让你在模板中直接获得属性、事件、插槽等智能补全和类型校验,极大减少低级错误和查文档的时间。

三、uView Pro 新版本 TS 类型支持方案

1. 类型声明文件的作用

目前,uView Pro 新版本内置了完整的 TypeScript 类型声明(uview-pro/types),覆盖所有全局组件、props、工具函数等。通过正确配置,开发者可在 IDE 中获得如下编码体验:

  • 组件标签、属性、事件的智能补全
  • 属性类型校验与错误提示
  • 代码跳转与类型追踪
  • 插槽、ref、emit 等 TS 语法无缝支持

2. 配置 tsconfig.json

对于使用 CLI(如 vite、webpack、uni-app cli)方式安装 uView Pro 的项目,需要在 tsconfig.json 中通过 compilerOptions.types 指定类型声明:

// tsconfig.json
{
  "compilerOptions": {
    "types": ["uview-pro/types"]
  }
}

这样,Volar 会自动加载 uView Pro 的全局组件类型声明,无需手动引入。

⚠️ 注意:HBuilderX 项目暂不支持此配置,目前只有 CLI 项目(npm 安装)需手动配置。

3. uni_modules 安装的特殊说明

如果你通过 uni_modules 方式安装 uView Pro(如 uni_modules/uview-pro),则无需任何额外配置,Volar 会自动识别类型声明。

如仍无类型提示与校验,请在 tsconfig.json 中添加以下配置:

// tsconfig.json
{
  "compilerOptions": {
    "typeRoots": ["./src/uni_modules/uview-pro/types"]
  }
}

四、实际开发体验与代码示例

1. 组件类型提示效果

配置完成后,在 .vue 文件模板中输入 <u-,即可获得所有 uView Pro 组件的智能补全:

<template>
  <u-button type="primary" @click="onClick">按钮</u-button>
</template>

<script setup lang="ts">
  function onClick() {
    // ...
  }
</script>

2.png

3.png

4.png

此时,Volar 会自动提示 type@click 等属性和事件类型,错误用法会有红色波浪线提示。

2. 属性类型校验

如果你写错了属性名或类型,Volar 会即时报错:

<u-button type="test" />
<!-- type 不存在 test,Volar 会提示 -->

1.png

3. 事件与插槽类型推断

<u-modal v-model="show" @confirm="onConfirm">
  <template #default>
    <div>内容</div>
  </template>
</u-modal>

Volar 能自动推断 show 的类型、onConfirm 的参数类型,以及插槽内容类型。

5.png

6.png

7.png

4. 工具函数类型提示

目前,uView Pro 工具类有三种使用方式:

  1. 按需导入。
import { deepClone } from "uview-pro";
const obj = { a: 1, b: { c: 2 } };
const copy = deepClone(obj); // copy 类型自动推断

8.png

这样的方式自动 tree-shaking 导入,无需通过 utils 对象间接访问。

  1. 导入 util 对象,即可获得工具函数的类型提示。
import { $u } from "uview-pro";
const obj = { a: 1, b: { c: 2 } };
const copy = $u.deepClone(obj);

10.png

  1. 直接使用 uni. 即可使用,无需导入。
const obj = { a: 1, b: { c: 2 } };
const copy = uni.$u.deepClone(obj);

9.png

5. 结合 <script setup> 的最佳实践

uView Pro 推荐配合 <script setup lang="ts"> 使用,享受最完整的类型推断和代码提示体验。

五、常见问题与排查

1. 为什么没有类型提示?

  • 检查 tsconfig.json 是否正确配置 types 字段。
  • 确认 VSCode 已安装 Volar 插件,并禁用 Vetur。
  • 确认 uView Pro 版本为最新。
  • 重启 VSCode 或重新打开项目。

2. HBuilderX 项目如何获得类型提示?

目前 HBuilderX 不支持 tsconfig.json 的 types 配置,建议使用 CLI 项目获得最佳 TS 体验。

3. 组件未识别或类型报错?

  • 检查组件名称拼写,确保与官方文档一致。
  • 检查依赖版本,升级到最新版本。

六、uView Pro 类型声明实现原理

uView Pro 在源码中为每个组件、工具函数都编写了详细的 d.ts 类型声明,并在 types 目录下集中导出。通过 types 字段,Volar 能自动将这些类型注册为全局组件类型,无需手动 import。

u-button 组件类型声明示例:

// uview-pro/components/u-button/types.ts
export const ButtonProps = {
  /** 是否细边框 */
  hairLine: { type: Boolean, default: true },
  /** 按钮的预置样式,default,primary,error,warning,success */
  type: { type: String as PropType<ButtonType>, default: "default" },
  /** 按钮尺寸,default,medium,mini */
  size: { type: String as PropType<ButtonSize>, default: "default" },
  /** 按钮形状,circle(两边为半圆),square(带圆角) */
  shape: { type: String as PropType<Shape>, default: "square" },
  /** 按钮是否镂空 */
  plain: { type: Boolean, default: false },
  /** 是否禁止状态 */
  disabled: { type: Boolean, default: false },
  /** 是否加载中 */
  loading: { type: Boolean, default: false },
};

export type ButtonProps = ExtractPropTypes<typeof ButtonProps>;

全局导出:

// uview-pro/types/index.d.ts
// uview-pro 模块类型声明
declare module "uview-pro" {
  // 导出安装函数
  export function install(): void;
  // 导出 utils 工具类型
  export interface Utils {
    // 所有工具类型
  }
}

// 全局类型扩展
declare global {
  interface Uni {
    u: import("uview-pro").Utils;
  }
}

七、总结

通过本篇文章,相信你已经了解了 uView Pro 新版本对 Volar 和 TypeScript 类型提示的全面支持。只需按照文中指引进行简单配置,即可在开发中获得全局组件类型推断、属性校验、事件提示等智能能力,提升开发效率和代码质量。

未来会持续完善类型声明,覆盖更多细节和边界场景,适配更多 IDE 和类型工具。

欢迎大家升级体验,积极反馈问题与建议,共同推动 uView Pro 生态持续完善!

uView Pro 开源地址:

如果 uView Pro 对您有帮助,欢迎给个 Star 支持一下!如果您有任何问题或建议,欢迎在 Issue 区留言或入群交流!

使用 Electron 在 5 分钟内创建一个桌面的 React 应用

原文链接 Create a Desktop React App in Under 5 Minutes with Electron - 作者 Mendoza

当我们使用 React 来构建应用的时候,有时创建一个在桌面被使用的界面更加有意义,特别是它不需要连接到网络上时。我遇到过着这种场景 - 当我想创建一个应用来管理本地文件的时候。我使用 Python 中的 Tkinter,但是,我觉得我可以通过 React 来构建运行更加快速,更加好看的与界面。这也是我开始学习 Electron 的契机。

ElectronGitHub 创造出来的,为了构建它们自己的 Atom 文本编辑器,而现在,已经在流行的软件,比如 VSCodeDiscord 中使用。通过将 ReactElectron 整合使用,就能够快速地开发跨平台的桌面应用。Electron 提供了特定于桌面的额外功能,比如系统级别的应用通知和托盘图标,但是,在本教程中,我将会介绍如何运行 ReactElectron 应用的基础知识。

首先,我们需要创建一个新的 React 应用。我个人喜欢使用 Vite 来创建,这也是我本教程中使用到的脚手架工具,但是,我们使用 create-react-app 异曲同工。我也将使用 Typescript 来配置我的 Vite 应用。

npm create vite@latest <electron-app-name>

然后,进入新创建的应用所在的目录,然后安装 Electron 依赖。

npm install electron --save-dev

接着,我们可以在项目的根目录下创建一个名为 electron.ts 的文件,然后在文件中添加下面的代码:

const { app, BrowserWindow } = require("electron");

function createWindow() {
  // 创建浏览器窗口
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true,
      enableRemoteModule: true
    }
  });
  
  // 加载 React 应用程序
  win.loadURL("http://localhost:5173");
  
  // 打开调试面板
  win.webContents.openDevTools();
}

// 当应用已经准备好了,创建窗口
app.whenReady().then(createWindow);

// 当所有的窗口到被关闭了,退出 Electron 应用
app.on("window-all-closed", () => {
  if (process.platform !== "darwin") {
    app.quit();
  }
});

app.on("activate", () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow()
  }
});

现在,我们需要更新根目录下的 package.json 文件。我们添加的内容到 script 脚本对象中:

"start": "react-scripts start",
"electron": "electron ."

然后,我们需要给 Electron 应用一个入口,所以,我们添加一个 main 属性到文件中,值为 electron.ts文件,表明是它就是入口点:

"main": "electron.ts",

最后的 package.json 文件如下所示:

{
  "name": "electron-app",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "main": "electron.ts",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "start": "react-scripts start",
    "electron": "electron ."
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@type/node": "^18.15.11",
    "@type/react": "^18.0.28",
    "@type/react-dom": "^18.0.11",
    "@vitejs/plugin-react": "^3.1.0",
    "electron": "^23.2.1",
    "typescript": "^4.9.3",
    "vite": "^4.2.0"
  }
}

现在,通过运行下面的命令行来开启我们的 React 应用。npm run dev 将会开启 React 前端界面,然后 npm run electron 将会开启 Electron 应用并打开在 electron.ts 文件中预设的地址 http://localhost:5173

npm run dev
npm run electron

result.webp

Electron app running on desktop

现在,我们可以用 React 开发我们自己的跨平台桌面应用程序。你可以通过 here 来获取本教程的源代码。我最近专注于使用 React 构建更好的用户界面,你可以到我的 GitHub  中查看。

感谢阅读!

LangChain.js 完全开发手册(二)Prompt Engineering 与模板系统深度实践

第2章:Prompt Engineering 与模板系统深度实践

前言

大家好,我是鲫小鱼。是一名不写前端代码的前端工程师,热衷于分享非前端的知识,带领切图仔逃离切图圈子,欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!

🎯 本章学习目标

  • 掌握 Prompt Engineering 的核心原则与方法论(指令式、少样本、思维链、反思等)
  • 熟练使用 LangChain.js 的 Prompt 模板系统(PromptTemplateChatPromptTemplateFewShotPromptTemplate 等)
  • 能够将 Prompt 与 RunnableCallbackMemoryOutputParser 组合为稳定的可复用链路
  • 学会结构化输出(JSON/Zod Schema)与健壮的错误处理策略
  • 建立 Prompt 评测与 A/B 测试工作流,掌握迭代优化方法
  • 通过两个实战项目,完成从「问题 → 设计 → 编码 → 评测 → 上线」的闭环

📖 理论基础:Prompt Engineering 核心理念(约 30%)

2.1 为什么需要 Prompt Engineering

  • 大模型具备强泛化能力,但输出稳定性受上下文、提示措辞、约束条件影响极大
  • Prompt 的质量决定了结果的可靠性、可控性与成本(token)
  • 工程化的 Prompt 可复用、可测试、可监控、可演进

2.2 基本类型与策略

  • 指令式(Instruction):清晰角色、任务、约束、步骤
  • 少样本(Few-shot):示例驱动,降低歧义,提高风格一致性
  • 思维链(CoT):显式“先思考后作答”,提升推理质量
  • 反思(Reflexion):要求模型自检与修正,降低幻觉率
  • 分而治之(Decompose):复杂任务拆解为子任务流水线
  • 工具化(Tool-Use):与外部工具/检索融合,提升事实性

2.3 好 Prompt 的 4 要素(RICE)

  • Role(角色定位):模型扮演谁(专家/审校/产品经理)
  • Instruction(任务指令):做什么、产出什么格式
  • Context(上下文):必要背景、约束、风格、领域词汇
  • Example(示例):正反示例、边界案例

2.4 可测试与可维护

  • 模板化:变量化可控输入,便于不同调用场景复用
  • 结构化输出:避免纯自然语言,降低解析成本
  • 评测指标:正确率、可读性、一致性、引用率、成本/时延
  • 版本迭代:Prompt 版本号、Changelog、灰度策略

🧩 LangChain.js Prompt 模板体系(约 15%)

2.5 常用类与能力

  • PromptTemplate:文本模板 → 可注入变量
  • ChatPromptTemplate:消息式模板(system/human/ai 等)
  • FewShotPromptTemplate:少样本示例自动拼接
  • PipelinePromptTemplate:子模板管道化组合
  • MessagesPlaceholder:与 Memory 协作,注入历史对话
  • OutputParser:搭配 JSON/Zod 解析为结构化对象

2.6 模板设计准则

  • 单一职责:每个模板聚焦一个目标
  • 边界清晰:说明输入变量、约束、输出格式
  • 可观测性:为评测与追踪预留标记(如 version、taskId)

💻 基础代码实践(约 20%)

2.7 指令式 + 变量注入

// 文件:src/ch02/basic-instruction.ts
import { PromptTemplate } from "@langchain/core/prompts";
import { ChatOpenAI } from "@langchain/openai";
import { StringOutputParser } from "@langchain/core/output_parsers";
import * as dotenv from "dotenv";
dotenv.config();

const prompt = PromptTemplate.fromTemplate(`
你是一个{role}。请用{style}风格解答:
问题:{question}
要求:
- 使用分点说明
- 控制在{maxTokens}字以内
- 若不确定,请直接说“我需要更多上下文”
`);

const model = new ChatOpenAI({ modelName: "gpt-3.5-turbo", temperature: 0.5 });
const chain = prompt.pipe(model).pipe(new StringOutputParser());

export async function run() {
  const result = await chain.invoke({
    role: "Web 性能专家",
    style: "简洁务实",
    question: "如何优化首屏渲染?",
    maxTokens: 200,
  });
  console.log(result);
}

if (require.main === module) {
  run();
}

2.8 ChatPromptTemplate + 多消息角色

// 文件:src/ch02/chat-prompt.ts
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { ChatOpenAI } from "@langchain/openai";
import { StringOutputParser } from "@langchain/core/output_parsers";

const chatPrompt = ChatPromptTemplate.fromMessages([
  ["system", "你是资深前端教练,善于用类比解释复杂概念。"],
  ["human", "请用类比解释 {topic},并提供一个代码示例。"],
]);

const model = new ChatOpenAI({ modelName: "gpt-3.5-turbo" });
const chain = chatPrompt.pipe(model).pipe(new StringOutputParser());

export async function run() {
  const text = await chain.invoke({ topic: "虚拟 DOM" });
  console.log(text);
}

if (require.main === module) { run(); }

2.9 FewShotPromptTemplate(少样本)

// 文件:src/ch02/few-shot.ts
import { FewShotPromptTemplate, PromptTemplate } from "@langchain/core/prompts";
import { ChatOpenAI } from "@langchain/openai";
import { StringOutputParser } from "@langchain/core/output_parsers";

const examplePrompt = PromptTemplate.fromTemplate(
  "用户:{input}\n分类:{label}"
);

const examples = [
  { input: "页面加载很慢", label: "性能问题" },
  { input: "按钮点击没反应", label: "交互缺陷" },
  { input: "接口经常 500", label: "后端故障" },
];

const fewShot = new FewShotPromptTemplate({
  examples,
  examplePrompt,
  prefix: "请根据用户诉求给出标签(性能问题/交互缺陷/后端故障):",
  suffix: "用户:{input}\n分类:",
  inputVariables: ["input"],
});

const chain = fewShot.pipe(new ChatOpenAI()).pipe(new StringOutputParser());

export async function run() {
  const out = await chain.invoke({ input: "首屏白屏 3 秒" });
  console.log(out);
}

if (require.main === module) { run(); }

2.10 PipelinePromptTemplate(管道模板)

// 文件:src/ch02/pipeline.ts
import { PromptTemplate, PipelinePromptTemplate } from "@langchain/core/prompts";
import { ChatOpenAI } from "@langchain/openai";
import { StringOutputParser } from "@langchain/core/output_parsers";

const partA = PromptTemplate.fromTemplate(
  "请将主题扩写为 3 个小标题:{topic}"
);

const partB = PromptTemplate.fromTemplate(
  "基于小标题生成提纲,风格:{style}\n小标题:{headings}"
);

const pipeline = new PipelinePromptTemplate({
  finalPrompt: partB,
  pipelinePrompts: [
    { name: "headings", prompt: partA },
  ],
});

const chain = pipeline.pipe(new ChatOpenAI()).pipe(new StringOutputParser());

export async function run() {
  const result = await chain.invoke({ topic: "前端监控系统", style: "专业简练" });
  console.log(result);
}

if (require.main === module) { run(); }

🧱 结构化输出与 OutputParser(约 10%)

2.11 JSON 输出与严格约束

// 文件:src/ch02/json-output.ts
import { ChatOpenAI } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";
import { JsonOutputParser } from "@langchain/core/output_parsers";

type Plan = { steps: { title: string; details: string }[] };

const prompt = PromptTemplate.fromTemplate(`
你是项目规划助手。请输出严格的 JSON:
{
  "steps": [
    { "title": string, "details": string }
  ]
}
主题:{topic}
`);

const model = new ChatOpenAI({ temperature: 0 });
const parser = new JsonOutputParser<Plan>();
const chain = prompt.pipe(model).pipe(parser);

export async function run() {
  const result = await chain.invoke({ topic: "前端监控平台搭建" });
  console.log(result.steps.map(s => `- ${s.title}`).join("\n"));
}

if (require.main === module) { run(); }

2.12 Zod Schema 强类型解析

// 文件:src/ch02/zod-output.ts
import { z } from "zod";
import { StructuredOutputParser } from "@langchain/core/output_parsers";
import { ChatOpenAI } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";

const schema = z.object({
  title: z.string(),
  tags: z.array(z.string()).max(5),
  estimateHours: z.number().min(1).max(80),
});

const parser = StructuredOutputParser.fromZodSchema(schema);

const prompt = PromptTemplate.fromTemplate(`
基于需求生成任务卡片:
需求:{requirement}
请严格输出:
{format_instructions}
`);

const chain = prompt.pipe(new ChatOpenAI({ temperature: 0 })).pipe(parser);

export async function run() {
  const out = await chain.invoke({
    requirement: "实现文章阅读进度统计与收藏功能",
    format_instructions: parser.getFormatInstructions(),
  });
  console.log(out);
}

if (require.main === module) { run(); }

🔗 与 Runnable、Memory、Callback 协作(约 10%)

2.13 Runnable 组合与复用

// 文件:src/ch02/runnable-compose.ts
import { RunnableLambda, RunnableSequence } from "@langchain/core/runnables";
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";

const normalize = new RunnableLambda((input: { q: string }) => ({
  q: input.q.trim().slice(0, 300),
}));

const prompt = ChatPromptTemplate.fromMessages([
  ["system", "你是严谨的技术作家,输出规范化 Markdown"],
  ["human", "请回答:{q}"],
]);

const chain = RunnableSequence.from([
  normalize,
  prompt,
  new ChatOpenAI({ temperature: 0.2 }),
  new StringOutputParser(),
]);

export async function run() {
  const md = await chain.invoke({ q: "  讲讲CSR/SSR/SSG 区别  " });
  console.log(md);
}

if (require.main === module) { run(); }

2.14 Memory 注入历史对话(滑动窗口)

// 文件:src/ch02/memory-window.ts
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { ConversationSummaryMemory } from "langchain/memory"; // 或 @langchain/community 中的记忆实现
import { RunnableSequence } from "@langchain/core/runnables";

const prompt = ChatPromptTemplate.fromMessages([
  ["system", "你是持续对话的技术顾问,回答要简洁并引用上下文"],
  new MessagesPlaceholder("history"),
  ["human", "{input}"],
]);

const model = new ChatOpenAI({ temperature: 0 });
const memory = new ConversationSummaryMemory({ llm: model as any, memoryKey: "history" });

const chain = RunnableSequence.from([
  async (input: { input: string }) => ({ ...input, history: await memory.loadMemoryVariables({}) }),
  prompt,
  model,
  async (output) => { await memory.saveContext({}, { output }); return output; },
]);

export async function chatOnce(text: string) {
  const res = await chain.invoke({ input: text });
  console.log(res);
}

if (require.main === module) {
  (async () => {
    await chatOnce("我们刚才讨论了哪些优化点?");
    await chatOnce("继续说说 CSS 层面的优化。");
  })();
}

2.15 Callback:日志与流式观测

// 文件:src/ch02/callbacks.ts
import { ChatOpenAI } from "@langchain/openai";
import { ConsoleCallbackHandler } from "@langchain/core/callbacks/console";
import { ChatPromptTemplate } from "@langchain/core/prompts";

const prompt = ChatPromptTemplate.fromMessages([
  ["system", "你是输出严格 JSON 的助手"],
  ["human", "给出 3 个性能优化建议,输出 JSON 数组。"],
]);

const model = new ChatOpenAI({
  modelName: "gpt-3.5-turbo",
  callbacks: [new ConsoleCallbackHandler()],
  verbose: true,
});

export async function run() {
  const res = await prompt.pipe(model).invoke({});
  console.log(res.content);
}

if (require.main === module) { run(); }

🧪 Prompt 评测与 A/B 测试(约 5%)

2.16 评测清单与指标

  • 准确性(是否回答正题)
  • 可读性(结构、格式、术语友好度)
  • 一致性(相同输入的稳定输出)
  • 事实性(是否引用可信来源,RAG 结合时引用率)
  • 成本与时延(token 使用、响应时间)

2.17 LangSmith 与回归样本集

  • 建立固定问题集(golden set),覆盖主流程与边界
  • 为每次 Prompt 版本变更做回归评测
  • 收集线上真实问题,持续补充样本

2.18 A/B 实战样例(伪代码)

// A/B 两套 Prompt 模板,线上分流 20%/80%
// 记录满意度、转化率、人工标注得分

🚀 实战项目一:FAQ RAG Chat(Prompt 为核心)(约 15%)

2.19 目标

  • 为产品 FAQ/文档提供问答;要求:结构化答案、来源引用、低幻觉
  • 用 Prompt 规范化回答格式;与向量检索(Vector/RAG)配合

2.20 项目结构

src/
  ch02/
    rag-faq/
      ingest.ts         # 文档加载与向量化
      retriever.ts      # 检索器
      prompt.ts         # 回答模板(含引用)
      answer.ts         # 组合链路
      server.ts         # API/CLI 入口

2.21 关键代码(节选)

// 文件:src/ch02/rag-faq/prompt.ts
import { ChatPromptTemplate } from "@langchain/core/prompts";

export const answerPrompt = ChatPromptTemplate.fromMessages([
  ["system", `你是严谨的 FAQ 智能助手。请基于“检索到的片段”回答,若无答案请说不知道。
必须输出以下 JSON:
{
  "answer": string,
  "citations": [{"source": string, "chunkId": string}],
  "confidence": number // 0-1
}`],
  ["human", `用户问题:{question}
检索片段:\n{chunks}\n请回答。`],
]);
// 文件:src/ch02/rag-faq/answer.ts
import { RunnableSequence } from "@langchain/core/runnables";
import { answerPrompt } from "./prompt";
import { ChatOpenAI } from "@langchain/openai";
import { JsonOutputParser } from "@langchain/core/output_parsers";

type QA = { answer: string; citations: { source: string; chunkId: string }[]; confidence: number };

export function buildQAChain(retriever: (q: string) => Promise<string>) {
  const model = new ChatOpenAI({ temperature: 0 });
  const parser = new JsonOutputParser<QA>();
  return RunnableSequence.from([
    async (input: { question: string }) => ({
      question: input.question,
      chunks: await retriever(input.question),
    }),
    answerPrompt,
    model,
    parser,
  ]);
}
// 文件:src/ch02/rag-faq/retriever.ts(示意)
// 实际可用 Chroma/Pinecone 等向量库
export async function simpleRetriever(q: string): Promise<string> {
  return `# chunk-01 来自 docs/intro.md\nLangChain.js 是构建 LLM 应用的框架...`;
}

2.22 Prompt 要点

  • 严格 JSON 输出,便于前端渲染
  • 含“若无答案请直说”的防幻觉护栏
  • 引用列表(citations)强制结构
  • 引入 confidence 便于排序/过滤

2.23 运行与评测

  • 建立 30+ 常见问题作为 gold set
  • A/B 不同语气/结构提示,观察答非所问率
  • 记录引用命中率与人工标注分

🛠️ 实战项目二:Prompt 驱动的「需求 → 任务卡片」生成器(约 15%)

2.24 目标

  • 输入自然语言需求,自动生成结构化任务卡(标题、标签、预估工时、验收标准)
  • 满足结构化输出、可复核、可回填的工程要求

2.25 项目结构

src/
  ch02/
    tasks/
      schema.ts
      prompt.ts
      chain.ts
      eval.ts
      cli.ts

2.26 核心代码(节选)

// 文件:src/ch02/tasks/schema.ts
import { z } from "zod";
export const TaskSchema = z.object({
  title: z.string().min(6),
  tags: z.array(z.string()).max(5),
  estimateHours: z.number().min(1).max(80),
  acceptance: z.array(z.string()).min(1),
});
export type Task = z.infer<typeof TaskSchema>;
// 文件:src/ch02/tasks/prompt.ts
import { PromptTemplate } from "@langchain/core/prompts";
export const taskPrompt = PromptTemplate.fromTemplate(`
请把下面需求转成任务卡片(严格 JSON):
需求:{requirement}
返回:
{
  "title": string,
  "tags": string[],
  "estimateHours": number,
  "acceptance": string[]
}
`);
// 文件:src/ch02/tasks/chain.ts
import { ChatOpenAI } from "@langchain/openai";
import { StructuredOutputParser } from "@langchain/core/output_parsers";
import { TaskSchema } from "./schema";
import { taskPrompt } from "./prompt";

const parser = StructuredOutputParser.fromZodSchema(TaskSchema);
const model = new ChatOpenAI({ temperature: 0 });

export async function generateTask(requirement: string) {
  const res = await taskPrompt
    .pipe(model)
    .pipe(parser)
    .invoke({ requirement });
  return res;
}
// 文件:src/ch02/tasks/eval.ts  (简易评测)
import { generateTask } from "./chain";

const cases = [
  "为博客增加全文搜索,支持标签过滤和高亮",
  "新增图片上传,自动压缩并生成 WebP",
];

(async () => {
  for (const c of cases) {
    const out = await generateTask(c);
    const ok = !!out.title && out.estimateHours > 0 && out.acceptance.length > 0;
    console.log("case:", c, ok ? "✅" : "❌", out);
  }
})();

2.27 质量要点

  • 严格 Schema 校验,异常直接可见
  • 失败样例回收成回归集,持续改进 Prompt
  • 允许少量温度,保证多样性但不过度发散

⚙️ 性能、成本与健壮性(约 5%)

2.28 优化建议

  • 模板复用与缓存:避免重复渲染模板与不必要的调用
  • 限制输出长度与格式:降低 token 使用与解析成本
  • 超时/重试/指数退避:应对网络抖动与临时错误
  • 失败降级:为空时返回兜底文案或引导收集更多上下文

2.29 回退策略(Guardrails)

  • 结构化输出失败 → 自动重试 + 降级纯文本 + 错误上报
  • 检索为空 → 提示继续提问或缩小范围
  • 敏感/越权问题 → 明确拒答并给出替代建议

🔍 与前端/产品工程协作要点(约 5%)

2.30 前端集成

  • 流式输出:打字机效果、取消/重试按钮
  • 结构化渲染:基于 JSON 直接渲染卡片/表格/引用
  • 错误兜底:可视化错误提示与重试引导

2.31 产品落地

  • 指标看板:满意度、人工接管率、拒答率、成本
  • 版本切换:Prompt 版本灰度与快速回滚
  • 合规安全:敏感词治理与数据最小化

📚 资源与延伸

  • LangChain.js 文档(JS):https://js.langchain.com/
  • LangGraph:https://langchain-ai.github.io/langgraph/
  • LangSmith:https://docs.smith.langchain.com/
  • 提示工程最佳实践合集:https://learnprompting.org/
  • OpenAI 指南:https://platform.openai.com/docs/guides/prompt-engineering

📦 附:示例项目最小可运行清单

mkdir -p src/ch02 && cd $_
# 将上述 *.ts 文件放入对应目录后:
npm i @langchain/core @langchain/openai @langchain/community zod dotenv
npm i -D tsx typescript @types/node
echo '{
  "compilerOptions": {"target":"ES2020","module":"commonjs","esModuleInterop":true,"strict":true},
  "include": ["src/**/*"]
}' > tsconfig.json
// package.json 片段
{
  "scripts": {
    "ch02:basic": "tsx src/ch02/basic-instruction.ts",
    "ch02:chat": "tsx src/ch02/chat-prompt.ts",
    "ch02:few": "tsx src/ch02/few-shot.ts",
    "ch02:pipe": "tsx src/ch02/pipeline.ts",
    "ch02:json": "tsx src/ch02/json-output.ts",
    "ch02:zod": "tsx src/ch02/zod-output.ts"
  }
}

✅ 本章小结

  • 系统化掌握了 Prompt 的核心方法与工程化落地
  • 熟练运用 LangChain.js 模板体系与结构化输出
  • 学会将 Prompt 与 Runnable/Memory/Callback 组合
  • 建立 Prompt 评测与 A/B 测试的迭代闭环
  • 完成两个实战项目,从 0 到 1 走通产品化路径

🎯 下章预告

下一章《Memory 系统与对话状态管理》中,我们将深入:

  • 短期/长期记忆策略的权衡
  • 滑动窗口与摘要记忆的组合
  • 与向量记忆(VectorStore)协同,打造可检索的对话系统
  • 记忆的一致性、成本与隐私安全

最后感谢阅读!欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!!

前端监控实战:从性能指标到用户行为,我是如何搭建监控体系的

image.png

还在当一线开发的时候,我最怕半夜接到电话,说:线上出问题了!!!。

那时候我们对线上环境几乎是两眼一抹黑。一个功能发布后,它在线上跑得快不快、有没有报错、用户到底喜不喜欢用,我们一概不知。出了问题,只能靠用户反馈和后端模糊的日志,排查效率极低,过程也极其痛苦。

当了组长后,我下定决心,必须改变这种被动救火的局面,能实时地看到线上应用的健康状况。

所以,我花了几个月的时间,搭建了一套前端监控体系。这篇文章,就是我们团队这套监控体系的实战总结。它主要分为三大块,回答三个核心问题:

  1. 性能监控:我们的网站快吗?
  2. 异常监控:我们的网站稳定吗?
  3. 行为监控:用户在我们网站上做了什么?

性能监控

监控什么?

性能指标有很多,但我们初期应该聚焦在 核心Web指标(Core Web Vitals) 上,这是Google官方定义的、直接关系到用户体验的三个核心指标。

  • LCP (Largest Contentful Paint):最大内容绘制。衡量加载性能。简单说,就是用户看到页面主要内容花了多久。
  • INP (Interaction to Next Paint):下次绘制前交互。衡量交互性能。这是2024年取代FID的新指标,衡量用户点击、输入等操作后,页面给出视觉反馈的速度。
  • CLS (Cumulative Layout Shift):累积布局偏移。衡量视觉稳定性。比如页面加载时,图片突然出现导致下方按钮被挤下去,就是一次不好的CLS。

怎么实现?

我们没有重复造轮子,直接选用了Google官方的小型库web-vitals来采集指标。

  • 第一步:安装
    pnpm add web-vitals
    
  • 第二步:在你的应用入口处(如main.js)进行采集
    import { onLCP, onINP, onCLS } from 'web-vitals';
    
    // 封装一个上报函数
    function sendToAnalytics(metric) {
      // 这里是你把数据发送到自己后端服务的逻辑
      // 为了保证数据在页面关闭前也能成功发送,强烈建议使用 navigator.sendBeacon
      const body = JSON.stringify({ [metric.name]: metric.value });
      const url = "/your/analytics/endpoint";
    
      if (navigator.sendBeacon) {
        navigator.sendBeacon(url, body);
      } else {
        fetch(url, { body, method: 'POST', keepalive: true });
      }
    }
    
    // 采集并上报
    onLCP(sendToAnalytics);
    onINP(sendToAnalytics);
    onCLS(sendToAnalytics);
    
  • 第三步:后端接收与展示 你需要一个简单的后端服务来接收这些数据,并存入数据库。然后通过可视化工具(如Grafana)来展示性能大盘。

image.png


异常监控

监控什么?

  • JS运行时错误:代码里的逻辑错误,比如cannot read property 'xxx' of undefined
  • Promise异步错误Promise中未被catchreject
  • 静态资源加载错误:JS、CSS、图片等资源加载失败。
  • 白屏错误:这是一个比较难定义,但非常致命的错误。

怎么实现?

异常监控的体系非常复杂,涉及到错误捕获、堆栈解析、SourceMap还原、上报、聚合、告警等一整套流程。这块我们坚决不自己造轮子,直接选择了成熟的开源方案:Sentry

  • 第一步:接入Sentry SDK

    pnpm add @sentry/browser @sentry/tracing
    
  • 第二步:在应用入口处初始化

    import * as Sentry from "@sentry/browser";
    import { BrowserTracing } from "@sentry/tracing";
    
    Sentry.init({
      dsn: "你在Sentry官网上获取的DSN地址",
      integrations: [new BrowserTracing()],
      
      // 我们只关心生产环境的错误
      environment: 'production', 
      
      // 设置性能监控的采样率
      tracesSampleRate: 0.1, // 10%的页面会采集性能数据
    });
    

    只需要这几行代码,Sentry就会自动帮我们监听全局的JS错误、Promise异常和资源加载异常。

  • 第三步(最重要的一步):上传Source Map

    Source Map是异常监控的灵魂。 没有它,你Sentry上看到的错误堆栈,就是一堆经过压缩混淆的、毫无意义的天书。

    我强制要求我们团队的CI/CD流程,必须在每次生产构建后,自动将Source Map上传到Sentry。这可以通过Sentry官方的sentry-cli或Webpack/Vite插件来完成。

具体怎么使用网上有不少教程,可以参考这位博主的👉 bug 追踪系统 Sentry (4) -- 关联 sourceMap


行为监控

性能和稳定性保证了我们的应用 能不能用,而行为监控,则是为了搞清楚应用 好不好用

监控什么?

  • 基础流量:PV(页面浏览量)、UV(独立访客数)、用户停留时长等。
  • 用户转化漏斗:比如“访问商品页 -> 点击加入购物车 -> 进入结算页 -> 支付成功”这一整个流程中,每一步的用户流失率是多少。
  • 功能使用率:某个按钮的点击次数、某个功能的渗透率等。

2. 怎么实现?

这块的选择很多,从免费的Google Analytics,到更专业的Mixpanel、Amplitude。

但无论用什么工具,作为组长,我要求团队在代码层面,必须遵守一个原则:封装一个统一的埋点(tracking)函数

  • 第一步:封装一个tracker.js
    // src/utils/tracker.js
    
    /**
     * 统一的埋点上报函数
     * @param {string} eventName 事件名称
     * @param {object} properties 事件属性
     */
    export function trackEvent(eventName, properties = {}) {
      // 可以在开发环境下打印,方便调试
      if (process.env.NODE_ENV === 'development') {
        console.log(`[Track Event]: ${eventName}`, properties);
      }
    
      // 在这里,你可以接入任何第三方或自研的埋点SDK
      // 比如 Google Analytics
      // window.gtag?.('event', eventName, properties);
    
      // 比如 Mixpanel
      // window.mixpanel?.track(eventName, properties);
    }
    
  • 第二步:在业务组件中调用
    import { trackEvent } from '@/utils/tracker';
    
    function AddToCartButton({ productId }) {
      const handleClick = () => {
        // 业务逻辑...
    
        // 调用统一的埋点函数
        trackEvent('click_add_to_cart', { 
          productId,
          from: 'product_detail_page' 
        });
      };
      return <button onClick={handleClick}>加入购物车</button>;
    }
    

为什么要封装? 因为这样能让我们的业务代码,和具体的埋点SDK实现解耦。未来如果我们想从Google Analytics切换到Mixpanel,我们只需要修改tracker.js这一个文件,而不需要去改动散落在项目里成百上千个业务组件。


搭建监控体系,终点不是收集数据和看报表,而是驱动行动

搭建这套体系,让我们的团队工作模式,真正从靠感觉猜,升级到了靠数据 处理特定问题。虽然过程很麻烦,但每一分投入,都非常值得。

分享完毕 谢谢大家

从0到1:手把手带你开发第一个Chrome插件

金金金上线!
话不多,只讲你能听懂的前端知识

image.png

前言

谁不想拥有一个属于自己的浏览器插件呢~

开始

  1. 创建一个新目录
  2. 创建manifest.json文件,添加以下内容:
    {
      "name": "Hello Extensions",
      "description": "Base Level Extension",
      "version": "1.0",
      "manifest_version": 3,
      "action": {
        "default_popup": "hello.html",
        "default_icon": "hello_extensions.png"
      }
    }
    
  3. 下载个icon图片并命名为hello_extensions.png,需要和default_icon一致
  4. 创建一个文件,hello.html(弹出式窗口,就是点击了扩展会弹出一个小框显示信息),添加以下内容:
    <html>
     <body>
       <h1>Hello Extensions</h1>
     </body>
    </html>
    

加载未打包的扩展程序

如需在开发者模式下加载未封装的扩展程序,请执行以下操作:

  1. 在新标签页中输入 chrome://extensions 即可前往“扩展程序”页面。(根据设计,chrome:// 网址不可链接。)

    • 或者,点击“扩展程序”菜单拼图按钮,然后选择菜单底部的管理扩展程序
    • 或者,点击 Chrome 菜单,将鼠标悬停在更多工具上,然后选择扩展程序
  2. 点击开发者模式旁边的切换开关,即可启用开发者模式。

  3. 点击加载已解压缩的文件按钮,然后选择扩展程序目录。(可能没那么快响应,记得稍等一会)

    image.png

这样就代表成功了

固定扩展程序

默认情况下,当您在本地加载扩展程序时,它会显示在扩展程序菜单中。将扩展程序固定到工具栏,以便在开发过程中快速访问扩展程序。

image.png

点击扩展程序的操作图标(工具栏图标);您应该会看到一个弹出式窗口。

企业微信截图_17560895667935.png

重新加载扩展程序

  • 有时候会有修改内容的需求,此时改完后 就需要重新加载扩展程序以看到最新的内容

  • 修改manifest.json,将扩展程序的名称更改为 Hello Extensions of the world!

    企业微信截图_17560897159213.png

保存文件后,您还必须刷新扩展程序,才能在浏览器中看到此更改。前往“附加信息”页面,然后点击开启/关闭切换开关旁边的刷新图标:

企业微信截图_17560898118564.png

验证

企业微信截图_17560898907529.png

何时应该重新加载扩展程序呢?

  • 下表显示了哪些组件需要重新加载才能看到更改:

    扩展程序组件 需要重新加载扩展程序
    清单
    Service Worker
    内容脚本 是(包括托管网页)
    弹出式窗口
    “选项”页面
    其他扩展程序 HTML 网页

查找控制台日志和错误

控制台日志

  • 在开发期间,您可以通过访问浏览器控制台日志来调试代码。在本例中,我们将找到弹出式窗口的日志。首先,将脚本标记添加到 hello.html
    <html>
      <body>
        <h1>Hello Extensions</h1>
        <script src="popup.js"></script>
      </body>
    </html>
    
  • 创建一个 popup.js 文件,并添加以下代码:
    console.log("This is a popup!")
    

在控制台中查看系统记录的这条消息,请执行以下操作:

  1. 打开弹出式窗口。

  2. 右键点击弹出式窗口。

  3. 选择检查

    image.png

  4. 在 DevTools 中,前往控制台面板

    image.png

错误日志

  • 现在,来破坏一下扩展程序。为此,移除 popup.js 中的闭引号:

    image.png

  • 前往“扩展程序”页面,然后打开弹出式窗口。系统随即会显示一个错误按钮。

    image.png

  • 点击错误按钮,详细了解相应错误:

image.png

如需详细了解如何调试服务工件、选项页面和内容脚本,请参阅调试扩展程序


可以通过多种方式构建扩展程序项目;不过,唯一的前提是将 manifest.json 文件放在扩展程序的根目录中(之后出如何构建扩展程序相关文章~)


编写有误还请各位指正,万分感谢

【AI解读源码系列】ant design mobile——Avatar头像

前言

笔者目前业务主要围绕ant design mobile组件库来交付H5前端工作。

故此出此专栏来解读每一个组件是如何实现的。

本文基于AI来解读image组件。

文档链接:

mobile.ant.design/zh/componen…

源码:

github.com/ant-design/…

设计prompt

解读antd-mobile avatar组件,结构化思维输出整个组件实现流程,并且解读fallback.tsx是如何实现组件兜底的,按步骤提供关键代码解读

// 附组件代码

实现步骤

第一步:属性设计与合并

  • Avatar 支持图片地址、兜底图、展示模式、事件等各种属性,并自动合并默认值。
const defaultProps = {
  fallback: <Fallback />, // 默认兜底头像
  fit: 'cover',           // 默认居中裁剪
};
const props = mergeProps(defaultProps, p); // 合并props

第二步:图片地址预处理

  • 去除 src 的空白字符,为空/无效用 undefined,保证 Image 组件兜底逻辑触发。
const mergedSrc = props.src?.trim() || undefined;

第三步:调用 Image 组件渲染头像(最核心)

  • withNativeProps 把所有原生属性增强到最外层 DOM,并传递给 <Image />
  • fallback 和 placeholder 都设置为兜底图,任何载入中/失败情况都一致。
return withNativeProps(
  props,
  <Image
    className={classPrefix}
    src={mergedSrc}
    fallback={props.fallback}
    placeholder={props.fallback}
    alt={props.alt}
    lazy={props.lazy}
    fit={props.fit}
    onClick={props.onClick}
    onError={props.onError}
    onLoad={props.onLoad}
  />
);

第四步:Image 组件内部实现兜底

  • Image 组件在 src 无效、加载失败时会自动渲染 fallback。

第五步:Fallback 组件(svg兜底)实现方式

  • 默认 fallback 是 <Fallback />,实际为一个灰色 SVG 头像(React.memo封装,避免多次渲染)。
  • 只要没图、加载失败、加载中都会显示这个兜底 SVG。
export const Fallback = memo(() => (
  <svg
    className='adm-avatar-fallback'
    width='88px'
    height='88px'
    viewBox='0 0 88 88'
    // ...省略具体SVG
  />
));

第六步:自定义支持

  • 支持自定义 fallback,开发者可以用自己的 logo/图片/图标作为头像兜底

总结

  1. 合并props,保证有默认fallback和fit
  2. 预处理src,避免空图带来异常
  3. 核心渲染Image,placeholder和fallback都传兜底SVG
  4. Image组件自动处理所有加载失败/空src,用fallback兜底
  5. Fallback.tsx里实现具体兜底SVG头像,memo优化渲染

Avatar组件比较简单,核心逻辑都包在Image组件中了,如果想了解Image组件的具体实现可参考:

juejin.cn/post/754057…

以上就是笔者基于AI返回的解读信息稍加了一些补充和修改,结合起来看源码提效真是太多了,对于前端本身就是基于视图所完成编码,因此把组件逻辑层交给AI来解读太适合不过了。

希望对大家有所帮助,共同学习源码。

Clean Code JavaScript小记(二)

单一职责原则

单一职责原则是面向对象设计原则,为了降低复杂性,耦合性和提高可维护性。一个类只承担一项职责与功能,承担的功能更多会让可读性降低。

实现单一职责的原则

1.识别和分离职责

识别类中是否存在多个功能。

2.分解类

识别出多功能,下一步分解功能到不同的类中。

// 反例
user类既维护了又用户信息做对用户做了校验。
class User {
  constructor(name, email, password) {
    this.name = name;
    this.email = email;
    this.password = password;
  }
 
  getUserInfo() {
    return { name: this.name, email: this.email };
  }
 
  authenticate(providedPassword) {
    return providedPassword === this.password;
  }
}

//正例
// 分解成2个类  区分用户信息和校验

class UserInfo {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
 
  getUserInfo() {
    return { name: this.name, email: this.email };
  }
}
 
class UserAuthentication {
  constructor(password) {
    this.password = password;
  }
 
  authenticate(providedPassword) {
    return providedPassword === this.password;
  }
}
开/闭原则

代码实体(类,模块,函数等)应该易于扩展,难于修改以适应新的需求

// 反例
class AjaxRequester {
  constructor() {
    // What if we wanted another HTTP Method, like DELETE? We would have to
    // open this file up and modify this and put it in manually.
    this.HTTP_METHODS = ['POST', 'PUT', 'GET'];
  }

  get(url) {
    // ...
  }

}
// 正例:
class AjaxRequester {
  constructor() {
    this.HTTP_METHODS = ['POST', 'PUT', 'GET'];
  }

  get(url) {
    // ...
  }

  addHTTPMethod(method) {
    this.HTTP_METHODS.push(method);
  }
}

实现类的开闭原则也会涉及到几个关键的设计模式和编程实践,如工厂模式,策略模式组合模式。

里氏替换原则

子类可以扩展父类的功能,但不能改变父类原有的功能。 遵循此原则的编码建议:

  • 子类应通过添加新方法或重写父类抽象方法扩展功能,而非修改父类抽象方法。
  • 参数与返回类型兼容:子类重写父类方法时参数应更宽松(支持多种类型),返回值应更严格。
  • 使用抽象类或接口:通过抽象类或接口定义稳定的部分,将可变的部分封装在具体实现中避免破坏原则。
示例:
class Animal {
  constructor () {
        
      }
      makeSound(...animals) {
        for(let animal of animals) {
            if (animal instanceof Animal) {
                console.log(animal.name, animal.makeSound())
            } else {
                console.log('拒绝表演,下次别来了')
            }
        }
    }
    
}

class Dog extends Animal{
  constructor (name) {
        this.name=name
      }
      makeSound() {
        console.log("汪汪");
    }

}

class Cat extends Animal{
  constructor (name) {
        this.name=name
      }
      makeSound() {
        console.log("喵");
    }

}
let Animal =new  Animal()
Animal.makeSound(new Dog('小狗'),new Cat('小喵'));

// 这里Animal.makeSound方法不依赖小狗和小喵的实例还是依赖 Animal实例;;小狗/小喵的 makeSound 方法的实现就是里氏替换。
接口隔离原则

在 JS 中,当一个类需要许多参数设置才能生成一个对象时,或许大多时候不需要设置这么多的参数。此时减少对配置参数数量提高系统的灵活性和可维护性。

接口隔离原则的核心思想

  • 单一职责原则:每个接口应该只包含一个客户端(使用者)使用的方法,这样可以避免客户端依赖于不需要的方法。
  • 高内聚、低耦合:通过将接口拆分成更小的部分,可以减少类之间的耦合,提高系统的内聚性。

// 反例
class MultiFunctionPrinter {
  print() {}
  scan() {}
  fax() {}
  staple() {}
}

// 正例 - 接口隔离
class Printer {
  print() {}
}

class Scanner {
  scan() {}
}

class FaxMachine {
  fax() {}
}

class Stapler {
  staple() {}
}

// 组合功能
class AllInOnePrinter {
  constructor(printer, scanner, faxMachine) {
    this.printer = printer;
    this.scanner = scanner;
    this.faxMachine = faxMachine;
  }

  print() {
    this.printer.print();
  }

  scan() {
    this.scanner.scan();
  }

  fax() {
    this.faxMachine.fax();
  }
}
依赖反转原则

高层模块不应该依赖低层模块 里氏替换实例中 ,Animal.makeSound方法 就是依赖自身的方法。不依赖底层类方法

 示例
 // 抽象的日志记录器接口
class Logger {
    log(message) {
        throw new Error("Abstract method must be implemented");
    }
}
// 具体的控制台日志记录器实现
class ConsoleLogger extends Logger {
    log(message) {
        console.log(message);
    }
}
// 业务逻辑类,依赖于抽象的日志记录器
class BusinessLogic {
    constructor(logger) {
        this.logger = logger;
    }
    doSomething() {
        let result = "Some operation result";
        this.logger.log(result);
    }
}
// 使用控制台日志记录器注入到业务逻辑类
let business = new BusinessLogic(new ConsoleLogger());
business.doSomething();

使用方法链

// 反例
class Car {
  constructor() {
    this.make = 'Honda';
    this.model = 'Accord';
    this.color = 'white';
  }

  setMake(make) {
    this.name = name;
  }

  setModel(model) {
    this.model = model;
  }

  setColor(color) {
    this.color = color;
  }

  save() {
    console.log(this.make, this.model, this.color);
  }
}

let car = new Car();
car.setColor('pink');
car.setMake('Ford');
car.setModel('F-150')
car.save();

//正例
class Car {
  constructor() {
    this.make = 'Honda';
    this.model = 'Accord';
    this.color = 'white';
  }

  setMake(make) {
    this.name = name;
    // NOTE: Returning this for chaining
    return this;
  }

  setModel(model) {
    this.model = model;
    // NOTE: Returning this for chaining
    return this;
  }

  setColor(color) {
    this.color = color;
    // NOTE: Returning this for chaining
    return this;
  }

  save() {
    console.log(this.make, this.model, this.color);
  }
}

let car = new Car()
  .setColor('pink')
  .setMake('Ford')
  .setModel('F-150')
  .save();

处理并发

用promise 替代回调

回调不够整洁可能会造成大量嵌套 ES6内置了promise直接使用,promise的使用提高代码整洁性和可维护性

//反例
require('request').get('https://en.wikipedia.org/wiki/Robert_Cecil_Martin', function(err, response) {
  if (err) {
    console.error(err);
  }
  else {
    require('fs').writeFile('article.html', response.body, function(err) {
      if (err) {
        console.error(err);
      } else {
        console.log('File written');
      }
    })
  }
})

//正例

require('request-promise').get('https://en.wikipedia.org/wiki/Robert_Cecil_Martin')
  .then(function(response) {
    return require('fs-promise').writeFile('article.html', response);
  })
  .then(function() {
    console.log('File written');
  })
  .catch(function(err) {
    console.error(err);
  })

Async/Await 是较 Promises 更好的选择

promise是相较回调来说是一种更好的选择,ES7中的await和async 更胜于promise

示例
async function getCleanCodeArticle() {
  try {
    var request = await require('request-promise')
    var response = await request.get('https://en.wikipedia.org/wiki/Robert_Cecil_Martin');
    var fileHandle = await require('fs-promise');

    await fileHandle.writeFile('article.html', response);
    console.log('File written');
  } catch(err) {
    console.log(err);
  }
}

错误处理

使用try/catch 捕获错误并且应当对 catch捕获的报错做出相应的处理方案

// 反例:
try {
  functionThatMightThrow();
} catch (error) {
  console.log(error);
}
// 正例
try {
  functionThatMightThrow();
} catch (error) {
  // One option (more noisy than console.log):
  console.error(error);
  // Another option:
  notifyUserOfError(error);
  // Another option:
  reportErrorToService(error);
  // OR do all three!
}

promise的catch捕获的报错做出相应的处理方案 与try/catch 同理

格式化

  • 大小写一致,针对团队制定的统一规则保持风格一致.
  • 调用函数和被调用的函数应该放在相近的位置.

性能治理之页面LongTask优化

前言

在现代 Web 应用中,页面性能直接影响用户体验和业务发展。我们近期对列表页进行了一次全面的性能优化,旨在提升用户访问效率,提升用户满意度。

接下来,将为大家详细分享这次优化的过程、方法和成果。

一、优化目标与用户分析

我们将优化目标聚焦于核心列表页,通过BLM健康平台(内部访问统计平台)筛选出高流量且秒开率低的页面作为重点优化对象。同时,利用日志进行深度剖析,明确优化的用户范围。

  • 初次进入用户:占比18.41%,秒开率接近于0;
  • 二次进入用户:占比81.87%,秒开率27.85%。

基于这一数据,我们确定二次进入用户中的未秒开用户是本次优化的主要目标群体,占比约为58.85%,这一决策基于两个关键考量:

  • 初次进入用户受网络环境和首次加载影响较大,优化空间有限;

  • 二次进入用户占比高且已有部分秒开,优化潜力更大。

二、性能优化尝试

qiankun 架构下某核心列表加载流程简图

在 qiankun 架构下,列表的加载流程具有一定的复杂性。我们针对这一流程,分阶段展开,具体内容如下:

第一阶段:静态资源优化(DLC)

首先从静态资源入手,对相关文件和模块进行调整,具体优化内容及前后指标对比如下:

关键收获:非关键资源的延迟加载或移除可以显著降低TBT(Total Blocking Time 总阻塞时间)。

第二阶段:DLC到FCP

本次优化暂不考虑

第三阶段:主应用FCP到LCP优化

针对主应用的关键渲染路径,重点优化了:

// 修改前:筛选项全部渲染
renderAllFilters() {
  // 渲染所有筛选项(包括隐藏内容)
}

// 修改后:按需渲染
renderVisibleFiltersOnly() {
  // 只渲染可见的筛选项
}

优化效果对比:

第四阶段:长任务分解

核心优化方案

基于以上分析,我们总结出以下优化建议(按收益排序):

  1. 筛选项懒加载:TTI最多减少642ms;
  2. 大数据枚举虚拟化:司机标签TTI减少594ms,城市组件减少179ms;
  3. 接口调用顺序调整:TTI减少355ms;
  4. 表格组件优化:TTI减少430ms;
  5. 缓存逻辑优化:TTI减少170ms;
  6. CSS合包处理:TTI减少60ms;

优化效果验证

通过A/B测试和监控平台数据对比:

  • 优化前(02.12-02.18):秒开率17.15%;
  • 优化后(02.28-03.04):秒开率31.92%;
  • 提升幅度:14.77个百分点;

三、分析方法沉淀

目标设定阶段

  1. 通过BLM健康平台确定需优化页面
  • 高流量页面优先
  • 秒开率低页面优先
  1. 通过性能埋点日志确定可优化范围

    使用 Node 脚本将一段时间日志进行分类分析:是否初次进入 / 是否存存在缓存。

    确定可优化用户范围,若用户范围过小或无法达成,可放弃优化,转向其他页面。

问题定位方法

我们主要使用以下工具进行性能分析:

关键指标解释:

  • FCP(First Contentful Paint):首次内容渲染
  • LCP(Largest Contentful Paint):最大内容渲染
  • TBT(Total Blocking Time):总阻塞时间(FCP到TTI之间超过50ms的任务部分总和)
  • TTI(Time to Interactive):可交互时间

  • Lighthouse初步诊断:获取整体性能评分和优化建议。

  • 任务分析:通过 Performance insights 的 Main 线程火焰图定位长任务 & 检查接口并行性和响应时间。

  • Performance深度分析
  1. 使用 Performance 工具打开待优化页面,根据任务分析中找到的阻塞点函数进行搜索定位。在有 map 文件的环境或本地环境中,通过调用栈信息,找到阻塞或长任务对应的代码块。
    • LCP 后的任务阻塞一般是由接口返回数据渲染导致,这时可以结合 Network 火焰图找对应关系,明确是那个 API;

    • Network 中资源条目的时间包括请求前等待时间以及请求后解析时间,可根据解析时间判断哪个接口数据导致了阻塞;

    • 可在函数执行前添加console.profile(),在函数执行后添加console.profileEnd()语句,定向分析该函数对性能的影响(此方法对异步任务的支持有限)。

// 示例:标记关键函数执行
console.profile('filterRender');
renderFilters();
console.profileEnd('filterRender');

  1. 主要通过以上三步确定阻塞点并进行分析。若想深入分析阻塞点的具体原因,可在本地环境中使用 Performance 进行分析(可获取具体函数调用栈),或使用 Profiles(在 Memory 标签下)进行分析,以确定哪些函数导致了内存占用的波动等问题。

方案验证(whistle)

安装好 whistle 客户端并配置规则,将需要更改的文件代理到本地进行修改;

本文所得数据均以此方式进行测试

确定修改方案的ROI

根据分析以上分析列出可优化点,并对所有优化方案进行评估计算 ROI,优先选择收益高且优化成本低的优化方案进行优化。

结果验证阶段

  • 对优化方案进行优化开发和测试;
  • 上线后通过健康平台和性能日志进行数据指标验证是否达到预期。

四、经验总结与未来建议

基于本次优化经验,我们提炼出以下通用建议:

1.按需加载原则

  • 非首屏内容延迟加载;
  • 大数据枚举使用虚拟滚动;

2.关键渲染路径优化:

  • 关键接口尽早调用(created生命周期);
  • 避免渲染阻塞接口请求;

3.长任务分解:

  • 将超过50ms的任务拆分为小任务;
  • 使用 Web Worker 处理复杂计算;

4.缓存策略:

  • 合理使用内存和 IndexedDB 缓存
  • 避免缓存更新阻塞主线程

5.监控体系:

  • 建立完整的性能监控体系

  • 定期分析用户行为日志

结语

本次优化实践表明,系统化的性能优化需要:

  1. 精准的用户行为分析确定优化方向;
  2. 科学的工具链进行问题定位;
  3. 严谨的ROI评估确定优化优先级;
  4. 完善的监控体系验证优化效果。

通过这四步方法论,不仅提升了特定页面的性能,更建立了一套可复用的前端性能优化体系,为后续其他页面的优化提供了宝贵经验。性能优化是一个持续的过程,我们将不断探索和实践,为用户带来更流畅、高效的使用体验。希望本次分享能为大家在页面性能优化方面提供有益的参考和借鉴。

附:

Lighthouse

Lighthouse 是 Google 开发的一款工具,用于分析网络应用和网页,收集现代性能指标并提供对开发人员最佳实践的意见。为 Lighthouse 提供一个需要审查的网址,它将针对此页面运行一连串的测试,然后生成一个有关页面性能的报告。

页面的性能 Performance 评分,包括:

  • 首次内容绘制(FCP First Contentful Paint);
  • 最大内容渲染时间(LCP largest contentful Paint)衡量感知加载速度;
  • 总阻塞时间 (TBT Total Blocking Time)TBT 衡量的是页面被阻止响应用户输入(例如点击鼠标、点按屏幕或按键盘)的总时长。总和的计算方法是将 First Contentful Paint 和 Time to Interactive 期间所有长任务的阻塞部分相加。任何执行时间超过 50 毫秒的任务都是耗时较长的任务。50 毫秒后的时间是阻塞部分。例如,如果 Lighthouse 检测到一个时长为 70 毫秒的任务,则阻塞部分将为 20 毫秒。
  • 累积布局偏移(CLS Cumulative Layout Shift)衡量视觉稳定性;
  • 速度指标(Speed Index)

Performance

Performance 是 Chrome 提供给我们的开发者工具,用于记录和分析我们的应用在运行时的所有活动。它呈现的数据具有实时性、多维度的特点,可以帮助我们很好地定位性能问题。 使用 Performance 工具时,为了规避其它 Chrome 插件对页面的性能影响,我们最好在无痕模式下打开页面。

  • 控制面板 | 开始记录,停止记录和配置记录期间捕获的信息。
  • 概览面板 | 对页面表现(行为)的一个概述。
    • FPS | 绿色的柱越高, FPS 值也越高,红色则说明可能出现了卡顿。
    • CPU | 表明了哪些事件在消耗 CPU 资源。
    • NET | 蓝色 代表 HTML 文件,黄色 代表 Script 文件,紫色 代表 Stylesheets 文件, 绿色 代表 Media 文件,灰色 代表其他资源。
  • 火焰图面板 | 可视化 CPU 堆栈(stack)信息记录。
    • 从不同的角度分析框选区域 。例如:Network,Frames, Interactions, Main 等。

Performance insights

Performance insights 是 Chrome Chrome DevTools中的自带工具(Chrome102 版本发布),目前还是在Chrome DevTool中启动即可,如下图所示:我们可以模拟cpu,选择4x slowdown,就开始模拟4倍低速 CPU,同理还可以模拟网络应对不同网络的测试需求。 Performance insights 工具最方便的部分是"insights"面板,它位于面板的最右侧。它以垂直时间线的形式按照事件发生的顺序显示事件,如渲染阻塞请求、长任务、布局变化等。点击这些具体事件将导航到"详细信息"选项卡,它给出了关于它的潜在原因和受它影响的元素等的详细信息,在Details中看到影响性能问题的各种因素。想要进一步进行优化,点击该事件就可以查看关于问题的详细描述和具体的优化方案。另外在页面的顶端(绿色框)我们可以方便的看到当前页面DCL,FCP,LCP和TTI这些参数指标

性能监控日志

性能监控日志以 JSON 格式记录了丰富的页面性能数据。我们可以将埋点日志下载到本地,通过 Node 脚本对其进行计算和分析,从而获取所需的数据,如 IndexedDB 缓存平均等待时间、资源加载及解析时间、接口响应后到 TTI 计算结束时间等,也可以通过此方式计算优化某个点的收益等。

开发小结(08.11-08.16)

本周一直在做 uniapp 的微信小程序项目,因此涉及的问题都是在该场景下出现

scroll-view 高度计算

在涉及下拉刷新和上拉加载的场景,scroll-view 是不错的选择,使用的时候需要传一个固定高度,如何准确计算出滚动区域高度,就很重要

如图,底部粉色区域为滚动区域,要计算滚动区域高度,用页面高度(100vh)减去其他区域(红色,绿色,蓝色)高度即可,还需要注意在滚动区域以外,如果有设置 marginTop,marginBottom,也要减去这个高度

在设计页面的时候,最好区分好每个部分的功能,不仅开发起来方便,也有利于计算高度,最后得出滚动区域高度

this.scrollViewHeight = `calc(100vh - (${statusBarHeight}px + ${navigationBarHeight}px + ${uni.upx2px(290)}px + ${uni.upx2px(44 + 10)}px))`;

红框部分实现的是自定义导航栏:高度为${statusBarHeight}px + ${navigationBarHeight}px

蓝框部分用rpx设置的相对高度,需要用uni.upx2px(290)

蓝框部分设置的44rpx加上蓝框和红框之间margin,同样要用uni.upx2px(44 + 10)转化

这样就能准确得出滚动区域高度,但如果其他区域没有设置高度,如何获取该区域高度呢,请接着看自定义 tabbar

自定义 tabbar

还是上面的页面,我需要实现一个 tabbar,要求当前激活项下显示红色短线,并且切换项目时候,短线有移动动画

先根据需求设置基本样式,.tab-item为选项卡部分,.active-bar为底部红色短线

  <view class="tabs">
    <view v-for="(item, idx) in tabList" :key="item.name" class="tab-item"
      :class="{ active: idx === activeTabIndex }" @click="changeTab(idx)">
      {{ item.name }}
    </view>
    <view class="active-bar" :style="{
          left: activeBarLeftPx + 'px'
        }">
    </view>
  </view>

部分样式

.active-bar {
  position: absolute;
  bottom: -6rpx;
  height: 6rpx;
  border-radius: 30rpx;
  background-color: rgba(255, 43, 88, 1);
  width: 40rpx;
}

难点在于获取当前选项卡的位置,才能正确设置短线的位置,因为在 uni 的小程序平台不支持页面的 ref 属性,这里要用到uni.createSelectorQueryapi

在页面加载完成后,查询默认第一个选项卡位置

mounted() {
  // 初始化时获取第一个tab的位置
  this.$nextTick(() => {
    this.queryTabRect(this.activeTabIndex);
  });
},
  
methods: {
  queryTabRect(idx) {
    uni.createSelectorQuery()
      .in(this)
      .selectAll('.tab-item')
      .boundingClientRect(rects => {
        if (rects && rects[idx]) {
          this.activeBarLeftPx = rects[idx].left + (rects[idx].width - uni.upx2px(40)) / 2; // 相对导航栏左侧
        }
      })
      .exec();
  },
}

this.activeBarLeftPx.active-bar相对父容器的距离,如何计算距离呢,rects[idx].left是当前选项卡距离父容器左边距,(rects[idx].width - uni.upx2px(40)) / 2是选项卡和底部短线在竖直方向居中对齐时,多出来长度的一半,加上这个长度,就能正确计算出底部短线距离父容器左边框的距离

容器高度

回到上面scroll-view 高度计算部分,其他区域高度也可以使用uni.createSelectorQueryapi 获取,结果就是rects[idx].height,需要注意这个高度不包括padding,所以计算高度时候需要加上padding

单行文字省略效果

小程序里有一个排行榜的页面,每条记录可能存在名称或者数值溢出的情况,可以考虑使用 css 实现文字溢出显示省略号的功能,不过需要注意使用条件

text-overflow 用于确定如何提示用户存在隐藏的溢出内容,但需要为容器设置准确宽度

从上图分析,内容区域宽度为蓝色区域的宽度,左边文字部分和右边数字部分可能存在溢出情况,就需要给这两个部分添加溢出样式

  .overflow-style {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

还需要设置准确的宽度,溢出效果才能生效,右边可以设置固定宽度100rpx,左边宽度如何计算呢,左边文字区域宽度可以用总宽度100%- 奖牌宽度 - 头像宽度,结果就是文字宽度

由于左边部分存在层级嵌套关系,我们可以逐层计算得出文字宽度

.left计算出左边宽度width: calc(100% - 110rpx),多 10rpx 为了在左右区域之间加个间隔

.info计算出除奖牌宽度,剩余宽度width: calc(100% - 60rpx)

.name计算出除头像宽度,剩余文字宽度width: calc(100% - 70rpx)

示例部分省略代码如下

...
  <view v-for="(item, index) in otherRankList" class="item">
    <view class="left">
      <template v-if="index < 3">
        <image :src="getMedal(index)" mode="aspectFit" class="medal" />
      </template>
      <template v-else>
        <view class="rank">{{ index + 1 }}</view>
      </template>
      <view class="info">
        <image class="avatar" :src="item.avatar" mode="scaleToFill" />
        <view class="name">{{ item.name }}</view>
      </view>
    </view>
    <view class="right">{{ item.value }}</view>
  </view>
...
      .other-rank {
        border-radius: 40rpx 40rpx 0 0;
        width: 100%;
        height: 100%;
        padding: 10rpx 54rpx;
        box-sizing: border-box;
        position: relative;
        z-index: 2;

        .item {
          @include flex-center;
          position: relative;
          justify-content: space-between !important;
          gap: 10rpx;
          height: 110rpx;
          width: 100%;

          .left {
            @include flex-center;
            justify-content: flex-start !important;
            gap: 20rpx;
            width: calc(100% - 110rpx);

            .medal {
              width: 40rpx;
              height: 50rpx;
            }

            .rank {
              width: 40rpx;
              text-align: center;
              font-size: 32rpx;
              font-weight: 700;
              color: #B4B6C0;
            }

            .info {
              @include flex-center;
              justify-content: flex-start !important;
              flex: 1;
              gap: 20rpx;
              width: calc(100% - 40rpx);

              .avatar {
                width: 50rpx;
                height: 50rpx;
                border-radius: 50%;
              }

              .name {
                width: calc(100% - 50rpx);
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
              }
            }
          }

          .right {
            width: 100rpx;
            text-align: right;
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
          }

        }
      }

注意

计算宽度也要把gap考虑在内,如果忽略了gap,在文字溢出时候,图片宽度会被压缩,这时候有两种方法解决

  1. 正确计算对应区域宽度,如果有gap,减去gap宽度
  2. 在被压缩的容器上添加样式flex-shrink: 0,在宽度不足时,禁止压缩

小程序动态样式

页面内如果绑定动态样式,类如

<template>
  <view :style="customStyle"></view>
</template>

<script>
  export default {
    data() {
      return {
        customStyle: {
          fontSize: '16px'
        }
      }
    }
  }
</script>

这种写法编译在微信平台并不能生效,正确写法是:style="[customStyle]"

🎬《Next 全栈 CRUD 的百老汇》

0️⃣ 开场彩蛋:一张图先声夺人

没图?那就画一张 ASCII 海报!

┌─────────────────────────────┐
│      Next.js Full Stack     │
│         CRUD Musical        │
│                             │
│   🎤 Next   💃 Prisma   🥁 Pg │
└─────────────────────────────┘

1️⃣ 舞台搭建:项目初始化

角色 作用 安装命令
Next.js 13 App Router 前端 + API npx create-next-app@latest next-prisma-crud --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
Prisma ORM 数据舞者 npm i prisma @prisma/client
PostgreSQL 幕后仓库 Docker 一行起: docker run --name pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:15

小贴士:
postgres 密码设成 postgres,就像把家门钥匙放在门垫下——方便但别上生产 🏠


2️⃣ 数据库建模:把剧本写成 schema

文件 prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  published Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

小图标:📜 “Prisma Schema:把 DDL 写成十四行诗”


3️⃣ 环境变量:别让密码裸奔

.env

DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public"

.env 加入 .gitignore,否则 GitHub 机器人会私信你:
“兄弟,密码走光了。”


4️⃣ 一键迁移:让数据库学会新舞步

npx prisma migrate dev --name init
npx prisma generate

这两行命令做了什么?

  1. migrate:把 schema 翻译成 SQL,像同声传译。
  2. generate:给 Prisma Client 生成 TypeScript 类型,像给演员发剧本。

5️⃣ 演员上场:Prisma Client 的单例模式

src/lib/prisma.ts

import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

小图标:🎭 “单例模式:确保全场只有一位女主角”


6️⃣ 全栈 CRUD:一场四幕歌剧

🎼 第 1 幕:CREATE — 新生

src/app/api/posts/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';

export async function POST(req: NextRequest) {
  const { title, content } = await req.json();
  const post = await prisma.post.create({ data: { title, content } });
  return NextResponse.json(post, { status: 201 });
}

前端 src/app/new/page.tsx

'use client';
import { useRouter } from 'next/navigation';

export default function NewPost() {
  const router = useRouter();
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = new FormData(e.currentTarget);
    await fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify({
        title: form.get('title'),
        content: form.get('content'),
      }),
    });
    router.push('/');
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4 p-8">
      <input name="title" placeholder="标题" className="border rounded w-full" />
      <textarea name="content" placeholder="内容" className="border rounded w-full h-32" />
      <button className="bg-blue-600 text-white px-4 py-2 rounded">发布</button>
    </form>
  );
}

观众反馈:🎉 “创建成功,数据库鼓掌 1 次”


🎼 第 2 幕:READ — 回眸

src/app/api/posts/route.ts(GET)

export async function GET() {
  const posts = await prisma.post.findMany({ orderBy: { createdAt: 'desc' } });
  return NextResponse.json(posts);
}

src/app/page.tsx

export default async function Home() {
  const posts: Post[] = await (await fetch('http://localhost:3000/api/posts', { cache: 'no-store' })).json();
  return (
    <ul>
      {posts.map(p => (
        <li key={p.id} className="border-b p-4">
          <h2 className="text-xl font-bold">{p.title}</h2>
          <p>{p.content}</p>
        </li>
      ))}
    </ul>
  );
}

小图标:👀 “服务端组件:SSR 免费,不用 Vercel 也请客”


🎼 第 3 幕:UPDATE — 易容

src/app/api/posts/[id]/route.ts

export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
  const data = await req.json();
  const post = await prisma.post.update({
    where: { id: Number(params.id) },
    data,
  });
  return NextResponse.json(post);
}

前端加一个编辑按钮,路由 src/app/edit/[id]/page.tsx,复用 NewPost 组件并预填表单即可。

观众反馈:✍️ “改完标题,数据库说:我年轻了 5 岁”


🎼 第 4 幕:DELETE — 谢幕

src/app/api/posts/[id]/route.ts

export async function DELETE(_: NextRequest, { params }: { params: { id: string } }) {
  await prisma.post.delete({ where: { id: Number(params.id) } });
  return NextResponse.json({ ok: true });
}

前端点击“删除”按钮触发:

await fetch(`/api/posts/${id}`, { method: 'DELETE' });

小图标:🗑️ “数据谢幕,观众请保持安静 404”


7️⃣ 彩蛋:实时订阅,让数据库开麦

Prisma 的实时功能(预览):

const stream = prisma.post.subscribe(); // 伪代码

下一季音乐剧:WebSocket + Server Sent Events + Prisma Pulse,敬请期待 🍿


8️⃣ 谢幕清单:部署走你

  1. 推代码到 GitHub
  2. 一键 Vercel:选 Postgres 模板,自动注入 DATABASE_URL
  3. 看日志:
    [POST] /api/posts 201 23ms
    [GET]  /api/posts 200 15ms
    

观众鼓掌:👏 “从本地 3000 到线上 HTTPS,只花了 30 秒”


9️⃣ 彩蛋台词墙

角色 经典台词
Prisma Client “我不是 ORM,我是类型安全的舞伴。”
Next.js “App Router:服务端组件,免费 SSR,不加价。”
PostgreSQL “索引就像高跟鞋,漂亮但别乱穿。”

“愿你每次 npx prisma migrate 都能顺利通过,
愿每个 404 都变成 200 OK。”
—— 一名把 SQL 跳成芭蕾舞的全栈工程师 🩰

🎭 一场浏览器里的文艺复兴

1. 开场:把 AI 请进网址栏

公元前 2025 年,一位前端工程师在键盘上敲下 npm create aigc-app@latest
浏览器瞬间变成了一座 24h 不打烊的剧院:

  • 观众是用户
  • 演员是 AI
  • 舞台是 Web

下面这份节目单,记录了最值得一聊的 Web 技术——它们就像后台道具师,默默把 AI 的魔法变成可点击的像素。


2. 道具一:WebGPU —— 把显卡借给 AI

关键台词 “亲爱的显卡,别只打游戏了,来跑个 7B 大模型吧!”
底层原理 浏览器→WebGPU→驱动→显卡→矩阵运算→AI 推理
生活比喻 把家里的台式机 GPU 像共享单车一样扫二维码骑走
// 1行代码,显卡到手
const adapter = await navigator.gpu.requestAdapter();

小贴士:
如果看到 undefined,说明浏览器还在劝你“显卡太老,先去喝杯咖啡”。


3. 道具二:WebAssembly + SIMD —— CPU 也想加班

关键台词 “CPU 大喊:我还能再战十年!”
底层原理 C/C++ 编译成 .wasm,SIMD 一次算 128 bit,像 4×4 拼乐高
// 加载 wasm,给 CPU 打鸡血
const wasmModule = await WebAssembly.instantiateStreaming(
  fetch('model_simd.wasm')
);

小图标:⚡️ “单核变八核,电费不变”


4. 道具三:Web Workers + SharedArrayBuffer —— 多线程不堵车

关键台词 “主线程:你慢慢算,我去刷 UI。”
底层原理 主线程 ↔ SharedArrayBuffer ↔ Worker ↔ AI 模型
生活比喻 老板(主线程)把报表丢给实习生(Worker),自己去开会
// 主线程
const worker = new Worker('aigc.worker.js');
worker.postMessage({ cmd: 'dream', prompt: '赛博猫咪' });

// worker.js
self.onmessage = ({ data }) => {
  const pixels = bigModel.inference(data.prompt);
  self.postMessage(pixels, [pixels.buffer]); // 零拷贝传输
};

5. 道具四:WebRTC + WebTransport —— 把服务器搬进浏览器

关键台词 “别再问我服务器在哪,它就在隔壁标签页!”
底层原理 P2P 数据通道 → UDP/QUIC → 低延迟流式推理
生活比喻 像打电话,但双方都是浏览器
// 建立 P2P 推理通道
const pc = new RTCPeerConnection();
const dataChannel = pc.createDataChannel('ai-stream');
dataChannel.onmessage = ({ data }) => drawImage(data);

6. 道具五:Web Codecs —— 让像素学会压缩瑜伽

关键台词 “一张 4K 图,秒变表情包。”
底层原理 浏览器原生硬件编解码 → H.264/AV1/VP9
生活比喻 把大象塞进冰箱,但冰箱自带黑洞压缩器
const decoder = new VideoDecoder({
  output: frame => canvas.drawImage(frame),
  error: e => console.error('解码器罢工', e)
});
decoder.configure({ codec: 'avc1.42E01E' });

7. 道具六:IndexedDB + OPFS —— 浏览器里藏个硬盘

关键台词 “模型 2 GB?本地缓存,下次秒开。”
底层原理 浏览器沙盒文件系统 → 异步 IO → 零拷贝映射
生活比喻 把模型当离线地图,没网也能导航
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('llama2_7b.bin', { create: true });
const writable = await fileHandle.createWritable();
await writable.write(modelBlob);
await writable.close();

8. 彩蛋:一行代码的 AI 魔法秀

把上文所有技术串成一串糖葫芦,只需:

// index.html
<canvas id="stage"></canvas>
<script type="module">
  import { AIGen } from './aigc.min.js';
  await AIGen.init({ gpu: true, wasmSimd: true, cache: true });
  AIGen.paint('月光下的独角兽', document.getElementById('stage'));
</script>

浏览器:“灯光、显卡、Action!”


9. 谢幕 & 加餐

下一站 学习资料
WebGPU 入门 gpuweb.github.io
wasm SIMD github.com/WebAssembly…
WebRTC datachannel webrtc.github.io/samples

小彩蛋:把本文打印出来折成纸飞机,飞得越远,模型跑得越快 🛩️


“愿你写的每一个 await,都能等到未来的惊喜。”
—— 一个把 AI 塞进地址栏的工程师

组件基础-List&Tabs

一、List

列表组件

结构:

@Entry
@Component
struct ListPage {

  build() {
    List() {
      ListItem() {
        Text("子组件")
      }
      ListItem()
      ListItem()
      ListItem()
    }
    .height('100%')
    .width('100%')
  }
}
  • 列表中的内容一般是相似的,因此我们可以利用ForEach来进行渲染,减少代码量
  • 当数据量过大时,我们就需要需要使用LazyForEach来提升效率,增加用户体验

ForEach(数据源, 组件生成函数, 键值生成函数) 键值生成函数是一个回调函数,用于生成唯一的key;若不写,系统会帮我们生成独一无二的key,这个参数,宁可不给也不要随意添加,不恰当会影响运行效率

image-20250825093718731.png

interface testListData {
  name: string
  age: number
}


@Entry
@Component
struct ListPage {
  @State data: testListData[] = [
    { name: "a", age: 12 },
    { name: "b", age: 13 },
    { name: "c", age: 14 },
    { name: "d", age: 15 },
    { name: "e", age: 16 },
  ]

  build() {
    List({ space: 5 }) {
      ForEach(this.data, (item: testListData, idx: number) => {
        ListItem() {
          Column() {
            Row() {
              Text(item.name).fontSize(30)
              Blank()
              Text(item.age + "").fontSize(30)
            }
            .width('100%')

            Divider().strokeWidth(2)
          }
          .width('100%')
        }
      }, (item: testListData, idx) => idx + "")
    }
    .height('100%')
    .width('100%')
  }
}

二、Tabs

类似于微信底部的切换栏

image-20250825094614285.png

切换栏默认是在顶部的,可以通过Tabs({barPosition: BarPosition.End})设置栏的位置为底部

image-20250825095012444.png

通过设置controller: this.barController给tabs设置控制器,方便后续的手动设置操作

.barMode(BarMode.Scrollable)// 滚动

@Entry
@Component
struct TabsPage {
  build() {
    Column() {
      TabsComponents()
    }
    .height('100%')
    .width('100%')
  }
}

@Component
struct TabsComponents {
  @State currentIdx: number = 0
  barController: TabsController = new TabsController()

  @Builder
  Bar(tabBarName: string, idx: number) {
    Text(tabBarName).fontSize(20)
      .fontColor(this.currentIdx === idx ? Color.Red : Color.Black)
      .onClick(() => {
        this.currentIdx = idx
        this.barController.changeIndex(this.currentIdx)
      })
  }

  build() {
    Column() {
      Tabs({ barPosition: BarPosition.End, controller: this.barController }) {
        TabContent() {
          Text("界面1").fontSize(60)
        }
        .tabBar(this.Bar("界面1", 0))
        TabContent() {
          Text("界面2").fontSize(60)
        }
        .tabBar(this.Bar("界面2", 1))
        TabContent() {
          Text("界面3").fontSize(60)
        }
        .tabBar(this.Bar("界面3", 2))
      }
    }
  }
}
  • 绑定的目标页数一定要绑定@State装饰器,否则只切换无效果@State currentIdx: number = 0

image-20250825102759325.png

  • 缺失@State

image-20250825103330813.png

揭秘DOM键盘事件:从基础到高级技巧全解析!

在前端开发中,键盘事件是用户交互的重要组成部分。无论是实现快捷键、实时搜索,还是复杂的表单验证,键盘事件都扮演着核心角色。今天,我们将深入探讨DOM事件中的键盘与输入事件,揭开它们的神秘面纱,掌握其背后的原理与实战技巧。


一、键盘事件的定义与分类

1. 什么是键盘事件?

键盘事件是用户在使用键盘与页面交互时触发的事件。常见的键盘事件包括:

  • keydown:按键被按下时触发(按住不放会重复触发)。
  • keypress:字符键被按下时触发(功能键如方向键不会触发)。
  • keyup:按键被松开时触发。

2. 事件触发顺序

当用户按下字符键(如字母、数字)时,事件触发顺序为:

keydown → keypress → keyup

而对于功能键(如方向键、Ctrl、Shift),仅触发 keydownkeyup


二、键盘事件的核心属性

1. keyCodewhich

  • keyCode:返回按键的ASCII码值(兼容性较差,现代浏览器已逐渐弃用)。
  • which:与 keyCode 类似,但兼容性更好(Firefox 2.0+ 支持)。
  • 兼容写法
    const keyCode = e.keyCode || e.which;
    

2. keycode

  • key:返回实际按下的字符(如 "a"、"Enter"),区分大小写。
  • code:返回物理按键的代码(如 "KeyA"、"ArrowUp"),不区分大小写。
    console.log(e.key);   // 输出 "a"
    console.log(e.code);  // 输出 "KeyA"
    

3. 组合键判断

通过以下属性判断是否按下了组合键:

  • altKey:Alt 键是否按下。
  • shiftKey:Shift 键是否按下。
  • ctrlKey:Ctrl 键是否按下。
  • metaKey:Mac 上的 Command 键是否按下。
    if (e.ctrlKey && e.key === "s") {
      console.log("你按下了 Ctrl + S");
    }
    

三、常用方法与实战技巧

1. 绑定事件

  • 传统方式
    document.onkeydown = function(e) {
      console.log("按键按下");
    };
    
  • 现代方式
    document.addEventListener("keydown", function(e) {
      console.log("按键按下");
    });
    

2. 实时搜索功能

通过 keyup 事件实现实时搜索:

document.getElementById("search").addEventListener("keyup", function(e) {
  const query = this.value;
  console.log("搜索内容:", query);
});

3. 快捷键设计

为页面添加快捷键,例如按 Ctrl + S 保存:

document.addEventListener("keydown", function(e) {
  if (e.ctrlKey && e.key === "s") {
    e.preventDefault(); // 阻止默认行为(如浏览器保存)
    console.log("保存操作");
  }
});

4. 组合键判断

判断用户是否按下了组合键:

document.addEventListener("keydown", function(e) {
  if (e.altKey && e.key === "a") {
    console.log("你按下了 Alt + A");
  }
});

四、应用场景

1. 游戏开发

  • 使用方向键或 WASD 控制角色移动。
  • 按空格键跳跃或射击。

2. 表单验证

  • 实时检测用户输入是否符合格式(如邮箱、手机号)。
  • 按回车键提交表单。

3. 快捷键优化

  • 提升用户操作效率(如 Markdown 编辑器中的快捷键)。
  • 自定义快捷键(如 IDE 中的 Ctrl + C/V/X)。

4. 无障碍设计

  • 为残障用户提供键盘导航支持(如通过 Tab 键切换焦点)。

五、注意事项与性能优化

1. 元素的可聚焦性

  • 非表单元素(如 div:默认无法触发键盘事件,需设置 tabindex 属性:
    <div tabindex="0" id="myDiv"></div>
    
  • 动态聚焦:通过 element.focus() 方法主动获取焦点。

2. 性能问题

  • 节流(Throttle):对于高频触发的 keydown/keypress 事件,使用节流函数避免页面卡顿。
    function throttle(fn, delay) {
      let last = 0;
      return function(...args) {
        const now = Date.now();
        if (now - last > delay) {
          last = now;
          fn.apply(this, args);
        }
      };
    }
    
    document.addEventListener("keydown", throttle(function(e) {
      console.log("节流后的按键事件");
    }, 300));
    

3. 兼容性处理

  • 移动端适配:部分移动设备对 keypress 事件支持较差,建议优先使用 keydownkeyup
  • 阻止默认行为:某些按键(如回车键)可能触发默认操作,需通过 e.preventDefault() 阻止。

六、经典案例解析

1. 模拟京东搜索框快捷键

当用户按下 S 键时,自动聚焦搜索框:

document.addEventListener("keydown", function(e) {
  if (e.key === "s" && !e.ctrlKey && !e.metaKey) {
    document.getElementById("search").focus();
  }
});

2. 组合键实现保存功能

按下 Ctrl + S 时保存数据:

document.addEventListener("keydown", function(e) {
  if (e.ctrlKey && e.key === "s") {
    e.preventDefault();
    console.log("数据已保存");
  }
});

七、总结

键盘事件是前端交互中不可或缺的一部分,掌握其原理与技巧能显著提升用户体验。从基础的按键判断到复杂的组合键设计,再到性能优化与无障碍适配,每一个细节都值得深入探索。通过本文的讲解,相信你已经对键盘事件有了全面的理解。接下来,不妨动手实践,将这些技巧应用到你的项目中,创造更高效的用户交互体验!

❌