阅读视图

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

## 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

每日一题-使数组和能被 P 整除🟡

给你一个正整数数组 nums,请你移除 最短 子数组(可以为 ),使得剩余元素的  能被 p 整除。 不允许 将整个数组都移除。

请你返回你需要移除的最短子数组的长度,如果无法满足题目要求,返回 -1 。

子数组 定义为原数组中连续的一组元素。

 

示例 1:

输入:nums = [3,1,4,2], p = 6
输出:1
解释:nums 中元素和为 10,不能被 p 整除。我们可以移除子数组 [4] ,剩余元素的和为 6 。

示例 2:

输入:nums = [6,3,5,2], p = 9
输出:2
解释:我们无法移除任何一个元素使得和被 9 整除,最优方案是移除子数组 [5,2] ,剩余元素为 [6,3],和为 9 。

示例 3:

输入:nums = [1,2,3], p = 3
输出:0
解释:和恰好为 6 ,已经能被 3 整除了。所以我们不需要移除任何元素。

示例  4:

输入:nums = [1,2,3], p = 7
输出:-1
解释:没有任何方案使得移除子数组后剩余元素的和被 7 整除。

示例 5:

输入:nums = [1000000000,1000000000,1000000000], p = 3
输出:0

 

提示:

  • 1 <= nums.length <= 105
  • 1 <= nums[i] <= 109
  • 1 <= p <= 109

🎨 用一次就爱上的图标定制体验: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/…

【套路】前缀和+哈希表(Python/Java/C++/Go)

前置知识

模运算的世界:当加减乘除遇上取模

提示 1

例如 $\textit{nums}=[11,2,5,7,8,9]$,$p=10$,那么把 $[5,7]$ 去掉,剩余的数字相加等于 $30$,可以被 $p$ 整除。

所有元素的和 $42\bmod 10=2$,而 $(5+7)\bmod 10$ 也等于 $2$。

设所有元素的和为 $x$,去掉的元素和为 $y$。要使 $x-y$ 能被 $p$ 整除,根据前置知识中同余的定义,这等价于满足

$$
y \equiv x \pmod p
$$

提示 2

把 $y$ 用 前缀和 表示,问题转换成:在前缀和数组上找到两个数 $s[\textit{left}]$ 和 $s[\textit{right}]$,满足 $\textit{right}-\textit{left}$ 最小且

$$
s[\textit{right}]-s[\textit{left}]\equiv x \pmod p
$$

根据前置知识,将上式移项,得

$$
s[\textit{right}]-x \equiv s[\textit{left}]\pmod p
$$

上式相当于

$$
((s[\textit{right}]-x)\bmod p+p)\bmod p= s[\textit{left}]\bmod p
$$

也可以写成

$$
(s[\textit{right}]\bmod p-x\bmod p+p)\bmod p= s[\textit{left}]\bmod p
$$

提示 3

遍历 $s$ 的同时,用哈希表 $\textit{last}$ 记录 $s[i]\bmod p$ 最近一次出现的下标,如果 $\textit{last}$ 中包含 $(s[i]\bmod p-x\bmod p+p)\bmod p$,设其对应的下标为 $j$,那么 $[j,i)$ 是一个符合题目要求的子数组。

注意:本题可以移除空子数组,所以要先更新 $\textit{last}$,再更新答案。

枚举所有 $i$,计算符合要求的子数组长度的最小值,就是答案。如果没有符合要求的子数组,则返回 $-1$。

代码实现时,可以把答案初始化成 $\textit{nums}$ 的长度 $n$。如果最后答案等于 $n$,则表示没有符合要求的子数组,因为题目不允许将整个数组都移除。

答疑

:为什么不能用双指针(不定长滑动窗口)做?

:使用双指针需要满足单调性,但是 $s[i]\bmod p$ 并不是单调的,所以不能用双指针。具体请看【基础算法精讲 03】

class Solution:
    def minSubarray(self, nums: List[int], p: int) -> int:
        s = list(accumulate(nums, initial=0))
        x = s[-1] % p
        if x == 0:
            return 0  # 移除空子数组(这行可以不要)

        ans = n = len(nums)
        last = {}
        for i, v in enumerate(s):
            last[v % p] = i
            j = last.get((v - x) % p, -n)  # 如果不存在,-n 可以保证 i-j >= n
            ans = min(ans, i - j)
        return ans if ans < n else -1
class Solution {
    public int minSubarray(int[] nums, int p) {
        int n = nums.length;
        int[] s = new int[n + 1];
        for (int i = 0; i < n; i++) {
            s[i + 1] = (s[i] + nums[i]) % p;
        }
        int x = s[n];
        if (x == 0) {
            return 0; // 移除空子数组(这行可以不要)
        }

        int ans = n;
        Map<Integer, Integer> last = new HashMap<>();
        for (int i = 0; i <= n; i++) {
            last.put(s[i], i);
            // 如果不存在,-n 可以保证 i-j >= n
            int j = last.getOrDefault((s[i] - x + p) % p, -n);
            ans = Math.min(ans, i - j);
        }
        return ans < n ? ans : -1;
    }
}
class Solution {
public:
    int minSubarray(vector<int> &nums, int p) {
        int n = nums.size(), s[n + 1];
        s[0] = 0;
        for (int i = 0; i < n; i++) {
            s[i + 1] = (s[i] + nums[i]) % p;
        }
        int x = s[n];
        if (x == 0) {
            return 0; // 移除空子数组(这行可以不要)
        }

        int ans = n;
        unordered_map<int, int> last;
        for (int i = 0; i <= n; ++i) {
            last[s[i]] = i;
            auto it = last.find((s[i] - x + p) % p);
            if (it != last.end()) {
                ans = min(ans, i - it->second);
            }
        }
        return ans < n ? ans : -1;
    }
};
func minSubarray(nums []int, p int) int {
    n := len(nums)
    s := make([]int, n+1)
    for i, v := range nums {
        s[i+1] = (s[i] + v) % p
    }
    x := s[n]
    if x == 0 {
        return 0 // 移除空子数组(这个 if 可以不要)
    }

    ans := n
    last := map[int]int{}
    for i, v := range s {
        last[v] = i
        if j, ok := last[(v-x+p)%p]; ok {
            ans = min(ans, i-j)
        }
    }
    if ans < n {
        return ans
    }
    return -1
}

也可以不用前缀和数组,一边遍历 $\textit{nums}$ 一边计算前缀和。

class Solution:
    def minSubarray(self, nums: List[int], p: int) -> int:
        x = sum(nums) % p
        if x == 0:
            return 0  # 移除空子数组(这行可以不要)

        ans = n = len(nums)
        s = 0
        last = {s: -1}  # 由于下面 i 是从 0 开始的,前缀和下标就要从 -1 开始了
        for i, v in enumerate(nums):
            s += v
            last[s % p] = i
            j = last.get((s - x) % p, -n)  # 如果不存在,-n 可以保证 i-j >= n
            ans = min(ans, i - j)  # 改成手写 min 会再快一些
        return ans if ans < n else -1
class Solution {
    public int minSubarray(int[] nums, int p) {
        long t = 0;
        for (int v : nums) {
            t += v;
        }
        int x = (int) (t % p);
        if (x == 0) {
            return 0; // 移除空子数组(这行可以不要)
        }

        int n = nums.length;
        int ans = n;
        int s = 0;
        Map<Integer, Integer> last = new HashMap<>();
        last.put(s, -1); // 由于下面 i 是从 0 开始的,前缀和下标就要从 -1 开始了
        for (int i = 0; i < n; i++) {
            s = (s + nums[i]) % p;
            last.put(s, i);
            // 如果不存在,-n 可以保证 i-j >= n
            int j = last.getOrDefault((s - x + p) % p, -n);
            ans = Math.min(ans, i - j);
        }
        return ans < n ? ans : -1;
    }
}
class Solution {
public:
    int minSubarray(vector<int> &nums, int p) {
        int x = reduce(nums.begin(), nums.end(), 0LL) % p;
        if (x == 0) {
            return 0; // 移除空子数组(这行可以不要)
        }

        int n = nums.size(), ans = n, s = 0;
        // 由于下面 i 是从 0 开始的,前缀和下标就要从 -1 开始了
        unordered_map<int, int> last{{s, -1}};
        for (int i = 0; i < n; i++) {
            s = (s + nums[i]) % p;
            last[s] = i;
            auto it = last.find((s - x + p) % p);
            if (it != last.end()) {
                ans = min(ans, i - it->second);
            }
        }
        return ans < n ? ans : -1;
    }
};
func minSubarray(nums []int, p int) int {
    x := 0
    for _, v := range nums {
        x += v
    }
    x %= p
    if x == 0 {
        return 0 // 移除空子数组(这个 if 可以不要)
    }

    n := len(nums)
    ans, s := n, 0
    // 由于下面 i 是从 0 开始的,前缀和下标就要从 -1 开始了
    last := map[int]int{s: -1}
    for i, v := range nums {
        s += v
        last[s%p] = i
        if j, ok := last[(s-x+p)%p]; ok {
            ans = min(ans, i-j)
        }
    }
    if ans < n {
        return ans
    }
    return -1
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 为 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

使数组和能被 P 整除

方法一:前缀和

定理一:给定正整数 $x$、$y$、$z$、$p$,如果 $y \bmod p = x$,那么 $(y - z) \bmod p = 0$ 等价于 $z \bmod p = x$。

证明:$y \bmod p = x$ 等价于 $y = k_1 \times p + x$,$(y-z) \bmod p = 0$ 等价于 $y - z = k_2 \times p$,$z \bmod p = x$ 等价于 $z = k_3 \times p + x$,其中 $k_1$、$k_2$、$k_3$ 都是整数,那么给定 $y = k_1 \times p + x$,有 $y - z = k_2 \times p \leftrightarrow z = (k_1 - k_2) \times p + x \leftrightarrow z = k_3 \times p + x$。

定理二:给定正整数 $x$,$y$,$z$,$p$,那么 $(y - z) \bmod p = x$ 等价于 $z \bmod p = (y - x) \bmod p$。

证明:$(y - z) \bmod p = x$ 等价于 $y - z = k_1 \times p + x$,其中 $k_1$ 是整数,经过变换有 $z = y - k_1 \times p - x = k_2 \times p + (y - x) \bmod p - k_1 \times p = (k_2 - k_1) \times p + (y - x) \bmod p$,等价于 $z \bmod p = (y - x) \bmod p$。

记数组和除以 $p$ 的余数为 $x$,如果 $x=0$ 成立,那么需要移除的最短子数组长度为 $0$。

记前 $i$ 个元素(不包括第 $i$ 个元素)的和为 $\textit{f}i$,我们考虑最右元素为 $\textit{nums}[i]$ 的所有子数组,假设最左元素为 $\textit{nums}[j]~(0 \le j \le i)$,那么对应的子数组和为 $\textit{f}{i+1}-\textit{f}j$,对应的长度为 $i-j+1$。由定理一可知,如果剩余子数组和能被 $p$ 整除,那么 $(\textit{f}{i+1}-\textit{f}j) \bmod p = x$。同时由定理二可知,$\textit{f}j \bmod p = (\textit{f}{i+1} - x) \bmod p$。因此当 $\textit{f}{i+1}$ 已知时,我们需要找到所有满足 $\textit{f}j \bmod p = (\textit{f}{i+1} - x) \bmod p$ 的 $\textit{f}_j$($0 \le j \le i$),从中找到最短子数组。

由于需要移除最短子数组,因此对于所有 $f_j$($0 \le j \le i$),只需要保存 $f_j \bmod p$ 对应的最大下标。

有些编程语言对负数进行取余时,余数为负数,因此计算 $f_{i+1} - x$ 除以 $p$ 的余数时,使用 $f_{i+1} - x + p$ 替代。

###Python

class Solution:
    def minSubarray(self, nums: List[int], p: int) -> int:
        x = sum(nums) % p
        if x == 0:
            return 0
        y = 0
        index = {0: -1}
        ans = len(nums)
        for i, v in enumerate(nums):
            y = (y + v) % p
            if (y - x) % p in index:
                ans = min(ans, i - index[(y - x) % p])
            index[y] = i
        return ans if ans < len(nums) else -1

###C++

class Solution {
public:
    int minSubarray(vector<int>& nums, int p) {
        int x = 0;
        for (auto num : nums) {
            x = (x + num) % p;
        }
        if (x == 0) {
            return 0;
        }
        unordered_map<int, int> index;
        int y = 0, res = nums.size();
        for (int i = 0; i < nums.size(); i++) {
            index[y] = i; // f[i] mod p = y,因此哈希表记录 y 对应的下标为 i
            y = (y + nums[i]) % p;
            if (index.count((y - x + p) % p) > 0) {
                res = min(res, i - index[(y - x + p) % p] + 1);
            }
        }
        return res == nums.size() ? -1 : res;
    }
};

###Java

class Solution {
    public int minSubarray(int[] nums, int p) {
        int x = 0;
        for (int num : nums) {
            x = (x + num) % p;
        }
        if (x == 0) {
            return 0;
        }
        Map<Integer, Integer> index = new HashMap<Integer, Integer>();
        int y = 0, res = nums.length;
        for (int i = 0; i < nums.length; i++) {
            index.put(y, i); // f[i] mod p = y,因此哈希表记录 y 对应的下标为 i
            y = (y + nums[i]) % p;
            if (index.containsKey((y - x + p) % p)) {
                res = Math.min(res, i - index.get((y - x + p) % p) + 1);
            }
        }
        return res == nums.length ? -1 : res;
    }
}

###C#

public class Solution {
    public int MinSubarray(int[] nums, int p) {
        int x = 0;
        foreach (int num in nums) {
            x = (x + num) % p;
        }
        if (x == 0) {
            return 0;
        }
        IDictionary<int, int> index = new Dictionary<int, int>();
        int y = 0, res = nums.Length;
        for (int i = 0; i < nums.Length; i++) {
            // f[i] mod p = y,因此哈希表记录 y 对应的下标为 i
            if (!index.ContainsKey(y)) {
                index.Add(y, i);
            } else {
                index[y] = i;
            }
            y = (y + nums[i]) % p;
            if (index.ContainsKey((y - x + p) % p)) {
                res = Math.Min(res, i - index[(y - x + p) % p] + 1);
            }
        }
        return res == nums.Length ? -1 : res;
    }
}

###C

#define MIN(a, b) ((a) < (b) ? (a) : (b))

typedef struct {
    int key;
    int val;
    UT_hash_handle hh;
} HashItem; 

HashItem *hashFindItem(HashItem **obj, int key) {
    HashItem *pEntry = NULL;
    HASH_FIND_INT(*obj, &key, pEntry);
    return pEntry;
}

bool hashAddItem(HashItem **obj, int key, int val) {
    if (hashFindItem(obj, key)) {
        return false;
    }
    HashItem *pEntry = (HashItem *)malloc(sizeof(HashItem));
    pEntry->key = key;
    pEntry->val = val;
    HASH_ADD_INT(*obj, key, pEntry);
    return true;
}

bool hashSetItem(HashItem **obj, int key, int val) {
    HashItem *pEntry = hashFindItem(obj, key);
    if (!pEntry) {
        hashAddItem(obj, key, val);
    } else {
        pEntry->val = val;
    }
    return true;
}

int hashGetItem(HashItem **obj, int key, int defaultVal) {
    HashItem *pEntry = hashFindItem(obj, key);
    if (!pEntry) {
        return defaultVal;
    }
    return pEntry->val;
}

void hashFree(HashItem **obj) {
    HashItem *curr = NULL, *tmp = NULL;
    HASH_ITER(hh, *obj, curr, tmp) {
        HASH_DEL(*obj, curr);  
        free(curr);             
    }
}

int minSubarray(int* nums, int numsSize, int p) {
     int x = 0;
    for (int i = 0; i < numsSize; i++) {
        x = (x + nums[i]) % p;
    }
    if (x == 0) {
        return 0;
    }
    HashItem *index = NULL;
    int y = 0, res = numsSize;
    for (int i = 0; i < numsSize; i++) {
        hashSetItem(&index, y, i); // f[i] mod p = y,因此哈希表记录 y 对应的下标为 i
        y = (y + nums[i]) % p;
        if (hashFindItem(&index, (y - x + p) % p)) {
            int val = hashGetItem(&index, (y - x + p) % p, 0);
            res = MIN(res, i - val + 1);
        }
    }
    hashFree(&index);
    return res == numsSize ? -1 : res;
}

###JavaScript

var minSubarray = function(nums, p) {
    let x = 0;
    for (const num of nums) {
        x = (x + num) % p;
    }
    if (x === 0) {
        return 0;
    }
    const index = new Map();
    let y = 0, res = nums.length;
    for (let i = 0; i < nums.length; i++) {
        index.set(y, i); // f[i] mod p = y,因此哈希表记录 y 对应的下标为 i
        y = (y + nums[i]) % p;
        if (index.has((y - x + p) % p)) {
            res = Math.min(res, i - index.get((y - x + p) % p) + 1);
        }
    }
    return res === nums.length ? -1 : res;
};

###go

func minSubarray(nums []int, p int) int {
    sum := 0
    mp := map[int]int{0: -1}
    for _, v := range nums {
        sum += v
    }
    rem := sum%p
    if rem == 0 {
        return 0
    }
    minCount := len(nums)
    sum = 0
    for i := 0; i < len(nums); i++ {
        sum += nums[i]
        tempRem := sum%p
        k := (tempRem - rem + p) % p
        if _, ok := mp[k]; ok {
            minCount = min(minCount, i - mp[k])
        }
        mp[tempRem] = i
    }
    
    if minCount >= len(nums) {
        return -1
    }
    
    return minCount
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。遍历数组 $\textit{nums}$ 需要 $O(n)$ 的时间。

  • 空间复杂度:$O(n)$。保存哈希表需要 $O(n)$ 的空间。

做一题送一题,力扣上不少类似题

首先,这道题的思路和 974 一样(当然有一些变化)。强烈建议对这个问题不熟悉的同学,看一下 974,搞懂以后,再回来看这道题:

974. 和可被 K 整除的子数组


假设 nums 的和除以 P,余数是 mod

如果 mod == 0,答案就是 0

如果 mod != 0,答案变成了找原数组中的最短连续子数组,使得其数字和除以 P,余数也是 mod


由于是求解连续子数组和的问题,很容易想到使用前缀和。

我们可以扫描一遍整个数组,计算到每个元素的前缀和。

假设当前前缀和除以 P 的余数是 curmod,为了找到一段连续子数组对 P 的余数是 mod,我们需要找到一段前缀和,对 P 的余数是 targetmod。其中 targetmod 的求法是:

如果 curmod >= mod,很简单:targetmod = curmod - mod

如果 curmod < mod,我们需要加上一个 Ptargetmod = curmod - mod + P

这样,我们可以保证,当前前缀和减去目标前缀和,剩余的数组对 P 的余数是 mod。我们只需要找最短的这样的数组就好。


最后,为了快速找到一段对 P 的余数为 targetmod 的前缀和,我们使用一个哈希表 table,来存储之前前缀和对 P 的余数和所在的索引。(key 为余数;value 为索引)。

table 在遍历过程中更新,以保证每次在 table 中查找到的,是离当前元素最近的索引,从而保证找到的是“最短”的连续子数组。


我的参考代码(C++):

class Solution {
public:
    int minSubarray(vector<int>& nums, int p) {

        long long sum = 0;
        for(int e: nums) sum += (long long)e;
        long long mod = sum % (long long)p;

        if(mod == 0ll) return 0;

        int res = nums.size();
        unordered_map<long long, int> table;
        table[0ll] = -1;

        sum = 0;
        for(int i = 0; i < nums.size(); i ++){
            sum += (long long)nums[i];
            long long curmod = sum % (long long)p;
            table[curmod] = i;

            long long targetmod = curmod >= mod ? (curmod - mod) : (curmod - mod + p);
            if(table.count(targetmod))
                res = min(res, i - table[targetmod]);
        }
        return res == nums.size() ? -1 : res;
    }
};

觉得有帮助请点赞哇!

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。

❌