阅读视图

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

## React Native 中的 dp、dpi 和分辨率,到底是什么关系?

一、三个概念:px、dpi、dp

  1. px:物理像素 / 分辨率里的那个像素 当我们说“720p、1080p”时,其实说的是分辨率:

    • 720p ≈ 1280 × 720 像素
    • 1080p ≈ 1920 × 1080 像素 这里的“1280、1920、720、1080”,都是 px(物理像素点的个数)。
  2. dpi:像素密度(每英寸多少个像素) dpi(dots per inch)表示:一英寸(2.54cm)长度上有多少个像素点。

    • 160dpi:每英寸约 160 个像素
    • 320dpi:每英寸约 320 个像素(比 160dpi 更细腻) dpi 是硬件属性,跟屏幕多大、分辨率多少一起决定“看起来多清晰”。
  3. dp:密度无关像素(React Native 用的逻辑单位) dp(density-independent pixel)是系统定义的一种“逻辑长度单位”,让同样的数值在不同 dpi 的设备上,看起来差不多大。 在 React Native 里,你写 width: 100,这个 100 本质上就是 100dp,而不是 100px。

二、dp 和 dpi 的数学关系

Android / React Native 的约定:

  • 160dpi 的屏幕当成基准屏幕。
  • 在 160dpi 屏幕上:1dp ≈ 1px
  • 在其它密度屏幕上,系统用这个公式换算:px = dp × (dpi / 160)

举几个数字例子:

  • 160dpi(基准) 1dp = 1 × (160 / 160) = 1px 100dp = 100px
  • 320dpi 1dp = 1 × (320 / 160) = 2px 100dp = 200px
  • 480dpi 1dp = 1 × (480 / 160) = 3px 100dp = 300px(四舍五入后近似)

也就是说:

当你在代码里写 width: 100 时,这个 100 实际上是 100dp。系统会根据当前设备的 dpi,把它换算成应该使用多少个物理像素:

  • 在 160dpi 的屏幕上,大约使用 100px(1dp ≈ 1px);
  • 在 320dpi 的屏幕上,大约使用 200px(1dp ≈ 2px);
  • 在 480dpi 的屏幕上,大约使用 300px(1dp ≈ 3px)。

随着屏幕像素密度提高,单个像素本身变得更小,所以虽然不同设备实际使用的物理像素数量不一样,但这个“100dp 宽”的控件在现实世界里的物理尺寸(比如看起来有多宽、占多大一块屏幕)会尽量保持接近。

现在,160/320/480dpi 和 dp→px 的关系就理清楚了,也解释了“为什么同样的 dp 在不同手机上看起来差不多大”。

三、示意图:同样 100dp 的按钮,在不同 dpi / 分辨率上长什么样?

Gemini_Generated_Image_hl6v29hl6v29hl6v.png1)左边:720p,160dpi

  • 分辨率:1280 × 720 px
  • dpi:160
  • 换算:1dp = 1px100dp = 100px
  • 按钮在屏幕上大约占据“某个具体宽度 A 厘米”。

2)中间:1080p,320dpi

  • 分辨率:1920 × 1080 px
  • dpi:320(像素更密)
  • 换算:1dp = 2px100dp = 200px
  • 按钮用更多像素画出来,但因为像素更小,实际占据的物理宽度依然接近 A 厘米。

3)右边:更高分辨率,2K,480dpi

  • 分辨率:例如 2560 × 1440 px(举例)
  • dpi:480
  • 换算:1dp = 3px100dp = 300px
  • 按钮在屏幕上依然大约是 A 厘米宽,只是边缘、文字更细腻。

图中三个按钮,看起来差不多宽,但右边那台的像素数量最多——这就是“dp + dpi 换算”的效果:同样的 dp 值,在高密度屏上用更多 px 去画,从而保持视觉尺寸接近。

四、那 720p、 1080p 在这个故事里扮演什么角色?

  • 720p、1080p 说明的是总像素数(分辨率),即屏幕一共多少个 px。
  • dpi 说明的是这些像素在物理尺寸上的“密度”,和“屏幕有多大”一起决定屏幕看起来多清晰。
  • dp 是在 React Native / Android / iOS 布局里用的“逻辑尺寸单位”,用来写 UI 尺寸。系统会根据当前设备的 dpi 和分辨率,用公式把 dp 换成 px。

你可以大致这样理解:

  • px:砖头总数(分辨率)
  • dpi:每 1cm 摆多少块砖(密度)
  • dp:设计图上画的“这堵墙要 2 米宽”,具体到现场要用多少砖,由工人(系统)根据砖尺寸(dpi)来算。

五、在 React Native 里怎么用 dp 写尺寸?

在 RN 代码里,你不用写 dp、px 这样的单位,直接写数字即可,这个数字默认就是 dp:

// 宽高都是 dp 单位
<View style={{ width: 100, height: 40, borderRadius: 8 }} />

React Native 会做几件事:

  1. 获取当前设备的:
    • 分辨率(总px数)
    • 屏幕尺寸(英寸)
    • 像素密度dpi(ppi)
  2. 用公式 px = dp × (dpi / 160),把你的dp换算成真实px
  3. 把这个px告诉底层原生视图去渲染

你也可以用 DimensionsPixelRatio 看一下当前设备的逻辑尺寸和像素比:

import { Dimensions, PixelRatio } from 'react-native';

const { width, height } = Dimensions.get('window'); // 宽高,单位是 dp
const pixelRatio = PixelRatio.get();                // 设备像素比,约等于 dpi / 160

比如某台设备:

  • width = 375,height = 812(单位是逻辑尺寸dp)
  • pixelRatio = 3
  • 分辨率大约是:宽 375 × 3 = 1125px,高 812 × 3 ≈ 2436px(类似iPhoneX)

六、和设计稿的连接:从px到dp

最后再把“设计稿”这块挂上来,形成闭环:

  1. 设计稿给的一般是px,比如 750 × 1334。
  2. 我们约定它对应某个“逻辑宽度”(比如375dp,即2倍图)。
  3. 换算关系:RN中 dp = 设计稿px / 2(以750px为例)。

uni-app开发app之前提须知(IOS/安卓)

大家好,我是鱼樱!!!

关注公众号【鱼樱AI实验室】持续分享更多前端和AI辅助前端编码新知识~~

不定时写点笔记写点生活~写点前端经验。

在当前环境下,纯前端开发者可以通过技术深化、横向扩展、切入新兴领域(MCP TOOLS AI应用产品方向 等)以及产品化思维找到突破口。(不管写多久的代码,技术深度广度如何一定要具备独立赚钱的能力呀!!老铁们,因为总有一天你不在职场总有一天会面对选择和职场的无情)

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

前端最卷的开发语言一点不为过,三天一小更,五天一大更。。。一年一个框架升级~=嗯,要的就是这样感觉!与时俱进~ 接下来会逐步分享uni-app的一些开发经验。从文档到基础到实战以及遇到的问题~时间节点不限。

最近依旧比较忙,技术文章还是会不定时更新一些核心的技术分享,和大家一起学习前端路程。

前言须知

uni-app App 端内置了一个基于 weex 改进的原生渲染引擎,提供了原生渲染能力,在 App 端,如果使用 vue 页面,则使用 webview 渲染;如果使用 nvue 页面(native vue 的缩写),则使用原生渲染。一个 App 中可以同时使用两种页面,比如首页使用 nvue,二级页使用 vue 页面

虽然 nvue 也可以多端编译,输出 H5 和小程序,但 nvue 的 css 写法受限,所以如果你不开发 App,那么不需要使用 nvue

nvue 的组件和 API 写法与 vue 页面一致

如果你熟悉 weex 或 react native 开发,那么 nvue 是你的更优选择,能切实提升你的开发效率,降低成本

如果你是 web 前端,不熟悉原生排版,那么建议你仍然以使用 vue 页面为主,在 App 端某些 vue 页面表现不佳的场景下使用 nvue 作为强化补充。

uni-app 在 App 端,支持 vue 页面和 nvue 页面混搭、互相跳转。也支持纯 nvue 原生渲染。

启用纯原生渲染模式,可以减少 App 端的包体积、减少使用时的内存占用。因为 webview 渲染模式的相关模块将被移除。

在 manifest.json 源码视图的"app-plus"下配置"renderer":"native",即代表 App 端启用纯原生渲染模式。此时 pages.json 注册的 vue 页面将被忽略,vue 组件也将被原生渲染引擎来渲染。

如果不指定该值,默认是不启动纯原生渲染的。

// manifest.json
{
   // ...
// App平台特有配置
   "app-plus": {
      "renderer": "native", //App端纯原生渲染模式
   }
}

编译模式

weex 编译模式和 uni-app 编译模式(推荐)

weex 的组件和 JS API,与 uni-app 不同。uni-app 与微信小程序相同 主要介绍 uni-app 方式 weex自行了解

image.png

在 manifest.json 中修改 2 种编译模式,manifest.json -> app-plus -> nvueCompiler 切换编译模式。 nvueCompiler 有两个值:

  • weex
  • uni-app
// manifest.json
{
// ...
// App平台特有配置
"app-plus": {
"nvueCompiler":"uni-app" //是否启用 uni-app 模式
}
}

原生开发没有页面滚动的概念,页面内容高过屏幕高度时,内容并不会自动滚动;只有将页面内容放在listwaterfallscroll-view/scroller这几个组件下内容才可滚动。这不符合前端开发的习惯,所以在 nvue 编译为 uni-app模式时,uni-app框架会给 nvue 页面外层自动嵌套一个 scroller,从而实现页面内容的自动滚动。

注意

  • uni-app框架仅对 nvue 页面嵌套scroller容器,不会给组件自动套scroller容器;
  • 若 nvue 页面有recycle-list组件时,uni-app框架也不会自动给页面嵌套scroller容器
  • 若你不希望自动嵌套scroller容器,可在pages.json中通过如下配置进行关闭:
{
    "path": "",
    "style": {
        "disableScroll": true // 不嵌套 scroller
    }
}

HBuilderX 3.1.0+ 开始支持新的样式编译模式

  • weex 编译模式:老模式,样式支持与普通 weex 相同
  • uni-app 编译模式:新模式,在 weex 原有样式基础上支持组合选择器(相邻兄弟选择器、普通兄弟选择器、子选择器、后代选择器)详见
  // manifest.json
  {
      // ...
      // App平台特有配置
      "app-plus":  {
          "nvueStyleCompiler": "uni-app"
      }
  }

快速上手

在 HBuilderX 的 uni-app 项目中,新建页面,弹出界面右上角可以选择是建立vue页面还是nvue页面,或者 2 个同时建

不管是 vue 页面还是 nvue 页面,都需要在pages.json中注册。如果在 HBuilderX 中新建页面是会自动注册的,如果使用其他编辑器,则需要自行在 pages.json 里注册。

如果一个页面路由下同时有 vue 页面和 nvue 页面,即出现同名的 vue 和 nvue 文件。那么在 App 端,会仅使用 nvue 页面,同名的 vue 文件将不会被编译到 App 端。而在非 App 端,会优先使用 vue 页面。

如果不同名,只有 nvue 页面,则在非 app 端,只有 uni-app 编译模式的 nvue 文件才会编译。

image.png

开发 nvue 页面需注意

nvue 页面结构同 vue, 由 templatestylescript 构成。

  • template: 模板写法、数据绑定同 vue。组件支持 2 种模式,

  • style:由于采用原生渲染,并非所有浏览器的 css 均支持,布局模型只支持 flex 布局,虽然不会造成某些界面布局无法实现,但写法要注意。详见:样式

  • script:写法同 vue,并支持 3 种 API:

调试 nvue 页面

HBuilderX 内置了 weex 调试工具的强化版,包括审查界面元素、看 log、debug 打断点,详见

注意事项

  • vue 和 nvue 页面均支持断点调试
  • 目前仅支持 nvue 页面审查元素,vue 页面暂不支持,以及 Android 平台的 nvue 审查元素暂不支持查看 style
  • App 端提供真机运行的console.log日志输出,运行到真机或模拟器时,不用点debug按钮,运行手机 App,会在HBuilderX的控制台直接输出日志。
  • 如果是调试App的界面和常规 API,推荐编译到 H5 端,点HBuilderX右上角的预览,在内置浏览器里调Dom,保存后立即看到结果,调试更方便。并且 H5 端也支持titleNView的各种复杂设置。唯一要注意的就是css兼容性,使用太新的csspc上预览可能正常,但低端Android上异常,具体可查询caniuse等网站。
  • 常用的开发模式就是pc上使用内置浏览器预览调 dom,运行到真机上看console.log。如果是很复杂的问题才使用debug
  • uni-app 的 App 端的 webkit remote debug,只能调试视图层,不能调试逻辑层。因为 uni-app 的 js 不是运行在 webview 里,而是独立的 jscore 里。
  • 部分 manifest 配置,如三方 sdk 配置,需要打包后生效的,可以打包一个自定义运行基座。打包自定义基座后运行这个自定义基座,同样可以真机运行和 debug。打包正式包将无法真机运行和 debug。
  • 调试依赖 chrome 安装位置,找不到可能会导致调试报错。社区反馈中有用户主动修改了 chrome 的安装位置,导致内置的 puppeteer 查找 chrome失败,如果你修改了 chrome 的安装位置,请参考 《HBuilderX APP端 uni/nvue 调试报错问题

nvue开发与vue开发的常见区别

  1. nvue 页面控制显隐只可以使用v-if不可以使用v-show
  2. nvue 页面只能使用flex布局,不支持其他布局方式。页面开发前,首先想清楚这个页面的纵向内容有什么,哪些是要滚动的,然后每个纵向内容的横轴排布有什么,按 flex 布局设计好界面。
  3. nvue 页面的布局排列方向默认为竖排(column),如需改变布局方向,可以在 manifest.json -> app-plus -> nvue -> flex-direction 节点下修改,仅在 uni-app 模式下生效。详情
  4. nvue页面编译为H5、小程序时,会做一件css默认值对齐的工作。因为weex渲染引擎只支持flex,并且默认flex方向是垂直。而H5和小程序端,使用web渲染,默认不是flex,并且设置display:flex后,它的flex方向默认是水平而不是垂直的。所以nvue编译为H5、小程序时,会自动把页面默认布局设为flex、方向为垂直。当然开发者手动设置后会覆盖默认设置。
  5. 文字内容,必须、只能在<text>组件下。不能在<div><view>text区域里直接写文字。否则即使渲染了,也无法绑定js里的变量。
  6. 只有text标签可以设置字体大小,字体颜色。
  7. 布局不能使用百分比、没有媒体查询。
  8. nvue 切换横竖屏时可能导致样式出现问题,建议有 nvue 的页面锁定手机方向。
  9. 支持的css有限,不过并不影响布局出你需要的界面,flex还是非常强大的。详见
  10. 不支持背景图。但可以使用image组件和层级来实现类似web中的背景效果。因为原生开发本身也没有web这种背景图概念
  11. css选择器支持的比较少,只能使用 class 选择器。详见
  12. nvue 的各组件在安卓端默认是透明的,如果不设置background-color,可能会导致出现重影的问题。
  13. class 进行绑定时只支持数组语法。
  14. Android端在一个页面内使用大量圆角边框会造成性能问题,尤其是多个角的样式还不一样的话更耗费性能。应避免这类使用。
  15. nvue页面没有bounce回弹效果,只有几个列表组件有bounce效果,包括 listrecycle-listwaterfall
  16. 原生开发没有页面滚动的概念,页面内容高过屏幕高度并不会自动滚动,只有部分组件可滚动(listwaterfallscroll-view/scroller),要滚的内容需要套在可滚动组件下。这不符合前端开发的习惯,所以在 nvue 编译为 uni-app模式时,给页面外层自动套了一个 scroller,页面内容过高会自动滚动。(组件不会套,页面有recycle-list时也不会套)。后续会提供配置,可以设置不自动套。
  17. 在 App.vue 中定义的全局js变量不会在 nvue 页面生效。globalDatavuex是生效的。
  18. App.vue 中定义的全局css,对nvue和vue页面同时生效。如果全局css中有些css在nvue下不支持,编译时控制台会报警,建议把这些不支持的css包裹在条件编译里,APP-PLUS-NVUE
  19. 不能在 style 中引入字体文件,nvue 中字体图标的使用参考:加载自定义字体。如果是本地字体,可以用plus.io的API转换路径。
  20. 目前不支持在 nvue 页面使用 typescript/ts21. nvue 页面关闭原生导航栏时,想要模拟状态栏,可以参考文章。但是,仍然强烈建议在nvue页面使用原生导航栏。nvue的渲染速度再快,也没有原生导航栏快。原生排版引擎解析json绘制原生导航栏耗时很少,而解析nvue的js绘制整个页面的耗时要大的多,尤其在新页面进入动画期间,对于复杂页面,没有原生导航栏会在动画期间产生整个屏幕的白屏或闪屏。

iOS 平台下拉组件 refresh 组件注意问题

iOS 平台默认情况下滚动容器组件(如listwaterfall组件)内容不足时,由于没有撑满容器的可视区域会导致无法上下滚动,此时无法操作下拉刷新功能,无法触发refresh组件的@refresh@pullingdown事件。 此时可在容器组件中配置alwaysScrollableVertical属性值为true来设置支持上下滚动,支持下拉刷新操作。

Android 平台不存在此问题

用法

<list class="scroll-v list" enableBackToTop="true" scroll-y alwaysScrollableVertical="true">
<refresh class="refresh" @refresh="onrefresh()" @pullingdown="onpullingdown">
<!-- refresh content -->
</refresh>
<cell v-for="(newsitem,index) in list" :key="newsitem.id">
<!-- cell content -->
</cell>
</list>

image.png

HTML5+ 能力

uni-app App 端内置 HTML5+ 引擎,让 js 可以直接调用丰富的原生能力

image.png

条件编译调用 HTML5+

小程序及 H5 等平台是没有 HTML5+ 扩展规范的,因此在 uni-app 调用 HTML5+ 的扩展规范时,需要注意使用条件编译。否则运行到h5、小程序等平台会出现 plus is not defined错误。

// #ifdef APP-PLUS
var appid = plus.runtime.appid;
console.log('应用的 appid 为:' + appid);
// #endif

uni-app不需要 plus ready

在html中使用plus的api,需要等待plus ready。 而uni-app不需要等,可以直接使用。而且如果你调用plus ready,反而不会触发。

uni-app 中的事件监听

在普通的 H5+ 项目中,需要使用 document.addEventListener 监听原生扩展的事件。

uni-app 中,没有 document。可以使用 plus.globalEvent.addEventListener 来实现。

// #ifdef APP-PLUS
// 监听新意图事件
plus.globalEvent.addEventListener('newintent', function(){});
// #endif

复制代码

同理,在 uni-app 中使用 Native.js 时,一些 Native.js 中对于原生事件的监听同样需要按照上面的方法去实现。

Native.js 能力

Native.js技术,简称NJS,是一种将手机操作系统的原生对象转义,映射为JS对象,在JS里编写原生代码的技术。 如果说Node.js把js扩展到服务器世界,那么Native.js则把js扩展到手机App的原生世界。 HTML/JS/Css全部语法只有7万多,而原生语法有几十万,Native.js大幅提升了HTML5的能力。 NJS突破了浏览器的功能限制,也不再需要像Hybrid那样由原生语言开发插件才能补足浏览器欠缺的功能。 NJS编写的代码,最终需要在HBuilder里打包发行为App安装包,或者在支持Native.js技术的浏览器里运行。目前Native.js技术不能在普通手机浏览器里直接运行。

  • NJS大幅扩展了HTML5的能力范围,原本只有原生或Hybrid App的原生插件才能实现的功能如今可以使用JS实现。
  • NJS大幅提升了App开发效率,将iOS、Android、Web的3个工程师组队才能完成的App,变为1个web工程师就搞定。
  • NJS不再需要配置原生开发和编译环境,调试、打包均在HBuilder里进行。没有mac和xcode一样可以开发iOS应用。
  • 如果不熟悉原生API也没关系,我们汇总了很多NJS的代码示例,复制粘贴就可以用。ask.dcloud.net.cn/article/114

再次强调,Native.js不是一个js库,不需要下载引入到页面的script中,也不像nodejs那样有单独的运行环境,Native.js的运行环境是集成在5+runtime里的,使用HBuilder打包的app或流应用都可以直接使用Native.js。

注意事项:

  • Uni-app不支持Native.js执行UI相关操作的API调用及webview相关API调用。将失效无法正常使用。Uni-app不推荐使用Native.js

  • Native API具有平台依赖性,所以需要通过以下方式判断当前的运行平台

function judgePlatform(){
switch ( plus.os.name ) {
case "Android":
// Android平台: plus.android.*
break;
case "iOS":
// iOS平台: plus.ios.*
break;
default:
// 其它平台
break;
}
}
  • 在NJS中调用Native API或从Native API返回数据到NJS时会自动转换数据类型

image.png

技术要求

由于NJS是直接调用Native API,需要对Native API有一定了解,知道所需要的功能调用了哪些原生API,能看懂原生代码并参考原生代码修改为JS代码。 否则只能直接copy别人写好的NJS代码。

renderjs能力

renderjs是一个运行在视图层的js。它比WXS更加强大。它只支持app-vue和web。

renderjs的主要作用有2个:

  1. 大幅降低逻辑层和视图层的通讯损耗,提供高性能视图交互能力
  2. 在视图层操作dom,运行 for web 的 js库

image.png

  • nvue的视图层是原生的,无法运行js。但提供了bindingx技术来解决通信阻塞。详见
  • 微信小程序下替代方案是wxs,这是微信提供的一个裁剪版renderjs。详见
  • web下不存在逻辑层和视图层的通信阻塞,也可以直接操作dom,所以在web端使用renderjs主要是为了跨端复用代码。如果只开发web端,没有必要使用renderjs
<script module="test" lang="renderjs">
export default {
mounted() {
// ...
},
methods: {
// ...
}
}
</script>

过去的问题

  • H5端流行的echart报表因为涉及大量dom操作,无法跨端使用
  • wx-chart在跨端和更新方面都不足
  • 插件市场提供了比wx-chart更好的、全端可用的uChart。但受限于小程序架构逻辑层和视图层分离,导致的通信折损,图表的动画性能不佳。并且uchart只实现了echart的常用功能,还有一些功能没有实现。

新的解决方案

从uni-app 2.5.5+起,新提供了renderjs技术。它是wxs的升级版,一种可以运行在视图层的js。

通过renderjs编写的代码,直接运行在视图层(也就是webview中),可以完整的运行echart等库,并且没有了逻辑层和视图层频繁通行的折损,让动画不再卡顿。

renderjs支持app-vue和h5,不支持其他平台。如果你只考虑这2个平台,可以直接使用本示例,使用完整版的echart。如果还要兼容多端小程序,建议仍然使用uchart。

renderjs,不止能运行echart,其他如F2、threejs等web库都可以运行。

renderjs使用注意事项

  • 目前仅支持内联使用。
  • 不要直接引用大型类库,推荐通过动态创建 script 方式引用。
  • 可以使用 vue 组件的生命周期(不支持 beforeDestroy、destroyed、beforeUnmount、unmounted),不可以使用 App、Page 的生命周期
  • 视图层和逻辑层通讯方式与 WXS 一致,另外可以通过 this.$ownerInstance 获取当前组件的 ComponentDescriptor 实例。
  • this.$ownerInstance.callMethod() 仅支持调用逻辑层vue选项式中的 methods 中定义的方法。
  • 注意逻辑层给数据时最好一次性给到渲染层,而不是不停从逻辑层向渲染层发消息,那样还是会产生逻辑层和视图层的多次通信,还是会卡
  • 观测更新的数据在视图层可以直接访问到。
  • APP 端视图层的页面引用资源的路径相对于根目录计算,例如:./static/test.js。
  • APP 端可以使用 dom、bom API,不可直接访问逻辑层数据,不可以使用 uni 相关接口(如:uni.request)
  • H5 端逻辑层和视图层实际运行在同一个环境中,相当于使用 mixin 方式,可以直接访问逻辑层数据。
  • vue3 项目不支持 setup script 用法。

APP 离线 SDK

App离线开发工具包,即App离线SDK,是把App运行环境(runtime)封装为原生开发调用接口,开发者可以在自己的 Android 及 iOS 原生开发环境配置工程使用,包括 Android离线开发SDK 及 iOS离线开发SDK。

注意:本SDK仅限于uni-app项目使用

使用 Node.js 批量导入多语言标签到 Strapi

在多语言网站开发中,我们常常需要在 Strapi 中维护大量的标签(Tags),比如文章标签、产品分类标签等。如果手动在后台创建上百条标签,会非常耗时且容易出错。本文将介绍如何使用 Node.js 脚本批量导入标签,并支持多语言(英文 / 德语 / 法语)与自动生成 slug。


一、项目背景

假设我们有一个 Next.js + Strapi 项目,Strapi 作为内容管理系统(CMS),我们希望:

  • 批量导入 1000+ 标签
  • 支持多语言(en / de / fr)
  • 自动生成 URL slug
  • 避免重复创建

为了实现这些目标,我们可以写一个 Node.js 脚本,调用 Strapi 的 REST API 来批量创建标签。


二、准备工作

  1. 获取 Strapi API Token 在 Strapi 后台创建一个 API Token,选择 Full Access 或者至少有 Tags CRUD 权限。 在项目根目录创建 .env 文件:

    STRAPI_API_URL=https://your-strapi-domain.com
    STRAPI_API_TOKEN=YOUR_API_TOKEN
    
  2. 安装依赖

    npm install node-fetch@2 dotenv
    
  3. 准备标签数据 我们将标签写成 tags.json 文件,示例:

    {
      "tags": [
        {
          "title_en": "Economy",
          "title_de": "Wirtschaft",
          "title_fr": "Économie",
          "slug_en": "economy",
          "slug_de": "wirtschaft",
          "slug_fr": "economie"
        },
        {
          "title_en": "Technology",
          "title_de": "Technologie",
          "title_fr": "Technologie",
          "slug_en": "technology",
          "slug_de": "technologie",
          "slug_fr": "technologie"
        }
      ]
    }
    

三、核心脚本解析

以下是 import_tags_to_strapi.js 的核心实现:

const fs = require('fs');
const fetch = require('node-fetch'); // npm install node-fetch@2
require('dotenv').config();

const STRAPI_URL = process.env.STRAPI_API_URL || 'http://localhost:1337';
const TOKEN = process.env.STRAPI_API_TOKEN;

const raw = fs.readFileSync('tags.json', 'utf8');
const { tags } = JSON.parse(raw);

// helper: sleep
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));

1. 创建英文标签

const enBody = {
  data: {
    title: tagObj.title_en,
    slug: tagObj.slug_en
  }
};
await fetch(`${STRAPI_URL}/api/tags?populate=none`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(enBody)
});

2. 创建德语和法语本地化

const deBody = {
  data: { title: tagObj.title_de, slug: tagObj.slug_de },
  locale: 'de'
};
await fetch(`${STRAPI_URL}/api/tags/${createdId}/localizations`, { ... });

const frBody = {
  data: { title: tagObj.title_fr, slug: tagObj.slug_fr },
  locale: 'fr'
};
await fetch(`${STRAPI_URL}/api/tags/${createdId}/localizations`, { ... });

这里使用了 Strapi Localizations API,保证不同语言之间的标签关联。

3. 批量处理与防刷限流

for (let i = 0; i < tags.length; i++) {
  await createTag(tags[i], i + 1);
  await sleep(200); // 避免 API 请求过快
}

四、运行脚本

node import_tags_to_strapi.js

执行后,你会看到:

1 created EN id= 15
1 created DE localization
1 created FR localization
2 created EN id= 16
...
done import

五、注意事项

  1. API Token 权限:确保 Token 有 Tag 的读写权限。
  2. slug 唯一性:Strapi 对 slug 有唯一性要求,建议提前生成或使用 slugify
  3. 请求频率:一次导入大量标签时,增加 sleep 时间可避免 Strapi 报 429。
  4. 多语言管理:Localizations API 保证标签在多语言之间关联,便于前端展示。

六、总结

通过 Node.js 脚本批量导入 Strapi 标签可以大幅提高效率,并且可以:

  • 支持上千条标签
  • 自动生成 slug
  • 支持多语言
  • 可在 CI/CD 或部署脚本中重复执行

这种方式特别适合新闻网站、博客、产品目录、跨语言项目等。

【URP】Unity[内置Shader]非光照Unlit

【从UnityURP开始探索游戏渲染】专栏-直达

URP内置Unlit Shader的作用与原理

Unlit Shader是Unity通用渲染管线(URP)中的基础着色器,主要用于渲染不受光照影响的物体。其核心原理是通过直接采样纹理或颜色值输出到屏幕,跳过了复杂的光照计算流程。这种着色器特别适合UI元素、粒子特效、全息投影等需要保持恒定亮度的场景,因为它的渲染结果不会随光照环境变化而改变。

在URP架构中,Unlit Shader通过ShaderLab语法定义,内部使用HLSL编写核心逻辑。与Built-in管线相比,URP版本优化了渲染流程,包含三个关键Pass:主绘制Pass、深度Only Pass和元数据Pass(用于光照烘焙)。其核心特点是:

  • 无光照计算:直接输出Albedo颜色或纹理采样结果
  • 支持Alpha混合:可实现透明效果
  • 移动端优化:减少了GPU指令数量

发展历史演变

Unlit Shader随着Unity渲染管线的演进经历了三个阶段:

  • Built-in管线时期‌(2012-2018):最初作为简单着色器出现在标准资源包中,使用CG语言编写,功能较为基础
  • LWRP过渡期‌(2018-2020):轻量级渲染管线中首次针对移动平台优化,引入HLSL替代CG
  • URP成熟期‌(2020至今):成为Universal RP的核心组件,支持Shader Graph可视化编程,并优化了多Pass协作机制

具体使用示例

创建Unlit材质的基本步骤:

  • 在Project窗口右键创建Material
  • 材质Inspector中选择Shader路径:"Universal Render Pipeline/Unlit"
  • 配置基础属性:
    • Base Map‌:主纹理贴图
    • Base Color‌:色调叠加
    • Alpha‌:透明度控制

代码说明:

  • 定义包含纹理和颜色属性的基础Unlit Shader

  • 使用URP核心库中的TransformObjectToHClip方法进行坐标转换

  • 片元着色器直接返回纹理采样结果与颜色的乘积

  • UnlitExample.shader

    Shader "Custom/UnlitTexture"
    {
        Properties {
            _MainTex ("Texture", 2D) = "white" {}
            _Color ("Color", Color) = (1,1,1,1)
        }
        SubShader {
            Tags { "RenderType"="Opaque" }
            Pass {
                HLSLPROGRAM
                #pragma vertex vert
                #pragma fragment frag
                #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
    
                struct Attributes {
                    float4 positionOS : POSITION;
                    float2 uv : TEXCOORD0;
                };
    
                struct Varyings {
                    float4 positionCS : SV_POSITION;
                    float2 uv : TEXCOORD0;
                };
    
                sampler2D _MainTex;
                float4 _Color;
    
                Varyings vert(Attributes IN) {
                    Varyings OUT;
                    OUT.positionCS = TransformObjectToHClip(IN.positionOS.xyz);
                    OUT.uv = IN.uv;
                    return OUT;
                }
    
                half4 frag(Varyings IN) : SV_Target {
                    return tex2D(_MainTex, IN.uv) * _Color;
                }
                ENDHLSL
            }
        }
    }
    
  • UnlitGraph.shadergraph

    {
        "m_Nodes": [
            {
                "m_Id": "d4f5e3c7-1a2d-4b8f-a3e1-6c9b8d2e1f0a",
                "m_Type": "UnityEditor.ShaderGraph.Texture2DNode",
                "m_Position": { "x": -208, "y": -16 },
                "m_Outputs": [ { "m_Id": "out" } ],
                "m_Texture": { "m_DefaultValue": {} }
            },
            {
                "m_Id": "a1b2c3d4-e5f6-7g8h-9i0j-k1l2m3n4o5p6",
                "m_Type": "UnityEditor.ShaderGraph.ColorNode",
                "m_Position": { "x": -200, "y": 100 },
                "m_Outputs": [ { "m_Id": "out" } ],
                "m_Color": { "r": 1, "g": 1, "b": 1, "a": 1 }
            },
            {
                "m_Id": "b2c3d4e5-f6g7-8h9i-0j1k-l2m3n4o5p6q7",
                "m_Type": "UnityEditor.ShaderGraph.MultiplyNode",
                "m_Position": { "x": 0, "y": 0 },
                "m_Inputs": [
                    { "m_Id": "a", "m_SlotId": 0 },
                    { "m_Id": "b", "m_SlotId": 1 }
                ],
                "m_Outputs": [ { "m_Id": "out" } ]
            }
        ],
        "m_Edges": [
            { "m_OutputSlot": "d4f5e3c7-1a2d-4b8f-a3e1-6c9b8d2e1f0a.out", "m_InputSlot": "b2c3d4e5-f6g7-8h9i-0j1k-l2m3n4o5p6q7.a" },
            { "m_OutputSlot": "a1b2c3d4-e5f6-7g8h-9i0j-k1l2m3n4o5p6.out", "m_InputSlot": "b2c3d4e5-f6g7-8h9i-0j1k-l2m3n4o5p6q7.b" }
        ]
    }
    

Shader Graph应用示例

在Shader Graph中创建Unlit效果的步骤:

  • 创建新的Shader Graph文件(右键 > Create > Shader > Universal Render Pipeline > Unlit Shader Graph)
  • 核心节点配置:
    • 添加‌Sample Texture 2D‌节点作为基础纹理输入
    • 连接‌Color‌参数节点实现色调控制
    • 使用‌Multiply‌节点混合纹理和颜色
  • 高级功能扩展:
    • 添加‌Time‌节点驱动UV动画
    • 通过‌Vertex Position‌节点实现顶点变形

代码说明:

  • 构建包含纹理采样和颜色混合的基础Unlit着色器

  • 通过节点连接实现材质属性的可视化编辑

  • 可扩展添加UV滚动、顶点动画等高级效果

  • UnlitExample.shader

    Shader "Custom/UnlitTexture"
    {
        Properties {
            _MainTex ("Texture", 2D) = "white" {}
            _Color ("Color", Color) = (1,1,1,1)
        }
        SubShader {
            Tags { "RenderType"="Opaque" }
            Pass {
                HLSLPROGRAM
                #pragma vertex vert
                #pragma fragment frag
                #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
    
                struct Attributes {
                    float4 positionOS : POSITION;
                    float2 uv : TEXCOORD0;
                };
    
                struct Varyings {
                    float4 positionCS : SV_POSITION;
                    float2 uv : TEXCOORD0;
                };
    
                sampler2D _MainTex;
                float4 _Color;
    
                Varyings vert(Attributes IN) {
                    Varyings OUT;
                    OUT.positionCS = TransformObjectToHClip(IN.positionOS.xyz);
                    OUT.uv = IN.uv;
                    return OUT;
                }
    
                half4 frag(Varyings IN) : SV_Target {
                    return tex2D(_MainTex, IN.uv) * _Color;
                }
                ENDHLSL
            }
        }
    }
    
  • UnlitGraph.shadergraph

    {
        "m_Nodes": [
            {
                "m_Id": "d4f5e3c7-1a2d-4b8f-a3e1-6c9b8d2e1f0a",
                "m_Type": "UnityEditor.ShaderGraph.Texture2DNode",
                "m_Position": { "x": -208, "y": -16 },
                "m_Outputs": [ { "m_Id": "out" } ],
                "m_Texture": { "m_DefaultValue": {} }
            },
            {
                "m_Id": "a1b2c3d4-e5f6-7g8h-9i0j-k1l2m3n4o5p6",
                "m_Type": "UnityEditor.ShaderGraph.ColorNode",
                "m_Position": { "x": -200, "y": 100 },
                "m_Outputs": [ { "m_Id": "out" } ],
                "m_Color": { "r": 1, "g": 1, "b": 1, "a": 1 }
            },
            {
                "m_Id": "b2c3d4e5-f6g7-8h9i-0j1k-l2m3n4o5p6q7",
                "m_Type": "UnityEditor.ShaderGraph.MultiplyNode",
                "m_Position": { "x": 0, "y": 0 },
                "m_Inputs": [
                    { "m_Id": "a", "m_SlotId": 0 },
                    { "m_Id": "b", "m_SlotId": 1 }
                ],
                "m_Outputs": [ { "m_Id": "out" } ]
            }
        ],
        "m_Edges": [
            { "m_OutputSlot": "d4f5e3c7-1a2d-4b8f-a3e1-6c9b8d2e1f0a.out", "m_InputSlot": "b2c3d4e5-f6g7-8h9i-0j1k-l2m3n4o5p6q7.a" },
            { "m_OutputSlot": "a1b2c3d4-e5f6-7g8h-9i0j-k1l2m3n4o5p6.out", "m_InputSlot": "b2c3d4e5-f6g7-8h9i-0j1k-l2m3n4o5p6q7.b" }
        ]
    }
    

实际应用时可结合粒子系统创建发光轨迹,或为UI元素添加动态高亮效果。URP Unlit Shader的轻量级特性使其在移动设备上能保持60fps以上的渲染性能

典型应用场景及实现

光晕效果(Halo)

  • 应用实例‌:角色技能特效、UI高亮提示。通过透明纹理实现边缘发光,如1中描述的透明光晕材质。
  • 实现步骤‌:
    • 导入纹理并设置:Texture TypeDefault (sRGB),勾选Alpha Is TransparencyWrap Mode设为Clamp
    • 创建材质:选择Universal Render Pipeline/Unlit Shader,设置Surface TypeTransparent,拖拽纹理到Base Map插槽。
    • 调整Tint颜色控制光晕色彩。

全息投影效果

  • 应用实例‌:科幻场景中的虚拟角色或界面。结合透明度与扫描线纹理。
  • 实现步骤‌:
    • 使用Unlit Shader并启用透明混合(Blend SrcAlpha OneMinusSrcAlpha)。
    • 添加顶点偏移代码模拟全息抖动,通过_Time变量控制动态效果。
    • 叠加扫描线纹理(如_HologramLine1)和菲涅尔反射增强立体感。

透明遮罩(如塑料薄膜)

  • 应用实例‌:UI遮罩或半透明装饰物。通过Alpha通道控制透明度,如中的塑料薄膜材质。
  • 实现步骤‌:
    • 在图片编辑器中创建带Alpha通道的纹理,白色区域不透明,灰色区域半透明。
    • 材质Shader选择Unlit,设置Transparent模式,纹理绑定到Base Map

发光广告牌(Billboard)

  • 应用实例‌:游戏内固定亮度标识或霓虹灯。直接显示纹理颜色不受光照影响。
  • 实现步骤‌:
    • 使用Unlit Shader,Surface Type设为Opaque
    • 通过Base Map设置发光纹理,调整Tint颜色增强亮度。

景深遮挡标记

  • 应用实例‌:半透明物体深度写入(如玻璃瓶),解决景深效果失效问题。
  • 实现步骤‌:
    • 创建两个材质:一个透明材质(Queue=Transparent),一个深度写入材质(Queue=2000)。
    • 深度写入材质使用Unlit Shader并启用ZWrite On

关键注意事项

  • 渲染顺序‌:透明物体需关闭深度写入(ZWrite Off),并合理设置Queue标签避免混合错误。
  • 性能优化‌:复杂效果(如全息投影)建议结合顶点着色器计算,减少片元着色器负担

【从UnityURP开始探索游戏渲染】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

HTML5敲击乐 PART--1

HTML5 敲击乐:前端开发实战 PART-1 —— 用代码“弹奏”你的第一架网页钢琴

在当今的 Web 开发世界中,前端不仅是用户与产品之间的桥梁,更是创意与技术融合的舞台。而作为前端三大基石——HTML、CSS 和 JavaScript——它们分别构建了网页的“骨骼”、“外貌”与“灵魂”。本文将以一个趣味十足的小项目 “HTML5 模拟钢琴” 为切入点,带你系统理解前端开发的核心逻辑,并掌握高效实现交互式应用的关键技巧。


一、前端三剑客:结构 × 样式 × 行为

任何现代网页都离不开这三位“主角”的协同工作:

  • HTML(超文本标记语言) :负责定义页面的结构与语义
    在模拟钢琴中,每个琴键都是一个 HTML 元素(如 <div><span>),共同组成完整的键盘布局。
  • CSS(层叠样式表) :掌控页面的视觉表现
    它让白键洁白、黑键深邃,还能通过 :hover:active 等伪类添加点击反馈,甚至用 transition 实现平滑动画,赋予琴键“生命感”。
  • JavaScript:驱动页面的交互行为
    它监听用户的点击或键盘输入,触发对应音符的播放,真正让这架“数字钢琴”发出声音——这才是“敲击乐”的核心!

设计哲学:结构、样式、行为分离(Separation of Concerns)是现代前端开发的基本原则。清晰的职责划分,让代码更易维护、扩展和协作。


二、HTML5 基础:从骨架搭建开始

HTML5 是当前 Web 标准的主流版本,其标志性开头是:

<!DOCTYPE html>

这一行声明告诉浏览器:“请以标准模式解析此文档”,避免因“怪异模式”(Quirks Mode)导致的渲染不一致问题。

使用 VS Code 等现代编辑器时,只需输入 ! 并按 Tab,即可自动生成完整的 HTML5 模板,极大提升开发效率。

构建琴键结构

<body> 中,我们用以下方式组织琴键:

  • 使用 <div class="piano"> 作为整个钢琴的容器;
  • 内部嵌套多个 <div class="key white"><div class="key black"> 分别代表白键与黑键。

小知识

  • <div>块级元素,默认独占一行,可设置宽高,适合做布局容器;
  • <span>行内元素,尺寸由内容决定,通常用于包裹文本。
    理解二者区别,是掌握页面布局的第一步。

三、CSS 样式:打造逼真的钢琴外观

有了结构,下一步就是“化妆”——用 CSS 赋予琴键真实感:

  • 基础样式:设置白键背景为白色、黑键为黑色,调整宽度、高度、边距;

  • 布局方案:推荐使用 Flexbox(而非过时的 float)实现琴键水平排列,代码简洁且响应友好;

  • 交互反馈

    • :hover:鼠标悬停时轻微变色或加阴影;
    • :active:点击瞬间产生“按下”效果;
  • 增强质感:利用 border-radius 圆角、box-shadow 阴影、transition 过渡动画,让界面更具现代感。

示例片段:

.key.white {
  width: 60px;
  height: 200px;
  background: #fff;
  border: 1px solid #ccc;
  transition: transform 0.1s;
}
.key.white:active {
  transform: translateY(2px);
}

四、JavaScript 行为:让钢琴真正“发声”

这是项目最激动人心的部分——让点击变成音乐!

核心功能实现:

  1. 事件监听
    为每个琴键绑定 click 事件,或监听全局 keydown 键盘事件(如 A/S/D 对应 Do/Re/Mi)。

  2. 音频播放
    可选择两种方式:

    • <audio> 标签预加载:简单直接,适合初学者;
    • Web Audio API:更强大灵活,支持音效合成与精确控制(进阶方向)。
  3. 状态管理
    避免快速连点导致声音重叠,可通过节流(throttle)或记录“正在播放”状态来优化体验。

模块化思维

将功能拆分为独立函数:

  • createPiano():动态生成琴键;
  • playNote(note):播放指定音符;
  • bindEvents():统一绑定交互逻辑。

这样不仅提升可读性,也为后续扩展(如添加录音、节奏训练等)打下基础。


五、提效利器:现代前端开发工具链

高效开发离不开这些“神助攻”:

  • Emmet 语法
    输入 div.piano>div.key*8 + Tab,秒速生成 8 个琴键结构;
  • Live Server(VS Code 插件)
    启动本地服务器,保存即自动刷新浏览器,所见即所得,调试效率翻倍。

六、面试高频考点回顾

在技术面试中,以下概念常被考察:

问题 答案要点
<!DOCTYPE html> 的作用? 声明 HTML5 文档类型,确保浏览器以标准模式渲染,避免兼容性问题。
HTML 文件的本质是什么? 一种结构化文本文档,浏览器将其解析为 DOM 树,供 JS 操作。
块级 vs 行内元素的区别? 块级独占一行、可设宽高;行内不换行、尺寸由内容决定。

结语:从“敲代码”到“敲出旋律”

通过“HTML5 模拟钢琴”这个小而美的项目,我们不仅实践了前端三剑客的协同开发流程,更体会到了用代码创造交互乐趣的魅力。从 ! + Tab 快速搭建骨架,到 Emmet 提升编码速度,再到 Live Server 实现实时预览——现代前端开发已变得前所未有的高效与直观。

未来,随着 AI 与 Web 技术的深度融合,前端工程师的角色将更像一位“数字导演”:用技术编排用户体验,用创意传递情感价值。而这一切的起点,正是对 HTML5 的深刻理解与灵活运用。

动手试试吧!
打开你的编辑器,敲下第一行代码,让浏览器为你奏响属于程序员的旋律 🎹✨

React 组件的组合模式之道 (Composition Pattern)

基于 React Universe Conf 2025 中 Fernando Rojo 的演讲《Composition is all you need》,以下是关于如何使用**组合模式(Composition Pattern)**重构复杂 React 组件的教程和代码总结。


React 组件的组合模式之道 (Composition Pattern)

1. 核心问题:单体组件的陷阱 (The Monolith Trap)

在开发初期,我们通常创建一个简单的组件(如 Composer 输入框)。随着需求增加(支持多态、编辑模式、转发模式等),我们往往通过添加 Boolean 属性来控制功能。

反模式代码示例:

// ❌ 典型的“单体”组件,充满条件判断
function Composer({ 
  onSubmit, 
  isThread, 
  isEditingMessage, 
  initialText, 
  onCancel 
}) {
  return (
    <div className="composer">
      {/* 只有非编辑模式才支持拖拽 */}
      {!isEditingMessage && <DropZone />}
      
      <Header />
      <Input defaultValue={initialText} />
      
      {/* 线程模式下的额外选项 */}
      {isThread && <Checkbox label="Also send to channel" />}
      
      <Footer>
        {/* 只有非编辑模式才显示附件按钮 */}
        {!isEditingMessage && <AttachmentButton />}
        
        {/* 提交按钮逻辑复杂 */}
        {isEditingMessage ? (
           <>
             <Button onClick={onCancel}>Cancel</Button>
             <Button onClick={onSubmit}>Save</Button>
           </>
        ) : (
           <Button onClick={onSubmit}>Send</Button>
        )}
      </Footer>
    </div>
  );
}

缺点: 代码难以维护,条件渲染(Ternary Hell)泛滥,且容易出现不可能的状态组合。


2. 解决方案:组合模式 (Composition)

与其通过属性(Props)告诉组件做什么,不如通过子组件(Children)直接构建组件。这种方式类似于 Radix UI 的设计理念。

我们将大组件拆分为多个小的、职责单一的子组件,并通过 Context 共享状态。

基础架构代码

// 1. 创建 Context
const ComposerContext = createContext(null);

// 2. Provider 组件:管理状态和对外接口
const ComposerProvider = ({ children, state, actions, meta }) => {
  return (
    <ComposerContext.Provider value={{ state, actions, meta }}>
      {children}
    </ComposerContext.Provider>
  );
};

// 3. 子组件:消费 Context
const ComposerInput = () => {
  const { state, actions } = useContext(ComposerContext);
  return (
    <input 
      value={state.text} 
      onChange={(e) => actions.update(e.target.value)} 
    />
  );
};

// ... 其他子组件 (Composer.Header, Composer.Footer, etc.)

3. 实战重构:构建不同的 Composer

通过组合,我们可以在不修改内部逻辑的情况下,构建出完全不同的 UI 变体。

场景 A:基础频道输入框 (Channel Composer)

function ChannelComposer() {
  // 使用自定义 Hook 获取全局频道逻辑
  const { state, actions } = useChannelLogic(); 

  return (
    <Composer.Provider state={state} actions={actions}>
      <Composer.DropZone />
      <Composer.Frame>
        <Composer.Header />
        <Composer.Input />
        <Composer.Footer>
          {/* 使用封装好的通用操作组 */}
          <Composer.CommonActions /> 
          <Composer.SubmitButton />
        </Composer.Footer>
      </Composer.Frame>
    </Composer.Provider>
  );
}

场景 B:编辑消息输入框 (Edit Message Composer)

需求差异:

  1. 不需要拖拽上传 (DropZone)。
  2. 底部按钮不同(取消/保存)。
  3. 某些操作按钮不可见(如附件)。

组合实现: 我们只需要不渲染不需要的组件,并替换底部的按钮即可,无需任何 Boolean 属性。

function EditMessageComposer({ messageId, initialText, onCancel }) {
  const { state, actions } = useEditMessageLogic(messageId, initialText);

  return (
    <Composer.Provider state={state} actions={actions}>
      {/* 移除 DropZone */}
      <Composer.Frame>
        <Composer.Header />
        <Composer.Input />
        <Composer.Footer>
          {/* 手动列出需要的 Action,而不是用通用的 */}
          <Composer.FormatText />
          <Composer.Emoji />
          
          {/* 自定义底部按钮布局 */}
          <div className="flex gap-2">
            <Button onClick={onCancel}>Cancel</Button>
            <Button onClick={actions.submit}>Save</Button>
          </div>
        </Composer.Footer>
      </Composer.Frame>
    </Composer.Provider>
  );
}

4. 进阶技巧:状态提升与解耦 (Lift Your State)

这是该演讲最核心的观点。状态管理应该与 UI 组件解耦。

Composer 的 UI 组件(Input, Footer 等)不应该知道状态是来自于 useState(本地状态)还是 useGlobalStore(全局同步状态)。它们只负责渲染 Provider 提供的数据。

场景 C:转发消息 (Forward Message)

复杂点:

  1. 这是一个模态框(Modal)。
  2. 提交按钮在 Composer 外部(Modal 的 Footer)。
  3. 状态是临时的(Ephemeral),不需要同步到服务器。

代码实现:

function ForwardMessageDialog() {
  // 1. 状态提升:在父组件控制状态
  const [text, setText] = useState("");
  const inputRef = useRef(null);

  // 定义符合 Provider 接口的 state 和 actions
  const state = { text };
  const actions = { 
    update: setText, 
    submit: () => console.log("Forwarding:", text) 
  };

  return (
    <Dialog>
      {/* 2. 将本地状态注入 Provider */}
      <Composer.Provider state={state} actions={actions} meta={{ inputRef }}>
        
        {/* UI 部分 */}
        <Composer.Frame>
          <Composer.Input /> 
          <Composer.Footer>
             {/* 只有少量的操作按钮 */}
             <Composer.Emoji />
          </Composer.Footer>
        </Composer.Frame>

        {/* 3. 外部按钮也可以消费同一个 Context */}
        <Dialog.Footer>
           <CopyLinkButton />
           {/* 这个按钮在 Composer 外部,但能触发提交 */}
           <ForwardButton /> 
        </Dialog.Footer>

      </Composer.Provider>
    </Dialog>
  );
}

// 外部按钮实现
const ForwardButton = () => {
  // 因为被包在 Composer.Provider 内,依然可以访问 context
  const { actions } = useContext(ComposerContext);
  return <Button onClick={actions.submit}>Forward</Button>;
}

总结:为什么要这样做?

  1. 消除“布尔地狱”: 不再需要传递 isEditing={true}isForwarding={true} 并在组件深处做判断。需要什么功能,就渲染什么组件。
  2. 状态灵活性: 同一套 UI 组件可以配合 useState(本地)、Redux/Zustand(全局)甚至 Ref 一起工作,只要通过 Provider 传入即可。
  3. 可维护性: 当需要在“转发”功能中修改按钮样式时,你只需要修改 ForwardMessageDialog,完全不会影响到“频道聊天”的代码。
  4. AI 友好: 这种结构化、声明式的代码更容易被 AI 理解和生成,减少了 AI 产生幻觉(Hallucination)或逻辑错误的概率。

一句话总结: 不要把所有的逻辑塞进一个组件里。提升你的状态 (Lift your state),组合你的内部组件 (Compose your internals)。

flutter 封装一个 tab

flutter 封装一个 tab

flutter版本:3.35.4

demo 链接:

https://github.com/yhtqw/FrontEndDemo/tree/main/flutter_demo/lib/pages/customize_tab

在flutter中使用 TabBar + TabBarView 就能很好的实现,为了使用的便捷性和通用性,所以进行一些封装。

需要看最终效果和最终的封装代码,直接拉到最底部即可~

简单的使用一下,对一些基本的结构限制做出解释。

import 'package:flutter/material.dart';

class Page19 extends StatefulWidget {
  const Page19({super.key});

  @override
  State<Page19> createState() => _Page19State();
}

class _Page19State extends State<Page19> with SingleTickerProviderStateMixin {
  // 必须,使用TabController作为桥梁连接TabBar和TabBarView
  late TabController _controller;

  @override
  void initState() {
    super.initState();

    _controller = TabController(
      length: 2,
      vsync: this,
      initialIndex: 0,
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: Column(
        children: [
          const SizedBox(height: 100,),

          TabBar(
            controller: _controller,
            // tab bar选项,数量得和下面一致
            tabs: const [
              Text('1111'),
              Text('2222'),
            ],
          ),

          // 使用TabBarView时,必须指定容器的限制范围。
          // 这里直接撑满剩余空间
          Expanded(
            child: TabBarView(
              controller: _controller,
              // tab bar每个选项对应的tab页面,数量得很上面一致
              children: const [
                Text('1'),
                Text('2'),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

效果如下:

image01.gif

简单的场景当然也可以不使用TabController,但需要DefaultTabController包裹,这样无需手动创建控制器自动就关联上了,大致使用如下:

// ... 其他省略
class _Page19State extends State<Page19> with SingleTickerProviderStateMixin {
  @override
  Widget build(BuildContext context) {
    return const DefaultTabController(
      // 数量得和下面的一致
      length: 2,
      child: Scaffold(
        backgroundColor: Colors.white,
        body: Column(
          children: [
            SizedBox(height: 100,),

            TabBar(
              tabs: [
                Text('1111'),
                Text('2222'),
              ],
            ),

            Expanded(
              child: TabBarView(
                children: [
                  Text('1'),
                  Text('2'),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

效果和上面一致。

从上面可以看出 flutter 已经实现了滑动的动画效果,我们只需要对结构样式进行封装,下面我们去体验一下其他的属性,为封装做准备。

// 其他省略...
class _Page19State extends State<Page19> with SingleTickerProviderStateMixin {
  late TabController _controller;

  @override
  void initState() {
    super.initState();

    _controller = TabController(
      length: 3,
      vsync: this,
      initialIndex: 0,
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: Column(
        children: [
          const SizedBox(height: 100,),

          TabBar(
            controller: _controller,
            // 注意:选项文本颜色就通过下面设置,不通过选项自身的TextStyle来设置
            // tab bar 选项文本选中时的颜色
            labelColor: Colors.blue,
            // tab bar 选项文本未选中时的颜色
            unselectedLabelColor: Colors.black,

            // 选中指示器相关的属性
            // 设置tab bar选中指示器的颜色
            indicatorColor: Colors.blue,
            // 设置tab bar选中指示器的大小,默认和选项的文本宽度一致。
            // 这里设置的指示器宽度和选项的宽度一致
            // 这样设置后切换tab的时候蠕虫蠕动效果就没有了
            indicatorSize: TabBarIndicatorSize.tab,
            // 设置tab bar选中指示器的高度
            indicatorWeight: 10,

            // 移除tab bar选项点击时的高亮和水波纹反馈效果
            overlayColor: WidgetStateProperty.all(Colors.transparent),

            // 设置tab bar选项的内边距
            // 在indicatorSize设置为label的时候再设置这个值比较好
            // labelPadding: const EdgeInsets.symmetric(horizontal: 40),

            // 设置tab bar底部的那条分割线高度,也可以通过dividerColor设置其颜色
            dividerHeight: 0,

            tabs: ['1111','2222','3333'].map(
              (item) => Text(item,),
            ).toList(),
          ),

          Expanded(
            child: TabBarView(
              controller: _controller,
              children: ['1','2','3'].map(
                (item) =>Text(item,),
              ).toList(),
            ),
          ),
        ],
      ),
    );
  }
}

效果如下:

image02.gif

可以看到,上面我们使用了许多选中和选中指示器的效果,也是在tab bar个数少的时候展示的效果,接下来我们加几个再看看效果:

image03.png

可以看到选项被挤压,这个时候我们就可以通过 TabBar 的 isScrollable 属性来设置是否可以滚动:

// 其他省略...
TabBar(
  // 其他省略...

  // 设置tab bar选项个数过多超过限制的宽度的时候,是否允许滚动
  isScrollable: true,
),
// 其他省略...

image04.gif

我们又发现了允许滚动后,第一个的排列距离容器左边有一些边距,如果想让选项在最左边开始显示,就需要设置tabAlignment属性:

// 其他省略...
TabBar(
  // 其他省略...

  // 设置tab bar选项个数过多超过限制的宽度的时候,是否允许滚动
  isScrollable: true,
  // 设置tab bar选项的显示位置
  tabAlignment: TabAlignment.start,
),
// 其他省略...

效果如下:

image05.png

关于TabBar的一些常用的属性就体验得差不多了,如果我们要设置整个TabBar容器的样式,例如背景色,圆角,高度等属性时,就需要对TabBar进行包裹,使用外层包裹部件来设置样式:

// 其他省略...
Container(
  height: 60,
  padding: const EdgeInsets.symmetric(horizontal: 20),
  decoration: const BoxDecoration(
    color: Colors.grey,
    borderRadius: BorderRadius.only(
      topLeft: Radius.circular(20),
      topRight: Radius.circular(20),
    )
  ),
  child: TabBar(
    // 其他省略...
  ),
),
// 其他省略...

效果如下:

image06.png

这下关于TabBar的样式就设置的差不多了,那么关于TabBarView的样式就自行通过传染的children部件自行封装实现。

我们从上面的效果中可以看到tab bar选中样式通过属性去自定义可能有些不好看,如果我们想用让指示器为整个背景,并且设置部分样式效果,我们就需要自定义TabBar的indicator属性。

image07.png

可以看到,如果我们要自定义指示器,需要实现Decoration,并且如果设置了indicator属性,则会忽略indicatorColor和indicatorWeight属性。

image08.png

image09.png

为了实现自定义的indicator,我们需要继承Decoration去实现绘制的方法,接下来我们就去实现一个简单的圆角矩形样式(这里不深入展开绘制相关的知识,后面有时间再单独写绘制相关的知识):

import 'package:flutter/material.dart';

class CustomizeTabIndicator extends Decoration {
  const CustomizeTabIndicator({
    required this.color,
    this.radius = BorderRadius.zero,
  });

  /// 指示器颜色
  final Color color;
  /// 指示器的圆角属性
  final BorderRadius radius;

  // 需要重写绘制方法
  @override
  BoxPainter createBoxPainter([VoidCallback? onChanged]) =>
      _RoundedPainter(this, onChanged);
}

// 自定义绘制方法
class _RoundedPainter extends BoxPainter {
  _RoundedPainter(this.decoration, VoidCallback? onChanged) : super(onChanged);

  final CustomizeTabIndicator decoration;

  // 重写绘制的方法,这个方法会传给我们绘制区域的信息
  // 我们利用这些信息就可以实现自定义的绘制
  @override
  void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
    // 获取绘制区域的大小,整个变换过程中都会更新
    double width = configuration.size!.width;
    double height = configuration.size!.height;
    // 获取绘制区域的偏移量(距离最边上的距离)
    Offset baseOffset = Offset(offset.dx, offset.dy,);

    // 设置要绘制的圆角矩形
    final RRect indicatorRRect = _buildRRect(
      baseOffset,
      width,
      height,
    );
    // 设置画笔属性
    final Paint paint = Paint()
      ..color = decoration.color
      ..style = PaintingStyle.fill;

    // 绘制圆角矩形
    canvas.drawRRect(indicatorRRect, paint);
  }

  /// 绘制圆角指示器
  RRect _buildRRect(
    Offset offset,
    double width,
    double height,
  ) {
    return RRect.fromRectAndCorners(
      // 圆角矩形的绘制中心
      Rect.fromCenter(
        center: Offset(
          offset.dx + width / 2,
          offset.dy + height / 2,
        ),
        width: width,
        height: height,
      ),
      topLeft: decoration.radius.topLeft,
      topRight: decoration.radius.topRight,
      bottomRight: decoration.radius.bottomRight,
      bottomLeft: decoration.radius.bottomLeft,
    );
  }
}

使用:

// 其他省略...
class _Page19State extends State<Page19> with SingleTickerProviderStateMixin {
  // 其他省略...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: Column(
        children: [
          const SizedBox(height: 100,),

          Container(
            height: 60,
            width: double.infinity,
            padding: const EdgeInsets.symmetric(horizontal: 20),
            decoration: const BoxDecoration(
              color: Colors.grey,
              borderRadius: BorderRadius.only(
                topLeft: Radius.circular(20),
                topRight: Radius.circular(20),
              )
            ),
            child: TabBar(
              controller: _controller,
              // 注意:选项文本颜色就通过下面设置,不通过选项自身的TextStyle来设置
              // tab bar 选项文本选中时的颜色
              labelColor: Colors.blue,
              // tab bar 选项文本未选中时的颜色
              unselectedLabelColor: Colors.black,

              // 选中指示器相关的属性
              // 自定义指示器
              indicator: const CustomizeTabIndicator(
                color: Colors.amber,
              ),

              // 设置tab bar选项个数过多超过限制的宽度的时候,是否允许滚动
              isScrollable: true,
              // 设置tab bar选项的显示位置
              tabAlignment: TabAlignment.start,

              // 设置tab bar选项的内边距
              // 在indicatorSize设置为label的时候再设置这个值比较好
              labelPadding: const EdgeInsets.only(right: 30),

              // 设置tab bar底部的那条分割线高度,也可以通过dividerColor设置其颜色
              dividerHeight: 0,

              tabs: ['1111','2222','3333','4444','5555','6666','7777'].map(
                (item) => Padding(
                  padding: const EdgeInsets.symmetric(
                    horizontal: 20,
                    vertical: 5,
                  ),
                  child: Text(item,)
                ),
              ).toList(),
            ),
          ),
          // 其他省略...
        ],
      ),
    );
  }
}

效果如下:

image10.gif

通过上面的一系列的使用,接下来封装就很明了了,属性主要分为:

  • tab bar相关
    • tab bar容器的属性
    • tab bar相关属性
  • tab views相关
  • 其他属性

接下来基于上述的开始封装:

import 'package:flutter/material.dart';

import 'customize_tab_indicator.dart';

/// 抽取一些默认的属性
BorderRadius ctDefaultIndicatorBorderRadius = BorderRadius.circular(10);

class CustomizeTab extends StatefulWidget {
  const CustomizeTab({
    super.key,
    this.tabBarHeight = kToolbarHeight,
    this.tabBarBackgroundColor,
    this.tabBarPadding,
    this.tabBarBorderRadius,
    required this.tabs,
    this.unselectedColor,
    this.selectedColor,
    this.tabBarOptionMargin = const EdgeInsets.only(right: 10),
    this.tabBarOptionPadding = EdgeInsets.zero,
    this.indicatorColor = Colors.blue,
    this.indicatorBorderRadius,
    required this.tabViews,
    this.initialIndex,
    this.onChangeTabIndex,
  });

  /// tab bar 容器的高度,默认为AppBar工具栏组件的高度
  final double tabBarHeight;
  /// tab bar 容器的背景颜色
  final Color? tabBarBackgroundColor;
  /// tab bar 容器的内边距
  final EdgeInsetsGeometry? tabBarPadding;
  /// tab bar 容器的圆角属性
  final BorderRadiusGeometry? tabBarBorderRadius;
  /// tab 选项
  final List<Widget> tabs;
  /// tab 选项未选中时的颜色
  final Color? unselectedColor;
  /// tab 选项选中时的颜色
  final Color? selectedColor;
  /// tab bar 选项每项的margin
  final EdgeInsetsGeometry tabBarOptionMargin;
  /// tab bar 选项每项的padding(因为大概率每项的padding是一致的,所以进行抽取)
  final EdgeInsetsGeometry tabBarOptionPadding;
  /// 指示器的颜色
  final Color indicatorColor;
  /// 指示器的圆角属性
  final BorderRadius? indicatorBorderRadius;
  /// tab 页面
  final List<Widget> tabViews;
  /// 初始化显示tab的索引
  final int? initialIndex;
  /// 当tab索引发生改变时的回调函数
  final Function(int)? onChangeTabIndex;

  @override
  State<CustomizeTab> createState() => _CustomizeTabState();
}

class _CustomizeTabState extends State<CustomizeTab> with SingleTickerProviderStateMixin {
  late TabController _controller;

  @override
  void initState() {
    super.initState();

    _controller = TabController(
      length: widget.tabs.length,
      vsync: this,
      initialIndex: widget.initialIndex ?? 0,
    )..addListener(() {
      // indexIsChanging主要作用就是标识TabController是否正处于索引切换过程中。
      // 点击切换,在执行动画期间为true,用户手势操作结束后且动画完成为false
      // 滑动切换为false
      // 使用indexIsChanging来判断当前tab变化是否完成,完成了就执行回调
      if (!_controller.indexIsChanging) {
        widget.onChangeTabIndex?.call(_controller.index);
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  /// 对每个tab加内边距
  Widget _buildTab(Widget tab) {
    return Padding(
      padding: widget.tabBarOptionPadding,
      child: tab,
    );
  }

  @override
  Widget build(BuildContext context) {
    // 通过父容器的约束动态构建子部件
    return LayoutBuilder(
      builder: (_, BoxConstraints boxConstraints) => Column(
        children: [
          Container(
            width: double.infinity,
            height: widget.tabBarHeight,
            padding: widget.tabBarPadding,
            decoration: BoxDecoration(
              color: widget.tabBarBackgroundColor,
              borderRadius: widget.tabBarBorderRadius,
            ),
            child: TabBar(
              controller: _controller,
              indicator: CustomizeTabIndicator(
                color: widget.indicatorColor,
                radius: widget.indicatorBorderRadius ??
                    ctDefaultIndicatorBorderRadius,
              ),
              isScrollable: true,
              dividerHeight: 0,
              labelPadding: widget.tabBarOptionMargin,
              tabAlignment: TabAlignment.start,
              unselectedLabelColor: widget.unselectedColor,
              labelColor: widget.selectedColor,
              tabs: widget.tabs.map((tab) => _buildTab(tab)).toList(),
            ),
          ),

          // 因为TabBarView必须要约束
          // 所以定义它的高度就为外界的约束高度减去TabBar的高度
          SizedBox(
            width: double.infinity,
            height: boxConstraints.maxHeight - widget.tabBarHeight,
            child: TabBarView(
              controller: _controller,
              children: widget.tabViews,
            ),
          ),
        ],
      ),
    );
  }
}

使用:

import 'package:flutter/material.dart';

import '../widgets/page19/customize_tab.dart';

class Page19 extends StatefulWidget {
  const Page19({super.key});

  @override
  State<Page19> createState() => _Page19State();
}

class _Page19State extends State<Page19> with SingleTickerProviderStateMixin {
  Widget _buildTab(String txt) {
    return Text(
      txt,
      style: const TextStyle(
        fontSize: 14,
      ),
    );
  }

  Widget _buildTabView(String txt) {
    return Container(
      padding: const EdgeInsets.all(20),
      color: Colors.grey,
      child: Text(txt),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: Column(
        children: [
          const SizedBox(height: 100,),

          Expanded(
            child: CustomizeTab(
              tabBarBackgroundColor: Colors.amber,
              tabBarPadding: const EdgeInsets.symmetric(
                horizontal: 20,
              ),
              tabBarBorderRadius: const BorderRadius.only(
                topRight: Radius.circular(20),
                topLeft: Radius.circular(20),
              ),
              unselectedColor: Colors.black,
              selectedColor: Colors.white,
              tabBarOptionPadding: const EdgeInsets.symmetric(
                horizontal: 15,
                vertical: 5,
              ),
              indicatorColor: Colors.orange,
              onChangeTabIndex: (index) {
                print('当前的索引为:$index');
              },
              tabs: ['tab1', 'tab2', 'tab3', 'tab4', 'tab5'].map(
                (item) => _buildTab(item),
              ).toList(),
              tabViews: ['1', '2', '3', '4', '5'].map(
                (item) => _buildTabView(item),
              ).toList(),
            ),
          ),
        ],
      ),
    );
  }
}

效果如下:

image11.gif

上面我们就完成了一个简单的tab封装,但是目前还不算很通用,例如我想要TabBar在下面,TabView在上面,这个就不能直接使用了,得更改内部代码,所以继续继续封装,允许用户自定义方向。

接下来进行TabBar位置的分析:

  1. 在顶部(top)

不用具体分析了,因为上面就是基于在顶部封装的。

  1. 在底部(bottom)

与在顶部相比,只是结构倒序一下就行了。

  1. 在左边(left)

在左边,结构从上下结构(Column)变成左右结构(Row),而且TabBar不支持直接转成上下结构,所以只有另辟蹊径。从左右结构变成上下结构旋转90度也能实现效果,基于此在做调整,让整个容器旋转90度的时候,TabBar的选项则也被旋转了90度,从视觉效果上看就是倒置的,大致效果如下:

image12.png

所以我们对每个子项向反方向旋转90度即可还原显示。

对于TabView,原先的交互方式为左右滑动切换,现在变成左右结构了,那么交互方式得从左右变成上下,TabView不支持直接变成上下滑动,所以有两种方式解决:

一种是使用PageView,这个天生就支持上下滑动,但是如果使用这个,得维护PageController,同步TabBar的切换,有兴趣的可自行试一下。

另一种方式依然是旋转,我们将其旋转90度后,自然而然滑动手势从左右变成了上下,不过需要注意的是,子项依然要反方向旋转90度还原。

  1. 在右边(right)

与在左边相比,只是结构倒序一下就行了。

简单的分析了如何实现,那么接下来就开始编写:

import 'package:flutter/material.dart';

import 'customize_tab_indicator.dart';

/// tab bar 位置的枚举
enum TabBarPosition { top, bottom, left, right }

/// 抽取一些默认的属性
BorderRadius ctDefaultIndicatorBorderRadius = BorderRadius.circular(10);

class CustomizeTab extends StatefulWidget {
  const CustomizeTab({
    super.key,
    this.tabBarHeight = kToolbarHeight,
    this.tabBarBackgroundColor,
    this.tabBarPadding = EdgeInsets.zero,
    this.tabBarBorderRadius,
    required this.tabs,
    this.unselectedColor,
    this.selectedColor,
    this.tabBarOptionMargin = const EdgeInsets.only(right: 10),
    this.tabBarOptionPadding = EdgeInsets.zero,
    this.indicatorColor = Colors.blue,
    this.indicatorBorderRadius,
    required this.tabViews,
    this.initialIndex,
    this.onChangeTabIndex,
    this.position = TabBarPosition.top,
  });

  /// tab bar 容器的高度,默认为AppBar工具栏组件的高度
  final double tabBarHeight;
  /// tab bar 容器的背景颜色
  final Color? tabBarBackgroundColor;
  /// tab bar 容器的内边距
  final EdgeInsetsGeometry tabBarPadding;
  /// tab bar 容器的圆角属性
  final BorderRadiusGeometry? tabBarBorderRadius;
  /// tab 选项
  final List<Widget> tabs;
  /// tab 选项未选中时的颜色
  final Color? unselectedColor;
  /// tab 选项选中时的颜色
  final Color? selectedColor;
  /// tab bar 选项每项的margin
  final EdgeInsetsGeometry tabBarOptionMargin;
  /// tab bar 选项每项的padding(因为大概率每项的padding是一致的,所以进行抽取)
  final EdgeInsetsGeometry tabBarOptionPadding;
  /// 指示器的颜色
  final Color indicatorColor;
  /// 指示器的圆角属性
  final BorderRadius? indicatorBorderRadius;
  /// tab 页面
  final List<Widget> tabViews;
  /// 初始化显示tab的索引
  final int? initialIndex;
  /// 当tab索引发生改变时的回调函数
  final Function(int)? onChangeTabIndex;
  /// tab bar所在位置,默认为top
  final TabBarPosition position;

  @override
  State<CustomizeTab> createState() => _CustomizeTabState();
}

class _CustomizeTabState extends State<CustomizeTab> with SingleTickerProviderStateMixin {
  late TabController _controller;

  @override
  void initState() {
    super.initState();

    _controller = TabController(
      length: widget.tabs.length,
      vsync: this,
      initialIndex: widget.initialIndex ?? 0,
    )..addListener(() {
      // indexIsChanging主要作用就是标识TabController是否正处于索引切换过程中。
      // 点击切换,在执行动画期间为true,用户手势操作结束后且动画完成为false
      // 滑动切换为false
      // 使用indexIsChanging来判断当前tab变化是否完成,完成了就执行回调
      if (!_controller.indexIsChanging) {
        widget.onChangeTabIndex?.call(_controller.index);
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  /// 对每个tab加内边距
  Widget _buildTabOption(Widget tab) {
    final Widget tabOption = Padding(
      padding: widget.tabBarOptionPadding,
      child: tab,
    );

    // 如果是left和right,因为外层的TabBar容器旋转了90度
    // 那Tab选项就旋转-90度还原,达到视觉的统一
    if (widget.position == TabBarPosition.left || widget.position == TabBarPosition.right) {
      return RotatedBox(
        quarterTurns: -1,
        child: tabOption,
      );
    } else {
      return tabOption;
    }
  }

  /// 构建TabBar
  Widget _buildTabBar() {
    final Widget tabBar = Container(
      width: double.infinity,
      height: widget.tabBarHeight,
      padding: widget.tabBarPadding,
      decoration: BoxDecoration(
        color: widget.tabBarBackgroundColor,
        borderRadius: widget.tabBarBorderRadius,
      ),
      child: TabBar(
        controller: _controller,
        indicator: CustomizeTabIndicator(
          color: widget.indicatorColor,
          radius: widget.indicatorBorderRadius
              ?? ctDefaultIndicatorBorderRadius,
        ),
        isScrollable: true,
        dividerHeight: 0,
        labelPadding: widget.tabBarOptionMargin,
        tabAlignment: TabAlignment.start,
        unselectedLabelColor: widget.unselectedColor,
        labelColor: widget.selectedColor,
        tabs: widget.tabs.map((tab) => _buildTabOption(tab)).toList(),
      ),
    );

    if (widget.position == TabBarPosition.left || widget.position == TabBarPosition.right) {
      // 如果是left和right,则旋转90度,
      return RotatedBox(
        quarterTurns: 1,
        child: tabBar,
      );
    } else {
      return tabBar;
    }
  }

  Widget _buildTabView(BoxConstraints boxConstraints) {
    final EdgeInsets tabBarPadding = widget.tabBarPadding.resolve(TextDirection.ltr);

    return widget.position == TabBarPosition.top || widget.position == TabBarPosition.bottom ? SizedBox(
      width: double.infinity,
      height: boxConstraints.maxHeight -
          widget.tabBarHeight -
          tabBarPadding.top -
          tabBarPadding.bottom,
      child: TabBarView(
        controller: _controller,
        children: widget.tabViews,
      ),
    ) : SizedBox(
      // 如果是left或者right,则宽高设置交换,并且将TabBarView旋转90度
      width: boxConstraints.maxWidth -
          widget.tabBarHeight -
          tabBarPadding.top -
          tabBarPadding.bottom,
      height: double.infinity,
      child: RotatedBox(
        quarterTurns: 1,
        child: TabBarView(
          controller: _controller,
          // 因为TabBarView旋转了90度,对应的Tab项要旋转-90度还原
          children: widget.tabViews.map((tabView) => RotatedBox(
            quarterTurns: -1,
            child: tabView,
          )).toList(),
        ),
      ),
    );
  }

  /// 构建tab
  Widget _buildTab(BoxConstraints boxConstraints) {
    List<Widget> children = [
      _buildTabBar(),
      _buildTabView(boxConstraints),
    ];

    // 如果是bottom和right,则渲染的结构会倒置
    if (widget.position == TabBarPosition.bottom
        || widget.position == TabBarPosition.right) {
      children = children.reversed.toList();
    }

    // 如果是top或者bottom,则是上下结构
    // 如果是left或者right,则是左右结构
    return widget.position == TabBarPosition.top || widget.position == TabBarPosition.bottom
        ? Column(
          children: children,
        )
        : Row(
          children: children,
        );
  }

  @override
  Widget build(BuildContext context) {
    // 通过父容器的约束动态构建子部件
    return LayoutBuilder(
      builder: (_, BoxConstraints boxConstraints) => _buildTab(boxConstraints),
    );
  }
}

完成编码后就开始测试使用:

import 'package:flutter/material.dart';

import '../widgets/page19/customize_tab.dart';

class Page19 extends StatefulWidget {
  const Page19({super.key});

  @override
  State<Page19> createState() => _Page19State();
}

class _Page19State extends State<Page19> with SingleTickerProviderStateMixin {
  // 用于动态切换tab bar的位置
  TabBarPosition _tabBarPosition = TabBarPosition.top;

  /// 构建tab项
  Widget _buildTab(String txt) {
    return Text(
      txt,
      style: const TextStyle(
        fontSize: 14,
      ),
    );
  }

  /// 构建tab view
  Widget _buildTabView(String txt) {
    return Container(
      padding: const EdgeInsets.all(20),
      color: Colors.grey,
      child: Column(
        children: [
          Container(
            width: double.infinity,
            height: 60,
            decoration: BoxDecoration(
              color: Colors.amber,
              borderRadius: BorderRadius.circular(20),
            ),
            alignment: Alignment.center,
            child: Text(txt),
          )
        ],
      ),
    );
  }

  /// 改变tab bar的位置
  void _onChangePosition(TabBarPosition tabBarPosition) {
    setState(() {
      _tabBarPosition = tabBarPosition;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: Column(
        children: [
          const SizedBox(height: 100,),

          Expanded(
            child: CustomizeTab(
              tabBarHeight: 100,
              tabBarBackgroundColor: Colors.amber,
              tabBarPadding: const EdgeInsets.symmetric(
                horizontal: 20,
              ),
              unselectedColor: Colors.black,
              selectedColor: Colors.white,
              tabBarOptionPadding: const EdgeInsets.symmetric(
                horizontal: 15,
                vertical: 5,
              ),
              indicatorColor: Colors.orange,
              position: _tabBarPosition,
              onChangeTabIndex: (index) {
                print('当前的索引为:$index');
              },
              tabs: ['tab1', 'tab2', 'tab3', 'tab4', 'tab5'].map(
                (item) => _buildTab(item),
              ).toList(),
              tabViews: ['1', '2', '3', '4', '5'].map(
                (item) => _buildTabView(item),
              ).toList(),
            ),
          ),

          Container(
            height: 200,
            color: Colors.black,
            padding: const EdgeInsets.symmetric(horizontal: 20),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                ElevatedButton(
                  onPressed: () => _onChangePosition(TabBarPosition.left),
                  child: const Text('左'),
                ),
                ElevatedButton(
                  onPressed: () => _onChangePosition(TabBarPosition.right),
                  child: const Text('右'),
                ),
                ElevatedButton(
                  onPressed: () => _onChangePosition(TabBarPosition.top),
                  child: const Text('上'),
                ),
                ElevatedButton(
                  onPressed: () => _onChangePosition(TabBarPosition.bottom),
                  child: const Text('下'),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

效果如下:

image13.gif

至此我们就简单封装了一个tab,极大的方便了使用。

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

文章到此就结束了,感谢阅读,拜拜~

Web 3D地球实时统计访问来源

前言

这篇文章介绍一个Web 3D地球实时统计访问来源的开源项目,效果如下,当服务器有http流量进来时,web通过3D地球+飞线实时绘制客户端的来源

Peek 2025-11-29 21-03.gif

项目地址:github.com/houxinlin/l…

实现原理

要在服务器上捕获http流量,也就是抓包,抓包有两种方案,一是使用类似pcap的库从内核捕获网络数据包,另一种比较复杂是使用ebpf拦截系统的函数点,以检测流量,本项目是使用pcap实现。

要通过代码把流量抓下来,其实逻辑比较简单,大概如下

  1. 打开设备 (Open Device)
    首先得告诉程序我们要监听哪个网卡(比如 eth0 还是 wlan0)。这里通常需要开启混杂模式 ,否则网卡默认只会接收发给它自己的包,而忽略广播或其他流量。
  2. 设置过滤器 (Set BPF Filter)
    这一步很重要,服务器上的流量太杂了,SSH、数据库、等数据包。我们只想看 HTTP 流量,所以需要设置一个过滤规则,比如 tcp port 80 or tcp port 443。这用的就是 BPF (Berkeley Packet Filter) 语法,效率极高,直接在内核层就把不相关的包扔掉了。
  3. 循环抓包 (Loop)
    前两步配置好后,就是在一个死循环里不断地从句柄中读取数据包(Packet)。

伪代码大概长这样:

// 1. 打开网卡 eth0,65535是最大捕获长度,1是开启混杂模式
handle = pcap_open_live("eth0", 65535, 1, 1000, errbuf);

// 2. 编译并设置过滤规则
pcap_compile(handle, &fp, "tcp port 80", 0, net);
pcap_setfilter(handle, &fp);

// 3. 每抓到一个包就回调 process_packet 函数
pcap_loop(handle, -1, process_packet, NULL);

pcap_loop 捕获到数据时,拿到手的是一堆原始的二进制字节,截获到的数据包,需要解析出以太网帧头,以太头由 14 字节固定长度构成,用于指明目标与源 MAC 地址,以及数据使用的上层协议类型,如下。

image.png

其中数据类型表示以太网帧中载荷(Payload)是什么协议的数据。

协议类型 十六进制 说明
IPv4 0x0800 表示数据部分是 IPv4 数据包
ARP 0x0806 地址解析协议
IPv6 0x86DD IPv6 数据包

在项目中,解析时直接跳过14字节去解析ip报文即可,因为用不到以太数据,解析到ip头后就可以获取客户端的地址了,ip头格式如下。

image.png

获取ip包信息代码如下

void process(u_char *d, struct pcap_pkthdr *h, u_char *p) {
    struct ip *ip4_pkt = (struct ip *) (p + link_offset);
    uint32_t ip_hl = ip4_pkt->ip_hl * 4;
    uint8_t ip_proto = ip4_pkt->ip_p;
    char ip_src[INET_ADDRSTRLEN];
    char ip_dst[INET_ADDRSTRLEN];
    unsigned char *data;
    uint32_t len = h->caplen;

    inet_ntop(AF_INET, (const void *) &ip4_pkt->ip_src, ip_src, sizeof(ip_src));
    inet_ntop(AF_INET, (const void *) &ip4_pkt->ip_dst, ip_dst, sizeof(ip_dst));
 }

接下来拿到客户端的ip后,解析出ip的经纬度通过websocket发送到前端即可,经纬度有两种办法可以获取。

  1. 使用maxminddb

    他是一种离线的经纬度查询系统,但是测试后不太准确,优点是速度极快。

  2. 在线服务

    寻找大量的在线ip转经纬度服务,找到他的api接口,在程序中使用负载均衡(因为免费服务都要分钟内次数限制)

但是,目前广泛的做法是nginx做web监听,使用https 443端口,虽然抓包获取ip来源没问题,但是有时候我们想通过http请求头中的字段获取ip,比如真实的用户IP往往藏在HTTP请求头的 X-Forwarded-For 或者 X-Real-IP 类似的字段里,这就需要解析tcp数据包,从tcp的Payload中解析出http请求头信息。

GET /api/v1/stats HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0...
X-Forwarded-For: 203.0.113.195
...

项目直接调用github.com/nodejs/http… 这个库去解析http头,他是被广泛验证的高性能 C 解析库(这也是 Node.js 早期底层使用的解析器)。

如何实现精准操控?Cesium模型移动旋转控件实现

最近在项目中遇到一个需求:需要在Cesium中动态编辑模型的位置

熟悉三维开发的朋友应该知道,Three.js提供了十分便捷的控件来操作模型(TransformControls),但是Cesium在这方面有所欠缺:虽然通过参数设置也能实现功能,但对用户来说操作不够直观。

为此,我花了两三天时间开发了一个类似的模型位置编辑控件,实现了较为直观的交互操作。

具体效果如下图所示。

Cesium控件.gif

整个实现过程还是有些复杂的,涉及到了坐标转换、交互处理、Cesium事件机制等。 因此在这里做个简单的分享,给大家提供一些参考。

本文主要从数据流的角度,说明整个系统的计算和变化过程:

1. 模型设置阶段的数据流

首先,需要获取到模型或组合模型的中心点,从而设置操作的中心点并生成对应的操作轴。

大致的过程:模型设置 → 计算包围球 → 生成操作轴

    // 获取基准点
    calcSceneBS() {
        const boundingSpheres = [];
        for (let i = 0; i < this._targetList.length; i++) {
            const model = this._targetList[i];
            let sphere = model.boundingSphere;
            if (model instanceof Cesium.Model) {
                sphere = this.getModelBS(model);
            } else if (model instanceof Cesium.Primitive) {
                const translation = Cesium.Matrix4.getTranslation(model.modelMatrix, new Cesium.Cartesian3);
                const boundingSpheres = model._boundingSpheres;
                sphere = new Cesium.BoundingSphere(translation, boundingSpheres.radius);
            } else if (model.point) {
                sphere = new Cesium.BoundingSphere(model.position.getValue(), 10);
            }

            if (sphere) {
                boundingSpheres.push(sphere);
            }
        }

        if (boundingSpheres.length > 0) {
            this._sceneSphere = Cesium.BoundingSphere.fromBoundingSpheres(boundingSpheres, new Cesium.BoundingSphere());
        }

        if (this._basePt === undefined && this._sceneSphere && this._sceneSphere.center) {
            this._basePt = Cesium.Cartographic.fromCartesian(this._sceneSphere.center);
            this._basePt.longitude = Cesium.Math.toDegrees(this._basePt.longitude);
            this._basePt.latitude = Cesium.Math.toDegrees(this._basePt.latitude);
        }
    }
    
    // 创建xyz轴
    _createAxis(name, color) {
        const positionsCallback = new Cesium.CallbackProperty(() => {
            if (!this._isValidCartesian(this._center)) return [];
            const axes = this._getLocalAxes();
            if (!axes) return [];
        
            // 计算轴的方向向量
            const dir = name === 'X' ? axes.xAxis : name === 'Y' ? axes.yAxis : axes.zAxis;
            if (!this._isValidCartesian(dir)) return [];
            
            const start = Cesium.Cartesian3.clone(this._center, new Cesium.Cartesian3());
            const end = Cesium.Cartesian3.add(
                start,
                Cesium.Cartesian3.multiplyByScalar(dir, this._axisLength, new Cesium.Cartesian3()),
                new Cesium.Cartesian3()
            );
            return [start, end];
        }, false);

        const entity = this._viewer.entities.add({
            name,
            polyline: {
                positions: positionsCallback,
                width: this._selectedAxis === name ? 10 : 5,
                clampToGround: false,
                material: new Cesium.PolylineArrowMaterialProperty(color)
            }
        });
        
        this._axisEntities.push(entity);
    }

2. 交互过程的数据流

完整的数据流:鼠标点击 → 拾取检测 → 坐标转换 → 变换计算 → 矩阵应用 → 视觉更新

2.1 鼠标操作

我们需要判断拾取的操作轴,根据不同的轴使用不同的拾取方法。

_onLeftDown(event) {
        if (!this._isActive) return;

        // 拾取操作轴
        const pickedObject = this._scene.pick(event.position);
        if (pickedObject && pickedObject.id) {
            const axisName = Cesium.clone(pickedObject.id.name, true);
            const validAxes = ["X", "Y", "Z", "XY", "XZ", "YZ",
                "XY_CIRCLE", "XZ_CIRCLE", "YZ_CIRCLE",
                "SCALE_X", "SCALE_Y", "SCALE_Z"];
            if (validAxes.indexOf(axisName) >= 0) {
                this._pickedObject = pickedObject;
                this._highlightPickedObject(this._pickedObject);

                this._viewer.scene.screenSpaceCameraController.enableRotate = false;
                this._viewer.scene.screenSpaceCameraController.enableLook = false;

                this._selectedAxis = axisName;
                this._isDragging = true;
                this._mouseDownPos = undefined;
                this._mouseDownXY = event.position;

                const X = ["XY", "XY_CIRCLE", "X", "Y", "Z"];
                const Y = ["YZ", "YZ_CIRCLE"];
                const Z = ["XZ", "XZ_CIRCLE"];

                if (X.indexOf(this._selectedAxis) > -1) {
                    this._mouseDownPos = this.pickPositionOnEllipsoid(event.position);
                    if (this._transType.indexOf("MOVE") >= 0) this.calcFixedAxis()
                }
                if (Y.indexOf(this._selectedAxis) > -1) {
                    this._mouseDownPos = this.pickPositionOnPlane(event.position, "YZ");
                    if (this._transType.indexOf("MOVE") >= 0) this.calcFixedAxis()
                }
                if (Z.indexOf(this._selectedAxis) > -1) {
                    this._mouseDownPos = this.pickPositionOnPlane(event.position, "XZ");
                    if (this._transType.indexOf("MOVE") >= 0) this.calcFixedAxis()
                }

                // 触发 Transform 事件
                this._raiseEvent("Transform", {
                    position: this._mouseDownPos,
                    cartographic: this._mouseDownPos,
                    axis: this._selectedAxis
                });
            }
        }
    }
    
    
    _onMouseMove(event) {
        if (!this._isActive || !this._isDragging) return;
        if (this._selectedAxis === "") return;

        let pos = undefined;
        const X = ["XY", "XY_CIRCLE", "X", "Y", "Z"];
        const Y = ["YZ", "YZ_CIRCLE"];
        const Z = ["XZ", "XZ_CIRCLE"];
        if (X.indexOf(this._selectedAxis) > -1) pos = this.pickPositionOnEllipsoid(event.endPosition);
        if (Y.indexOf(this._selectedAxis) > -1) pos = this.pickPositionOnPlane(event.endPosition, "YZ");
        if (Z.indexOf(this._selectedAxis) > -1) pos = this.pickPositionOnPlane(event.endPosition, "XZ");

        // 触发 Transforming 事件
        try {
            this._raiseEvent("Transforming", {
                MouseDownPos: this._mouseDownPos,
                MouseMovePos: pos,
                MouseDownXY: this._mouseDownXY,
                MouseMoveXY: event.endPosition,
                SelectedAxis: this._selectedAxis,
                FixedAxis: this._fixedAxis
            });
        } catch (err) {
            console.error('Error raising Transforming event:', err);
        }

        this._requestRender();
    }

2.2 拾取操作及坐标转换

    /**
     * 在椭球面上拾取位置
     * @param {Cesium.Cartesian2} screenPosition 屏幕位置
     * @returns {Cesium.Cartographic} 地理坐标
     */
    pickPositionOnEllipsoid(screenPosition) {
        if (!this._initSphere || !this._initSphere.center) return undefined;

        // 获取中心点的地理坐标
        const centerCartographic = Cesium.Cartographic.fromCartesian(this._initSphere.center);

        // 根据中心点高度调整椭球半径
        const ellipsoid = Cesium.clone(Cesium.Ellipsoid.WGS84, true);
        ellipsoid.radii.x += centerCartographic.height;
        ellipsoid.radii.y += centerCartographic.height;
        ellipsoid.radii.z += centerCartographic.height;
        const adjustedEllipsoid = new Cesium.Ellipsoid(
            ellipsoid.radii.x,
            ellipsoid.radii.y,
            ellipsoid.radii.z
        );

        // 拾取位置
        const cartesian = this._viewer.camera.pickEllipsoid(screenPosition, adjustedEllipsoid, new Cesium.Cartesian3());
        if (cartesian === undefined) return undefined;

        // 转换为地理坐标
        const cartographic = Cesium.Cartographic.fromCartesian(cartesian);
        return cartographic;
    }

    /**
     * 在指定平面上拾取位置
     * @param {Cesium.Cartesian2} screenPosition 屏幕位置
     * @param {String} planeType 平面类型 ("XY"|"YZ"|"XZ")
     * @returns {Cesium.Cartographic} 地理坐标
     */
    pickPositionOnPlane(screenPosition, planeType) {
        this._calcCallbackArgs();

        if (!this._initSphere || !this._initSphere.center) return undefined;
        if (!this._callbackCartographic) return undefined;

        const r = this._axisZoom * 1e-5;
        const lon = this._callbackCartographic.longitude;
        const lat = this._callbackCartographic.latitude;
        const height = this._callbackCartographic.height;

        // 创建三角形用于平面相交测试
        let i, n, a;
        if (planeType === "YZ") {
            i = Cesium.Cartesian3.fromRadians(lon, lat - r, -2e3);
            n = Cesium.Cartesian3.fromRadians(lon, lat, height + this._axisZoom * 0.95 + 1e3);
            a = Cesium.Cartesian3.fromRadians(lon, lat + r, -2e3);
        } else {
            // XZ 平面
            i = Cesium.Cartesian3.fromRadians(lon - r, lat, -2e3);
            n = Cesium.Cartesian3.fromRadians(lon, lat, height + this._axisZoom * 0.95 + 1e3);
            a = Cesium.Cartesian3.fromRadians(lon + r, lat, -2e3);
        }

        const ray = this._viewer.camera.getPickRay(screenPosition);
        if (!ray) return undefined;

        // 计算平面法向量(基于经度)
        const centerCartographic = Cesium.Cartographic.fromCartesian(this._initSphere.center);
        const s = Math.cos(centerCartographic.longitude);
        const l = Math.sin(centerCartographic.longitude);
        const d = 0;
        const plane = new Cesium.Plane.fromPointNormal(
            new Cesium.Cartesian3(0, 0, 0),
            new Cesium.Cartesian3(s, l, d)
        );

        // 创建射线终点
        const rayEnd = new Cesium.Cartesian3();
        rayEnd.x = ray.origin.x + ray.direction.x * 1e6;
        rayEnd.y = ray.origin.y + ray.direction.y * 1e6;
        rayEnd.z = ray.origin.z + ray.direction.z * 1e6;

        // 线段与三角形相交测试
        const intersection = Cesium.IntersectionTests.lineSegmentTriangle(
            ray.origin,
            rayEnd,
            i,
            n,
            a
        );

        if (intersection === undefined) return undefined;

        // 转换为地理坐标
        const cartographic = Cesium.Cartographic.fromCartesian(intersection);
        return cartographic;
    }
    
    /**
     * 计算回调参数(用于动态更新)
     */
    _calcCallbackArgs() {
        if (!this._initSphere || !this._initSphere.center) return;
        this._callbackCenter = this._initSphere.center;
        this._callbackCartographic = Cesium.Cartographic.fromCartesian(this._callbackCenter);
        this._callbackLonDeg = Cesium.Math.toDegrees(this._callbackCartographic.longitude);
        this._callbackLatDeg = Cesium.Math.toDegrees(this._callbackCartographic.latitude);
        this._callbackHeight = this._callbackCartographic.height;
        this._viewer.scene.requestRender();
    }

2.3 变换矩阵

getMatrixOfTileset(tileset, deltaLon, deltaLat, deltaHeight, heading, pitch, roll, scaleX, scaleY, scaleZ, baseLon, baseLat, baseHeight) {
    let baseLonRad = baseLon;
    let baseLatRad = baseLat;
    let baseHeightM = baseHeight;

    // 计算基准位置和目标位置
    const basePos = Cesium.Cartesian3.fromRadians(baseLonRad, baseLatRad, baseHeightM);
    const targetPos = Cesium.Cartesian3.fromRadians(
        baseLonRad + deltaLon,
        baseLatRad + deltaLat,
        baseHeightM + deltaHeight
    );

    // 角度转换为弧度
    heading = Cesium.Math.toRadians(heading);
    pitch = Cesium.Math.toRadians(pitch);
    roll = Cesium.Math.toRadians(roll);

    // 创建四元数旋转
    const quaternion = Cesium.Quaternion.fromHeadingPitchRoll(
        new Cesium.HeadingPitchRoll(heading, pitch, roll)
    );
    const scale = new Cesium.Cartesian3(scaleX, scaleY, scaleZ);
    
    // 创建变换矩阵(旋转 + 缩放)
    const transform = Cesium.Matrix4.fromTranslationQuaternionRotationScale(
        Cesium.Cartesian3.ZERO,
        quaternion,
        scale
    );

    // 坐标系变换
    const baseFrame = Cesium.Transforms.eastNorthUpToFixedFrame(basePos);
    const baseFrameInv = Cesium.Matrix4.inverse(baseFrame, new Cesium.Matrix4);
    const targetFrame = Cesium.Transforms.eastNorthUpToFixedFrame(targetPos);

    // 合成最终变换矩阵
    const result = Cesium.Matrix4.multiply(transform, baseFrameInv, new Cesium.Matrix4);
    return Cesium.Matrix4.multiply(targetFrame, result, new Cesium.Matrix4);
}

2.4 矩阵应用到模型

applyTransformToTileset(tileset, deltaLon, deltaLat, deltaHeight, heading, pitch, roll, scaleX, scaleY, scaleZ, baseLon, baseLat, baseHeight) {
    // 获取变换矩阵
    const transformMatrix = this.getMatrixOfTileset(
        tileset, deltaLon, deltaLat, deltaHeight, heading, pitch, roll,
        scaleX, scaleY, scaleZ, baseLon, baseLat, baseHeight
    );
    
    if (transformMatrix) {
        // 将变换矩阵应用到初始模型矩阵
        const resultMatrix = Cesium.Matrix4.multiply(transformMatrix, this._mouseDownMM, new Cesium.Matrix4);
        tileset.modelMatrix = resultMatrix;
    }
}

2.5 轴更新

_getLocalAxes() {
    let enuMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(this._center)

    // 从ENU矩阵提取坐标轴
    const xAxis = Cesium.Matrix4.multiplyByPointAsVector(
        enuMatrix,
        new Cesium.Cartesian3(1, 0, 0),
        new Cesium.Cartesian3()
    );
    const yAxis = Cesium.Matrix4.multiplyByPointAsVector(
        enuMatrix,
        new Cesium.Cartesian3(0, 1, 0),
        new Cesium.Cartesian3()
    );
    const zAxis = Cesium.Matrix4.multiplyByPointAsVector(
        enuMatrix,
        new Cesium.Cartesian3(0, 0, 1),
        new Cesium.Cartesian3()
    );

    return { xAxis, yAxis, zAxis };
}

3. 简单总结一下

应该还有其他优化的部分,比如轴大小随视距缩放之类的。

有机会把完整代码放出来开源,争取达到开箱即用的效果。

Vue 中 nextTick 的魔法:为什么它能拿到更新后的 DOM?

Vue 中 nextTick 的魔法:为什么它能拿到更新后的 DOM?

深入理解 Vue 异步更新机制的核心

一个令人困惑的场景

很多 Vue 开发者都遇到过这样的场景:在改变数据后,立即访问 DOM,却发现拿到的是旧的值。这时候,我们就会用到 nextTick 这个神奇的解决方案。

// 改变数据
this.message = 'Hello Vue'

// 此时 DOM 还没有更新
console.log(this.$el.textContent) // 旧内容

// 使用 nextTick 获取更新后的 DOM
this.$nextTick(() => {
  console.log(this.$el.textContent) // 'Hello Vue'
})

那么,nextTick 到底是如何工作的?为什么它能够确保我们在 DOM 更新后再执行回调?今天,我们就来彻底揭开 nextTick 的神秘面纱。

nextTick 的核心作用

nextTick 是 Vue 提供的一个异步方法,它的主要作用是:

将回调函数延迟到下次 DOM 更新循环之后执行

在 Vue 中,数据变化时,DOM 更新是异步的。Vue 会开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。这样可以避免不必要的计算和 DOM 操作。

源码解析:nextTick 的实现

让我们深入到 Vue 的源码中,看看 nextTick 到底是如何实现的。

1. 核心变量定义

// 回调队列
const callbacks = []
// 标记是否已经有 pending 的 Promise
let pending = false
// 当前是否正在执行回调
let flushing = false
// 回调执行的位置索引
let index = 0

2. nextTick 函数主体

export function nextTick(cb?: Function, ctx?: Object) {
  let _resolve
  // 将回调函数包装后推入回调队列
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  
  // 如果当前没有 pending 的 Promise,就创建一次
  if (!pending) {
    pending = true
    // 执行异步延迟器
    timerFunc()
  }
  
  // 如果没有提供回调且支持 Promise,返回一个 Promise
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

3. timerFunc:异步延迟器的实现

这是 nextTick 最核心的部分,Vue 会按照以下优先级选择异步方案:

let timerFunc

// 优先级:Promise > MutationObserver > setImmediate > setTimeout
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  // 情况1:支持 Promise
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    // 在一些有问题的 UIWebView 中,Promise.then 不会完全触发
    // 所以需要额外的 setTimeout 来强制刷新
    if (isIOS) setTimeout(noop)
  }
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // 情况2:支持 MutationObserver
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // 情况3:支持 setImmediate
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // 情况4:降级到 setTimeout
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

4. flushCallbacks:执行回调队列

function flushCallbacks() {
  flushing = true
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  index = 0
  
  // 执行所有回调
  for (let i = 0; i < copies.length; i++) {
    index = i
    copies[i]()
  }
  
  flushing = false
  index = 0
}

完整流程图

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

graph TD
    A[调用 nextTick] --> B[回调函数推入 callbacks 队列]
    B --> C{是否有 pending 的 timerFunc?}
    C -->|否| D[设置 pending = true]
    D --> E[执行 timerFunc]
    E --> F{选择异步方案}
    F -->|优先级1| G[Promise.resolve.then]
    F -->|优先级2| H[MutationObserver]
    F -->|优先级3| I[setImmediate]
    F -->|优先级4| J[setTimeout]
    G --> K[异步任务完成]
    H --> K
    I --> K
    J --> K
    K --> L[执行 flushCallbacks]
    L --> M[遍历执行所有回调]
    M --> N[重置状态]
    C -->|是| O[等待现有 timerFunc 触发]

Vue 的异步更新队列

要真正理解 nextTick,我们还需要了解 Vue 的异步更新队列机制。

Watcher 与更新队列

当数据发生变化时,Vue 不会立即更新 DOM,而是将需要更新的 Watcher 放入一个队列中:

// 简化版的更新队列实现
const queue = []
let has = {}
let waiting = false
let flushing = false

export function queueWatcher(watcher) {
  const id = watcher.id
  // 避免重复添加
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // 如果已经在刷新,按 id 排序插入
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    
    // 开启下一次的异步更新
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

刷新调度队列

function flushSchedulerQueue() {
  flushing = true
  let watcher, id
  
  // 队列排序,确保:
  // 1. 组件更新顺序为父到子
  // 2. 用户 watcher 在渲染 watcher 之前
  // 3. 如果一个组件在父组件的 watcher 期间被销毁,它的 watcher 可以被跳过
  queue.sort((a, b) => a.id - b.id)
  
  // 不要缓存队列长度,因为可能会有新的 watcher 加入
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    // 执行更新
    watcher.run()
  }
  
  // 重置状态
  resetSchedulerState()
}

实际应用场景

场景 1:获取更新后的 DOM

export default {
  data() {
    return {
      list: ['a', 'b', 'c']
    }
  },
  methods: {
    addItem() {
      this.list.push('d')
      console.log(this.$el.querySelectorAll('li').length) // 3,还是旧的
      
      this.$nextTick(() => {
        console.log(this.$el.querySelectorAll('li').length) // 4,更新后的
      })
    }
  }
}

场景 2:在 created 钩子中操作 DOM

export default {
  created() {
    // DOM 还没有被创建
    this.$nextTick(() => {
      // 现在可以安全地操作 DOM 了
      this.$el.querySelector('button').focus()
    })
  }
}

场景 3:与 Promise 结合使用

async function updateData() {
  this.message = 'Updated'
  this.value = 10
  
  // 等待所有 DOM 更新完成
  await this.$nextTick()
  
  // 现在可以执行依赖于更新后 DOM 的操作
  this.calculateLayout()
}

性能优化考虑

Vue 使用异步更新队列有重要的性能优势:

  1. 批量更新:同一事件循环内的所有数据变更会被批量处理
  2. 避免重复计算:相同的 Watcher 只会被推入队列一次
  3. 优化渲染:减少不必要的 DOM 操作

常见问题解答

Q: nextTick 和 setTimeout 有什么区别?

A: 虽然 nextTick 在降级情况下会使用 setTimeout,但它们有本质区别:

  • nextTick 会尝试使用微任务(Promise、MutationObserver),而 setTimeout 是宏任务
  • 微任务在当前事件循环结束时执行,宏任务在下一个事件循环开始执行
  • nextTick 能确保在 DOM 更新后立即执行,而 setTimeout 可能会有额外的延迟

Q: 为什么有时候需要连续调用多个 nextTick?

A: 在某些复杂场景下,可能需要确保某些操作在特定的 DOM 更新之后执行:

this.data1 = 'first'
this.$nextTick(() => {
  // 第一次更新后执行
  this.data2 = 'second'
  this.$nextTick(() => {
    // 第二次更新后执行
    this.data3 = 'third'
  })
})

Q: nextTick 会返回 Promise 吗?

A: 是的,当不传入回调函数时,nextTick 会返回一个 Promise:

// 两种写法是等价的
this.$nextTick(function() {
  // 操作 DOM
})

// 或者
await this.$nextTick()
// 操作 DOM

总结

通过本文的深入分析,我们可以看到 nextTick 的实现体现了 Vue 在性能优化上的深思熟虑:

  1. 异步更新:通过队列机制批量处理数据变更
  2. 优先级策略:智能选择最优的异步方案
  3. 错误处理:完善的异常捕获机制
  4. 兼容性:优雅的降级方案

理解 nextTick 的工作原理,不仅可以帮助我们更好地使用 Vue,还能让我们对 JavaScript 的异步机制有更深入的认识。

希望这篇文章能帮助你彻底掌握 Vue 中 nextTick 的魔法!如果你有任何问题或想法,欢迎在评论区留言讨论。

antd渐变色边框按钮

直接贴代码把方便,掘金的写代码添加依赖太麻烦了

import styled from 'styled-components';
import { Button } from 'antd';
import { v4 } from 'uuid';
import type { ButtonProps } from 'antd';
import type { SizeType } from 'antd/lib/config-provider/SizeContext';

interface LinearGradientProps {
  id: string;
  colors: string[];
  direction?: 'to right' | 'to left' | 'to top' | 'to bottom' | string | number;
}

function angleToCoordinates(angleDeg: number) {
  // 将角度转换为弧度
  const angleRad = angleDeg * (Math.PI / 180);

  // 计算终点坐标 (起点固定为 [0,0])
  // 使用单位圆上的点,长度=1
  const x = Math.cos(angleRad);
  const y = Math.sin(angleRad);

  // 将坐标转换为百分比字符串
  // SVG 渐变坐标可以是负数或大于100%
  return {
    x1: '0%',
    y1: '0%',
    x2: `${(x * 100).toFixed(2)}%`,
    y2: `${(y * 100).toFixed(2)}%`
  };
}

const LinearGradientComponent: React.FC<LinearGradientProps> = ({ id, colors, direction = 'to right' }) => {
  const getCoordinates = () => {
    // 处理数字角度值 (如 150deg)
    if (typeof direction === 'number') {
      return angleToCoordinates(direction);
    }

    // 处理字符串角度值 (如 "150deg")
    if (typeof direction === 'string' && direction.endsWith('deg')) {
      const angle = parseFloat(direction);
      if (!isNaN(angle)) {
        return angleToCoordinates(angle);
      }
    }

    // 处理关键词方向
    switch (direction) {
      case 'to right':
        return { x1: '0%', y1: '0%', x2: '100%', y2: '0%' };
      case 'to left':
        return { x1: '100%', y1: '0%', x2: '0%', y2: '0%' };
      case 'to top':
        return { x1: '0%', y1: '100%', x2: '0%', y2: '0%' };
      case 'to bottom':
        return { x1: '0%', y1: '0%', x2: '0%', y2: '100%' };
      case 'to top right':
        return { x1: '0%', y1: '100%', x2: '100%', y2: '0%' };
      case 'to bottom left':
        return { x1: '100%', y1: '0%', x2: '0%', y2: '100%' };
      default:
        return { x1: '0%', y1: '0%', x2: '100%', y2: '0%' };
    }
  };

  const { x1, y1, x2, y2 } = useMemo(() => getCoordinates(), [direction]);

  return (
    <linearGradient id={id} x1={x1} y1={y1} x2={x2} y2={y2}>
      {colors.map((color, index) => (
        <stop key={index} offset={`${(index / (colors.length - 1)) * 100}%`} stopColor={color} />
      ))}
    </linearGradient>
  );
};

type Props = {
  linear: string;
  hoverIconColor?: string;
} & ButtonProps;

const SvgButton = styled(Button) <{ disabled?: boolean; linear: string; linearid: string }>`
  &.ant-btn-variant-outlined:not(:disabled):not(.ant-btn-disabled) {
    --ant-button-default-hover-bg: transparent;
    background: transparent;
  }
  width: 100%;
  height: 100%;
  background: transparent;
  border: none;
  padding: 0;
  cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
  outline: none;

  background-clip: text;
  color: transparent;
  fill: transparent;
  background-image: ${({ linear }) => linear};
  p {
    color: #9980df;
  }
  svg {
    fill: url(#${({ linearid }) => linearid});
  }
`;

const SvgButtonWrapper = styled.div<{ size?: SizeType }>`
  --ant-control-height: 32px;
  --font-size: 14px;

  &.button-size-large {
    --ant-control-height: 40px;
    --font-size: 16px;
  }
  &.button-size-small {
    --ant-control-height: 24px;
    --font-size: 12px;
  }

  display: inline-block;
  position: relative;
  height: var(--ant-control-height);
  width: fit-content;

  & > svg {
    position: absolute;
    z-index: 0;
    pointer-events: none;

    & > rect {
      transition: fill 0.3s;
    }
  }

  &[aria-disabled='false'] {
    &:hover {
      & > svg > rect:last-child {
        fill: transparent;
      }

      ${SvgButton} {
        color: #000;
        background-clip: unset;
        background-image: unset;

        p {
          color: ${({ theme }) => theme.same};
        }

        svg {
          fill: ${({ theme }) => theme.same};
        }
      }
    }
  }
  &[aria-disabled='true'] {
    opacity: 0.5;
  }
`;

function LinearButton({ linear, children, size, disabled, loading, ...buttonProps }: Props) {
  const colors = useMemo(
    () =>
      linear.match(/#(?:[a-f0-9]{3}|[a-f0-9]{6})\b|rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*[\d.]+\s*)?\)/gi) || [],
    [linear]
  );
  const deg = useMemo(() => linear.match(/[0-9]deg/)?.[0] || '', [linear]);

  const linearId = useMemo(
    () =>
      `${v4({
        random: Uint8Array.from(linear)
      })}`,
    []
  );

  const buttonRef = useRef<HTMLButtonElement>(null);
  const [contentSize, setContentSize] = useState<{ width: number; height: number }>(
    buttonRef.current?.getBoundingClientRect() || { width: 100, height: 32 }
  );

  const handle = useCallback(() => {
    if (buttonRef.current) {
      setContentSize(buttonRef.current.getBoundingClientRect());
    } else {
      setTimeout(handle, 100);
    }
  }, []);

  useLayoutEffect(() => {
    handle();
  }, [children, buttonProps.icon]);

  return (
    // 使用svg做背景,按钮背景统一透明,避免比例影响、边框锯齿等问题
    <SvgButtonWrapper className={`button-size-${size || 'default'}`} aria-disabled={!!disabled}>
      <svg
        width="100%"
        height="100%"
        viewBox={`0 0 ${contentSize.width} ${contentSize.height}`}
        preserveAspectRatio="none"
      >
        <defs>
          <LinearGradientComponent id={linearId} colors={[...colors]} direction={deg} />
        </defs>

        {/* 背景边框 */}
        <rect
          x="1"
          y="1"
          width={Math.max(contentSize.width - 2, 0)}
          height={Math.max(contentSize.height - 2, 0)}
          rx={Math.max(contentSize.height / 2, 0)}
          ry={Math.max(contentSize.height / 2, 0)}
          fill={`url(#${linearId})`}
          stroke="none"
        />

        {/* 内部填充 */}
        <rect
          x="2"
          y="2"
          width={Math.max(contentSize.width - 4, 0)}
          height={Math.max(contentSize.height - 4, 0)}
          rx={Math.max(contentSize.height / 2 - 2, 0)}
          ry={Math.max(contentSize.height / 2 - 2, 0)}
          fill={disabled ? '#050505' : '#000'}
          stroke="none"
        />
      </svg>

      {/* 实际可点击的按钮 */}
      <SvgButton
        ref={buttonRef}
        linear={linear}
        size={size}
        linearid={linearId}
        disabled={!!disabled || !!loading}
        {...buttonProps}
      >
        {children}
      </SvgButton>
    </SvgButtonWrapper>
  );
}

按钮加渐变边框主要需要解决两个问题

  1. 不同分辨率下会出现边框消失或视觉不等的情况
  2. 如何给文本和svg图标加上渐变

所以也尝试过使用border或者渐变背景的方案,最后还是选择svg渲染背景,使用的图标也需要是svg的,hover后的效果根据主题来随便改。容器的大小是通过contentSize计算更新上去的,border默认就是2px,代码上可以写的再整洁一点

效果

output.gif

🎨 用一次就爱上的图标定制体验:CustomIcons 实战

在前端项目里,图标不是“点缀”,它往往是信息结构与互动线索的关键。如何让图标既统一又可配、既美观又可国际化?这篇文章带你用 @infinilabs/custom-icons 打造一套“可配置、可主题、可国际化”的图标解决方案。

背景

  • Coco AI(开源) 用户需要可以配置自定义 Icon。
  • 多品牌与多区域:同一产品在不同客户、不同区域需要差异化的风格与语言。
  • 设计与工程协作:设计希望图标统一;工程需要灵活调整尺寸、颜色、类型、甚至自定义图片。
  • 运营与配置:希望在管理面板里直接挑选或调整图标,而不是改代码、发版本。

于是,我做了一个轻量、直观、开箱即用的组件库:@infinilabs/custom-icons

适用场景

  • 可视化配置台:在后台面板中为功能、菜单或模块选择与配置图标。
  • 多主题产品:快速切换深色/浅色主题,保证图标在不同背景下的对比度与风格。
  • 国际化应用:在不同语言环境下自动切换文案与控件标签。
  • 自定义品牌:支持上传自定义图片作为图标,满足品牌个性化需求。

主要能力

  • 图标渲染组件:ConfigurableIcon
    • 指定类型(如 lucide)、图标名、颜色与尺寸即可渲染。
    • 支持数据 URL(自定义图片)模式。
  • 图标选择器:IconPicker
    • 一站式选择与配置:类型、名称、尺寸、颜色与图片上传。
    • 可选主题与国际化支持。
    • 可通过 controls 精细开关各子控件。

快速开始

# 使用你熟悉的包管理器安装
pnpm add @infinilabs/custom-icons
# 或
npm i @infinilabs/custom-icons
# 或
yarn add @infinilabs/custom-icons

在项目中引用:

import { useState } from "react";
import { ConfigurableIcon, IconPicker } from "@infinilabs/custom-icons";

export default function Demo() {
  const [config, setConfig] = useState({
    type: "lucide",
    name: "Bot",
    size: 28,
    color: "#1e90ff",
    dataUrl: undefined,
  });

  return (
    <div style={{ padding: 24 }}>
      {/* 渲染当前配置的图标 */}
      <ConfigurableIcon
        type={config.type}
        name={config.name}
        size={config.size}
        color={config.color}
        dataUrl={config.dataUrl}
      />

      {/* 交互式选择与配置 */}
      <IconPicker
        value={config}
        onChange={setConfig}
        configurable
        theme="light"
        locale="zh-CN"
        controls={{
          type: true,
          name: true,
          size: true,
          color: true,
          image: true,
        }}
      />
    </div>
  );
}

如果你需要查看可选的 Lucide 图标名称,选择器旁已内置快捷链接:

基础效果

image.png

组件详解

ConfigurableIcon

用于在任意位置渲染一个图标。

  • 关键属性
    • type: 图标类型(如 lucide 或自定义)
    • name: 图标名称(type=lucide 时为 Lucide 名称)
    • size: 数值尺寸(px)
    • color: 颜色(十六进制或 CSS 颜色)
    • dataUrl: 当使用自定义图片时的 data: URL

示例(自定义图片):

<ConfigurableIcon
  type="custom"
  name="my-logo"
  dataUrl="data:image/png;base64,...."
  size={28}
  color="#1e90ff" // 自定义图片时通常忽略颜色
/>

IconPicker

一个将预览与配置控件整合在一起的选择器。可插在设置面板或表单中,让用户自行挑选或上传。

  • 常用属性

    • value: 当前图标配置对象
    • onChange(next): 配置变化回调
    • configurable: 是否展示配置面板
    • controls: 控件开关集合(type/name/size/color/image 等)
    • theme: light | dark
    • locale: zh-CN | en-US
    • i18n: 文案对象(可覆盖默认文案)
  • 控件开关示例

<IconPicker
  value={config}
  onChange={setConfig}
  configurable
  controls={{
    type: true,
    name: true,
    size: true,
    color: true,
    image: true, // 打开即出现上传控件
  }}
/>
  • 主题与国际化
<IconPicker
  value={config}
  onChange={setConfig}
  configurable
  theme="dark"
  locale="en-US"
/>

进阶示例:面板内批量配置

将多个图标配置成一组,供菜单或卡片模块统一管理:

function IconsPanel() {
  const [items, setItems] = useState([
    { id: 1, config: { type: "lucide", name: "Home", size: 24, color: "#444" } },
    { id: 2, config: { type: "lucide", name: "Settings", size: 24, color: "#444" } },
  ]);

  const updateItem = (id, next) =>
    setItems((prev) =>
      prev.map((it) => (it.id === id ? { ...it, config: next } : it))
    );

  return (
    <div style={{ display: "grid", gap: 16 }}>
      {items.map((it) => (
        <div key={it.id} style={{ padding: 12, border: "1px solid #eee", borderRadius: 8 }}>
          <ConfigurableIcon {...it.config} />
          <IconPicker
            value={it.config}
            onChange={(next) => updateItem(it.id, next)}
            configurable
            theme="light"
            locale="zh-CN"
            controls={{ type: true, name: true, size: true, color: true, image: false }}
          />
        </div>
      ))}
    </div>
  );
}

设计与工程协作建议

  • 设计提供命名规范:例如统一使用 Lucide 的图标名集合,避免随意命名。
  • 管理面板适配:通过 controls 开关不同角色看到的控件(运营只改颜色与大小、开发可修改类型与名称)。
  • 主题变量托管:将颜色与尺寸作为“设计令牌”,统一管理与回收。

常见问题

  • 自定义图片会应用颜色吗?
    • 通常不会;颜色更适用于矢量图标。自定义图片由图片本身决定视觉。
  • 如何选择 Lucide 图标名?
    • 打开 https://lucide.dev/icons/,在选择器里输入对应名称即可。

image.png

小结

@infinilabs/custom-icons 让“图标即配置”的能力落地:从主题与国际化,到自定义图片与统一风格,既能保证设计一致性,又给予业务足够自由度。把它接入你的管理面板或设置页,让图标成为产品的强大表达力,而不是维护负担。

如果你对更多场景(如基于角色的控件可见性、图标库扩展)有想法,欢迎继续交流与共建!

开源共建:github.com/infinilabs/…

mac电脑安装nvm

方案一、常规安装

  1. 下载安装脚本:在终端中执行以下命令来下载并运行 NVM 的安装脚本3:

    bash

    curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.39.5/install.sh | bash
    

  2. 配置环境变量:安装完成后,需要配置环境变量。如果你的终端使用的是 bash,打开或创建~/.bash_profile文件,添加以下内容3:

    bash

    export NVM_DIR="$HOME/.nvm"
    [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # 加载nvm
    [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # 加载bash自动补全(可选)
    

    如果使用的是 zsh,则打开或创建~/.zshrc文件,添加相同内容。然后执行source ~/.bash_profilesource ~/.zshrc使配置生效。

方案二、解决网络问题的安装

如果因为网络原因无法直接访问官方源,可以尝试以下方法:

  1. 通过国内镜像下载安装脚本:可以从 gitee 等国内代码托管平台的镜像下载安装脚本,例如:

    bash

    curl -o- https://gitee.com/cunkai/nvm-cn/raw/master/install.sh | bash
    

  2. 配置 NVM 使用国内镜像:安装完成后,编辑~/.zshrc(或~/.bashrc),添加以下内容来配置 NVM 使用国内的 Node.js 镜像源:

    bash

    export NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node
    export NVM_IOJS_ORG_MIRROR=https://npmmirror.com/mirrors/iojs
    

    保存后执行source ~/.zshrcsource ~/.bashrc使配置生效。

安装完成后,可以通过nvm -v命令查看 NVM 的版本,以确认是否安装成功。

nvm常用命令

;安装node18.16.0
nvm install 18.16.0

;查看nvm安装的node版本
nvm list

;通过nvm list查看电脑已有的版本号,设置默认的版本
nvm alias default v22.16.0

5 分钟把 Coze 智能体嵌入网页:原生 JS + Vite 极简方案

你已经创建好了 Coze 智能体,现在想快速把它接入一个网页?不用 React、不用 Vue,甚至不用手敲 npm create —— 本文教你用 trae 的 AI 助手 + 原生 HTML/JS,5 分钟搭建一个可运行、可部署、安全调用 Coze OpenAPI 的前端 Demo。

我们将实现:

  • 通过 trae AI 一键生成项目并初始化 Vite
  • 安全注入 Bot ID 和 API Token
  • 调用 Coze 接口实现问答交互

一、用 trae AI 快速搭建项目(无需手动命令)

告别 npm init 和配置文件!我们借助 trae 的右侧 AI 对话栏,全自动完成项目创建。

操作步骤如下:

  1. 打开 trae 平台,进入任意工作区

  2. 在右侧 AI 对话框 中输入:

    创建一个通用的原生HTML/CSS/JS 项目
    
  3. 等待 AI 生成基础结构(通常包含 index.htmlmain.jsstyle.css

  4. 接着在同一对话中继续输入:

    帮我初始化vite配置
    
  5. AI 会自动为你:

    • 创建 vite.config.js
    • 添加 package.json 脚本(如 devbuild
    • 安装 vite 依赖(或提示你运行 npm install

✅ 此时你已拥有一个标准的 Vite 原生 JS 项目,无需任何手动配置!

将项目同步到本地后,执行:

npm run dev

确保页面能正常打开,接下来我们集成 Coze。


二、获取 Coze 智能体凭证

  1. 复制两个关键信息:

    • Bot ID 进入你的智能体,在链接最后那一串就是你的ID,选择复制

    • API Key 点击Web SDK 将其中的token复制下来

image.png

⚠️ 这个 API Key 具有调用权限,请务必保密!

关于智能体具体的创建 juejin.cn/post/757769… 这篇文章里面有,当然智能体发布的时候一定要选择API选项


三、安全注入环境变量

在项目根目录创建 .env.local 文件:

VITE_BOT_ID=your_actual_bot_id
VITE_API_KEY=your_actual_api_key

🔒 Vite 只会暴露以 VITE_ 开头的变量到客户端代码,这是官方推荐的安全做法。


四、编写前端交互逻辑

1. index.html

可以把trae生成的代码删掉用下面这份

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Coze API Demo</title>
  <script src="https://cdn.jsdelivr.net/npm/marked@4.3.0/lib/marked.umd.min.js"></script>
</head>
<body>
  <h1>Coze API Demo 随处智能</h1>
  <input type="text" id="ipt" placeholder="请输入问题">
  <div id="reply">think...</div>
  <script type="module" src="./script.js"></script>
</body>
</html>

在这段代码看起有点不一样

<script src="https://cdn.jsdelivr.net/npm/marked@4.3.0/lib/marked.umd.min.js"></script>

是哪里冒出来的呢?

其实加上这个主要是为了待会我们从智能体那里获取图片展示到网页上,如果不加的话我们只会获得图片的链接,这还要结合待会的js一起使用

2. main.js

const ipt = document.getElementById('ipt');
const reply = document.getElementById('reply');
const endpoint = 'https://api.coze.cn/open_api/v2/chat';
// DOM 2 
ipt.addEventListener('change',async function(event) {
  const prompt = event.target.value;
  console.log(prompt);
  const payload = {
    bot_id: import.meta.env.VITE_BOT_ID,
    user: 'yvo',
    query: prompt,
    chat_history:[],
    stream: false,
    custom_variables: {
      prompt: '你是一个AI助手'
    }
  }
  const response = await fetch(endpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${import.meta.env.VITE_API_KEY}`
    },
    body: JSON.stringify(payload)
  })
  const data = await response.json();
  console.log(data, '////');
  // reply.innerHTML = data.messages[1].content;
  reply.innerHTML=marked.parse(data.messages.find(item => item.type === 'answer').content);
})

代码分析

  reply.innerHTML=marked.parse(data.messages.find(item => item.type === 'answer').content);

这段代码可能看起来有点突兀,那我们拆开来看首先我们看吧

data.messages.find(item => item.type === 'answer').content

这主要是获取智能体的回答,这时就有人问了一般获取信息不都是使用 .choices[0].message.content来获取吗?

但是coze的智能体返回的结构是不一样的

image.png

看这个结构很容易观察到其实coze智能体返回的结构需要在messages[1].content或type:"answer"才能拿到结果,这就是coze与我们调用一般的llm不一样的地方。

接下来我们继续分析

marked.parse()

将 Markdown 格式的字符串 → 转换成 HTML 字符串

这样浏览器才能正确显示标题、列表、链接、图片等内容。

这也就实现了我们能在页面上获取智能体给我们的图片了。 我们可以删去试试看效果

image.png

我们并没有得到我们想要的只获得了https地址

那加上试试呢?

image.png

成功将照片拿到。

 const payload = {
    bot_id: import.meta.env.VITE_BOT_ID,
    user: 'yvo',
    query: prompt,
    chat_history:[],
    stream: false,
    custom_variables: {
      prompt: '你是一个AI助手'
    }

这段代码好像见的也不多,这段其实就要根据www.coze.cn/open/docs/d… coze的官方文档去使用了


五、启动 & 验证

npm run dev

在浏览器输入问题(如“JavaScript 如何判断数组?”),即可看到 Coze 智能体的实时回复!


七、常见问题

Q:返回 {"code":4101,"msg":"The token you entered is incorrect"}
A:请检查:

  • .env.local 是否命名正确
  • Token 是否正确或过期

结语

通过 trae AI + Vite + Coze OpenAPI,我们用最轻量的方式实现了智能体前端集成。整个过程:

  • 无框架负担
  • 无复杂构建
  • 环境变量安全隔离
  • 代码清晰可维护

一个输入框,一行 API 调用,背后是千行训练数据与万亿参数的智能体在为你思考。
而你,只用了 5 分钟,就把它请进了自己的网页。
这不是魔法——这是新时代前端工程师的日常。

从「似懂非懂」到「了如指掌」:Promise 与原型链全维度拆解

前言

在前端世界里,Promise原型链(Prototype) 是两个看似毫不相干,却又互相影响、甚至能相互解释的重要概念。

很多人学习 Promise 时,会关注它的使用:thencatchfinallyPromise.all 等;
学习原型链时,又会关注 __proto__prototype、构造函数与实例之间的关系。

但鲜有人把 Promise 本身也是一个对象,它也依赖原型链运作 这件事真正联系起来讲透。

本文将以一次完整的 Promise 异步流程为主线,把“原型链 + 状态机 + 微任务”融合讲解,让你完全理解 Promise 到底是怎么在底层“跑”起来的。


一、Promise 为什么是“对象”?

我们常常写:

const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve('OK'), 1000)
})

很多人知道 Promise 是“异步解决方案”,但忽略了一个基本事实:

Promise 是一个构造函数(类),你创建的是它的实例。

也就是说:

  • Promise —— 构造函数(带 prototype
  • p —— 实例对象(带 __proto__

打开控制台试试:

console.log(p.__proto__ === Promise.prototype) // true

这里马上就把原型链扯进来了。

🔍 Promise.prototype 上都有啥?

输入:

console.log(Promise.prototype)

你会看到:

then: ƒ then()
catch: ƒ catch()
finally: ƒ finally()
constructor: ƒ Promise()
...

这说明:

所有 Promise 实例都是通过原型链访问 then/catch/finally 的。

也就是说 p.then() 并不是实例自身有,而是:

p ---> Promise.prototype ---> Object.prototype ---> null

这为后文理解 Promise “链式调用”机制奠定基础。


二、原型链视角下,看懂 Promise 的执行流

我们直接看一个你提供的代码精简版:

const p = new Promise((resolve, reject) => {
  console.log(111)
  setTimeout(() => {
    reject('失败1')
  }, 1000)
})

console.log(222)

p.then(data => {
  console.log(data)
}).catch(err => {
  console.log(err)
}).finally(() => {
  console.log('finally')
})

输出顺序:

111
222
失败1
finally

要理解为什么 Promise 能这样执行,必须从两个角度讲:

  • (1)Promise 内部是状态机(pending → fulfilled / rejected)
  • (2)then/catch/finally 是通过原型链挂载的“回调注册器”

我们分开看看。


1)Promise 内部是一个状态机

内部状态(无法手动修改):

状态 描述 何时出现
pending 初始状态 执行 executor 期间
fulfilled resolve 被调用 成功
rejected reject 被调用 失败

也就是说:

new Promise(executor)

执行后:

  • 立即执行 executor
  • executor 只在同步阶段运行
  • 真正的 resolve/reject 回调是“挂起来”,等事件循环驱动

所以你看到:

111(executor 同步执行)
222(外部同步执行)
失败1(异步到点后 reject)
finally(状态 settled 后触发)

2)then/catch/finally:它们不是魔法,是原型链的方法

看看这段链式调用:

p.then(...).catch(...).finally(...)

为什么可以一直“链式”?

因为每次调用 then 都 返回一个新的 Promise 实例

p.then(...) → p2
p2.catch(...) → p3
p3.finally(...) → p4

这几个实例的原型链依然是:

p2.__proto__ === Promise.prototype
p3.__proto__ === Promise.prototype
...

因此:

链式本质 = 每次链式都返回一个新的 Promise 实例,然后继续在原型链上查找 then/catch/finally。

这就是原型链在 Promise 底层的重要性。


三、原型链的类比:Promise 就像“火车头 + 车厢”系统

你提到的类比非常棒,我把它整理成完整模型:

✨ Promise = 火车系统

  • 构造函数(Promise) = 火车制造厂
  • 原型对象(Promise.prototype) = “火车车厢模板”
  • 实例(p) = 火车头
  • then/catch/finally = 可以接在车头后的“车厢类型”

于是我们看到:

p(车头).then(挂一个车厢)
         .then(再挂一节)
         .catch(挂一个处理失败的车厢)
         .finally(挂尾部的清理车厢)

每次挂车厢(调用 then/catch)时,都会生成 新的火车车头(新的 Promise 实例)

整个火车最终沿着轨道(事件循环)开动到终点。

⚠️ 注意:为什么 finally 一定执行?

因为 finally 不关心结果,只关心火车是否开到终点(settled)。


四、Promise 与普通对象原型链的对比

你提供了一个经典例子:

function Person(name, age) {
  this.name = name
  this.age = age
}

Person.prototype.speci = '人类'

let zhen = new Person('白兰地空瓶', 18)
console.log(zhen.speci)

const kong = {
  name: '空瓶',
  hobbies: ['读书', '喝酒']
}

zhen.__proto__ = kong

console.log(zhen.hobbies, zhen.speci)

输出:

人类
['读书','喝酒'] undefined

这个例子非常适合用来对比 Promise 的原型链逻辑。

对比 1:实例可以动态改原型(不推荐)

zhen.__proto__ = kong 改掉了原来的 Person.prototype

所以:

  • 能访问 hobbies:因为来自 kong
  • 不能访问 speci:因为已脱离 Person.prototype

Promise 则不能做这种事

你不能这样做:

p.__proto__ = {}

否则:

p.then is not a function

因为 then/catch/finally 都来自 Promise.prototype。

这反而让我们更清楚地理解:

Promise 的能力几乎全部来自原型链。


五、Promise.all 的底层逻辑:一辆多车头的“联挂火车”

提到 Promise.all,这里正好顺便讲讲它的底层设计。

Promise.all([p1, p2, p3])

机制可以用一个形象类比解释:

  • 假设有三辆火车(p1/p2/p3)
  • Promise.all 创建一辆“总火车头” pAll
  • pAll 盯着三个火车头,只要全部变成 fulfilled,就把所有结果一次性返回
  • 如果有一个 reject,则整个 pAll 变成 rejected(列车脱轨)

也就是说:

Promise.all = 多个 Promise 状态机的并联 + 一个新的总状态机。

为什么它能做到?

答案依旧在原型链:

  • Promise.all 本质是一个静态方法,返回新的 Promise 实例
  • 新的 Promise 实例依然沿用同一套路(prototype → then/catch)

六、用真实工程场景收尾:Promise 原型链为什么重要?

在真实项目里,理解 Promise 的原型机制有三个实际价值:

① debugger 时能看清原型链,定位异步回调来源

你能区分:

  • then 回调从哪里来的?(Promise.prototype.then)
  • promise 链断在哪一层?

② 手写 Promise 时必须实现 then/catch/finally

如果你手写 Promise A+:

MyPromise.prototype.then = function(onFulfilled, onRejected) {}

这里你就必须自己处理链式、状态机、回调队列。

③ 能理解 async/await 的底层依赖 Promise 链式调度

await 会把后续步骤注册到 promise.then 中。

理解 then 的原型链,就能理解 async/await 的机制本质。


七、总结:Promise + 原型链的全景图

// 创建实例
const p = new Promise(executor)

// 原型链:调用能力来自这里
p.__proto__ = Promise.prototype

// 状态机:内部维护 pending → fulfilled/rejected

// then/catch/finally:注册微任务

// 链式调用:每次都返回一个新的 Promise 实例

// Promise.all:多个状态机的并联

一句话总结:

Promise 本质是一个基于“原型链 + 状态机 + 微任务队列”的异步调度框架。

它既是面向对象设计(通过原型链复用方法),又是异步控制核心工具(内部状态机)。

理解二者的融合,你就真正吃透了 Promise。

🧠 深入理解 JavaScript Promise 与 `Promise.all`:从原型链到异步编程实战

在现代 JavaScript 开发中,Promise 是处理异步操作的核心机制之一。ES6 引入的 Promise 极大地简化了“回调地狱”(Callback Hell)问题,并为后续的 async/await 语法奠定了基础。而 Promise.all 则是并发执行多个异步任务并统一处理结果的强大工具。

本文将结合 原型链原理Promise 基础用法实际示例代码,带你系统掌握 Promise 及其静态方法 Promise.all 的使用与底层逻辑。


🔗 一、JavaScript 的面向对象:原型链而非“血缘”

在深入 Promise 之前,我们先厘清一个关键概念:JavaScript 的继承不是基于“类”的血缘关系,而是基于原型(prototype)的链式查找机制

1.1 🏗️ 构造函数与原型对象

function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.speci = '人类';

let zhen = new Person('张三', 18);
console.log(zhen.speci); // 输出: "人类"
  • Person 是构造函数。
  • Person.prototype 是所有 Person 实例共享的原型对象。
  • zhen.__proto__ 指向 Person.prototype
  • Person.prototype.constructor 又指回 Person,形成闭环。

🚂 小比喻:可以把 constructor 看作“车头”,prototype 是“车身”。实例通过 __proto__ 连接到车身,而车身知道自己的车头是谁。

1.2 ⚡ 动态修改原型链(不推荐)

const kong = {
    name: '孔子',
    hobbies: ['读书', '喝酒']
};

zhen.__proto__ = kong;
console.log(zhen.hobbies);     // ✅ 输出: ['读书', '喝酒']
console.log(kong.prototype);   // ❌ undefined!普通对象没有 prototype 属性

⚠️ 注意:

  • 只有函数才有 prototype 属性;
  • 普通对象(如 kong)只有 __proto__,没有 prototype
  • 在这里kong是object的一个实例kong.__prpto__ == object.prototype

💡 虽然可以动态修改 __proto__,但会破坏代码可预测性,影响性能,应避免使用。


⏳ 二、Promise:ES6 的异步解决方案

2.1 🧩 Promise 基本结构

<script>
const p = new Promise((resolve, reject) => {
    console.log(111); // 同步执行
    setTimeout(() => {
        console.log(333);
        // resolve('结果1');  // 成功
        reject('失败1');      // 失败
    }, 1000);
});

console.log(222);
console.log(p, '////////'); // 此时 p 状态仍是 pending
console.log(p.__proto__ == Promise.prototype); // true
</script>

📋 执行顺序分析:

  1. 111 立即输出(executor 函数同步执行)✅
  2. 222 紧接着输出 ✅
  3. p 此时处于 pending(等待) 状态 ⏳
  4. 1 秒后,333 输出,调用 reject('失败1'),状态变为 rejected
  5. .catch() 捕获错误,.finally() 无论成功失败都会执行 🔁

2.2 🎯 Promise 的三种状态

  • ⏳ pending:初始状态,既不是成功也不是失败。
  • ✅ fulfilled:操作成功完成(通过 resolve 触发)。
  • ❌ rejected:操作失败(通过 reject 触发)。

🔒 核心特性:一旦状态改变,就不可逆。这是 Promise 的设计基石。

2.3 🔍 原型关系验证

console.log(p.__proto__ === Promise.prototype); // ✅ true
  • pPromise 的实例。
  • 所有 Promise 实例的 __proto__ 都指向 Promise.prototype
  • Promise.prototype 上定义了 .then(), .catch(), .finally() 等方法。
  • Promise.prototype.__proto__ == object.prototype

🚀 三、Promise.all:并发处理多个异步任务

3.1 ❓ 什么是 Promise.all

Promise.all(iterable) 接收一个可迭代对象(如数组),其中包含多个 Promise。它返回一个新的 Promise:

  • ✅ 全部成功 → 返回一个包含所有结果的数组(顺序与输入一致)。
  • ❌ 任一失败 → 立即 rejected,返回第一个失败的原因。

3.2 💻 使用示例

const task1 = fetch('/api/user');       // 假设返回 { id: 1, name: 'Alice' }
const task2 = fetch('/api/posts');      // 假设返回 [{ title: 'JS' }]
const task3 = new Promise(resolve => setTimeout(() => resolve('done'), 500));

Promise.all([task1, task2, task3])
  .then(([user, posts, msg]) => {
    console.log('全部完成:', user, posts, msg);
  })
  .catch(err => {
    console.error('某个任务失败:', err);
  });

🌐 适用场景:需要同时加载用户信息、文章列表、配置数据等,全部就绪后再渲染页面。

3.3 ⚠️ 错误处理演示

const p1 = Promise.resolve('成功1');
const p2 = Promise.reject('失败2');
const p3 = Promise.resolve('成功3');

Promise.all([p1, p2, p3])
  .then(results => console.log('不会执行'))
  .catch(err => console.log('捕获错误:', err)); // 输出: "失败2"

关键点:只要有一个失败,整个 Promise.all 就失败,其余成功的 Promise 结果会被丢弃。

3.4 🛡️ 替代方案:Promise.allSettled(ES2020)

如果你希望无论成功失败都等待所有任务完成,可以使用 Promise.allSettled

Promise.allSettled([p1, p2, p3])
  .then(results => {
    results.forEach((res, i) => {
      if (res.status === 'fulfilled') {
        console.log(`✅ 任务${i} 成功:`, res.value);
      } else {
        console.log(`❌ 任务${i} 失败:`, res.reason);
      }
    });
  });

✅ 适用于:批量上传、日志收集、非关键资源加载等场景。


📚 四、总结:从原型到实践

概念 说明
🔗 原型链 JS 对象通过 __proto__ 查找属性,constructor 指回构造函数
Promise 表示异步操作的最终完成或失败,具有 pending/fulfilled/rejected 三种状态
🧩 Promise.prototype 所有 Promise 实例的方法来源(.then, .catch 等)
🚀 Promise.all 并发执行多个 Promise,全成功则成功,任一失败则整体失败
🛡️ 最佳实践 使用 Promise.all 提升性能;用 allSettled 处理非关键任务

💭 五、思考题

  1. 🤔 为什么 console.log(p)setTimeout 之前打印时,状态是 pending
  2. 🛠️ 能否通过修改 Promise.prototype.then 来全局拦截所有 Promise 的成功回调?这样做有什么风险?
  3. 📦 如果 Promise.all 中传入空数组 [],结果会是什么?

💡 答案提示

  1. 因为异步任务尚未执行,状态未改变。
  2. 技术上可行,但会破坏封装性、可测试性和团队协作,强烈不推荐
  3. 立即 resolved,返回空数组 [] —— 这是符合规范的!

通过本文,你不仅掌握了 PromisePromise.all 的用法,还理解了其背后的 原型机制异步执行模型。这将为你编写健壮、高效的异步代码打下坚实基础。🌟

Happy Coding! 💻✨

从摄影新手到三维光影师:Three.js 核心要素的故事

当我第一次学习摄影时,老师告诉我一句话:

“你不是在拍东西,而是在拍光。”

后来我学习 Three.js 时突然意识到:
这句话原来依旧成立。

Three.js 不只是一个 3D 引擎,更像是一台虚拟相机。要拍好这张“虚拟的照片”,我们必须掌握三个核心要素:

场景(Scene)

相机(Camera)
灯光与材质(Light & Material)

于是,我把学习过程想象成一个摄影新手成长为三维光影师的故事。

空无一物的影棚 —— Scene 场景

故事从一个空影棚开始。

当我第一次打开 Three.js 时,教程告诉我:

const scene = new THREE.Scene();

这就像摄影师走进了一个空旷的工作室:
没有布景、没有模特、没有灯光,甚至连相机都还没架好, 在影棚这个场景中,摄影师可以在这个场景中放任何的东西:

  • 架好摄像机(Camera 📹)
  • 拍照的物体(Mesh 网格物体)、物体拥有着自己的形状(Geometry几何体)和材质(Material)
  • 摆设好灯光(Light)
  • 也可以是任意的对象 (Object3D)

摄影师往 Scene 里布置道具,而程序员的你往 Scene 里添加各种对象,因此 场景就是一个可以放任何东西的容器

找到你要观看的角度 —— Camera 相机

刚学摄影时,我最常做的事情,就是移动、蹲下、趴着、绕圈……
只为了找到一个“对的角度”。

Three.js 的相机就是你的眼睛。创建相机就像准备拍摄时拿起单反:

const camera = new THREE.PerspectiveCamera(const camera = new THREE.PerspectiveCamera(
  50, // 相机视野角度,摄像机的视野角度越大,摄像机看到的场景就越大,反之越小
  window.innerWidth / window.innerHeight, // 宽高比
  0.1, // 近平面(近端渲染距离),指定从距离相机多近的位置开始渲染,推荐默认值0.1
  1000 // 远平面(远端渲染距离)指定摄像机从它所在的位置最远能看到多远,太小场景中的远处物体会看不见,太大会浪费资源影响性能,推荐默认值1000
);

// 2.1 设置相机的位置,放在不同的位置看到的风景当然不一样
camera.position.set(5, 10, 10); // x, y, z

camera.lookAt(0, 0, 0); // 设置相机方向(这就是你女朋友让你找最佳角度的原因)

摄影师会说:“我走两步,让模特在背景中更突出。”
程序员会说:

camera.position.z = 3;
camera.lookAt(0, 0, 0)

本质完全一样:
都是在调整观察世界的方式。

让世界真正亮起来 —— Light & Material 灯光与材质

你可以有再漂亮的模特、再好的相机,如果没有光——
一切都会变成漆黑一片。

Three.js 也是如此。你搭了一个完美的 3D 模型,如果没有光,它看起来只是纯黑。

于是我制作“虚拟布光”:

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 5);
scene.add(light);

摄影师打灯,而我在代码里放置光源:

  • DirectionalLight(平行光)= 太阳光
  • PointLight(点光源)= 想象灯泡发光,由点向八方发射
  • SpotLight(聚光灯)= 舞台灯,从上打下来,呈现圆锥体,它离光越远,它的尺寸就越大。这种光源会产生阴影
  • AmbientLight(环境光)= 影棚柔光,环境光没有特定的来源方向,不会产生阴影

同时材质(Material)也等同于现实世界的“被光击中时的反应”:

  • 皮肤 = standard material
  • 金属 = metalness 高
  • 塑料 = roughness 较高
  • 玻璃 = transparent=True + envMap

想要一个皮肤质感的物体?
那么你就得给材质加入 roughness、metalness、normalMap 就像摄影师在打柔光,为人物皮肤创造质感。

光与材质的搭配,就是 Three.js 里的“布光艺术”。

最终章:按下快门 —— Renderer 渲染器

当场景布好、相机调好、灯光到位后——
摄影师要做的就是按下快门。

在 Three.js 里:

renderer.render(scene, camera);

渲染器就是那个“快门”,
真正把世界投射到屏幕上。

摄影师用快门把现实世界的光记录下来;
Three.js 用 GPU 把虚拟世界的光影计算出来。

本质上,两者做的是同一件事:

把真实或虚拟的三维世界,投射成一张二维图像。

import * as THREE from "three";

// 1. 创建场景
const scene = new THREE.Scene();

// 2. 创建相机(透视投影相机)
const camera = new THREE.PerspectiveCamera(
  50, // 相机视野角度,摄像机的视野角度越大,摄像机看到的场景就越大,反之越小
  window.innerWidth / window.innerHeight, // 宽高比
  0.1, // 近平面(近端渲染距离),指定从距离相机多近的位置开始渲染,推荐默认值0.1
  1000 // 远平面(远端渲染距离)指定摄像机从它所在的位置最远能看到多远,太小场景中的远处物体会看不见,太大会浪费资源影响性能,推荐默认值1000
);

// 2.1 设置相机的位置
camera.position.set(5, 10, 10); // x, y, z

camera.lookAt(0, 0, 0); // 设置相机方向(默认看向场景原点)

// 3. 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true }); // 开启抗锯齿,使边缘更平滑
// 3.1 设置渲染器的大小
renderer.setSize(window.innerWidth, window.innerHeight);
// 3.2 将渲染器的canvas内容添加到body
document.body.appendChild(renderer.domElement);

// 4. 创建一个立方体几何体
const geometry = new THREE.BoxGeometry(4, 4, 4); // 宽、高、深

// 为了让光源有效果,我们使用 MeshLambertMaterial 或 MeshPhongMaterial
//  创建材质 MeshLambertMaterial (兰伯特材质) 是一种非光泽材质,会受光照影响,但没有镜面高光
const material = new THREE.MeshLambertMaterial({
  color: 0x00ff00, // 颜色
  // wireframe: true, // 如果需要线框效果可以加上
});

// 6. 创建一个网格模型(网格模型由几何体和材质组成)
// Mesh 构造函数通常只接受一个材质。如果需要多材质,Three.js 有专门的 MultiMaterial 或 Group 来处理
const cube = new THREE.Mesh(geometry, material); // 使用 MeshLambertMaterial

// 6.1 将几何模型添加到场景中
scene.add(cube);

// 6.2 设置相机看向物体(拍摄对象)的位置(默认状态下相机看向的是场景的原点(0,0,0))
camera.lookAt(cube.position);

// 7. 创建光源
const spotLight = new THREE.SpotLight(0xffffff); // 创建聚光灯,颜色为白色
// 7.1 设置光源的位置
spotLight.position.set(0, 20, 20); // 调整光源位置,使其能够照亮立方体
// 7.2 设置光源照射的强度,默认值为1, 越大越亮
spotLight.intensity = 2;
// 7.3 将光源添加到场景中
scene.add(spotLight);

// 8. 为了方便观察 3D 图像,添加三维坐标系对象
const axesHelper = new THREE.AxesHelper(6); // 参数表示坐标系的大小 (x轴红色, y轴绿色, z轴蓝色)
scene.add(axesHelper); // 将坐标系添加到场景中

// 9. 渲染函数
function animate() {
  requestAnimationFrame(animate); // 请求再次执行渲染函数animate,形成循环

  // 让立方体动起来
  cube.rotation.x += 0.01; // 沿x轴旋转
  cube.rotation.y += 0.01; // 沿y轴旋转
  cube.rotation.z += 0.01;

  renderer.render(scene, camera); // 使用渲染器,通过相机将场景渲染出来
}

animate(); // 执行渲染函数,进入无限循环,完成渲染

2025年CSS新特性大盘点

大家好,我是 Immerse,一名独立开发者、内容创作者、AGI 实践者。

关注公众号:沉浸式趣谈,获取最新文章(更多内容只在公众号更新)

个人网站:yaolifeng.com 也同步更新。

转载请在文章开头注明出处和版权信息。

我会在这里分享关于编程独立开发AI干货开源个人思考等内容。

如果本文对您有所帮助,欢迎动动小手指一键三连(点赞评论转发),给我一些支持和鼓励,谢谢!


2025年了,CSS又进化了

去年写过一篇 CSS 新特性盘点,本来以为今年不会有太大变化。结果一看,新东西比去年还多。

这次整理了几个我觉得特别实用的功能,浏览器支持也都不错,可以用起来了。

终于可以动画到 auto 了

之前我们做高度展开动画,基本都是靠 max-height 硬撑。

比如从 0 展开到实际高度,只能写个超大的值,体验很差。

现在可以直接动画到 auto 了:

html {
  interpolate-size: allow-keywords;
}

加上这一行,所有 height: 0 到 height: auto 的过渡都能生效。

或者你也可以用 calc-size() 函数,不需要全局设置:

.content {
  height: 3lh;
  overflow: hidden;
  transition: height 0.2s;

  &.expanded {
    height: calc-size(auto, size);
  }
}

这个功能总算来了。

而且不只是 height,任何接受尺寸的属性都能用,不只是 auto,min-content 这些关键字也行。

目前 Chrome 已经支持,其他浏览器应该也快了。

Popover 和 Invoker

Popover 是个 HTML 属性,给任意元素加上就有开关功能。

配合 Invoker 用起来更爽,不用写 JavaScript 就能控制弹窗。

<button commandfor="menu" command="toggle">
  打开菜单
</button>

<div id="menu" popover>
  菜单内容
</div>

这样就够了,按钮点击自动控制弹窗显示隐藏。

浏览器会自动处理无障碍访问、键盘操作、焦点管理这些细节。

而且还能配合 Anchor Positioning 用,让弹窗自动定位到触发元素旁边。

Popover 已经全浏览器支持,Invoker 目前只有 Chrome,不过有 polyfill 可以用。

CSS 里可以写函数了

CSS 有 calc()、clamp() 这些内置函数,现在我们可以自己写了:

@function --titleBuilder(--name) {
  result: var(--name) " is cool.";
}

然后就能在任何地方调用:

.title::after {
  content: --titleBuilder("CSS");
}

这个功能让 CSS 更像编程语言了。

把复杂逻辑封装到函数里,代码更清爽,也更好维护。

不过目前只有 Chrome 支持,可以先用着,不支持的浏览器会回退到默认值。

if() 函数也来了

CSS 本来就有很多条件逻辑,比如选择器匹配、媒体查询。

但这次的 if() 函数是第一个专门做条件分支的:

.grid {
  display: grid;
  grid-template-columns:
    if(
      media(max-width > 300px): repeat(2, 1fr);
      media(max-width > 600px): repeat(3, 1fr);
      media(max-width > 900px): repeat(auto-fit, minmax(250px, 1fr));
      else: 1fr;
    );
}

看起来像不像 switch 语句?第一个匹配的条件会生效。

条件可以是 media()、supports()、style() 这几种。

把所有逻辑都写在一个属性里,代码可读性好很多。

目前 Chrome 独占,其他浏览器还在路上。

表单输入框自动调整大小

field-sizing 这个属性专门解决表单输入框的问题。

textarea {
  field-sizing: content;
}

加上这一行,textarea 会自动根据内容调整高度。

用户输入多少内容,输入框就有多高,不用手动拖拽了。

在手机上体验特别好,拖拽调整大小本来就很难操作。

这个功能之前都是用 JavaScript 实现,现在 CSS 原生支持了。

Chrome 和 Safari 都能用,Firefox 估计也快了。

select 下拉框终于能自定义样式了

select 元素的外观一直很难自定义,打开后显示的选项更是完全没法控制。

现在可以完全自定义了,只要先开启:

select,
::picker(select) {
  appearance: base-select;
}

然后想怎么改就怎么改,选项的样式、布局、动画都能控制。

目前 Chrome 独占,不过不支持的浏览器会回退到原生样式,完全不影响使用。

text-wrap 让排版更好看

text-wrap: balance 可以让每行文字长度尽量接近:

h1 {
  text-wrap: balance;
}

用在标题上效果特别好,不会出现最后一行只有一个词的情况。

还有个 text-wrap: pretty,专门优化正文排版:

p {
  text-wrap: pretty;
}

浏览器会自动调整断行,避免孤词,让文字看起来更舒服。

balance 已经全浏览器支持,pretty 在 Chrome 和 Safari 能用。

这种优化对用户体验很重要,而且完全不影响功能,可以直接加上。

linear() 实现复杂缓动效果

CSS 的 linear 关键字之前就是匀速动画,很无聊。

但 linear() 函数可以实现超复杂的缓动,比如弹跳效果:

.bounce {
  animation-timing-function: linear(
    0, 0.004, 0.016, 0.035, 0.063, 0.098, 0.141 13.6%, 0.25, 0.391, 0.563, 0.765,
    1, 0.891 40.9%, 0.848, 0.813, 0.785, 0.766, 0.754, 0.75, 0.754, 0.766, 0.785,
    0.813, 0.848, 0.891 68.2%, 1 72.7%, 0.973, 0.953, 0.941, 0.938, 0.941, 0.953,
    0.973, 1, 0.988, 0.984, 0.988, 1
  );
}

这种效果用 cubic-bezier() 根本做不出来。

而且已经全浏览器支持了,可以放心用。

有在线工具可以生成这些值,不用自己手写。

shape() 函数画任意图形

CSS 之前有 path() 函数,但语法很难写,而且只能用像素。

shape() 是专门为 CSS 设计的,支持所有单位和自定义属性:

.arrow {
  clip-path: shape(
    evenodd from 97.788201% 41.50201%,
    line by -30.839077% -41.50201%,
    curve by -10.419412% 0% with -2.841275% -3.823154% / -7.578137% -3.823154%,
    smooth by 0% 14.020119% with -2.841275% 10.196965%,
    close
  );
}

可以用在 clip-path 裁剪元素,也能用在 offset-path 做路径动画。

而且可以响应式调整,配合媒体查询和容器查询都没问题。

Chrome 和 Safari 已经支持,Firefox 也在开发中。

attr() 变强了

之前 attr() 只能取字符串,现在可以指定类型了:

<div data-count="42" data-color="#ff0000">
div {
  --count: attr(data-count type(<number>));
  --color: attr(data-color type(<color>));
}

这样可以直接把 HTML 属性当数字或颜色用,方便多了。

目前 Chrome 独占,不过对于不支持的浏览器,可以设置回退值。

reading-flow 解决 Tab 顺序问题

用 Grid 或 Flexbox 重新排列元素后,Tab 键的焦点顺序会乱。

现在可以用 reading-flow 告诉浏览器按照视觉顺序来:

.grid {
  reading-flow: grid-rows;
}

这样焦点就会按照 Grid 的行顺序移动,不会乱跳了。

Flexbox 用 flex-flow,其他布局也有对应的值。

这个功能对无障碍访问很重要,不过目前只有 Chrome 支持。

等其他浏览器跟进之前,最好不要大量重排布局。

值得期待的功能

还有一些功能在开发中,但还没正式发布:

Masonry 布局虽然各浏览器实现不同,但在稳步推进。

Safari 的 random() 函数可以生成随机数,玩起来很有意思。

margin-trim 可以自动去掉容器边缘元素的外边距,Safari 独占中。

sibling-index() 和 sibling-count() 函数在 Chrome 能用,做交错动画很方便。

View Transitions 的 match-element 不用给每个元素起名字了,而且 Firefox 也在开发中。

还有很多其他功能在路上。

别忘了这些已经能用的

Container Queries 和 :has() 这些去年的新功能,现在已经全浏览器支持。

View Transitions、Anchor Positioning、Scroll-Driven Animations 也都在 Safari 上线了。

dvh 这些视口单位也成为标准了。

CSS 现在能做的事情越来越多,写起来也越来越顺手。

参考:frontendmasters.com/blog/what-y…

其他好文推荐

2025 最新!独立开发者穷鬼套餐

Windows 安装 Claude Code 的新姿势,保姆级教程

Claude Code 从入门到精通:最全配置指南和工具推荐

Claude Code 终极配置指南:一行命令搞定各种配置

一个配置文件搞定!Claude Code 多模型智能切换

这个 361k Star 的项目,一定要收藏!

搞定 XLSX 预览?别瞎找了,这几个库(尤其最后一个)真香!

【完整汇总】近 5 年 JavaScript 新特性完整总览

关于 Node,一定要学这个 10+万 Star 项目!

【翻译】使用 React 19 操作构建可复用组件

使用 React 19 Actions 构建可复用的 React 组件,通过 useTransition()useOptimistic() 实现功能。通过实际案例学习如何追踪待处理状态、实现乐观更新,并在 Next.js 应用路由器中暴露动作属性以实现自定义逻辑。

作者:Aurora Scharff

首发于 aurorascharff.no

React 19 Actions 简化了待处理状态、错误、乐观更新和顺序请求的处理。本文将探讨如何在 Next.js App Router 中使用 React 19 Actions 构建可复用组件。我们将利用 useTransition() 追踪过渡状态,使用 useOptimistic() 向用户提供即时反馈,并暴露 action 属性以支持父组件中的自定义逻辑。

React 19 Actions

根据更新后的 React 文档,动作(Actions)是在过渡(Transitions)内部调用的函数。过渡可以更新状态并执行副作用,相关操作将在后台执行,不会阻塞页面上的用户交互。过渡内部的所有动作都会被批量处理,组件仅在过渡完成时重新渲染一次。

Action 可用于自动处理待定状态、错误、乐观更新及顺序请求。在 React 19 表单中使用 <form action={} 属性时,以及向 useActionState() 传递函数时,也会自动创建动作。有关这些 API 的概述,请参阅我的 React 19 速查表或官方文档。

使用 useTransition() 钩子时,您还将获得一个 pending 状态,这是一个布尔值,用于指示过渡是否正在进行。这有助于在过渡过程中显示加载指示器或禁用按钮。

const [isPending, startTransition] = useTransition(); 
const updateNameAction = () => { 
  startTransition(async () => { 
    await updateName(); 
  }) 
})

此外,在钩子版本的 startTransition() 中调用的函数抛出的错误将被捕获,并可通过错误边界进行处理。

Action函数是常规事件处理的替代方案,因此应相应地命名。否则,该函数的使用者将无法明确预期其行为类型。

用例:路由器选择组件

假设我们要构建一个可复用的下拉菜单组件,该组件会将下拉菜单选中的值设置为URL中的参数。其实现方式可能如下所示:

export interface RouterSelectProps { 
  name: string; 
  label?: string; 
  value?: string; 
  options: Array<{ value: string; label: string }>; 
} 

export const RouterSelect = React.forwardRef<HTMLSelectElement, RouterSelectProps>(   
  function Select({ name, label, value, options, ...props }, 
    ref 
) { 
... 
return ( 
  <div> 
    {label && <label htmlFor={name}>{label}</label>} 
      <select 
        ref={ref} 
        id={name} 
        name={name} 
        value={value} 
        onChange={handleChange} 
        {...props} 
      > 
        {options.map((option) => ( 
           <option key={option.value} value={option.value}> 
             {option.label} 
           </option> 
         ))} 
      </select> 
  </div> 
  ) 
}

它可能会这样处理变化:

const handleChange = async ( 
  event: React.ChangeEvent<HTMLSelectElement> 
) => { 
  const newValue = event.target.value; 
  
  // Update URL 
  const url = new URL(window.location.href); 
  url.searchParams.set(name, newValue); 
  
  // Simulate a delay that would occur if the route destination is doing async work 
  await new Promise((resolve) => setTimeout(resolve, 500)); 
  
  // Navigate 
  router.push(url.href, { scroll: false }); 
};

可通过路由器传递 searchParams 来使用:

<RouterSelect
  name="lang" 
  options={Object.entries(languages).map(([value, label]) => { 
    return { value, label, }; 
  })} 
  label="Language" 
  value={searchParams.lang} 
/>

由于我们使用 Next.js 应用路由器,当延迟推送到路由时,下拉框的值不会立即更新,而是等到 router.push() 完成且搜索参数更新后才会刷新。

这会导致糟糕的用户体验:用户必须等待路由推送完成才能看到下拉框的新值,可能因此产生困惑,误以为下拉框功能失效。

使用Action追踪待处理状态

让我们创建一个使用 useTransition() 钩子的 Action 来追踪推送至路由器的状态。

我们将向路由器的推送封装在返回的 startNavTransition() 函数中,该函数将追踪该转场的待处理状态。这将使我们能够知道转场的进展以及何时完成。

  const [isNavPending, startNavTransition] = useTransition(); 
  const handleChange = async ( 
    event: React.ChangeEvent<HTMLSelectElement> 
  ) => { 
    const newValue = event.target.value; 
    startNavTransition(async () => { 
      const url = new URL(window.location.href); 
      url.searchParams.set(name, newValue); 
      await new Promise((resolve) => setTimeout(resolve, 500)); 
      router.push(url.href, { scroll: false }); 
    }); 
  };

现在,我们可以利用 isNavPending 状态在过渡过程中显示加载指示器,并添加 aria-busy 等辅助功能属性。

<div> 
  {label && <label htmlFor={name}>{label}</label>} 
  <select 
    ref={ref} 
    id={name} 
    name={name} 
    aria-busy={isNavPending} 
    value={value} 
    onChange={handleChange} 
    {...props} 
  > 
    {options.map((option) => ( 
      <option key={option.value} value={option.value}> 
        {option.label} 
      </option> 
    ))}
  </select> 
  {isNavPending && 'Pending nav...'} 
</div>

现在,用户将收到关于其与下拉菜单交互的反馈,不会认为它无法正常工作。

然而,下拉菜单仍然无法立即更新。

使用 useOptimistic() 添加乐观更新

此时就需要用到 useOptimistic() 函数。它允许我们立即更新状态,同时仍能追踪过渡的待处理状态。我们可以在过渡内部调用它:

const [optimisticValue, setOptimisticValue] = useOptimistic(value); 

const handleChange = async ( 
  event: React.ChangeEvent<HTMLSelectElement> 
) => { 
  const newValue = event.target.value; 
  startNavTransition(async () => { 
    setOptimisticValue(newValue); 
    const url = new URL(window.location.href); 
    url.searchParams.set(name, newValue); 
    await new Promise((resolve) => setTimeout(resolve, 500)); 
    router.push(url.href, { scroll: false }); 
  }); 
};

在过渡期间,optimisticValue 将作为临时客户端状态,用于立即更新下拉菜单。过渡完成后,optimisticValue 将最终更新为路由器返回的新值。

现在,我们的下拉菜单实现了即时更新,用户在过渡过程中即可看到菜单中的新值。

暴露Action属性

假设作为 RouterSelect 的用户,我们希望在选项变更时执行额外逻辑。例如,可能需要更新父组件中的其他状态或触发副作用。此时可暴露一个在选项变更时执行的函数。

参照 React 文档,我们可以向父组件暴露一个 action 属性。由于暴露的是 Action,命名时应符合规范,以便组件使用者明确预期行为。

具体实现如下:

export interface RouterSelectProps { 
  name: string; 
  label?: string; 
  value?: string; 
  options: Array<{ value: string; label: string }>; 
  setValueAction?: (value: string) => void; 
}

我们可以在handleChange过渡中调用此属性:

const handleChange = async ( 
  event: React.ChangeEvent<HTMLSelectElement> 
) => { 
  const newValue = event.target.value; 
  startNavTransition(async () => { 
    setOptimisticValue(newValue); 
    setValueAction?.(newValue); '
    const url = new URL(window.location.href); 
    url.searchParams.set(name, newValue); 
    await new Promise((resolve) => setTimeout(resolve, 500)); 
    router.push(url.href, { scroll: false }); 
  });
};

我们还应支持 async 函数。这使得操作回调既可以是同步的,也可以是异步的,而无需额外使用 startTransition 来包裹操作中的 await 语句。

export interface RouterSelectProps { 
  ...// other props 
  setValueAction?: (value: string) => void | Promise<void>; 
}

然后只需 await 操作完成,再推送到路由器:

const handleChange = async ( 
  event: React.ChangeEvent<HTMLSelectElement> 
) => { 
  const newValue = event.target.value; 
  startNavTransition(async () => { 
    setOptimisticValue(newValue); 
    await setValueAction?.(newValue); 
    ... // Push to router 
  }); 
};

在父组件中使用 Action 属性

现在,我们可以通过 setValueAction 属性执行状态更新,并且由于命名规范,我们清楚会行为的结果。

例如,如果我们使用 useState() 设置一条消息:

const [message, setMessage] = useState(''); 
return ( 
  <> 
  <div> 
    Message: {message} <br /> 
  </div> 
  <RouterSelect 
    setValueAction={(value) => { 
      setMessage(`You selected ${value}`);
    }}

我们知道,此状态更新将在向路由器推送完成后发生。

此外,若现在需要乐观更新,可调用 useOptimistic()

const [message, setMessage] = useState(''); 
const [optimisticMessage, setOptimisticMessage] = useOptimistic(message); 

return ( 
  <> 
  <div> 
    Message: {message} <br /> 
    Optimistic message: {optimisticMessage} 
  </div> 
  <RouterSelect 
    setValueAction={(value) => { 
      setOptimisticMessage(`You selected ${value}`); 
      setMessage(`You selected ${value}`); 
    }}

我们知道此状态更新将立即发生。

最终的select实现如下所示:

'use client'; 

... 
export interface RouterSelectProps { 
  name: string; 
  label?: string; 
  value?: string | string[]; 
  options: Array<{ value: string; label: string }>; 
  setValueAction?: (value: string) => void | Promise<void>; 
} 

export const RouterSelect = React.forwardRef<HTMLSelectElement, RouterSelectProps>( 
  function Select( 
    { name, label, value, options, setValueAction, ...props }, 
    ref 
  ) { 
    const router = useRouter(); 
    const [isNavPending, startNavTransition] = React.useTransition(); 
    const [optimisticValue, setOptimisticValue] = React.useOptimistic(value); 
    
    const handleChange = async ( 
      event: React.ChangeEvent<HTMLSelectElement> 
    ) => { 
      const newValue = event.target.value; 
      startNavTransition(async () => { 
        setOptimisticValue(newValue); 
        await setValueAction?.(newValue); 
        const url = new URL(window.location.href); 
        url.searchParams.set(name, newValue); 
        await new Promise((resolve) => setTimeout(resolve, 500)); 
        router.push(url.href, { scroll: false }); 
      }); 
    }; 
    
    return ( 
      <div> 
        {label && <label htmlFor={name}>{label}</label>} 
        <select 
          ref={ref} 
          id={name} 
          name={name} 
          value={optimisticValue} 
          onChange={handleChange} 
          {...props} 
        > 
          {options.map((option) => ( 
            <option key={option.value} value={option.value}> 
              {option.label} 
            </option> 
          ))} 
          </select> 
          {isNavPending && 'Pending nav...'} 
      </div> 
    ); 
  } 
);

查看这个StackBlitz,获取一个可运行的示例。

若需查看本文所述模式的更实用、更贴近实际的应用示例,请参阅我Next.js 15 Conferences项目中的Filters.tsx组件。

构建复杂、可重用的组件

在构建更复杂的可复用组件时,我们可能会遇到限制,迫使我们将乐观更新等逻辑移至父组件。

以我尝试的Ariakit示例为例,显示值的生成必须在可复用选择组件外部完成。这意味着我们无法在可复用选择组件内部调用 useOptimistic 。为解决此问题,可暴露 setValueAction 属性,然后在父组件中调用 useOptimistic() 立即更新状态。

通过这种方式,既能保持组件复用性,又允许父组件实现自定义Action逻辑。

关键要点

  • 动作是在过渡中调用的函数,可更新状态并执行副作用。
  • useTransition() 提供待处理状态以追踪过渡进度。
  • useOptimistic() 允许在过渡中立即更新状态。
  • 向可复用组件暴露动作属性,可在父组件中实现自定义逻辑。
  • 在父组件中使用 useOptimistic() 可立即更新状态,同时保持组件的复用性。
  • 动作的命名对向组件使用者传达预期行为至关重要。

结论

在本篇博文中,我们探讨了如何利用 React 19 动作构建可复用组件,追踪过渡状态,采用乐观更新策略,并暴露动作属性以实现自定义逻辑。我们演示了 useTransition() 如何提供待处理状态以优化用户反馈,useOptimistic() 如何实现即时 UI 更新,以及暴露动作属性如何在保持组件复用性的同时允许父组件执行自定义逻辑。

通过遵循动作命名规范并运用 React 的并发特性,我们能够构建出复杂度极低却能提供流畅用户体验的组件。

源码

❌