注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

可重试接口请求

web
概述 日常开发中,接口数据请求失败是很常见的需求,因此我们有时候可能需要对失败的请求进行重试,提高用户体验。 实现 如下案例通过fetch方法做请求,项目中肯定使用axios居多,思路都是一致的 原理 要想实现请求重试,我们需要清楚如下问题: R: 什么时候...
继续阅读 »

概述


日常开发中,接口数据请求失败是很常见的需求,因此我们有时候可能需要对失败的请求进行重试,提高用户体验。


实现


如下案例通过fetch方法做请求,项目中肯定使用axios居多,思路都是一致的


原理


要想实现请求重试,我们需要清楚如下问题:



  • R: 什么时候重试?

    • A: 请求失败的时候



  • R:请求重试次数?

    • A:外部传入



  • R:如何失败后重新请求?

    • A:利用请求promise状态和递归重新请求实现




程序


/**
* @Description 发送请求,返回promise
* @param { string } url 请求地址
* @param { number } maxCount 最大重试次数
* @returns { Promise<any> } 返回请求结果的promise
**/


// 定义
function sendRequest(url, maxCount = 3) {
return fetch(url).catch((error) => {
return maxCount <= 0
? Promise.reject(error)
: sendRequest(url, maxCount - 1);
});
}

// 使用

sendRequest("https://api.example.com/data").then((response) => {
console.log("Request succeeded:", response);
});


作者:gnip
来源:juejin.cn/post/7535765649114808339
收起阅读 »

🥳Elx开源升级:XMarkdown 组件加入、Storybook 预览体验升级

web
Element Plus XV1.3.0上新XMarkdown 组件 🙊大家好,我是嘉悦。经过一周 beta 版本的测试,我们推出了 v1.3.0 主版本,并且将 main 分支的代码进行了替换。移除了旧的 playground 代码,换成了新的 story...
继续阅读 »

Element Plus XV1.3.0上新XMarkdown 组件



🙊大家好,我是嘉悦。经过一周 beta 版本的测试,我们推出了 v1.3.0 主版本,并且将 main 分支的代码进行了替换。移除了旧的 playground 代码,换成了新的 storybook 在线预览体验更好。同时我们也在我们的👉仿豆包模版项目 中升级了最新的自建库依赖,并集成了 xmd 组件



🥪现在的在线预览:可以在右侧进行调试,实时预览,让你更快理解组件属性

image.png


🫕最新的模版项目代码已经更新,请大家酌情拉取,可能会和你本地的已修改的代码有冲突

image.png


这一次主版本的更新,主要是给 XMarkdown 组件进行了优化升级,我们内置了更多功能


🍍内置更多功能,支持自定义


功能描述是否支持
增量渲染极致的性能
自定义插槽可以是 h 函数的组件,也可以是 template 模版组件,上手更简单
行级代码块高亮内置样式,可自定义
代码块高亮内置折叠、切换主题色、复制代码块、滚动吸顶功能
数学公式支持行级公式和块级公式
mermaid 图表内置切换查看代码、缩放、归位、下载、复制代码块功能
自定义 echarts自定义渲染
拦截 ``` 后面的标识符拦截后可获取内容进行自定义渲染
拦截标签拦截后可进行自定义渲染
支持预览 html 代码块内置对 html 标签的预览功能

🐝在项目中使用后,大概是这个样子


💌 mermaid 图表


image.png


💌 数学公式


image.png


💌 预览 html


image.png


image.png


💌 代码块


image.png


💌 自定义代码块


image.png


💌 自定义属性


image.png


💌 自定义标签


image.png



目前,我们已经将组件上新到组件库 main 分支开源,请大家及时fork最新的 main 分支代码。💐欢迎大家升级体验最新V1.3.0版本


pnpm add vue-element-plus-x@1.3.0

V1.3.0版本更新内容速递:




🍉 后续计划



  • 😁我们近期会对组件库的官网进行更新

  • 🥰预计下周,我们将会推出一个对 vue2 的支持库,并负责维护下去

  • 🐒预计下下周,我们将会推出 编辑发送框组件,这个组件已经在测试阶段

  • 🙉同时已经组建了一个30+人的开发者群,后续会在开发者群中开放更多的贡献任务

  • 💩对这个项目感兴趣的朋友,可以加交流群或者作者微信 👉交流邀请


📢 项目地址,快速链接体验


这里是最全的项目地址,方便大家跳转查看


名称链接
👀 模版项目 预览👉 在线预览
🍉 模版项目 源码👉 github
👉 gitee
🎀 模版项目 开发文档👉 模版项目 开发文档
💟 Element-Plus-X 组件库👉 Element-Plus-X 组件库 开发文档
🎃 Element-Plus-X 组件库交流群👉 交流4群二维码地址 github
👉 交流4群二维码地址 gitee 💖加入交流群,获取最新的技术支持💖
🚀 若依AI项目 源码👉 github
👉 gitee
🔥 Hook-fetch 超优雅请求库👉 源码学习

作者:KeyNG_Jykxg
来源:juejin.cn/post/7527034544663461898
收起阅读 »

🔥 enum-plus:前端福利!介绍一个天花板级的前端枚举库

web
Github            像原生 enum 一样,但更强大!            简介 enum-plus是一个增强版的枚举类库,完全兼容原生enum的基本用法,同时支持扩展显示文本、绑定到 UI 组件以及提供丰富的扩展方法,是原...
继续阅读 »

Github




enum-plus

           像原生 enum 一样,但更强大!           


简介


enum-plus是一个增强版的枚举类库,完全兼容原生enum的基本用法,同时支持扩展显示文本、绑定到 UI 组件以及提供丰富的扩展方法,是原生enum的一个直接替代品。它是一个轻量级、零依赖、100% TypeScript 实现的工具,适用于多种前端框架,并支持本地化。


枚举项列表可以用来一键生成下拉框、复选框等组件,可以轻松遍历枚举项数组,获取某个枚举值的显示文本,判断某个值是否存在等。支持本地化,可以根据当前语言环境返回对应的文本,轻松满足国际化的需求。


还有哪些令人兴奋的特性呢?请继续探索吧!或者不妨先看下这个使用视频。



usage video


特性



  • 完全兼容原生 enum 的用法

  • 支持numberstring等多种数据类型

  • 增强的枚举项,支持自定义显示文本

  • 内置本地化能力,枚举项文本可实现国际化,可与任何 i18n 库集成

  • 支持枚举值转换为显示文本,代码更简洁

  • 可扩展设计,允许在枚举项上添加自定义字段

  • 支持将枚举绑定到 Ant DesignElementPlusMaterial-UI 等 UI 库,一行代码枚举变下拉框

  • 支持 Node.js 环境,支持服务端渲染(SSR)

  • 零依赖,纯原生 JavaScript,可用于任何前端框架

  • 100% TypeScript 实现,具有全面的类型推断能力

  • 轻量(gzip 压缩后仅 2KB+)


安装


npm install enum-plus

枚举定义


本节展示了使用 Enum 函数初始化枚举的多种方式,你可以根据不同的使用场景选择最合适的方法


1. 基础格式,与原生枚举用法基本一致


import { Enum } from 'enum-plus';

const Week = Enum({
Sunday: 0,
Monday: 1,
} as const);

Week.Monday; // 1


as const 类型断言用于将枚举值变成字面量类型,类型更精确,否则它们将被作为number类型。如果你使用的是JavaScript,请删除as const



2. 标准格式(推荐)


为每个枚举项指定 value (枚举值) 和 label(显示文本)字段,这是最常用的格式,也是推荐的格式。这种格式允许你为每个枚举项设置显示文本,这些文本可以在UI组件中使用


import { Enum } from 'enum-plus';

const Week = Enum({
Sunday: { value: 0, label: '星期日' },
Monday: { value: 1, label: '星期一' },
} as const);

Week.Sunday; // 0
Week.label(0); // 星期日

3. 数组格式


数组格式在需要动态创建枚举时很有用,例如从 API 获取数据中动态创建一个枚举。这种方式还允许自定义字段映射,这增加了灵活性,可以适配不同的数据格式


import { Enum } from 'enum-plus';

const petTypes = await getPetsData();
// [ { value: 1, key: 'dog', label: '狗' },
// { value: 2, key: 'cat', label: '猫' },
// { value: 3, key: 'rabbit', label: '兔子' } ];
const PetTypes = Enum(petTypes);

4. 原生枚举格式


如果你已经有一个原生的枚举,你可以直接传递给Enum函数,它会自动转换为增强版的枚举,这样可以借用原生枚举的枚举值自动递增特性


import { Enum } from 'enum-plus';

enum init {
Sunday = 0,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
}
const Week = Enum(init);

Week.Sunday; // 0
Week.Monday; // 1
Week.Saturday; // 6
Week.label('Sunday'); // Sunday

API


💎   拾取枚举值       


像原生enum一样,直接拾取一个枚举值


Week.Sunday; // 0
Week.Monday; // 1



💎   items       


获取一个包含全部枚举项的只读数组,可以方便地遍历枚举项。由于符合 Ant Design 组件的数据规范,因此支持将枚举一键转换成下拉框、复选框等组件,只需要一行代码!




💎   keys       


获取一个包含全部枚举项Key的只读数组




💎   label       


  根据某个枚举值或枚举 Key,获取该枚举项的显示文本。如果设置了本地化,则会返回本地化后的文本。


Week.label(1); // 星期一
Week.label('Monday'); // 星期一



💎   key       


  根据枚举值获取该枚举项的 Key,如果不存在则返回undefined


Week.key(1); // 'Monday'



💎   has       


判断某个枚举项(值或 Key)是否存在


Week.has(1); // true
Week.has('Sunday'); // true
Week.has(9); // false
Week.has('Birthday'); // false



💎   toSelect       


toSelectitems相似,都是返回一个包含全部枚举项的数组。区别是,toSelect返回的元素只包含labelvalue两个字段,同时,toSelect方法支持在数组头部插入一个可自定义的默认元素,一般用于下拉框等组件的默认选项




💎   toMenu       


生成一个对象数组,可以绑定给 Ant DesignMenuDropdown等组件


import { Menu } from 'antd';

<Menu items={Week.toMenu()} />;

数据数据格式为:


[
{ key: 0, label: '星期日' },
{ key: 1, label: '星期一' },
];



💎   toFilter       


生成一个对象数组,可以直接传递给 Ant Design Table 组件的列配置,在表头中显示一个下拉筛选框,用来过滤表格数据


数据数据格式为:


[
{ text: '星期日', value: 0 },
{ text: '星期一', value: 1 },
];



💎   toValueMap       


生成一个符合 Ant Design Pro 规范的枚举集合对象,可以传递给 ProFormFieldProTable 等组件。


数据格式为:


{
0: { text: '星期日' },
1: { text: '星期一' },
}



💎   raw       


方法重载^1   raw(): Record<K, T[K]>
方法重载^2   raw(keyOrValue: V | K): T[K]


第一个重载方法,返回枚举集合的初始化对象,即用来初始化 Enum 原始 init 对象。


第二个重载方法,用来处理单个枚举项,根据获取单个枚举项的原始初始化对象。


这个方法主要作用是,用来获取枚举项的自定义字段,支持无限扩展字段


const Week = Enum({
Sunday: { value: 0, label: '星期日', happy: true },
Monday: { value: 1, label: '星期一', happy: false },
} as const);

Week.raw(0).happy // true
Week.raw(0); // { value: 0, label: '星期日', happy: true }
Week.raw('Monday'); // { value: 1, label: '星期一', happy: false }
Week.raw(); // { Sunday: { value: 0, label: '星期日', happy: true }, Monday: { value: 1, label: '星期一', happy: false } }



⚡️   valueType   TypeScript ONLY


在 TypeScript 中,提供了一个包含所有枚举值的联合类型,用于缩小变量或组件属性的数据类型。这种类型替代了像 numberstring 这样宽泛的原始类型,使用精确的值集合,防止无效赋值,同时提高代码可读性和编译时类型安全性。


type WeekValues = typeof Week.valueType; // 0 | 1

const weekValue: typeof Week.valueType = 1; // ✅ 类型正确,1 是一个有效的周枚举值
const weeks: (typeof Week.valueType)[] = [0, 1]; // ✅ 类型正确,0 和 1 是有效的周枚举值
const badWeekValue: typeof Week.valueType = 8; // ❌ 类型错误,8 不是一个有效的周枚举值
const badWeeks: (typeof Week.valueType)[] = [0, 8]; // ❌ 类型错误,8 不是一个有效的周枚举值


注意,这只是一个 TypeScript 类型,只能用来约束类型,不可在运行时调用,运行时调用会抛出异常





用法


• 基本用法,与原生枚举用法一致


const Week = Enum({
Sunday: { value: 0, label: '星期日' },
Monday: { value: 1, label: '星期一' },
} as const);

Week.Sunday; // 0
Week.Monday; // 1



• 支持为枚举项添加 Jsdoc 注释,代码提示更友好


在代码编辑器中,将光标悬停在枚举项上,即可显示关于该枚举项的详细 Jsdoc 注释,而不必再转到枚举定义处查看


const Week = Enum({
/** 星期日 */
Sunday: { value: 0, label: '星期日' },
/** 星期一 */
Monday: { value: 1, label: '星期一' },
} as const);

Week.Monday; // 将光标悬浮在 Monday 上

jsdoc


可以看到,不但提示了枚举项的释义,还有枚举项的值,无需跳转离开当前光标位置,在阅读代码时非常方便




• 获取包含全部枚举项的数组


Week.items; // 输出如下:
// [
// { value: 0, label: '星期日', key: 'Sunday', raw: { value: 0, label: '星期日' } },
// { value: 1, label: '星期一', key: 'Monday', raw: { value: 1, label: '星期一' } }
// ]



• 获取第一个枚举值


Week.items[0].value; // 0



• 检查一个值是否一个有效的枚举值


Week.has(1); // true
Week.items.some(item => item.value === 1); // true
1 instanceof Week; // true



• 支持遍历枚举项数组,但不可修改


Week.items.length; // 2
Week.items.map((item) => item.value); // [0, 1],✅ 可遍历
Week.items.forEach((item) => {}); // ✅ 可遍历
for (const item of Week.items) {
// ✅ 可遍历
}
Week.items.push({ value: 2, label: '星期二' }); // ❌ 不可修改
Week.items.splice(0, 1); // ❌ 不可修改
Week.items[0].label = 'foo'; // ❌ 不可修改



• 枚举值(或Key)转换为显示文本


Week.label(1); // 星期一,
Week.label(Week.Monday); // 星期一
Week.label('Monday'); // 星期一



• 枚举值转换为Key


Week.key(1); // 'Monday'
Week.key(Week.Monday); // 'Monday'
Week.key(9); // undefined, 不存在此枚举项



• 添加扩展字段,不限数量


const Week = Enum({
Sunday: { value: 0, label: '星期日', active: true, disabled: false },
Monday: { value: 1, label: '星期一', active: false, disabled: true },
} as const);

Week.raw(0).active // true
Week.raw(Week.Sunday).active // true
Week.raw('Sunday').active // true



🔥   转换成 UI 组件



  • 生成 Select 下拉框



    import { Select } from 'antd';

    <Select options={Week.items} />;


    import { MenuItem, Select } from '@mui/material';

    <Select>
    {Week.items.map((item) => (
    <MenuItem key={item.value} value={item.value}>
    {item.label}
    </MenuItem>
    ))}
    </Select>
    ;


    import { DropDownList } from '@progress/kendo-react-dropdowns';

    <DropDownList data={Week.items} textField="label" dataItemKey="value" />;


    <el-select>
    <el-option v-for="item in Week.items" v-bind="item" />
    </el-select>


    <a-select :options="Week.items" />


    <v-select :items="Week.items" item-title="label" />


    <mat-select>
    <mat-option *ngFor="let item of Week.items" [value]="item.value">{{ item.label }}</mat-option>
    </mat-select>


    <nz-select>
    <nz-option *ngFor="let item of Week.items" [nzValue]="item.value">{{ item.label }}</nz-option>
    </nz-select>






  • 生成下拉菜单


toMenu方法可以为 Ant Design MenuDropdown 等组件生成数据源,格式为:{ key: number|string, label: string } []


import { Menu } from 'antd';

<Menu items={Week.toMenu()} />;


  • 生成表格列筛选


toFilter方法可以生成一个对象数组,为表格绑定列筛选功能,列头中显示一个下拉筛选框,用来过滤表格数据。对象结构遵循 Ant Design 的数据规范,格式为:{ text: string, value: number|string } []


import { Table } from 'antd';

const columns = [
{
title: 'week',
dataIndex: 'week',
filters: Week.toFilter(),
},
];
// 在表头中显示下拉筛选项
<Table columns={columns} />;


  • 支持 Ant Design Pro 组件生成


toValueMap方法可以为 Ant Design ProProFormFieldsProTable等组件生成数据源,这是一个类似 Map 的数据结构,格式为:{ [key: number|string]: { text: string } }


import { ProFormSelect, ProFormCheckbox, ProFormRadio, ProFormTreeSelect, ProTable } from '@ant-design/pro-components';

<ProFormSelect valueEnum={Week.toValueMap()} />; // 下拉框
<ProFormCheckbox valueEnum={Week.toValueMap()} />; // 复选框
<ProFormRadio.Gr0up valueEnum={Week.toValueMap()} />; // 单选框
<ProFormTreeSelect valueEnum={Week.toValueMap()} />; // 树选择
<ProTable columns={[{ dataIndex: 'week', valueEnum: Week.toValueMap() }]} />; // ProTable



• 枚举合并(或者扩展枚举)


const myWeek = Enum({
...Week.raw(),
Friday: { value: 5, label: '星期五' },
Saturday: { value: 6, label: '星期六' },
});



• 使用枚举值序列来缩小 number 取值范围   [TypeScript ONLY]


使用 valueType 类型约束,可以将数据类型从宽泛的numberstring类型缩小为有限的枚举值序列,这不但能减少错误赋值的可能性,还能提高代码的可读性


const weekValue: number = 8; // 👎 任意数字都可以赋值给周枚举,即使错误的
const weekName: string = 'Birthday'; // 👎 任意字符串都可以赋值给周枚举,即使错误的

const goodWeekValue: typeof Week.valueType = 1; // ✅ 类型正确,1 是一个有效的枚举值
const goodWeekName: typeof Week.keyType = 'Monday'; // ✅ 类型正确,'Monday' 是一个有效的枚举名

const badWeekValue: typeof Week.valueType = 8; // ❌ 类型报错,8 不是一个有效的枚举值
const badWeekName: typeof Week.keyType = 'Birthday'; // ❌ 类型报错,'Birthday' 不是一个有效的枚举值

type FooProps = {
value?: typeof Week.valueType; // 👍 组件属性类型约束,可以防止错误赋值,还能智能提示取值范围
names?: (typeof Week.keyType)[]; // 👍 组件属性类型约束,可以防止错误赋值,还能智能提示取值范围
};



本地化


enum-plus 本身不内置国际化能力,但支持通过 localize 可选参数传入一个自定义方法,来实现本地化文本的转化。这是一个非常灵活的方案,这使你能够实现自定义的本地化函数,根据当前的语言环境将枚举的 label 值转换为适当的翻译文本。语言状态管理仍由您自己负责,您的 localize 方法决定返回哪种本地化文本。对于生产环境的应用程序,我们强烈建议使用成熟的国际化库(如 i18next),而不是创建自定义解决方案。


以下是一个简单的示例,仅供参考。请注意,第一种方法由于缺乏灵活性,不建议在生产环境中使用,它仅用于演示基本概念。请考虑使用第二种及后面的示例。


import { Enum } from 'enum-plus';
import i18next from 'i18next';
import Localize from './Localize';

let lang = 'zh-CN';
const setLang = (l: string) => {
lang = l;
};

// 👎 这不是一个好例子,仅供演示,不建议生产环境使用
const sillyLocalize = (content: string) => {
if (lang === 'zh-CN') {
switch (content) {
case 'enum-plus.options.all':
return '全部';
case 'week.sunday':
return '星期日';
case 'week.monday':
return '星期一';
default:
return content;
}
} else {
switch (content) {
case 'enum-plus.options.all':
return 'All';
case 'week.sunday':
return 'Sunday';
case 'week.monday':
return 'Monday';
default:
return content;
}
}
};
// 👍 建议使用 i18next 或其他国际化库
const i18nLocalize = (content: string | undefined) => i18next.t(content);
// 👍 或者封装成一个基础组件
const componentLocalize = (content: string | undefined) => <Localize value={content} />;

const Week = Enum(
{
Sunday: { value: 0, label: 'week.sunday' },
Monday: { value: 1, label: 'week.monday' },
} as const,
{
localize: sillyLocalize,
// localize: i18nLocalize, // 👍 推荐使用i18类库
// localize: componentLocalize, // 👍 推荐使用组件形式
}
);
setLang('zh-CN');
Week.label(1); // 星期一
setLang('en-US');
Week.label(1); // Monday

当然,每个枚举类型都这样设置可能比较繁琐,enum-plus 提供了一种全局设置方案,可以通过 Enum.localize 全局方法,来全局设置本地化。如果两者同时存在,单个枚举的设置会覆盖全局设置。


Enum.localize = i18nLocalize;



全局扩展


虽然 Enum 提供了一套全面的内置方法,但如果这些还不能满足你的需求,你可以使用 Enum.extends API 扩展其功能,添加自定义方法。这些扩展会全局应用于所有枚举实例,包括在扩展应用之前创建的实例,并且会立即生效,无需任何其它设置。


Enum.extends({
toMySelect(this: ReturnType<typeof Enum>) {
return this.items.map((item) => ({ value: item.value, title: item.label }));
},
reversedItems(this: ReturnType<typeof Enum>) {
return this.items.reverse();
},
});

Week.toMySelect(); // [{ value: 0, title: '星期日' }, { value: 1, title: '星期一' }]



兼容性


enum-plus 提供了完善的兼容性支持。



  • 浏览器环境



    • 现代打包工具:对于支持 exports 字段的打包工具(如 Webpack 5+、Vite、Rollup),enum-plus 的目标是 ES2020。如果需要更广泛的浏览器支持,可以在构建过程中使用 @babel/preset-env 转译为更早期的语法。

    • 旧版打包工具:对于不支持 exports 字段的工具(如 Webpack 4),enum-plus 会自动回退到 main 字段的入口点,其目标是 ES2016

    • Polyfill 策略:为了最小化包的体积,enum-plus 不包含任何 polyfill。如果需要支持旧版浏览器,可以引入以下内容:



      • core-js

      • 配置适当的 @babel/preset-envuseBuiltIns 设置

      • 其他替代的 polyfill 实现





  • Node.js 兼容性:enum-plus 需要至少 ES2016 的特性,兼容 Node.js v7.x 及以上版本。




意犹未尽,还期待更多?不妨移步 Github官网,你可以发现更多的高级使用技巧。


相信我,一定会让你感觉相见恨晚!


如果你喜欢这个项目,欢迎在GitHub上给项目点个Star⭐ —— 这是程序员表达喜爱的通用语言😜~ 可以让更多开发者发现它!


作者:作业逆流成河
来源:juejin.cn/post/7493721453537116169
收起阅读 »

一个 4.7 GB 视频把浏览器拖进 OOM

web
你给一家在线教育平台做「课程视频批量上传」功能。 需求听起来很朴素:讲师后台一次性拖 20 个 4K 视频,浏览器要稳、要快、要能断网续传。 你第一版直接 <input type="file"> + FormData,结果上线当天就炸: 讲师 A...
继续阅读 »

你给一家在线教育平台做「课程视频批量上传」功能。

需求听起来很朴素:讲师后台一次性拖 20 个 4K 视频,浏览器要稳、要快、要能断网续传。

你第一版直接 <input type="file"> + FormData,结果上线当天就炸:



  • 讲师 A 上传 4.7 GB 的 .mov,Chrome 直接 内存溢出 崩溃;

  • 讲师 B 网断了 3 分钟,重新上传发现进度条归零,心态跟着归零;

  • 运营同学疯狂 @ 前端:“你们是不是没做分片?”




解决方案:三层防线,把 4 GB 切成 2 MB 的“薯片”


1. 表面用法:分片 + 并发,浏览器再也不卡


// upload.js
const CHUNK_SIZE = 2 * 1024 * 1024; // 🔍 2 MB 一片,内存友好
export async function* sliceFile(file) {
let cur = 0;
while (cur < file.size) {
yield file.slice(cur, cur + CHUNK_SIZE);
cur += CHUNK_SIZE;
}
}

// uploader.js
import pLimit from 'p-limit';
const limit = pLimit(5); // 🔍 最多 5 并发,防止占满带宽
export async function upload(file) {
const hash = await calcHash(file); // 🔍 秒传、断点续传都靠它
const tasks = [];
for await (const chunk of sliceFile(file)) {
tasks.push(limit(() => uploadChunk({ hash, chunk })));
}
await Promise.all(tasks);
await mergeChunks(hash, file.name); // 🔍 通知后端合并
}

逐行拆解:



  • sliceFilefile.slice 生成 Blob 片段,不占额外内存

  • p-limit 控制并发,避免 100 个请求同时打爆浏览器;

  • calcHash 用 WebWorker 算 MD5,页面不卡顿(后面细讲)。


2. 底层机制:断点续传到底续在哪?


角色存储位置内容生命周期
前端IndexedDBhash → 已上传分片索引数组浏览器本地,清缓存即失效
后端Redis / MySQLhash → 已接收分片索引数组可配置 TTL,支持跨端续传

sequenceDiagram
participant F as 前端
participant B as 后端

F->>B: POST /prepare {hash, totalChunks}
B-->>F: 200 OK {uploaded:[0,3,7]}

loop 上传剩余分片
F->>B: POST /upload {hash, index, chunkData}
B-->>F: 200 OK
end

F->>B: POST /merge {hash}
B-->>F: 200 OK
Note over B: 按顺序写磁盘



  1. 前端先 POST /prepare 带 hash + 总分片数;

  2. 后端返回已上传索引 [0, 3, 7]

  3. 前端跳过这 3 片,只传剩余;

  4. 全部完成后 POST /merge,后端按顺序写磁盘。


3. 设计哲学:把“上传”做成可插拔的协议


interface Uploader {
prepare(file: File): Promise<PrepareResp>;
upload(chunk: Blob, index: number): Promise<void>;
merge(): Promise<string>; // 🔍 返回文件 URL
}

我们实现了三套:



  • BrowserUploader:纯前端分片;

  • TusUploader:遵循 tus.io 协议,天然断点续传;

  • AliOssUploader:直传 OSS,用 OSS 的断点 SDK。


方案并发控制断点续传秒传代码量
自研手动自己实现手动300 行
tus内置协议级需后端100 行
OSS内置SDK 级自动50 行



应用扩展:拿来即用的配置片段


1. WebWorker 算 Hash(防卡顿)


// hash.worker.js
importScripts('spark-md5.min.js');
self.onmessage = ({ data: file }) => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReaderSync();
for (let i = 0; i < file.size; i += CHUNK_SIZE) {
spark.append(reader.readAsArrayBuffer(file.slice(i, i + CHUNK_SIZE)));
}
self.postMessage(spark.end());
};

2. 环境适配


环境适配点
浏览器需兼容 Safari 14 以下无 File.prototype.slice(用 webkitSlice 兜底)
Nodefs.createReadStream 分片,Hash 用 crypto.createHash('md5')
Electron渲染进程直接走浏览器方案,主进程可复用 Node 逻辑



举一反三:3 个变体场景



  1. 秒传

    上传前先算 hash → 调后端 /exists?hash=xxx → 已存在直接返回 URL,0 流量完成。

  2. 加密上传

    uploadChunk 里加一层 AES-GCM 加密,后端存加密块,下载时由前端解密。

  3. P2P 协同上传

    用 WebRTC 把同局域网学员的浏览器变成 CDN,分片互传后再统一上报,节省 70% 出口带宽。


小结


大文件上传的核心不是“传”,而是“断”。

把 4 GB 切成 2 MB 的薯片,再配上一张能续命的“进度表”,浏览器就能稳稳地吃下任何体积的视频。


作者:前端微白
来源:juejin.cn/post/7530868895768838179
收起阅读 »

让 Vue 动画如德芙般丝滑!这个 FLIP 动画组件绝了!

web
“还在为 Vue 动画卡顿掉帧烦恼?只需 3 行代码,让你的元素切换丝滑到飞起!🚀” 今天给大家安利一个我最近发现的宝藏 Vue 组件——vue-flip-motion!它基于 FLIP 动画技术(First Last Invert Play),能轻松实现高性...
继续阅读 »

“还在为 Vue 动画卡顿掉帧烦恼?只需 3 行代码,让你的元素切换丝滑到飞起!🚀”


今天给大家安利一个我最近发现的宝藏 Vue 组件——vue-flip-motion!它基于 FLIP 动画技术(First Last Invert Play),能轻松实现高性能、无卡顿的过渡效果,无论是列表重排、元素缩放还是颜色渐变,统统搞定!




🌟 核心亮点:



  1. ⚡️ 性能狂魔:FLIP 技术减少布局抖动,60fps 流畅到窒息!

  2. 🎨 傻瓜式操作:数据驱动动画,改个 mutation 就能触发效果!

  3. 🔄 双版本兼容:Vue 2 和 Vue 3 一把梭,无缝迁移!

  4. 🎚️ 高度可定制:支持嵌套动画、自定义缓动函数,想怎么玩就怎么玩!


丝滑动画演示

(GIF 展示:点击按钮瞬间触发的丝滑重排/颜色变化)


录屏2025-09-28 15.53.43.gif
(GIF 展示:运动轨迹叠加动画)




🛠️ 快速上手:


安装


npm install vue-flip-motion

代码示例(Vue 3):


<template>
<Flip
:mutation="styles"
:styles="['backgroundColor']"
:animate-option="{ duration: 1000 }"
>
<div
class="box"
@click="handleClick"
:style="{ height: styles.height, background: styles.bgColor }"
/>
</Flip>
</template>

<script setup>
import { ref } from 'vue';
import Flip from 'vue-flip-motion';

const styles = ref({ height: '100px', bgColor: '#42b983' });

const handleClick = () => {
styles.value = { height: '200px', bgColor: '#ff0000' }; // 点我触发动画!
};
</script>



💥 高级玩法:


1. 嵌套动画:叠加缩放+旋转效果,轻松实现「多重影分身」!

2. 自定义选择器:精准控制子元素动画,比如列表重排时的「交错入场」特效!

3. 精细化配置animateOption 支持 easingdelay 等参数,连贝塞尔曲线都能玩!


:animate-option="{
duration: 800,
easing: 'cubic-bezier(0.68, -0.6, 0.32, 1.6)', // 弹跳效果
iterations: Infinity // 无限循环
}"




❓ 为什么选它?



  • 对比原生 CSS 动画:无需手动计算关键帧,数据一变自动补间!

  • 对比 GSAP:更轻量(压缩后仅 5KB),专为 Vue 定制!

  • 对比其他 FLIP 库:API 设计更符合 Vue 生态,上手零成本!




📢 行动号召:


👉 GitHub 地址github.com/qianyuanjia…

👉 npm 地址http://www.npmjs.com/package/vue…


现在就用起来,让你的项目动画体验提升 200%! 🚀


作者:浮幻云月
来源:juejin.cn/post/7553245651938066467
收起阅读 »

useReducer : hook 中的响应式状态管理

web
在前端开发中,状态管理是构建复杂应用的核心能力之一,而React作为主流框架,它提供了多种状态管理方案. 然而,随着应用规模扩大,组件层级加深,传统的状态传递方式似乎优点捉襟见肘了,于是,为了解决这种问题,useReducer和useContext诞生了。 今...
继续阅读 »

在前端开发中,状态管理是构建复杂应用的核心能力之一,而React作为主流框架,它提供了多种状态管理方案.


然而,随着应用规模扩大,组件层级加深,传统的状态传递方式似乎优点捉襟见肘了,于是,为了解决这种问题,useReduceruseContext诞生了。


今天,我将从组件通信的不足开始,逐渐深入地讲解如何通过useReducer实现高效、可维护的全局状态管理。





一、组件通信简单介绍


1.1 组件通信的常见方式:



  • 父子组件通信:通过props传递数据,子组件通过props接收父组件的数据。

  • 子父组件通信:子组件通过props传递回调函数(自定义事件)给父组件,实现数据反向传递。

  • 兄弟组件通信:通过父组件作为中间人进行传递数据。

  • 跨层级通信:使用useContext创建共享上下文(Context),直接跨层级传递状态,详细讲解可以看我之前的文章《useContext : hook中跨层级通信的优雅方案》


1.2 Context 的不足:


然而,尽管useContext解决了跨层级传递状态的问题,避免了数据臃肿,但是,它在以下场景中仍存在一些缺陷:



  • 当Context频繁更新时,所有依赖该Context的组件都会重新渲染,即使某些组件并未使用更新后的数据,容易导致性能问题

  • Context能解决标签的跨级传输,然而,多个Context嵌套也会导致组件层级臃肿(比如<LoginContext.Provider>中包裹<ThemeContext.Provider>)。

  • Context本身只提供数据共享能力,它并不涉及到状态更新逻辑,需结合useStateuseReducer使用,这就导致了状态管理分散问题。


因此,当应用状态逻辑变得复杂、需集中管理时,useReducer就成为了更优的选择。




二、useReducer详解


2.1 useReducer的定义与作用


useReducer,响应式状态管理,它是React提供的用于管理复杂状态逻辑的Hook。


useReducer通过将状态(state)交由一个纯函数(reducer)进行统一管理,并通过派发动作(dispatch action)触发状态更新,而非直接修改状态。


2.2 useReducer的参数与返回值


const [state, dispatch] = useReducer(reducer, initialState);


  • 参数1:reducer函数:根据当前状态和传入的action,返回新的状态。

  • 参数2:initialState:初始状态对象。

  • 返回值



    • state:表示当前状态值。

    • dispatch:用于触发状态更新的函数,接受一个action对象作为参数。




2.3 纯函数(Pure Function)


useReducer的参数里面,其中,要求reducer函数必须是一个纯函数


纯函数的特性:



  1. 相同输入,相同输出:给定相同的输入参数,纯函数始终返回相同的结果。

  2. 无副作用:函数内部不修改外部变量、不依赖或修改全局状态、不发起网络请求或操作DOM。

  3. 不可变更新:函数不会直接修改输入参数,而是通过创建新对象或数组返回结果。


举个例子


// 不纯的函数
let total = 0;
function addToTotal(a) {
total += a; // 修改了外部变量
return total;
}

// 纯函数
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 }; // 返回新对象
default:
return state;
}
}

代码功能说明



  • addToTotal函数直接修改了外部变量total,导致结果不可预测。

  • reducer函数通过返回新对象的方式更新状态,符合纯函数的要求。




三、用计数器案例讲解useReducer


3.1 代码实现的功能


以下代码实现了一个计数器功能,它通过按钮点击+1-1修改Count值,输入自定义数值后,通过+???按钮,将该数值加到Count上。


效果如下:


mye8k-53wrb.gif
关键代码片段


import { useState,useReducer } from 'react'
import './App.css'

const initialState ={
count :0,
}
//关键代码
const reducer = (state ,action)=>{
switch(action.type){
case 'increment':
return {
count:state.count +1
};
case 'decrement':
return {
count:state.count -1
};
case 'incrementByNum':
return{
count:state.count +parseFloat(action.payload)
}
default:
return state
}
}

function App(){
const [count ,setCount] = useState(0)
const [state, dispatch]= useReducer(reducer, initialState)

return (
<>
<p>Count:{state.count}</p>
<input type="text" value={count} onChange={(e)=>setCount(e.target.value)}/>
<button onClick={()=>dispatch({type:'increment'})}> +1 </button>
<button onClick={()=>dispatch({type:'decrement'})}> -1</button>
<button onClick={()=>dispatch({type:'incrementByNum',payload:count})}> +??? </button>
</>

)
}
export default App

3.2 代码讲解:



  • 在第9行中,reducer函数通过switch语句处理三种类型的action即当触发incrementdecrementincrementByNum行为时,分别返回不同的新的状态对象。

  • dispatch函数用于触发状态更新,例如第35行,dispatch({ type: 'increment' })函数会在我们触发increment行为时,将计数器值增加1。

  • 用户可以通过输入框输入自定义数值,并通过incrementByNum操作将其加到当前计数器上。


关键部分



  1. reducer函数的设计



    • action.type决定了状态更新的逻辑,例如'increment'对应递增操作。

    • action.payload用于传递额外参数(如自定义数值)。



  2. 不可变更新



    • 所有状态更新均通过创建新对象实现(如{ count: state.count + 1 }),而非直接修改state



  3. dispatch的使用



    • dispatch接受一个action对象,触发状态更新。例如,dispatch({ type: 'incrementByNum', payload: inputValue })会将输入框中的值加到计数器上。






四、总结


4.1 useReducer的适用场景



  • 复杂状态逻辑:当状态更新逻辑涉及多个条件分支或嵌套结构时(如计数器的incrementByNum操作)。

  • 集中管理状态:通过将状态更新规则统一到reducer中,避免分散在多个组件或回调函数中。


4.2 实际应用建议



  • 结合useContext:通过useContext创建共享状态,useReducer管理状态更新,形成轻量级全局状态管理方案。

  • 模块化设计:将不同功能的reducer拆分为独立文件(如counterReducer.jsformReducer.js),提升代码可维护性。


作者:轻语呢喃
来源:juejin.cn/post/7527585340145713206
收起阅读 »

el-table实现可编辑表格的开发历程

web
写在前面的话   想直接看代码的朋友可以省略下面的历程直接翻到最底下,我把完整示例代码放在最下面 引子   笔者最近在做项目中遇到了一件事,某个迭代我们需要对项目进行UI改造,特别是把当前正在使用的一个可编辑表格换一下UI。说是换UI,其实是换表格,因为当前在...
继续阅读 »

写在前面的话


  想直接看代码的朋友可以省略下面的历程直接翻到最底下,我把完整示例代码放在最下面


引子


  笔者最近在做项目中遇到了一件事,某个迭代我们需要对项目进行UI改造,特别是把当前正在使用的一个可编辑表格换一下UI。说是换UI,其实是换表格,因为当前在用的表格组件是项目组花钱买的,但老板应该是对这个组件的UI有别的想法(其实就是觉得丑),然后经过老大的决定,我们需要换成Element-UI的组件(Element打钱~~ )。
  虽说组件要换,但是我们要尽可能的保留原先的功能,原来的组件,在使用上面非常贴近于Excel表格。然后,笔者开始库库干了。。。


初步实现


  为了快速实现功能,我们首先选择的是把这个可编辑表格的所有编辑项全部展示出来,这样用户就可以直接进行表格的编辑,就像这样:


PixPin_2025-09-23_01-08-19.gif
  但很快,我们就发现了第一个问题。
  我们的表格中,有两列下拉框使用了远端搜索功能,同时使用了一个封装的下拉选择组件。这就使得当下拉框有值的时候,它会尝试用value在下拉选项中去匹配对应的label,而下拉选项需要通过远端搜索即调接口获取。这两列调的是同一个接口,哪怕这里做了分页并且默认一页10条,仍然默认会调同一个接口20次,这是一个很影响性能的问题,如果切换成一页20条、30条、50条的话,后果不堪设想。。。
  考虑到这种情况,我们首先采取的方法是只调一次接口,把选项数据全部拉回来本地,然后让使用这些选项的下拉框直接引用。但在这里,我们又发现,这些下拉框是这样的:


PixPin_2025-09-23_01-47-28.gif
  是的,label和value同时展示出来,而且在远程搜索中,可以搜索label或value来找对应项。
  那这里我们就得使用filter-method自定义搜索方法咯,但这里有个问题,那就是搜索结果得要是独立的才行,即:第一次搜索的选项结果,不能出现在第二次的搜索选项里,意思就是每次搜索完,需要把选项还原到默认状态。这好办,visible-change事件可以实现。
  当我们把实现的功能交付出去后,产品给我们带来了一个噩耗:用户非得要跟Excel一样的,也就是说为了满足用户的使用习惯,我们需要尽可能还原出原来的表格组件来


image.png


解决之道


第一步


  事已至此,先吃饭吧,啊,不是,先百度吧
  在某次冲浪中,我发现了一篇文章,里面提到使用el-table组件的cell-dblclick事件来实现双击进入编辑状态的做法,也就是下面这样:
  通过列的prop和行的id一起来定位到双击选中的单元格的位置,然后通过v-if使得输入框渲染出来


PixPin_2025-09-23_02-48-59.gif


<template>
<el-table
:data="tableData"
style="width: 100%"
@cell-dblclick="cellDblclick"
>

<el-table-column prop="name" label="姓名" width="180">
<template slot-scope="scope">
<el-input v-if="formViewMethod(scope)" v-model="scope.row.name"></el-input>
<span v-else>{{ scope.row.name }}</span>
</template>
</el-table-column>
</el-table>

</template>
<script>
export default {
data() {
return {
editColumnProp: null,
editRowId: null
}
},
methods: {
cellDblclick(row, column, cell) {
cell.style.background = 'pink'
this.editColumnProp = column.property
this.editRowId = row.id
},
formViewMethod(scope) {
const { row, column } = scope
return (
row.id === this.editRowId &&
this.editColumnProp === column.property
)
}
}
}
</script>


  这方法确实可行!在默认是text的情况下,也就不会去调接口,这样,哪怕是用回远端搜索功能,也能保证对性能没有那种压力。
  第一步走出了,另一个问题就摆在眼前了:当我点击编辑框以外的地方,该怎么让它恢复默认那种文本状态呢?文章的作者并没有给出答案,那就得自己去寻找了


新的曙光


  最近在冲浪中,我了解到有一个名为ClickOutside的指令,这是一个vue3中的自定义指令,顾名思义,就是点击外面的意思。这下子灵感就来了:在cell-dblclick事件中,我们可以获取到当前单元格的dom,那如果我们在获取dom的时候,给它加上一个点击事件,当点击到外面的时候,就清空当前单元格的选中状态,那是不是就可以实现了呢?说干就干,上代码:


cellDblclick(row, column, cell) {
cell.style.background = 'pink'
cell.__clickOutside__ = (e) => {
if (cell.contains(e.target)) {
return console.log('点击了自己')
}
console.log('点击了外面')
cell.__clickOutside__ && document.removeEventListener('click', cell.__clickOutside__)
}
document.addEventListener('click', cell.__clickOutside__)
this.editColumnProp = column.property
this.editRowId = row.id
}

在这里我们使用了dom的contains()方法,这个方法用于检测一个元素是否包含另一个元素,返回的是一个布尔值。也就是当点击的时候,判断被点击元素B是否在双击的时候绑定点击事件的元素A之内,如果返回true的话,就是点击自己了,否则就是点击外面,这样就能实现清空选中状态的方法了。就像下面这样子:


PixPin_2025-09-23_03-54-37.gif
到这里,可编辑表格的功能就算实现了,谢谢大家观看,下面会贴上完整的示例代码,大伙儿可以直接复制粘贴来看看效果。


完整代码


<template>
<div class="irregular-table-container">
<div class="custom-table">
<!-- 表格区域 -->
<el-table
:data="tableData"
style="width: 100%"
@cell-dblclick="cellDblclick"
>

<el-table-column
prop="date"
label="日期"
width="180">

</el-table-column>
<el-table-column prop="name" label="姓名" width="180">
<template slot-scope="scope">
<el-input v-if="formViewMethod(scope)" v-model="scope.row.name"></el-input>
<span v-else>{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column
prop="gender"
label="性别"
width="120"
:formatter="formatGender"
>

</el-table-column>
<el-table-column
prop="city"
label="城市"
width="200"
:formatter="formatCity"
>

</el-table-column>
<el-table-column prop="address" label="地址"/>
</el-table>
</div>
</div>

</template>

<script>
export default {
name: "EditableTable",
data() {
return {
tableData: [
{
id: 1,
date: '2016-05-02',
name: '王小虎',
gender: '男',
city: 'Beijing',
address: '上海市普陀区金沙江路 1518 弄'
}, {
id: 2,
date: '2016-05-04',
name: '王小虎',
gender: '男',
city: 'Nanjing',
address: '上海市普陀区金沙江路 1517 弄'
}, {
id: 3,
date: '2016-05-01',
name: '王小虎',
gender: '男',
city: 'Guangzhou',
address: '上海市普陀区金沙江路 1519 弄'
}, {
id: 4,
date: '2016-05-03',
name: '王小虎',
gender: '男',
city: 'Shanghai',
address: '上海市普陀区金沙江路 1516 弄'
}
],
options: [
{ label: '男', value: 1 },
{ label: '女', value: 0 }
],
cities: [
{
value: 'Beijing',
label: '北京'
}, {
value: 'Shanghai',
label: '上海'
}, {
value: 'Nanjing',
label: '南京'
}, {
value: 'Chengdu',
label: '成都'
}, {
value: 'Shenzhen',
label: '深圳'
}, {
value: 'Guangzhou',
label: '广州'
}
],
editColumnProp: null,
editRowId: null
}
},
computed: {},
created() {},
methods: {
formatName(row) {
const input = (
<el-input v-model={row.name} clearable />
)
return input
},
formatGender(row) {
const select = (
<el-select v-model={row.gender}>
{this.options.map(item => {
return (
<el-option
key={item.value}
label={item.label}
value={item.value}
/>

)
})}
</el-select>

)
return select
},
formatCity(row) {
const select = (
<el-select v-model={row.city}>
{this.cities.map(item => {
return (
<el-option
key={item.value}
label={item.label}
value={item.value}
>

<span style="float: left">{ item.label }</span>
<span style="float: right; color: #8492a6; font-size: 13px">{ item.value }</span>
</el-option>
)
})}
</el-select>

)
return select
},
cellDblclick(row, column, cell) {
cell.style.background = 'pink'
cell.__clickOutside__ = (e) => {
if (cell.contains(e.target)) {
return console.log('点击了自己')
}
// console.log('点击了外面')
this.editColumnProp = null
this.editRowId = null
cell.__clickOutside__ && document.removeEventListener('click', cell.__clickOutside__)
}
document.addEventListener('click', cell.__clickOutside__)
this.editColumnProp = column.property
this.editRowId = row.id
},
formViewMethod(scope) {
const { row, column } = scope
return (
row.id === this.editRowId &&
this.editColumnProp === column.property
)
}
}
}
</script>



感谢名单


  写完一看时间,嚯,好家伙,凌晨4点了,赶紧碎觉,狗命要紧~~
  最后的最后,这里要感谢两位给我提供灵感和思路的大大,我把他们的文章链接放到下面了,感兴趣的小伙伴可以过去学习下。
vue对el-table的二次封装,双击单元格编辑,避免表格输入框过多卡顿
vue自定义指令(v-clickoutside)-点击当前区域之外的位置


作者:午言
来源:juejin.cn/post/7552789573735907328
收起阅读 »

event.currentTarget 、event.target 傻傻分不清楚?

web
在前端开发中,事件处理是交互逻辑的核心。但你是否会遇到这样的困惑:绑定事件时明明用的是父元素,触发时却总获取到子元素的信息?或是想优化大量子元素的事件绑定,却不知从何下手? 这一切的答案,都藏在 event.currentTarget和 event.targe...
继续阅读 »

在前端开发中,事件处理是交互逻辑的核心。但你是否会遇到这样的困惑:绑定事件时明明用的是父元素,触发时却总获取到子元素的信息?或是想优化大量子元素的事件绑定,却不知从何下手?


这一切的答案,都藏在 event.currentTarget和 event.target这对“双胞胎”属性里。


一、核心概念:谁在触发?谁在处理?


要理解这两个属性,首先需要明确事件流的基本概念。当用户与页面交互(如点击)时,事件会经历 ​捕获阶段 → 目标阶段 → 冒泡阶段​ 传播。而 event.currentTarget和 event.target的差异,正源于它们在这场“事件旅行”中的不同角色。


1. event.target:事件的“起点”


定义​:触发事件的最深层元素,即用户实际交互的对象。


特点​:



  • 从事件触发到结束,始终指向最初的“罪魁祸首”(即使事件冒泡到父元素,它也不会变)。

  • 可能是按钮、文本节点,甚至是动态生成的元素。


示例​:点击一个嵌套的 <div>内部的 <span>event.target始终是 <span>


2. event.currentTarget:事件的“处理者”


定义​:当前正在执行事件处理程序的元素,即绑定事件监听器的那个元素。


特点​:



  • 随着事件在捕获/冒泡阶段流动,它的值会动态变化(从外层元素逐渐向内,或从内层向外)。

  • 在非箭头函数的回调中,this等价于 event.currentTarget


示例​:父元素绑定点击事件,子元素被点击时,父元素的回调函数中 event.currentTarget是父元素,而 event.target是子元素。


二、一张图看懂:事件流中的身份切换


为了更直观理解二者的差异,我们通过一个三层嵌套结构的交互演示:


<div id="outer" class="box">外层(绑定事件)
<div id="middle" class="box">中层
<div id="inner" class="box">内层(点击我)</div>
</div>
</div>

当点击最内层的 inner元素时,事件流的三个阶段中,currentTarget和 target的变化如下:


阶段event.currentTarget(处理者)event.target(触发者)
捕获阶段outer → middle → innerinner(始终不变)
目标阶段innerinner
冒泡阶段inner → middle → outerinner(始终不变)

关键结论​:



  • target是“事件的源头”,永远指向用户点击的那个元素。

  • currentTarget是“事件的搬运工”,随事件传播阶段变化,指向当前处理事件的元素。


三、为什么需要事件委托?用差异解决实际问题


传统事件绑定方式为每个子元素单独添加监听器,但在动态列表、表格等场景下,这会导致 ​内存浪费动态元素难维护代码冗余​ 三大痛点。而事件委托的出现,正是利用 currentTarget和 target的差异,提供了一种“集中管理、按需处理”的优化方案。


事件委托的核心逻辑


原理​:将子元素的事件监听绑定在父元素上,利用事件冒泡机制,由父元素统一处理子元素的事件。


关键依赖​:



  • 父元素(currentTarget)负责接收事件。

  • 通过 event.target识别实际触发的子元素,执行针对性逻辑。


经典场景实战


场景 1:动态待办列表的点击交互


需求:点击待办项标记完成,支持动态添加新任务。


传统方式的问题​:每次新增任务都要重新绑定事件,代码冗余且易出错。


事件委托方案​:


<ul id="todoList">
<li class="todo-item">任务 1(点击标记完成)</li>
<li class="todo-item">任务 2(点击标记完成)</li>
</ul>
<button id="addTodo">添加新任务</button>

const todoList = document.getElementById('todoList');
const addTodoBtn = document.getElementById('addTodo');

// 父元素 todoList 绑定唯一点击事件(冒泡阶段)
todoList.addEventListener('click', function(event) {
// event.target 是实际点击的元素(可能是 li 或其子元素)
const target = event.target.closest('.todo-item'); // 向上查找最近的 li

if (!target) return; // 非目标元素,跳过

// 标记完成(切换类名)
target.classList.toggle('completed');

// 若点击删除按钮(假设子元素有 .delete-btn)
if (target.querySelector('.delete-btn')) {
target.remove(); // 直接删除父元素 li
}
});

// 动态添加新任务(无需重新绑定事件)
addTodoBtn.addEventListener('click', () => {
const newTodo = document.createElement('li');
newTodo.className = 'todo-item';
newTodo.innerHTML = `新任务 ${Date.now()} <button class="delete-btn">删除</button>`;
todoList.appendChild(newTodo);
});

优势​:



  • 仅需绑定一次父元素事件,内存占用极低。

  • 新增任务自动继承事件处理能力,无需额外代码。


场景 2:表格单元格的双击编辑


需求:双击表格单元格(td)转换为输入框编辑。


事件委托方案​:


<table id="dataTable">
<thead><tr><th>ID</th><th>名称</th></tr></thead>
<tbody>
<tr><td>1</td><td>苹果</td></tr>
<tr><td>2</td><td>香蕉</td></tr>
</tbody>
</table>

const dataTable = document.getElementById('dataTable');

// 父元素 tbody 监听双击事件(冒泡到 tbody)
dataTable.addEventListener('dblclick', function(event) {
// event.target 是实际双击的元素(可能是文本或 td)
const td = event.target.closest('td');
if (!td) return;

// 转换为输入框编辑
const originalText = td.textContent;
td.innerHTML = `<input type="text" value="${originalText}" class="edit-input">`;

const input = td.querySelector('.edit-input');
input.focus();

// 输入完成后保存(监听输入框失焦)
input.addEventListener('blur', () => {
td.textContent = input.value;
});
});

优势​:



  • 无论表格有多少行,只需绑定一次 tbody事件。

  • 新增行(如 AJAX 加载数据后插入)自动支持编辑功能。


四、避坑指南:事件委托的注意事项



  1. 选择合适的父元素


    父元素应尽可能靠近目标子元素(如列表用 ul而非 body),避免不必要的事件判断逻辑,减少性能损耗。


  2. 精确过滤目标元素


    使用 event.target.closest(selector)或 event.target.matches(selector)确保只处理目标子元素。例如:


    if (event.target.matches('.todo-item')) { ... }


    const target = event.target.closest('.todo-item'); if (target) { ... }


  3. 处理事件冒泡的中断


    若子元素调用了 event.stopPropagation(),事件不会冒泡到父元素,委托会失效。需避免在关键子元素中阻止冒泡,或改用捕获阶段监听(addEventListener第三个参数为 true)。


  4. 性能优化的边界


    对于极少量子元素(如 5 个以内),直接绑定可能更简单;但对于动态或大量子元素,事件委托是更优选择。



五、总结:从“混淆”到“精通”的关键


event.currentTarget和 event.target的核心差异,本质是 ​​“处理者”与“触发者”​​ 的分工:



  • target是用户交互的起点,始终指向实际触发的元素。

  • currentTarget是事件处理程序的载体,随事件传播阶段变化。


而事件委托,正是利用这一差异,通过父元素(currentTarget)集中处理子元素事件,解决了动态内容、批量操作的维护难题。


掌握这对属性和事件委托模式,不仅能写出更高效的代码,更能让你在前端交互设计中游刃有余。下次遇到大量子元素的事件绑定需求时,不妨试试事件委托——它会是你最可靠的“效率工具”。


作者:若尘的技术之旅
来源:juejin.cn/post/7553245651939131427
收起阅读 »

前端数据请求对决:Axios 还是 Fetch?

web
在 2025 年的现代前端开发中,高效可靠的数据请求依然是核心挑战。Axios 和 Fetch API 作为两大主流解决方案,一直让开发者难以抉择。本文将深入剖析两者特点,通过实际对比助你做出技术选型决策。原生之力:Fetch APIFetch 是浏览器原生提...
继续阅读 »

在 2025 年的现代前端开发中,高效可靠的数据请求依然是核心挑战。Axios 和 Fetch API 作为两大主流解决方案,一直让开发者难以抉择。本文将深入剖析两者特点,通过实际对比助你做出技术选型决策。

原生之力:Fetch API

Fetch 是浏览器原生提供的请求接口,无需安装即可使用:

// 基础 GET 请求
fetch('https://api.example.com/data')
.then(response => {
if (!response.ok) throw new Error('Network error');
return response.json(); // 需手动解析JSON
})
.then(data => console.log(data))
.catch(error => console.error('Request failed:', error));

核心特性:

  • 原生内置:现代浏览器(包括IE11+)全面支持
  • Promise 架构:告别回调地狱,支持 async/await
  • 精简设计:无额外依赖,项目体积零负担

2025 年增强特性:

  • AbortController:支持请求取消(原生能力已普及)
const controller = new AbortController();
fetch(url, { signal: controller.signal });
controller.abort(); // 取消请求
  • Streams API:直接处理数据流(适用于大文件)
  • 请求优先级:通过priority: 'high'参数优化关键请求

全能战士:Axios

Axios 作为久经考验的第三方库,提供了更完善的功能封装:

// 完整特性的 POST 请求
axios.post('https://api.example.com/users', {
name: 'John',
age: 30
}, {
headers: { 'X-Custom-Header': 'value' },
timeout: 5000
})
.then(response => console.log(response.data)) // 自动解析JSON
.catch(error => {
if (axios.isCancel(error)) {
console.log('Request canceled');
} else {
console.error('Request error:', error);
}
});

不可替代的优势:

  1. 开箱即用的 JSON 处理:自动转换响应数据
  2. 拦截器机制:全局管理请求/响应
// 身份验证拦截器
axios.interceptors.request.use(config => {
config.headers.Authorization = `Bearer ${token}`;
return config;
});

// 错误统一处理
axios.interceptors.response.use(
response => response,
error => {
alert(`API Error: ${error.response.status}`);
return Promise.reject(error);
}
);
  1. 便捷的取消机制
const source = axios.CancelToken.source();
axios.get(url, { cancelToken: source.token });
source.cancel('Operation canceled');
  1. 客户端防御:内置 XSRF 防护
  2. 进度跟踪:上传/下载进度监控
  3. 多环境支持:浏览器与 Node.js 通用

关键决策因素对比

特性AxiosFetch
安装需求需安装 (13KB gzip)浏览器内置
JSON 处理自动转换需手动 response.json()
错误处理捕获所有HTTP错误需检查 response.ok
取消请求专用CancelToken使用AbortController
拦截器
超时设置原生支持 timeout 参数需结合AbortController实现
请求进度追踪通过response.body实现
浏览器兼容性IE10+ (需polyfill)现代浏览器(IE11+)
XSRF防护✅自动需手动配置
TypeScript支持完善的类型定义需额外声明

2025 年选型建议

  1. 选择 Fetch 当:

    • 项目无复杂请求逻辑
    • 追求零依赖和最小打包体积
    • 目标平台均为现代浏览器
    • 使用框架内置封装(如 Next.js 的 fetch 增强)
  2. 选择 Axios 当:

    • 需要统一处理错误/权限
    • 项目涉及文件上传等复杂场景
    • 服务端渲染(SSR)应用
    • 团队已有Axios使用规范
    • 需要TypeScript深度支持

融合解决方案

在大型项目中,可以采用混合方案:

// 封装原生fetch获得axios式体验
const http = async (url, options = {}) => {
const controller = new AbortController();
const config = {
...options,
signal: controller.signal,
headers: { 'Content-Type': 'application/json', ...options.headers }
};

const timeoutId = setTimeout(() => controller.abort(), 8000);

try {
const response = await fetch(url, config);
clearTimeout(timeoutId);

if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (error.name === 'AbortError') console.error('Request timed out');
throw error;
}
};

结论

在 2025 年的技术背景下,Fetch API 的原生能力得到大幅增强,对于小型项目和简单场景愈发得心应手。但对于企业级应用,Axios 仍然凭借其人性化设计和功能完备性保持不可替代的地位。建议开发者根据项目的规模、运行环境、团队习惯三大核心因素制定技术决策,必要时可进行混合封装以平衡开发效率与性能要求。


作者:艾小码
来源:juejin.cn/post/7535907433278226474
收起阅读 »

ts的迭代器和生成器

web
在 TypeScript(以及 JavaScript)中,迭代器和生成器是用于处理集合数据(如数组、对象等)的强大工具。它们允许你按顺序访问集合中的元素,并提供了一种控制数据访问的方式。 迭代器(Iterator) 迭代器是一个对象,它定义了一个序列,并且提供...
继续阅读 »

在 TypeScript(以及 JavaScript)中,迭代器和生成器是用于处理集合数据(如数组、对象等)的强大工具。它们允许你按顺序访问集合中的元素,并提供了一种控制数据访问的方式。


迭代器(Iterator)
迭代器是一个对象,它定义了一个序列,并且提供了一种方法来访问这个序列的元素。迭代器对象实现了 Iterator 接口,该接口要求它有一个 next() 方法。


Iterable 接口
一个对象如果实现了Iterable接口,那么它就是可迭代的。这个接口要求对象必须有一个Symbol.iterator方法,这个方法返回一个迭代器对象。


Iterator 接口
迭代器对象必须实现Iterator接口。这个接口定义了next()方法,该方法返回一个对象,这个对象有两个属性:value和done。value表示当前的元素值,done是一个布尔值,表示是否还有更多的元素可以迭代。


1.可迭代对象(Iterable)

一个对象如果实现了[Symbol.iterator]方法,它就是可迭代的。这个方法必须返回一个迭代器对象。常见的内置可迭代对象包括:Array,String,Map,Set,arguments对象(可通过Array.from()或扩展运算符使用),DOM的NodeList(部分浏览器)。


const arr = [1, 2, 3];
console.log(arr[Symbol.iterator]);

image.png
这些对象之所以能被for...of循环遍历,正是因为它们实现了Symbol.iterator方法。


2.迭代器(Iterator)

迭代器是一个带有next()方法的对象,调用next()返回{value,done}.
value:当前值(当done:true时,value可省略或为undefined)
done:布尔值,表示是否遍历完成。


const arr = [10, 20];
const iterator = arr[Symbol.iterator]();

console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());

image.png


注意:迭代器本身通常也是可迭代的(即它自己也有[Symbol.iterator()]),这样它就可以用于for...of。


3.for...of与展开运算符的工作原理

for (const item of [1, 2, 3]) {
console.log(item);
}

image.png


背后的逻辑:



  • 获取[1,2,3] [Symbol.iterator]()

  • 不断调用.next()直到done:true


同理:


const str = "hi";
const chars = [...str];
console.log(chars)

image.png


手动实现一个可迭代对象

class Countdown {
constructor(private start: number) {}

[Symbol.iterator](): Iterator<number> {
let current = this.start;
return {
next(): { value: number; done: boolean } {
if (current < 0) {
return { value: undefined, done: true };
}
return { value: current--, done: false };
},
// 可选:支持 return() 方法用于提前终止(如 break)
return() {
console.log("迭代被中断");
return { value: undefined, done: true };
}
};
}
}

// 使用
const countdown = new Countdown(3);
for (const n of countdown) {
console.log(n);
}

生成器(Generator):简化迭代器创建

生成器函数是创建迭代器的便捷方式。使用function* 定义,内部用yield暂停执行。


function* idGenerator() {
let id = 1;
while (true) {
yield id++;
}
}

const gen = idGenerator();
console.log(gen.next().value);
console.log(gen.next().value);

image.png


生成器函数返回一个生成器对象,它即是迭代器,也是可迭代对象。


你甚至可以让一个对象使用生成器作为[Symbol.iterator]


const myRange = {
from: 1,
to: 5,
*[Symbol.iterator]() {
for (let i = this.from; i <= this.to; i++) {
yield i;
}
}
};

console.log([...myRange]);

image.png


迭代器协议总结:



协议:Iterable


方法:[Symbol.iterator]():Iterator


返回值:迭代器对象


说明:表示可被遍历




协议:Iterator


方法:next():{value,done}


返回值:状态对象


说明:提供下一个值




协议:可选


方法:return?():{value,done}


返回值:状态对象


说明:处理提前退出




协议:可选


方法:throw?():{value,done}


返回值:状态对象


说明:处理异常抛出



作者:怪可爱的地球人
来源:juejin.cn/post/7547346633400221747
收起阅读 »

仿照豆包实现 Prompt 变量模板输入框

web
先前在使用豆包的Web版时,发现在“帮我写作”模块中用以输入Prompt的模板输入框非常实用,既可以保留模板输入的优势,来调优指定的写作方向,又能够不失灵活地自由编辑。其新对话的输入交互也非常细节,例如选择“音乐生成”后技能提示本身也是编辑器的嵌入模块,不可以...
继续阅读 »

先前在使用豆包的Web版时,发现在“帮我写作”模块中用以输入Prompt的模板输入框非常实用,既可以保留模板输入的优势,来调优指定的写作方向,又能够不失灵活地自由编辑。其新对话的输入交互也非常细节,例如选择“音乐生成”后技能提示本身也是编辑器的嵌入模块,不可以直接删除。


image.png


虽然看起来这仅仅是一个文本内容的输入框,但是实现起来并不是那么容易,细节的交互也非常重要。例如技能提示节点直接作为输入框本身模块,多行文本就可以在提示下方排版,而不是类似网格布局需要在左侧留空白内容。那么在这里我们就以豆包的交互为例,来实现Prompt的变量模板输入框。



AI Infra 系列相关文章


概述


当我们开发AI相关的应用时,一个常见的场景便是需要用户输入Prompt,或者是在管理后台维护Prompt模板提供给其他用户使用。此时我们就需要一个能够支持内容输入或者模板变量的输入框,那么常见的实现方式有以下几种:



  • 纯文本输入框,类似于<input><textarea>等标签,在其DOM结构周围实现诸如图片、工具选择等按钮的交互。

  • 表单变量模板,类似于填空的形式,将Prompt模板以表单的形式填充变量,用户只需要填充所需要的变量内容即可。

  • 变量模板输入框,同样类似于填空的形式,但是其他内容也是可以编辑的,以此实现模版变量调优以及灵活的自由指令。


在这里有个有趣的事情,豆包的这个模板输入框是用slate做的,而后边生成文档的部分却又引入了新的富文本框架。也就是其启用分步骤“文档编辑器”模式的编辑器框架与模板输入框的编辑器框架并非同一套实现,毕竟引入多套编辑器还是会对应用的体积还是有比较大的影响。


因此为什么不直接使用同一套实现则是非常有趣的问题,虽然一开始可能是想着不同的业务组实现有着不同的框架选型倾向。但是仔细研究一下,想起来slate对于inline节点是特殊类型实现,其内嵌的inline左右是还可以放光标的,重点是inline内部也可以放光标。


这个问题非常重要,如果不能实现空结构的光标位置,那么就很难实现独立的块结构。而这里的实现跟数据结构和选区模式设计非常相关,若是针对连续的两个DOM节点位置,如果需要实现多个选区位置,就必须有足够的选区表达,而如果是纯线性的结构则无法表示。


// <em>text</em><strong>text</strong>

// 完全匹配 DOM 结构的设计
{ path: [0], offset: 4 } // 位置 1
{ path: [1], offset: 0 } // 位置 2

// 线性结构的设计
{ offset: 4 } // 位置 1

对于类似的光标位置问题,开源的编辑器编辑器例如QuillLexical等,甚至商业化的飞书文档、Notion都没有直接支持这种模式。这些编辑器的schema设计都是两个字符间仅会存在一个caret的光标插入点,验证起来也很简单,只要看能否单独插入一个空内容的inline节点即可。


在这里虽然我们主要目标是实现变量模板的输入框形式,但是其他的形式也非常有意思,例如GitHub的搜索输入框高亮、CozeLoopPrompt变量调时输入等。因此我们会先将这些形式都简单叙述一下,在最后再重点实现变量模板输入框的形式,最终的实现可以参考 BlockKit Variables 以及 CodeSandbox


纯文本输入框


纯文本输入框的形式就比较常见了,例如<input><textarea>等标签,当前我们平时使用的输入框也都是类似的形式,例如DeepSeek就是单纯的textarea标签。当然也有富文本编辑器的输入框形式,例如Gemini的输入框,但整体形式上基本一致。


文本 Input


单纯的文本输入框的形式自然是最简单的实现了,直接使用textarea标签即可,只不过这里需要实现一些控制形式,例如自动计算文本高度等。此外还需要根据业务需求实现一些额外的交互,例如图片上传、联网搜索、文件引用、深度思考等。


+-------------------------------------------+
| |
| DeepThink Search|
+-------------------------------------------+

文本高亮匹配


在这里更有趣的是GitHub的搜索输入框,在使用综合搜索、issue搜索等功能时,我们可以看到如果关键词不会会被高亮。例如is:issue state:open 时,issueopen会被高亮,而F12检查时发现其仅是使用input标签,并没有引入富文本编辑器。


在这里GitHub的实现方式就非常有趣,实际上是使用了div渲染格式样式,来实现高亮的效果,然后使用透明的input标签来实现输入交互。如果在F12检查时将input节点的color透明隐藏掉,就可以发现文本的内容重叠了起来,需要关注的点在于怎么用CSS实现文本的对齐。


我们也可以实现一个类似的效果,主要关注字体、spacing的文本对齐,以及避免对浮层的事件响应,否则会导致鼠标点击落到浮层div而不是input导致无法输入。其实这里还有一些其他的细节需要处理,例如可能存在滚动条的情况,不过在这里由于篇幅问题我们就不处理了。


<div class="container">
<div id="$$1" class="overlay"></div>
<input id="$$2" type="text" class="input" value="变量文本{{vars}}内容" />
</div>
<script>
const onInput = () => {
const text = $$2.value;
const html = text.replace(/{{(.*?)}}/g, `<span style="color: blue;">{{$1}}</span>`);
$$1.innerHTML = html;
};
$$2.oninput = onInput;
onInput();
</script>
<style>
.container { position: relative; height: 30px; width: 800px; border: 1px solid #aaa; border-radius: 3px; }
.container > * { width: 800px; height: 30px; font-size: 16px; box-sizing: border-box; font-family: inherit; }
.overlay { pointer-events: none; position: absolute; left: 0; top: 0; height: 100%; width: 100%; }
.overlay { white-space: pre; display: flex; align-items: center; word-break: break-word; }
.input { padding: 0; border-width: 0; word-spacing: 0; letter-spacing: 0; color: #0000; caret-color: #000; }
</style>

表单变量模板


变量模板的形式非常类似于表单的形式,在有具体固定的Prompt模板或者具体的任务时,这种模式非常合适。还有个有意思的事情,这种形式同样适用于非AI能力的渐进式迭代,例如文档场景常见的翻译能力,原有的交互形式是提交翻译表单任务,而在这里可以将表单形式转变为Prompt模板来使用。


表单模板


表单模版的交互形式比较简单,通常都是左侧部分编写纯文本并且预留变量空位,右侧部分会根据文本内容动态构建表单,CozeLoop中有类似的实现形式。除了常规的表单提交以外,将这种交互形式融入到LLms融入到流程编排中实现流水线,以提供给其他用户使用,也是常见的场景。


此外,表单模版适用于比较长的Prompt模版场景,从易用性上来说,用户可以非常容易地专注变量内容的填充,而无需仔细阅读提供的Prompt模版。并且这种形式还可以实现变量的复用,也就是在多个位置使用同一个变量。


+--------------------------------------------------+------------------------+
| 请帮我写一篇关于 {{topic}} 的文章,文章内容要 | 主题: ________________ |
| 包含以下要点: {{points}},文章风格符合 {{style}}, | 要点: ________________ |
| 文章篇幅为 {{length}},并且要包含一个吸引人的标题。 | 风格: ________________ |
| | 长度: ________________ |
+--------------------------------------------------+------------------------+

行内变量块


行内变量块就相当于内容填空的形式,相较表单模版来说,行内变量块则会更加倾向较短的Prompt模板。整个Prompt模板绘作为整体,而变量块则作为行内的独立块结构存在,用户可以直接点击变量块进行内容编辑,注意此处的内容是仅允许编辑变量块的内容,模板的文本是不能编辑的。


+---------------------------------------------------------------------------+
| 请帮我写一篇关于 {{topic}} 的文章,文章内容要包含以下要点: {{points}}, |
| 文章风格符合 {{style}},文章篇幅为 {{length}},并且要包含一个吸引人的标题。 |
+---------------------------------------------------------------------------+

这里相对豆包的变量模板输入框形式来说,最大的差异就是非变量块不可编辑。那么相对来说这种形式就比较简单了,普通的文本就使用span节点,变量节点则使用可编辑的input标签即可。看起来没什么问题,然而我们需要处理其自动宽度,类似arco的实现,否则交互起来效果会比较差。


实际上input的自动宽度并没有那么好实现,通常来说这种情况需要额外的div节点放置文本来同步计算宽度,类似于前文我们聊的GitHub搜索输入框的实现方式。那么在这里我们使用Editablespan节点来实现内容的编辑,当然也会存在其他问题需要处理,例如避免回车、粘贴等。


<div id="$$0" class="container"><span>请帮我写一篇关于</span><span class="input" placeholder="{{topic}}" ></span><span>的文章,文章内容要包含以下要点:</span><span class="input" placeholder="{{points}}" ></span><span>文章风格符合</span><span class="input" placeholder="{{style}}" ></span><span>,文章篇幅为</span><span class="input" placeholder="{{length}}" ></span><span>,并且要包含一个吸引人的标题。</span></div>
<style>
.container > * { font-size: 16px; display: inline-block; }
.input { outline: none; margin: 3px 2px; border-radius: 4px; padding: 2px 5px; }
.input { color: #0057ff; background: rgba(0, 102, 255, 0.06); }
.input::after { content: attr(data-placeholder); cursor: text; opacity: 0.5; pointer-events: none; }
</style>
<script>
const inputs = document.querySelectorAll(".input");
inputs.forEach(input => {
input.setAttribute("contenteditable", "true");
const onInput = () => {
!input.innerText ? input.setAttribute("data-placeholder", input.getAttribute("placeholder"))
: input.removeAttribute("data-placeholder");
}
onInput();
input.oninput = onInput;
});
</script>

变量模板输入框


变量模板输入框可以认为是上述实现的扩展,主要是支持了文本的编辑,这种情况下通常就需要引入富文本编辑器来实现了。因此,这种模式同样适用于较短的Prompt模版场景,并且用户可以在模板的基础上进行灵活的调整,参考下面的示例实现的 DEMO 效果。


+---------------------------------------------------------------------------+
| 我是一位 {{role}},帮我写一篇关于 {{theme}} 内容的 {{platform}} 文章, |
| 需要符合该平台写作风格,文章篇幅为 {{space}} 。 |
+---------------------------------------------------------------------------+

方案设计


实际上只要涉及到编辑器相关的内容,无论是富文本编辑器、图形编辑器等,都会比较复杂,其中的都涉及到了数据结构、选区模式、渲染性能等问题。而即使是个简单的输入框,也会涉及到其中的很多问题,因此我们必须要做好调研并且设计好方案。


开篇我们就讲述了为何slate可以实现这种交互,而其他的编辑器框架则不行,主要是因为slateinline节点是特殊类型实现。具体来说,slateinline节点是一个children数组,因此这里看起来是同个位置的选区可以通过path的不同区分,child内会多一层级。


[
{
type: "paragraph",
children: [{
type: "badge",
children: [{ text: "Approved" }],
}],
},
]

因此既然slate本身设计上支持这种选区行为,那么实现起来就会非常方便了。然而我对于slate编辑器实在是太熟悉了,也为slate提过一些PR,所以在这里我并不太想继续用slate实现,而恰好我一直在写 从零实现富文本编辑器 的系列文章,因此用自己做的框架BlockKit实现是个不错的选择。


而实际上,用slate的实现并非完全没有问题,主要是slate的数据结构完全支持任意层级的嵌套,那么也就是说,我们必须要用很多策略来限制用户的行为。例如我们复制了嵌入节点,是完全可以将其贴入到其他块结构内,造成更多级别的children嵌套,类似这种情况必须要写完善的normalize方法处理。


那么在BlockKit中并不支持多层级的嵌套,因为我们的选区设计是线性的结构,即使有多个标签并列,大多数情况下我们会认为选区是在偏左的DOM节点末尾。而由于某些情况下节点在浏览器中的特殊表现,例如Embed类型的节点,我们才会将光标放置在偏右的DOM位置。


// 左偏选区设计
{ offset: 4 }
// <em>text[caret]</em><strong>text</strong>
{ offset: 5 }
// <em>text</em><strong>t[caret]ext</strong>

因此我们必须要想办法支持这个行为,而更改架构设计则是不可行的,毕竟如果需要修改诸如选区模式、数据结构等模块,就相当于修改了地基,上层的所有模块都需要重新适配。因此我们需要通过其他方式来实现这个功能,而且还需要在整体编辑器的架构设计基础上实现。


那么这里的本质问题是我们的编辑器不支持独立的空结构,其中主要是没有办法额外表示一个选区位置,如果能够通过某些方式独立表达选区位置,理论上就可以实现这个功能。沿着这个思路,我们可以比较容易地想出来下面的两个方式:



  1. 在变量块周围维护配对的Embed节点,即通过额外的节点构造出新的选区位置,再来适配编辑器的相关行为。

  2. 变量块本身通过独立的Editable节点实现,相当于脱离编辑器本身的控制,同样需要适配内部编辑的相关行为。


方案1的优点是其本身并不会脱离编辑器的控制,整体的选区、历史记录等操作都可以被编辑器本身管理。缺点是需要额外维护Embed节点,整体实现会比较复杂,例如删除末尾Embed节点时需要配对删除前方的节点、粘贴的时候也需要避免节点被重复插入、需要额外的包装节点处理样式等。


方案2的优点是维护了独立的节点,在DOM层面上不需要额外的处理,将其作为普通可编辑的Embed节点即可。缺点是脱离了编辑器框架本身的控制,必须要额外处理选区、历史记录等操作,相当于本身实现了内部的不受控的新编辑器,独立出来的编辑区域自然需要额外的Case需要处理。


最终比较起来,我们还是选择了方案2,主要是其实现起来会比较简单,并且不需要额外维护复杂的约定式节点结构。虽然脱离了编辑器本身的控制,但是我们可以通过事件将其选区、历史记录等操作同步到编辑器本身,相当于半受控处理,虽然会有一些边界情况需要处理,但是整体实现起来还比较可控。


Editable 组件


那么在方案2的基础上,我们就首先需要实现一个Editable组件,来实现变量块的内容编辑。由于变量块的内容并不需要支持任何加粗等操作,因此这里我们并不需要嵌套富文本编辑器本身,而是只需要支持一个纯文本的可编辑区域即可,通过事件通信的形式实现半受控处理。


因此在这里我们就只需要一个span标签,并且设置其contenteditable属性为true即可。至于为什么不使用input来实现文本的输入框,主要是input的宽度跟随文本长度变化需要自己测量,而直接使用可编辑的span标签是天然支持的。


<div
className="block-kit-editable-text"
contentEditable
suppressContentEditableWarning
></div>

可输入的变量框就简单地实现出来了,而仅仅是可以输入文本并不够,我们还需要空内容时的占位符。由于Editable节点本身并不支持placeholder属性,因此我们必须要自行注入DOM节点,而且还需要避免占位符节点被选中、复制等,这种情况下伪元素是最合适的选择。


.block-kit-editable-text {
display: inline-block;
outline: none;

&::after {
content: attr(data-vars-placeholder);
cursor: text;
opacity: 0.5;
pointer-events: none;
user-select: none;
}
}

当然placeholder的值可以是动态设置的,并且placeholder也仅仅是在内容为空时才会显示,因此我们还需要监听input事件来动态设置data-vars-placeholder属性。


const showPlaceholder = !value && placeholder && !isComposing;
<div
className="block-kit-editable-text"
data-vars-placeholder={showPlaceholder ? placeholder : void 0}
>
</div>


这里的isComposing状态可以注意一下,这个状态是用来处理输入法IME的。当唤醒输入法输入的时候,编辑器通常会处于一个不受控的状态,这点我们先前在处理输入的文章中讨论过,然而此时文本区域是存在候选词的,因此这个情况下不应该显示占位符。


const [isComposing, setIsComposing] = useState(false);
const onCompositionStart = useMemoFn(() => {
setIsComposing(true);
});

const onCompositionEnd = useMemoFn((e: CompositionEvent) => {
setIsComposing(false);
});

接下来需要处理内容的输入,在此处的半受控主要是指的我们并不依靠BeforeInput事件来阻止用户输入,而是在允许用户输入后,主动通过onChange事件将内容同步到外部。而外部编辑器接收到变更后,会触发该节点的rerender,在这里我们再检查内容是否一致决定更新行为。


在这里不使用input标签其实也会存在一些问题,主要是DOM标签本身内部是可以写入很多复杂的HTML内容的,而这里我们是希望将其仅仅作为普通的文本输入框来使用,因此我们在检查到DOM节点不符合要求的时候,需要将其重置为纯文本内容。


useEffect(() => {
if (!editNode) return void 0;
if (isDOMText(editNode.firstChild)) {
if (editNode.firstChild.nodeValue !== props.value) {
editNode.firstChild.nodeValue = props.value;
}
for (let i = 1, len = editNode.childNodes.length; i < len; i++) {
const child = editNode.childNodes[i];
child && child.remove();
}
} else {
editNode.innerText = props.value;
}
}, [props.value, editNode]);

const onInput = useMemoFn((e: InputEvent) => {
if (e.isComposing || isNil(editNode)) {
return void 0;
}
const newValue = editNode.textContent || "";
newValue !== value && onChange(newValue);
});

对于避免Editable节点出现非文本的HTML内容,我们还需要在onPaste事件中阻止用户粘贴非文本内容,这里需要阻止默认行为,并且将纯文本的内容提取出来重新插入。这里还涉及到了使用旧版的浏览器API,实际上L0的编辑器就是基于这些旧版的浏览器API实现的,例如pell编辑器。


此外,我们还需要避免用户按下Enter键导致换行,在Editable里回车各大浏览的支持都不一致,因此这里即使是真的需要支持换行,我们也最好是使用\n来作为软换行使用,然后将white-space设置为pre-wrap来实现换行。我们可以回顾一下浏览器的不同行为:



  • 在空contenteditable编辑器的情况下,直接按下回车键,在Chrome中的表现是会插入<div><br></div>,而在FireFox(<60)中的表现是会插入<br>IE中的表现是会插入<p><br></p>

  • 在有文本的编辑器中,如果在文本中间插入回车例如123|123,在Chrome中的表现内容是123<div>123</div>,而在FireFox中的表现则是会将内容格式化为<div>123</div><div>123</div>

  • 同样在有文本的编辑器中,如果在文本中间插入回车后再删除回车,例如123|123->123123,在Chrome中的表现内容会恢复原本的123123,而在FireFox中的表现则是会变为<div>123123</div>


const onPaste = useMemoFn((e: ClipboardEvent) => {
preventNativeEvent(e);
const clipboardData = e.clipboardData;
if (!clipboardData) return void 0;
const text = clipboardData.getData(TEXT_PLAIN) || "";
document.execCommand("insertText", false, text.replace(/\n/g, " "));
});

const onKeyDown = useMemoFn((e: KeyboardEvent) => {
if (isKeyCode(e, KEY_CODE.ENTER) || isKeyCode(e, KEY_CODE.TAB)) {
preventNativeEvent(e);
return void 0;
}
})

至此Editable变量组件就基本实现完成了,接下来我们就可以实现一个变量块插件,将其作为Embed节点Schema集合进编辑器框架当中。在编辑器的插件化中,我们主要是将当前的值传递到编辑组件中,并且在onChange事件中将变更同步到编辑器本身,这就非常类似于表单的输入框处理了。


export class EditableInputPlugin extends EditorPlugin {
public key = VARS_KEY;
public options: EditableInputOptions;

constructor(options?: EditableInputOptions) {
super();
this.options = options || {};
}
public destroy(): void {}

public match(attrs: AttributeMap): boolean {
return !!attrs[VARS_KEY];
}

public onTextChange(leaf: LeafState, value: string, event: InputEvent) {
const rawRange = leaf.toRawRange();
if (!rawRange) return void 0;
const delta = new Delta().retain(rawRange.start).retain(rawRange.len, { [VARS_VALUE_KEY]: value });
this.editor.state.apply(delta, { autoCaret: false, });
}

public renderLeaf(context: ReactLeafContext): React.ReactNode {
const { attributes: attrs = {} } = context;
const varKey = attrs[VARS_KEY];
const placeholders = this.options.placeholders || {};
return (
<Embed context={context}>
<EditableTextInput
className={cs(VARS_CLS_PREFIX, `${VARS_CLS_PREFIX}-${varKey}`)}
value={attrs[VARS_VALUE_KEY] || ""}
placeholder={placeholders[varKey]}
onChange={(v, e) =>
this.onTextChange(context.leafState, v, e)}
></EditableTextInput>
</Embed>

);
}
}

然而,当我们将Editable节点集成后出现了问题,特别是选区无法设置到变量编辑节点内。主要是这里的选区会不受编辑器控制,因此我们还需要在编辑器的核心包里,避免选区被编辑器框架强行拉取到leaf节点上,这还是需要编辑器本身支持的。


同样的,很多事件同样需要避免编辑器框架本身处理,得益于浏览器DOM事件流的设计,我们可以比较轻松地通过阻止事件冒泡来避免编辑器框架处理这些事件。当然还有一些不冒泡的如Focus等事件,以及SelectionChange等全局事件,我们还需要在编辑器本身的事件中心中处理这些事件。


/**
* 独立节点嵌入 HOC
* - 独立区域 完全隔离相关事件
* @param props
*/

export const Isolate: FC<IsolateProps> = props => {
const [ref, setRef] = useState<HTMLSpanElement | null>(null);

useEffect(() => {
// 阻止事件冒泡
}, [ref]);

return (
<span
ref={setRef}
{...{ [ISOLATED_KEY]: true }}
contentEditable={false}
>

{props.children}
</span>

);
};

/**
* 判断选区变更时, 是否需要忽略该变更
* @param node
* @param root
*/

export const isNeedIgnoreRangeDOM = (node: DOMNode, root: HTMLDivElement) => {
for (let n: DOMNode | null = node; n !== root; n = n.parentNode) {
// node 节点向上查找到 body, 说明 node 并非在 root 下, 忽略选区变更
if (!n || n === document.body || n === document.documentElement) {
return true;
}
// 如果是 ISOLATED_KEY 的元素, 则忽略选区变更
if (isDOMElement(n) && n.hasAttribute(ISOLATED_KEY)) {
return true;
}
}
return false;
};

到这里,模板输入框基本已经实现完成了,在实际使用中问题太大的问题。然而在测试兼容性时发现一个细节,在FirefoxSafari中,按下方向键从非变量节点跳到变量节点时,不一定能够成功跳入或者跳出,具体的表现在不同的浏览器都有差异,只有Chrome是完全正常的。


因此为了兼容浏览器的处理,我们还需要在KeyDown事件中主动处理在边界上的跳转行为。这部分的实现是需要适配编辑器本身的实现的,需要完全根据DOM节点来处理新的选区位置,因此这里的实现主要是根据预设的DOM结构类型来处理,这里实现代码比较多,因此举个左键跳出变量块的例子。


const onKeyDown = useMemoFn((e: KeyboardEvent) => {
LEFT_ARROW_KEY: if (
!readonly &&
isKeyCode(e, KEY_CODE.LEFT) &&
sel &&
sel.isCollapsed &&
sel.anchorOffset === 0 &&
sel.anchorNode &&
sel.anchorNode.parentElement &&
sel.anchorNode.parentElement.closest(`[${LEAF_KEY}]`)
) {
const leafNode = sel.anchorNode.parentElement.closest(`[${LEAF_KEY}]`)!;
const prevNode = leafNode.previousSibling;
if (!isDOMElement(prevNode) || !prevNode.hasAttribute(LEAF_KEY)) {
break LEFT_ARROW_KEY;
}
const selector = `span[${LEAF_STRING}], span[${ZERO_SPACE_KEY}]`;
const focusNode = prevNode.querySelector(selector);
if (!focusNode || !isDOMText(focusNode.firstChild)) {
break LEFT_ARROW_KEY;
}
const text = focusNode.firstChild;
sel.setBaseAndExtent(text, text.length, text, text.length);
preventNativeEvent(e);
}
})

最后,我们还需要处理History的相关操作,由于变量块本身是脱离编辑器框架的,选区实际上是并没有被编辑器本身感知的。所以这里的undoredo等操作实际上是无法处理变量块选区的变更,因此这里我们就简单处理一下,避免输入组件undo本身的操作被记录到编辑器内。


public onTextChange(leaf: LeafState, value: string, event: InputEvent) {
this.editor.state.apply(delta, {
autoCaret: false,
// 即使不记录到 History 模块, 仍然存在部分问题
// 但若是受控处理, 则又存在焦点问题, 因为此时焦点并不在编辑器
undoable: event.inputType !== "historyUndo" && event.inputType !== "historyRedo",
});
}

选择器组件


选择器组件主要是固定变量的值,例如上述的的例子中我们将篇幅这个变量固定为短篇、中篇、长篇等选项。这里的实现就比较简单了,主要是选择器组件本身不需要处理选区的问题,其本身就是常规的Embed类型节点,因此只需要实现选择器组件,并且在onChange事件中将值同步到编辑器本身即可。


export class SelectorInputPlugin extends EditorPlugin {
public key = SEL_KEY;
public options: SelectorPluginOptions;

constructor(options?: SelectorPluginOptions) {
super();
this.options = options || {};
}

public destroy(): void {}

public match(attrs: AttributeMap): boolean {
return !!attrs[SEL_KEY];
}

public onValueChange(leaf: LeafState, v: string) {
const rawRange = leaf.toRawRange();
if (!rawRange) return void 0;
const delta = new Delta().retain(rawRange.start).retain(rawRange.len, {
[SEL_VALUE_KEY]: v,
});
this.editor.state.apply(delta, { autoCaret: false });
}

public renderLeaf(context: ReactLeafContext): React.ReactNode {
const { attributes: attrs = {} } = context;
const selKey = attrs[SEL_KEY];
const value = attrs[SEL_VALUE_KEY] || "";
const options = this.options.selector || {};
return (
<Embed context={context}>
<SelectorInput
value={value}
optionsWidth={this.options.optionsWidth || SEL_OPTIONS_WIDTH}
onChange={(v: string) =>
this.onValueChange(context.leafState, v)}
options={options[selKey] || [value]}
/>
</Embed>

);
}
}

SelectorInput组件则是常规的选择器组件,这里需要注意的是避免该组件被浏览器的选区处理,因此会在MouseDown事件中阻止默认行为。而弹出层的DOM节点则是通过Portal的形式挂载到编辑器外部的节点上,这样自然不会被选区影响。


export const SelectorInput: FC<{ value: string; options: string[]; optionsWidth: number; onChange: (v: string) => void; }> = props => {
const { editor } = useEditorStatic();
const [isOpen, setIsOpen] = useState(false);

const onOpen = (e: React.MouseEvent<HTMLSpanElement>) => {
if (isOpen) {
MountNode.unmount(editor, SEL_KEY);
} else {
const target = (e.target as HTMLSpanElement).closest(`[${VOID_KEY}]`);
if (!target) return void 0;
const rect = target.getBoundingClientRect();
const onChange = (v: string) => {
props.onChange && props.onChange(v);
MountNode.unmount(editor, SEL_KEY);
setIsOpen(false);
};
const Element = (
<SelectorOptions
value={props.value}
width={props.optionsWidth}
left={rect.left + rect.width / 2 - props.optionsWidth / 2}
top={rect.top + rect.height}
options={props.options}
onChange={onChange}
>
</SelectorOptions>

);
MountNode.mount(editor, SEL_KEY, Element);
const onMouseDown = () => {
setIsOpen(false);
MountNode.unmount(editor, SEL_KEY);
document.removeEventListener(EDITOR_EVENT.MOUSE_DOWN, onMouseDown);
};
document.addEventListener(EDITOR_EVENT.MOUSE_DOWN, onMouseDown);
}
setIsOpen(!isOpen);
};

return (
<span className="editable-selector" onMouseDownCapture={preventReactEvent} onClick={onOpen}>
{props.value}
</span>

);
};

总结


在本文中我们调研了用户Prompt输入的相关场景实现,且讨论了纯文本输入框模式、表单模版输入模式,还观察了一些有趣的实现方案。最后重点基于富文本编辑器实现了变量模板输入框,特别适配了我们从零实现的编辑器框架BlockKit,并且实现了Editable变量块、选择器变量块等插件。


实际上引入富文本编辑器总是会比较复杂,在简单的场景下直接使用Editable自然也是可行的,特别是类似这种简单的输入框场景,无需处理复杂的性能问题。然而若是要实现更复杂的交互形式,以及多种块结构、插件化策略等,使用富文本编辑器框架还是更好的选择,否则最终还是向着编辑器实现了。


每日一题



参考



作者:WindRunnerMax
来源:juejin.cn/post/7551995949503840292
收起阅读 »

关于排查问题的总结

web
1. 写在最前面 用了这么久的 Cursor ,还是会时不时的感慨科技使人类进步。尤其是最近的「Claude Sonnet 4」 好用的不得了,在丢给它一个需求之后,从设计方案、到 coding、以及编写 tase case 、修复验证逻辑、甚至还记的 lin...
继续阅读 »


1. 写在最前面


用了这么久的 Cursor ,还是会时不时的感慨科技使人类进步。尤其是最近的「Claude Sonnet 4」 好用的不得了,在丢给它一个需求之后,从设计方案、到 coding、以及编写 tase case 、修复验证逻辑、甚至还记的 lint 一下,无比贴心。但是随之而来的坏处就是,过分相信 Cursor 之后,不在去理解库是否符合业务场景。



注:尽信书,则不如无书。看来在提升 coding 效率的同时,也要更为慎重的思考每个使用 场景是否符合最初设计的预期。



2. 问题小计


2.1 aiohttp 库


前置说明,笔者并不是很熟悉 Python ,属于一边用,一边学习的状态。接的需求是,在使用 aiohttp 库的时候,能够复用 http client ,无需每次请求都重新建立连接,以达到最终减少请求和返回的耗时的目的。


笔者实现的方式:


def _get_session(self) -> aiohttp.ClientSession:
if self._session is not and not self._session.closed:
return self._session

new_session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
connector=aiohttp.TCPConnector(limit=100, enable_cleanup_closed=True),
trace_configs=[self.trace_config],
)

self._session = new_session
return self._session


注:这当然是 cursor 帮忙给的思路。但是笔者当时大意了,没有细细的深究 aiohttp.ClientTimeout 这字段的意思。


不推卸责任,问题当时是实现代码人的问题,记录在这里,方便下次在类似的改动前,能够在深入探究一步,减少后续返工以及故障的可能性。



先解释一下 aiohttp.ClientTimeout 字段的意思


timeout = aiohttp.ClientTimeout(
total=, # 总超时时间
connect=, # 连接超时时间
sock_connect=, # Socket 连接超时时间
sock_read= # Socket 读取超时时间
)

各参数详细说明



  • total (float | )



    • 整个请求的总超时时间(秒),包括连接建立、发送请求、接收响应的全部时间,默认值:5 分钟 (300 秒),设置为  表示无限制



  • connect (float | )



    • 建立连接的超时时间(秒),包括 DNS 解析、TCP 连接建立、SSL 握手等,默认值:无限制 ()



  • sock_connect (float | )



    • 底层 socket 连接的超时时间(秒),仅针对 TCP socket 连接建立阶段,默认值:无限制 ()



  • sock_read (float | )



    • Socket 读取数据的超时时间(秒),在连接建立后读取响应数据的超时,默认值:无限制 ()




先解释了参数的含义之后,就可以推测到现象是,如果 response 持续返回的时间超过 30s ,就会主动断开连接。



注:除了 aiohttp.ClientTimeout,建议再用 aiohttp.TCPConnector 字段时也要先确认字段的生效规则后使用。



2.2 奇怪的问题


笔者本次提测改动的功能很少,但是 QA 测试的时候报了很多跟功能无关的 jira ,比如:



  • start 的时候返回超时报错

  • 启动的任务,自动退出查询不到了



注:这种跟改动无任何关系的问题,查起来真的有点子费人……



问题1:start 的时候返回超时报错


结论:最终查下来, start 超时是因为请求的机器,磁盘读写耗时极高,分析下来,可能是因为混用的测试环境,其他服务的测试写入了过多的音视频文件导致……


还好,公司的机器是有 cpu、memery、磁盘等 Zeus 监控的,不然都没办法自证业务的清白了。



注:不过这里虽然机器有问题的可能性比较高,但有一点,我还是没有想通的为什么磁盘耗时高会表现 start 超时呢?印象中业务的 start 行为中没有任何写入磁盘的行为。


cursor 一个比较符合的原因的回答是:



  • 依赖库加载:Python模块、动态库加载需要磁盘I/O

  • 超时连锁反应:磁盘I/O慢 → 启动步骤延迟 → 健康检查超时 → 服务被认为启动失败



问题2: 启动的任务,自动退出查询不到了


结论:是因为业务消耗的资源的内存变多了,之前 pod 设置的 memory 是 1G,导致 oom 了……


企业微信截图_b791a84a-ecc3-4d9d-bab1-f0573c9e7019.png


为了能够按时交付版本,本次的改动是先调整部署的 chart ,将 memory 从 1G 调整 2G


至此,那些奇怪的问题就排查完成了。还好,虽然排查的过程有点痛苦,但是还是从中学到了之前不知道的支持,比如dmesg -T 查看内核的输出信息。


3. 碎碎念


终于在十一前,挤出时间来整理了一下最近遇到有些奇怪的问题。希望这个十一能够玩的开心。



  • 到底是什么伟大前程,值得我们把每个四季都错过?(ps:读的时候,心理酸酸的

  • 人一旦有退路,无论是你愿意还是不愿意接受的退路,就不会用全力了。


作者:夕颜111
来源:juejin.cn/post/7554979158435364879
收起阅读 »

VitePress 博客变身 APP,支持离线访问,只需这一招。

web
大家好,我是不如摸鱼去,uni-app vue3 组件库 wot-ui 的主要维护者,欢迎来到我的工具分享专栏。 前阵子解决网站国内访问慢的问题之前,总有朋友问:“网站太慢了,能离线使用吗?” 答案是:“可以!” 这需求正是 PWA 能解决的嘛!今天我们花几分...
继续阅读 »

大家好,我是不如摸鱼去,uni-app vue3 组件库 wot-ui 的主要维护者,欢迎来到我的工具分享专栏。


前阵子解决网站国内访问慢的问题之前,总有朋友问:“网站太慢了,能离线使用吗?”


答案是:“可以!” 这需求正是 PWA 能解决的嘛!今天我们花几分钟时间,将我的个人博客改造为 PWA ,支持安装到本地并且可以离线访问,我的博客地址: blog.wot-ui.cn/。


PWA 是什么?


渐进式 Web 应用(Progressive Web App,PWA)是一个使用 web 平台技术构建的应用程序,但它提供的用户体验就像一个特定平台的应用程序。


它像网站一样,PWA 可以通过一个代码库在多个平台和设备上运行。它也像一个特定平台的应用程序一样,可以安装在设备上,可以离线和在后台运行,并且可以与设备和其他已安装的应用程序集成。


说句题外话,国内 PWA 的生态位其实是被各大 APP 的小程序占据了,不过小程序各自为战的标准实在是令人头疼,写小程序会让人变得烦恼,写多个平台的小程序会让人变得不幸。你可能会笑,而我却真的在写,wot-ui 组件库为了兼容多平台小程序不知道让我掉了多少头发。



具体定义见: developer.mozilla.org/zh-CN/docs/…



PWA 可以安装到本地,支持离线访问,并将快捷方式放到启动台中。



VitePress 添加 PWA 支持


我博客是使用 VitePress 搭建的,生态里有个现成的插件 @vite-pwa/vitepress,可以为 VitePress 项目添加 PWA 能力。



VitePress 是一个静态站点生成器 (SSG),专为构建快速、以内容为中心的站点而设计。简而言之,VitePress 获取用 Markdown 编写的内容,对其应用主题,并生成可以轻松部署到任何地方的静态 HTML 页面。 信息来自 vitepress 官网。



1. 安装


# 我用的pnpm,快!
pnpm add @vite-pwa/vitepress -D
# npm/yarn用户自己替换一下

2. 配置(.vitepress/config.mts


这是核心步骤,直接上我改完的配置,关键地方我加了个**“唠叨版”注释**,解释下为啥这么设:


import { defineConfig } from 'vitepress'
import { withPwa } from '@vite-pwa/vitepress'
export default withPwa(defineConfig({
// 博客基础信息,老样子
title: '不如摸鱼去',
description: '不如摸鱼去的博客,分享前端、uni-app、AI编程相关知识',

// PWA 配置区,重点来了!
pwa: {
base: '/',
scope: '/',
includeAssets: ['favicon.ico', 'logo.png', 'images/**/*'], // 告诉插件,这些静态资源要缓存起来
registerType: 'prompt', // 有更新别偷偷刷新,得问问我(用户)同不同意
injectRegister: 'auto',

// 开发环境专用,关掉烦人的警告
devOptions: {
enabled: true,
suppressWarnings: true, // 开发时警告太多,眼花,先屏蔽
navigateFallback: '/',
type: 'module'
},

// Service Worker 配置,缓存策略的灵魂
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,jpg,jpeg,gif,svg,woff2}'], // 需要缓存哪些类型的文件
cleanupOutdatedCaches: true, // 老缓存?清理掉!别占地方
clientsClaim: true, // 新的Service Worker来了,立刻接管页面
skipWaiting: true, // 新SW别等了,赶紧干活
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 单个文件最大10MB,再大就不缓存了

// 针对不同资源,用不同缓存策略(这里踩过坑)
runtimeCaching: [
// Google Fonts这类外部字体:缓存优先,存久点(一年),反正不常变
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365
}
}
},
// 图片:也缓存优先,但别存太久(30天),万一我换了图呢?
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30
}
}
}
// 注意:JS/CSS/HTML Workbox默认会处理,通常用 StaleWhileRevalidate 策略(缓存优先,后台更新)
]
},

// App清单,告诉系统“我是个App!”
manifest: {
name: '不如摸鱼去', // 完整名
short_name: '摸鱼去', // 桌面图标下面显示的短名,太长显示不全
description: '分享前端、uni-app、AI编程相关知识',
theme_color: '#ffffff', // 主题色,影响状态栏、启动画面背景
background_color: '#ffffff', // 启动画面背景色
display: 'standalone', // 独立显示模式(全屏,无浏览器UI)
orientation: 'portrait', // 默认竖屏
scope: '/', // PWA能管哪些页面
start_url: '/', // 点开图标从哪开始
icons: [ // 图标!重中之重!
{
src: '/logo.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/logo.png',
sizes: '512x512',
type: 'image/png'
},
{
src: '/logo.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable' // 这个重要!告诉系统这图标能被裁剪成各种形状(圆的、方的)
}
]
}
}
}))

看看效果


部署上线后,浏览器打开 blog.wot-ui.cn/ 会发现地址栏旁边会提示安装应用。


安装后将我们的app放到启动台中就可以快捷访问了,断网后也可以访问,很方便。




搞完之后,有啥变化?


说实话,效果比我预期的好:



  • 快! 二次访问(尤其是同一设备)快多了,静态资源直接读本地缓存,秒开。在没网的地方(比如电梯里),打开博客,之前看过的文章照样能看,体验不掉线。

  • 体验升级: 全屏阅读,没有浏览器边框干扰,沉浸感强不少。

  • 方便访问: 可以在桌面、启动台创建快捷方式,方便读者找到我们,不需要记忆网址。

  • 服务器压力小点: 缓存命中率高了,请求自然少了点。


总结


给 VitePress 博客加PWA,投入产出比真挺高的。主要就是装个插件、改改配置、准备个图标。过程不算复杂,但带来的体验提升是实打实的。如果你的网站访问速度比较慢,或者期望提高用户的粘性,提供一下 PWA 能力还是很不错的。读到这里还不把「不如摸鱼去」博客添加到桌面吗(偷笑)?


参考资料



往期精彩


uni-app vue3 也能使用 Echarts?Wot Starter 是这样做的!


当年偷偷玩小霸王,现在偷偷用 Trae Solo 复刻坦克大战


告别 HBuilderX,拥抱现代化!这个模板让 uni-app 开发体验起飞


uni-app 还在手写请求?alova 帮你全搞定!


uni-app 无法实现全局 Toast?这个方法做到了!


Vue3 uni-app 主包 2 MB 危机?1 个插件 10 分钟瘦身


欢迎评论区沟通讨论👇👇


作者:不如摸鱼去
来源:juejin.cn/post/7554204108154159156
收起阅读 »

🚀 告别 Electron 的臃肿:用 Tauri 打造「轻如鸿毛」的桌面应用

web
Tauri:从300MB到5MB!这才是桌面应用的未来 你有没有这样的体验? 打开一个用 Electron 写的桌面工具,任务管理器瞬间飙出 300MB+ 内存占用,启动要等好几秒,系统风扇呼呼作响……而它的功能,可能只是一个简单的 Markdown 编辑器。...
继续阅读 »

Tauri:从300MB到5MB!这才是桌面应用的未来


你有没有这样的体验?


打开一个用 Electron 写的桌面工具,任务管理器瞬间飙出 300MB+ 内存占用,启动要等好几秒,系统风扇呼呼作响……而它的功能,可能只是一个简单的 Markdown 编辑器。


今天,我要向你介绍一位 Electron 的「性能杀手」——Tauri


它不仅能让你用 React/Vue/Svelte 构建界面,还能把最终应用打包成 小于 5MB 的安装包,启动速度接近原生!


🚀 什么是 Tauri?颠覆性的轻量级方案


Tauri 是一个基于 Rust 构建的开源框架,允许开发者使用前端技术创建安全、轻量、高性能的跨平台桌面应用。



  • ✅ 支持 Windows / macOS / Linux

  • ✅ 前端任意框架:React、Vue、Svelte、Solid.js 等

  • ✅ 核心逻辑由 Rust 编写,极致安全与性能

  • ✅ 即将支持移动端,迈向全平台统一


📊 性能对比:数字会说话


指标ElectronTauri优势
应用体积80~200MB<5MB减少95%
内存占用150~300MB<30MB减少85%
启动时间2~5秒<0.5秒快5-10倍
安全性Node.js 全权限Rust + 权限控制企业级安全

Tauri 的秘诀在于:利用操作系统自带的 WebView,而不是捆绑整个 Chromium。


🛡️ 安全架构:Rust 原生的降维打击


多层安全防护



  1. 内存安全:Rust 编译时防止空指针、数据竞争等漏洞

  2. 权限控制:细粒度的能力声明,前端只能访问明确授权的系统功能

  3. 沙箱机制:前端代码运行在隔离环境中,无法直接调用系统 API


// Rust 后端:类型安全的系统调用
#[tauri::command]
fn read_file(path: String) -> Result<String, String> {
std::fs::read_to_string(&path)
.map_err(|e| format!("读取文件失败: {}", e))
}

即使前端遭遇 XSS 攻击,攻击者也无法越权访问系统资源。


💻 实战演示:5分钟构建文件管理器


1. 项目初始化


npm create tauri-app@latest my-files-app
cd my-files-app
npm install

2. 前端界面(React示例)


import { invoke } from '@tauri-apps/api/tauri';

function FileManager() {
const [files, setFiles] = useState([]);

const listFiles = async (path) => {
const fileList = await invoke('list_files', { path });
setFiles(fileList);
};

return (
<div>
<button onClick={() => listFiles('/')}>浏览文件</button>
<ul>
{files.map(file => (
<li key={file.name}>{file.name}</li>
))}
</ul>
</div>

);
}

3. Rust 后端实现


use std::fs;

#[tauri::command]
fn list_files(path: String) -> Result<Vec<FileInfo>, String> {
let entries = fs::read_dir(path)
.map_err(|e| e.to_string())?;

let mut files = Vec::new();
for entry in entries {
if let Ok(entry) = entry {
files.push(FileInfo {
name: entry.file_name().to_string_lossy().to_string(),
size: entry.metadata().map(|m| m.len()).unwrap_or(0),
});
}
}

Ok(files)
}

4. 构建发布


npm run tauri build
# 生成 3.8MB 的安装包!

🎯 Tauri 的适用场景


✅ 强烈推荐



  • 效率工具:笔记软件、截图工具、翻译软件

  • 开发工具:API 测试、数据库管理、日志查看器

  • 内部系统:监控面板、数据可视化、配置工具

  • 轻量应用:计算器、单位转换、密码管理器


⚠️ 谨慎选择



  • 复杂图形渲染(游戏、3D 编辑)

  • 重度依赖 Chrome 扩展生态

  • 需要支持老旧操作系统


🔮 生态展望:不只是桌面


Tauri 正在快速进化:



  1. 移动端支持:一套代码,多端部署

  2. 插件生态:官方维护的常用功能模块

  3. 云集成:无缝对接云服务

  4. AI 集成:本地模型推理能力


💡 迁移策略:从 Electron 平滑过渡


如果你已有 Electron 项目,可以这样迁移:



  1. 渐进式迁移:先移植核心功能模块

  2. 并行开发:保持 Electron 版本,逐步替换

  3. 性能对比:AB 测试验证用户体验提升

  4. 用户反馈:收集真实使用数据优化方向


🌟 总结:为什么 Tauri 是未来?


维度ElectronTauri结论
用户体验笨重缓慢轻快流畅Tauri 胜出
开发体验成熟稳定现代高效各有优势
资源消耗浪费严重极致优化Tauri 完胜
安全性能依赖配置内置安全Tauri 领先

Tauri 不是另一个 Electron,而是桌面应用开发的范式革命。


它证明了:Web 技术的灵活性 + 原生语言的性能 = 最佳桌面开发方案


🚀 立即开始


# 1. 安装 Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# 2. 创建项目
npm create tauri-app@latest my-app

# 3. 开始开发
npm run tauri dev

💬 互动讨论



  • 你在项目中用过 Tauri 吗?体验如何?

  • 你认为 Tauri 会取代 Electron 吗?

  • 最期待 Tauri 的哪些新特性?


欢迎在评论区分享你的观点!如果觉得这篇文章有帮助,请点赞支持~


作者:大前端历险记
来源:juejin.cn/post/7553912808804384818
收起阅读 »

异步函数中return与catch的错误处理

web
详细解释: 加 return 的情况: return createRequest(...) 返回一个 Promise 链。 当 createRequest 失败时,.catch 会捕获错误,并抛出新的错误。 由于整个 Promise 链被返回,before...
继续阅读 »

image.png


详细解释:



  1. 加 return 的情况



    • return createRequest(...) 返回一个 Promise 链。

    • 当 createRequest 失败时,.catch 会捕获错误,并抛出新的错误。

    • 由于整个 Promise 链被返回,beforeSubmit 的 Promise 会被 reject,错误会传递到调用方,从而中断后续操作。



  2. 不加 return 的情况



    • createRequest(...) 会启动一个 Promise 链,但未被返回。

    • beforeSubmit 函数不会等待这个 Promise 链完成,而是立即返回一个 resolved 的 Promise(因为 async 函数默认返回一个 resolved Promise,除非有 await 或 return)。

    • 即使 .catch 捕获了错误并抛出,也只是在内部的 Promise 链中处理,不会影响 beforeSubmit 的返回值。因此,外部调用者认为 beforeSubmit 成功完成,后续代码会继续执行。




总结:



  • 当前代码中使用了 return createRequest(...),这是正确的做法,可以确保错误被传播并阻止后续执行。

  • 如果不加 return,即使 URL 校验失败,beforeSubmit 也会成功返回,表单提交可能会继续,这不符合预期。


作者:刀疤
来源:juejin.cn/post/7546105524095696947
收起阅读 »

前端性能基准测试入门:用 Benchmark.js 做出数据驱动的选择

web
前端性能基准测试入门:用 Benchmark.js 做出数据驱动的选择 背景 在前端开发过程中,会有一些需要注重代码性能的场景,比如:一个复杂功能依赖的数据基于嵌套数组实现(比如支持拖拽的行程规划需要有行程单、日期、时间、地点等多种维度的数据)、一个功能需要前...
继续阅读 »

前端性能基准测试入门:用 Benchmark.js 做出数据驱动的选择


背景


在前端开发过程中,会有一些需要注重代码性能的场景,比如:一个复杂功能依赖的数据基于嵌套数组实现(比如支持拖拽的行程规划需要有行程单、日期、时间、地点等多种维度的数据)、一个功能需要前端来做大量数据的计算。


在这些场景中,同样的操作我们会针对不同的实现方式进行测试,来得到不同实现方式的性能差异,便于选择最优的实现方式。


为什么使用 Benchmark.js


我最开始其实也有这样的疑问,为什么不能 直接在本地执行一遍代码,然后自己计算执行时间来 测试性能?


详细了解相关资料后发现会有以下几个问题:



  1. 计时精度问题: JavaScript 自带的 Date.now() 最小单位是毫秒,对于 CPU 执行代码的耗时来说精度是不够的。同时,如果代码执行时间过短,可能无法准确测量。

  2. 引擎优化问题: JavaScript 引擎会对代码进行优化,比如:一段代码会有“冷启动”和“热状态”的差异,有些没有被使用到的执行结果会被直接优化掉等等。

  3. 单次测试不具备参考性: 单次测试可能会受到很多因素的影响,可能一段代码第一次的执行用了 3 毫秒,第二次只用了 1 毫秒等等。


专业的事情还是要交给专业的人去做,就好像在实验室进行专业温度测量不会使用体温计一样。我们可以使用 Benchmark.js 为我们进行更加精确的基准测试。


Benchmark.js 基本使用


Benchmark.js 官方的文档写的比较晦涩,不太利于新手阅读,下面会通过一个简单的例子来介绍如何使用 Benchmark.js 进行性能测试。


引入或安装 Benchmark.js


在浏览器环境中可以使用 CDN 引入,在 Node.js 环境中可以使用 npm 安装。


需要特别注意的是:benchmark.js 依赖于 lodash.js,所以通过 Script 引入时需要先引入 lodash.js。(使用 npm 安装时会自动处理依赖,无需手动引入 lodash)


<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/benchmark@2.1.4/benchmark.min.js"></script>

npm install benchmark

创建套件


Benchmark.js 默认提供了一个构造函数,我们可以通过这个构造函数来创建一个性能测试的实例,通常会把这个内容叫做 suite 套件。在 Benchmark.js 里,每次测试都是以一个 suite 套件为范围的


const Benchmark = require("benchmark");
const suite = new Benchmark.Suite();

添加测试用例


有了套件之后,我们就可以往套件中添加测试用例了。假设我们有一段简单的数据,需要计算出数组中每个元素的平方最后加和。那实现方式可能会包含以下两种:



  1. 提前定义好一个变量,使用 for 循环遍历数组,然后计算每个元素的平方最后加到这个变量中。

  2. 使用 reduce 方法,直接计算出数组中每个元素的平方最后加和。


我们可以使用 suite.add 方法来往套件中添加测试用例。这个方法接收两个参数:第一个参数是测试用例的名称,第二个参数是测试用例的函数。


const suite = new Benchmark.Suite();
const arr = [1, 2, 3, 4, 5]; // 测试数据
suite.add("使用 for 循环", () => {
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i] * arr[i];
}
});
suite.add("使用 reduce 方法", () => {
const sum = arr.reduce((acc, val) => acc + val * val, 0);
});

监听测试过程中的事件


suite 还提供了 on 方法,可以监听测试用例的开始、结束、完成等事件


suite.on("事件的名字", 触发的回调函数);

常见的监听事件包括:



  • start:整个测试环节开始时触发

  • cycle:每个测试用例完成一个循环周期时触发

  • complete:所有测试用例都执行完毕时触发


比如:如果给之前添加的测试用例添加 cycle 事件,那么每次单个测试用例执行完,都会触发 cycle 事件。我们也可以在 complete 事件中统计并输出本次测试中最快的用例。


suite.on("cycle", (event) => {
const result = event.target;
const name = result.name;
const hz = Math.round(result.hz);
const mean = result.stats.mean;
console.log(`[CYCLE] ${name}: ${hz} 次/秒 平均耗时: ${mean}s`);
});

suite.on("complete", function () {
const fastest = this.filter("fastest").map("name");
console.log(`[COMPLETE] 最快的是: ${fastest}`);
});

cycle 事件的回调函数参数中提供了很多有用的信息,比如 event.target.hz 表示当前测试用例的执行频率,event.target.stats.mean 表示当前测试用例的平均执行时间。我们可以在回调函数中打印出这些信息,来查看测试用例的执行情况。


执行测试


有了套件和测试用例之后,我们就可以执行测试了。执行测试的命令是 suite.run()。执行测试后,会自动触发 start 事件,然后依次触发 cycle 事件,最后触发 complete 事件。


suite.run 方法接收一个对象作为参数,这个对象中可以配置一些选项。通常情况下,我们只需要配置 async: true 以异步方式启动测试,避免长时间阻塞页面交互


suite.run({ async: true });

完整代码


const suite = new Benchmark.Suite();

// 更大的数据规模能更好地放大实现差异
const arr = Array.from({ length: 100000 }, (_, i) => i + 1);

suite
.add("使用 for 循环", () => {
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i] * arr[i];
}
})
.add("使用 reduce 方法", () => {
const sum = arr.reduce((acc, val) => acc + val * val, 0);
});

suite
.on("start", () => {
console.log("[START] 开始基准测试");
})
.on("cycle", (event) => {
const r = event.target;
console.log(
`[CYCLE] ${r.name}: ${Math.round(r.hz)} 次/秒 平均耗时: ${r.stats.mean}s`
);
})
.on("complete", function () {
console.log(`[COMPLETE] 最快的是: ${this.filter("fastest").map("name")}`);
});

suite.run({ async: true });

// [START] 开始基准测试
// [CYCLE] 使用 for 循环: 15875 次/秒 平均耗时: 0.00006299306622951745s
// [CYCLE] 使用 reduce 方法: 1936 次/秒 平均耗时: 0.0005163982717989002s
// [COMPLETE] 最快的是: 使用 for 循环

由上述代码测试结果可见,在更大的数据规模下,使用 for 方法的执行速度比使用 reduce 方法的执行速度快很多。


总结


本文从为什么不能直接用 Date.now() 计时出发,说明了 Benchmark.js 在计时精度、引擎优化与多次运行统计上的优势,并给出 suite、add、on、run 的基本实践路径。


更多内容可以参考 Benchmark.js 官方文档


作者:南屿im
来源:juejin.cn/post/7554402481913315328
收起阅读 »

<a>标签下载文件 download 属性无效?原来问题出在这里

web
最近在开发中遇到一个小坑:我想用 <a> 标签下载文件,并通过 download 属性来自定义文件名。代码写好后,却发现文件名始终是默认的,根本没有按照我设置的来。 一番调查后才发现,这里面还真有点门道。 1. download 的正常使用方式 ...
继续阅读 »

最近在开发中遇到一个小坑:我想用 <a> 标签下载文件,并通过 download 属性来自定义文件名。代码写好后,却发现文件名始终是默认的,根本没有按照我设置的来。


一番调查后才发现,这里面还真有点门道。




1. download 的正常使用方式


在同源环境下,给 <a> 标签设置 download 属性,确实能生效。比如:


const downloadFile = (url, fileName) => {
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
};

这段代码会触发浏览器下载,并且文件名会按我们设置的 fileName 来保存。




2. 为何文件名没有生效?


关键点在于:跨域下载时,浏览器出于安全策略,会忽略 download 设置的文件名


这么设计是有原因的:



  • 假设某个网站偷偷嵌入了一段恶意代码,让用户下载一个木马文件。

  • 如果它能随意改文件名(比如改成 resume.pdf),用户就可能在不知情的情况下打开恶意程序。


为了避免这种“文件欺骗”,浏览器在 跨域资源 上直接禁用了 download 属性的文件重命名能力。




3. 怎么解决?


既然浏览器对跨域有限制,那解决思路就是:想办法让文件下载看起来是同源的。常见有两种方法。




方法一:前端先拉取文件,再触发下载


思路是:



  1. 通过 fetch / XHR 把文件以 blob 的形式拉到本地(前提是目标服务允许跨域访问,需正确配置 CORS)。

  2. 用 URL.createObjectURL 生成临时链接,再用 a.download 触发下载。


示例代码:


const fetchFile = (url, callback) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'blob'; // 以二进制形式拿到数据
xhr.onload = () => {
if (xhr.status === 200) {
const blob = new Blob([xhr.response], {
type: 'application/octet-stream'
});
callback(blob);
}
};
xhr.send();
};

const downloadFile = (url, fileName) => {
fetchFile(url, (blob) => {
const objectURL = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectURL;
link.download = fileName; // ✅ 现在可以自定义文件名了
link.click();
URL.revokeObjectURL(objectURL); // 释放内存
});
};

这种方法的前提是:服务端必须配置了允许跨域的 CORS 响应头,否则浏览器会拦截请求。




方法二:服务端做代理,转发请求


如果目标服务不支持 CORS,或者你不想暴露原始文件地址,可以在自己的后端加一层代理。


流程:



  • 前端请求自己的服务 /server-proxy?originalURL=xxx

  • 后端去目标服务下载文件,再流式返回给前端

  • 由于下载来源变成了“同源”,download 属性就能生效


前端代码


const downloadFile = (url, fileName) => {
const a = document.createElement('a');
a.href = `http://localhost:3000/server-proxy?originalURL=${encodeURIComponent(url)}`;
a.download = fileName;
a.click();
};

Node.js 服务端(纯 http/https 实现)


import http from 'http';
import https from 'https';
import { URL } from 'url';

const server = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname === '/server-proxy') {
const originalURL = parsedUrl.query?.originalURL || '';
if (!originalURL) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'Missing originalURL' }));
}
//待转发的原始URL
console.log('originalURL', originalURL);
// 发起请求到目标服务
const urlOptions = new URL(originalURL);
const client = urlOptions.protocol === 'https:' ? https : http;
const proxyReq = client.request(urlOptions, (proxyRes) => {
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
proxyRes.pipe(res);
});

proxyReq.on('error', (err) => {
console.error('Proxy error:', err);
res.writeHead(500);
res.end('Proxy error');
});

proxyReq.end();
} else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
}
});

server.listen(3000, () => {
console.log('Server running at http://localhost:3000');
});



4. 小结



  • a.download 生效条件:



    • 资源必须是同源,或者 CORS 允许访问

    • 否则浏览器会忽略自定义文件名



  • 解决方案:



    1. 前端 fetch + blob 下载,再触发保存

    2. 后端做代理,转发文件




这样就能既保证安全,又能灵活设置下载文件名。




📌 补充:



  • 某些情况下,服务端返回的 Content-Disposition: attachment; filename="xxx" 头也会影响最终文件名,如果设置了,它会覆盖前端的 download。

  • 如果文件非常大,前端 fetch + blob 可能会占用较多内存,建议使用服务端代理方案。

  • fetch + blob 可能会被打断,文件尚未下载完毕时刷新浏览器会导致下载中断,如果不希望被打断可以考虑服务端代理方案。




作者:AndyLaw
来源:juejin.cn/post/7554677260344950847
收起阅读 »

每天一个知识点——dayjs常用的语法示例

web
日期时间处理需求 关于时间的处理,一般来说使用公共库更加优雅、方便 否则的话,自己就要写一堆处理时间的函数 比如:我需要一个将当前时间,转换成年月日时分秒格式的函数 如下: function formatCurrentTimeFn() { const ...
继续阅读 »

日期时间处理需求



  • 关于时间的处理,一般来说使用公共库更加优雅、方便

  • 否则的话,自己就要写一堆处理时间的函数

  • 比如:我需要一个将当前时间,转换成年月日时分秒格式的函数

  • 如下:


function formatCurrentTimeFn() {
const now = new Date();

const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0'); // 月份从0开始,所以要+1
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');

return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}

// 使用示例
console.log(formatCurrentTimeFn()); // 输出类似:2025-06-04 14:30:45


  • 而使用了时间日期处理的库后,直接:dayjs().format('YYYY-MM-DD HH:mm:ss'))即可


dayjs VS momentjs



  • momentjs大而全(67KB),兼容性好,但是笨重

  • dayjs正好相反(2KB),并且可通过各种插件,弥补和momentjs的差距

  • 笔者建议:如果有时区要求,不建议用dayjs


dayjs获取当前的年月日时分秒


假设今天是2025年6月4日


新建一个html文件,而后引入cdn:


<script src="https://cdn.bootcdn.net/ajax/libs/dayjs/1.11.13/dayjs.min.js"></script>

获取时间日期相关信息:


// 获取当前时间的年份
console.log('当前年份:', dayjs().year()); // 2025年

// 获取当前时间的月份(0-11)
console.log('当前月份:', dayjs().month() + 1); // 6月 // 月份从0开始,所以加1

// 获取当前时间的日期几号
console.log('当前日期几号:', dayjs().date()); // 4号

// 获取当前时间的星期几(0-6,0表示星期日,6表示星期六)
console.log('当前星期几:', dayjs().day()); // 3 // 星期三

// 获取当前时间的小时(几点)
console.log('当前小时:', dayjs().hour()); // 12时

// 获取当前时间的分钟
console.log('当前分钟:', dayjs().minute()); // 35分

// 获取当前时间的秒钟
console.log('当前秒:', dayjs().second()); // 4秒

// 获取当前时间的毫秒
console.log('当前毫秒:', dayjs().millisecond()); // 667


注意:dayjs的语法中:dayjs()[unit]() === dayjs().get(unit)


所以,还可以这样写:


console.log(dayjs().get('year')); // 2025

console.log(dayjs().get('month')); // 5 // 月份从0开始,所以是5

console.log(dayjs().get('date')); // 4

console.log(dayjs().get('day')); // 3 // 星期三

console.log(dayjs().get('hour')); // 12

console.log(dayjs().get('minute')); // 35

console.log(dayjs().get('second')); // 4

console.log(dayjs().get('millisecond')); // 667

dayjs的format格式化


// 国际化时间格式(ISO 8601)默认带时区
const ISO8601 = dayjs().format();
console.log('ISO 8601国际化时间格式:', ISO8601); // 2025-06-04T09:35:04+08:00

// 自定义格式化时间【 dayjs()不传时间,就表示当前】
console.log('四位数年月日时分秒格式:', dayjs().format('YYYY-MM-DD HH:mm:ss')); // 2025-06-04 09:35:04

// 两位数年月日时分秒格式
console.log('两位数年月日时分秒格式:', dayjs().format('YY-MM-DD HH:mm:ss')); // 25-06-04 09:35:04

console.log('横杠年月日格式:', dayjs().format('YYYY-MM-DD')); // 2025-06-04

console.log('斜杠年月日格式:', dayjs().format('DD/MM/YYYY')); // 04/06/2025

console.log('时分秒格式:', dayjs().format('HH:mm:ss')); // 09:35:04

console.log('时分格式:', dayjs().format('HH:mm')); // 09:35

// 自定义格式化时间【 dayjs()传时间,就格式化传递进去的时间】
console.log('年月日格式:', dayjs('2025-06-04 10:25:20').format('YYYY-MM-DD')); // 2025-06-04

console.log('时分秒格式:', dayjs('2025-06-04 10:25:20').format('HH:mm:ss')); // 09:35:04

console.log('时分格式:', dayjs('2025-06-04 10:25:20').format('HH:mm')); // 09:35

// 当然,也可以传递时间戳毫秒数之类的,不赘述
console.log('时分格式:', dayjs(1749013684020).format('HH:mm')); // 09:35

dayjs的日期加减


// 获取当前时间
console.log('当前时间:', dayjs().format('YYYY-MM-DD HH:mm:ss')); // 2025-06-04 09:35:04

// 获取当前时间的前一天
console.log('前一天:', dayjs().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss')); // 2025-06-03 09:35:04

// 获取当前时间的后一天
console.log('后一天:', dayjs().add(1, 'day').format('YYYY-MM-DD HH:mm:ss')); // 2025-06-05 09:35:04

// 获取当前时间的前一周
console.log('前一周:', dayjs().subtract(1, 'week').format('YYYY-MM-DD HH:mm:ss')); // 2025-05-28 09:35:04

// 获取当前时间的后一周
console.log('后一周:', dayjs().add(1, 'week').format('YYYY-MM-DD HH:mm:ss')); // 2025-06-11 09:35:04

// 获取当前时间的前一个月
console.log('前一个月:', dayjs().subtract(1, 'month').format('YYYY-MM-DD HH:mm:ss')); // 2025-05-04 09:35:04

// 获取当前时间的后一个月
console.log('后一个月:', dayjs().add(1, 'month').format('YYYY-MM-DD HH:mm:ss')); // 2025-07-04 09:35:04

// 获取当前时间的前一年
console.log('前一年:', dayjs().subtract(1, 'year').format('YYYY-MM-DD HH:mm:ss')); // 2024-06-04 09:35:04

// 获取当前时间的后一年
console.log('后一年:', dayjs().add(1, 'year').format('YYYY-MM-DD HH:mm:ss')); // 2026-06-04 09:35:04

// 获取当前时间的前一个小时
console.log('前一个小时:', dayjs().subtract(1, 'hour').format('YYYY-MM-DD HH:mm:ss')); // 2025-06-04 08:35:04

// 获取当前时间的后一个小时
console.log('后一个小时:', dayjs().add(1, 'hour').format('YYYY-MM-DD HH:mm:ss')); // 2025-06-04 10:35:04

// 获取当前时间的前一分钟
console.log('前一分钟:', dayjs().subtract(1, 'minute').format('YYYY-MM-DD HH:mm:ss')); // 2025-06-04 09:34:04

// 获取当前时间的后一分钟
console.log('后一分钟:', dayjs().add(1, 'minute').format('YYYY-MM-DD HH:mm:ss')); // 2025-06-04 09:36:04

// 获取当前时间的前一秒
console.log('前一秒:', dayjs().subtract(1, 'second').format('YYYY-MM-DD HH:mm:ss')); // 2025-06-04 09:35:03

// 获取当前时间的后一秒
console.log('后一秒:', dayjs().add(1, 'second').format('YYYY-MM-DD HH:mm:ss')); // 2025-06-04 09:35:05

// 获取当前时间的前一毫秒
console.log('前一毫秒:', dayjs().subtract(1, 'millisecond').format('YYYY-MM-DD HH:mm:ss.SSS')); // 2025-06-04 09:35:04.003

// 获取当前时间的后一毫秒
console.log('后一毫秒:', dayjs().add(1, 'millisecond').format('YYYY-MM-DD HH:mm:ss.SSS')); // 2025-06-04 09:35:04.005

日期前后相等比较


/**
* 假设今天是6月5号
* */


console.log('是否日期相同', dayjs().isSame(dayjs('2025-06-05'), 'day')); // true

console.log('是否在日期之前', dayjs().isBefore(dayjs('2025-06-06'), 'day')); // true

console.log('是否在日期之后', dayjs().isAfter(dayjs('2025-06-03'), 'day')); // true

日期的差值diff


计算两个日期之间,差了多久时间


const date1 = dayjs('2019-01-25 12:00:02')
const date2 = dayjs('2019-01-25 12:00:01')
console.log('date1和date2差了:', date1.diff(date2)); // 默认差值单位毫秒数 1000

const date3 = dayjs('2019-01-25')
const date4 = dayjs('2019-02-25')
console.log(date4.diff(date3, 'month')) // 1


指定以月份为单位,可选单位有 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' 想要支持季度,需额外下载QuarterOfYear插件



获取时间戳毫秒数


// 获取当前时间的时间戳,单位为毫秒
console.log('毫秒时间戳', dayjs().valueOf()); // 1749113764926

获取时间戳秒数


// 获取当前时间的时间戳,单位为秒
console.log('秒时间戳', dayjs().unix()); // 1749113764

获取月份有多少天


// 获取某个时间的月份有多少天
console.log('dayjs().daysInMonth()', dayjs().daysInMonth()); // 30 // 现在是6月份,所以30天

开始时间和结束时间


// 获取当前时间所在天的开始时间
console.log('开始时间', dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss')); // 2025-06-05 00:00:00

// 获取当前时间所在天的结束时间
console.log('结束时间', dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss')); // 2025-06-05 23:59:59


每天一个知识点...



作者:水冗水孚
来源:juejin.cn/post/7512270432213876762
收起阅读 »

【小程序】迁移非主包组件以减少主包体积

web
代码位置 gitee.com/zhangkb/exa… 问题及背景 微信小程序主包体积最大为 2M,超出体积无法上传。 组件放在不同的目录下的表现不同: src/components 目录中的组件会被打包到主包中,可以被所有页面引用。 src/pages/...
继续阅读 »

代码位置


gitee.com/zhangkb/exa…


问题及背景



  • 微信小程序主包体积最大为 2M,超出体积无法上传。

  • 组件放在不同的目录下的表现不同:

    • src/components 目录中的组件会被打包到主包中,可以被所有页面引用。

    • src/pages/about/components 目录中的组件会被打印到对应分包中,只能被当前分包引用(只考虑微信小程序的话可以用分包异步化,我这边因为需要做不同平台所以不考虑这个方案)。




在之前的项目结构中,所有的组件都放在 src/components 目录下,因此所有组件都会被打包到主包中,这导致主包体积超出了 2M 的限制。


后续经过优化,将一些与主包无关的组件放到了对应分包中,但是有一些组件,在主包页面中没有被引用,但是被多个不同的分包页面引用,因此只能放到 src/components 目录下打包到主包中。


本文的优化思路就是将这一部分组件通过脚本迁移到不同的分包目录中,从而减少主包体积,这样做的缺点也显而易见:会增加代码包的总体积(微信还有总体积小于 20M 的限制 🤮)。


实现思路


项目中用 gulp 做打包流程管理,因此将这个功能封装成一个 task,在打包之前调用。


1. 分析依赖


分析 src/components 组件是否主包页面引用,有两种情况:



  1. 直接被主页引用。

  2. 间接被主页引用:主页引用 aa 引用 b,此时 a 为直接引用,b 为间接引用。


const { series, task, src, parallel } = require("gulp");
const tap = require("gulp-tap");
const path = require("path");
const fs = require("fs");
const pages = require("../src/pages.json");

// 项目根目录
const rootPath = path.join(__dirname, "../");
const srcPath = path.join(rootPath, "./src");
const componentsPath = path.join(rootPath, "./src/components");

// 组件引用根路径
const componentRootPath = "@/components"; // 替换为 pages 页面中引入组件的路径

// 组件依赖信息
let componentsMap = {};

// 从 pages 文件中获取主包页面路径列表
const mainPackagePagePathList = pages.pages.map((item) => {
let pathParts = item.path.split("/");

return pathParts.join(`\\${path.sep}`);
});

/**
* 组件信息初始化
*/

function initComponentsMap() {
// 为所有 src/components 中的组件创建信息
return src([`${srcPath}/@(components)/**/**.vue`]).pipe(
tap((file) => {
let filePath = transferFilePathToComponentPath(file.path);

componentsMap[filePath] = {
refers: [], // 引用此组件的页面/组件
quotes: [], // 此组件引用的组件
referForMainPackage: false, // 是否被主包引用,被主包引用时不需要 copy 到分包
};
})
);
}

/**
* 分析依赖
*/

function analyseDependencies() {
return src([`${srcPath}/@(components|pages)/**/**.vue`]).pipe(
tap((file) => {
// 是否为主包页面
const isMainPackagePageByPath = checkIsMainPackagePageByPath(file.path);

// 分析页面引用了哪些组件
const componentsPaths = Object.keys(componentsMap);
const content = String(file.contents);

componentsPaths.forEach((componentPath) => {
if (content.includes(componentPath)) {
// 当前页面引用了这个组件
componentsMap[componentPath].refers.push(file.path);

if (file.path.includes(componentsPath)) {
// 记录组件被引用情况
const targetComponentPath = transferFilePathToComponentPath(
file.path
);

componentsMap[targetComponentPath].quotes.push(componentPath);
}

// 标记组件是否被主页引用
if (isMainPackagePageByPath) {
componentsMap[componentPath].referForMainPackage = true;
}
}
});
})
);
}

/**
* 分析间接引用依赖
*/

function analyseIndirectDependencies(done) {
for (const componentPath in componentsMap) {
const componentInfo = componentsMap[componentPath];

if (!componentInfo.referForMainPackage) {
const isIndirectReferComponent =
checkIsIndirectReferComponent(componentPath);

if (isIndirectReferComponent) {
console.log("间接引用组件", componentPath);
componentInfo.referForMainPackage = true;
}
}
}

done();
}

/**
* 是否为被主页间接引用的组件
*/

function checkIsIndirectReferComponent(componentPath) {
const componentInfo = componentsMap[componentPath];

if (componentInfo.referForMainPackage) {
return true;
}

for (const filePath of componentInfo.refers) {
if (filePath.includes(componentsPath)) {
const subComponentPath = transferFilePathToComponentPath(filePath);
const result = checkIsIndirectReferComponent(subComponentPath);

if (result) {
return result;
}
}
}
}

/**
* 将文件路径转换为组件路径
*/

function transferFilePathToComponentPath(filePath) {
return filePath
.replace(componentsPath, componentRootPath)
.replaceAll(path.sep, "/")
.replace(".vue", "");
}

/**
* 判断页面路径是否为主包页面
*/

function checkIsMainPackagePageByPath(filePath) {
// 正则:判断是否为主包页面
const isMainPackagePageReg = new RegExp(
`(${mainPackagePagePathList.join("|")})`
);

return isMainPackagePageReg.test(filePath);
}

经过这一步后会得到一个 json,包含被引用文件信息和是否被主页引用,格式为:


{
"@/components/xxxx/xxxx": {
"refers": [
"D:\\code\\miniPrograme\\xxxxxx\\src\\pages\\xxx1\\index.vue",
"D:\\code\\miniPrograme\\xxxxxx\\src\\pages\\xxx2\\index.vue",
"D:\\code\\miniPrograme\\xxxxxx\\src\\components\\xxx\\xxx\\xxx.vue"
],
"referForMainPackage": false
}
}

2. 分发组件


经过第一步的依赖分析,我们知道了 referForMainPackage 值为 false 的组件是不需要放在主包中的,在这一步中将这些组件分发到对应的分包中。


思路:



  1. 遍历所有 referForMainPackage 值为 false 的组件。

  2. 遍历所有组件的 refers 列表,如果 refer 能匹配到分包,做以下动作:



    1. 在分包根目录下创建 componentsauto 目录,将组件复制到这里。

    2. 复制组件中引用的相对路径资源。



  3. 删除 pages/components 中的当前组件。


const taskMap = {};
const changeFileMap = {};
const deleteFileMap = {};

// 分发组件
async function distributionComponents() {
for (let componentPath in componentsMap) {
const componentInfo = componentsMap[componentPath];

// 未被主包引用的组件
for (const pagePath of componentInfo.refers) {
// 将组件复制到分包
if (pagePath.includes(pagesPath)) {
// 将组件复制到页面所在分包
await copyComponent(componentPath, pagePath);
}
}
}
}

/**
* 复制组件
* @param {*} componentPath
* @param {*} targetPath
* @returns
*/

async function copyComponent(componentPath, pagePath) {
const componentInfo = componentsMap[componentPath];

if (componentInfo.referForMainPackage) return;

const key = `${componentPath}_${pagePath}`;

// 避免重复任务
if (taskMap[key]) return;

taskMap[key] = true;

const subPackageRoot = getSubPackageRootByPath(pagePath);

if (!subPackageRoot) return;

const componentFilePath = transferComponentPathToFilePath(componentPath);
const subPackageComponentsPath = path.join(subPackageRoot, "componentsauto");
const newComponentFilePath = path.join(
subPackageComponentsPath,
path.basename(componentFilePath)
);
const newComponentsPath = newComponentFilePath
.replace(srcPath, "@")
.replaceAll(path.sep, "/")
.replaceAll(".vue", "");

// 1. 复制组件及其资源
await copyComponentWithResources(
componentFilePath,
subPackageComponentsPath,
componentInfo
);

// 2. 递归复制引用的组件
if (componentInfo.quotes.length > 0) {
let tasks = [];

componentInfo.quotes.map((quotePath) => {
// 复制子组件
tasks.push(copyComponent(quotePath, pagePath));

const subComponentInfo = componentsMap[quotePath];

if (!subComponentInfo.referForMainPackage) {
// 2.1 修改组件引用的子组件路径
const newSubComponentFilePath = path.join(
subPackageComponentsPath,
path.basename(quotePath)
);
const newSubComponentsPath = newSubComponentFilePath
.replace(srcPath, "@")
.replaceAll(path.sep, "/")
.replaceAll(".vue", "");
updateChangeFileInfo(
newComponentFilePath,
quotePath,
newSubComponentsPath
);
}
});
await Promise.all(tasks);
}

// 3. 修改页面引用当前组件路径
updateChangeFileInfo(pagePath, componentPath, newComponentsPath);

// 4. 删除当前组件
updateDeleteFileInfo(componentFilePath);
}

/**
* 更新删除文件信息
* @param {*} filePath
*/

function updateDeleteFileInfo(filePath) {
deleteFileMap[filePath] = true;
}

/**
* 更新修改文件内容信息
* @param {*} filePath
* @param {*} oldStr
* @param {*} newStr
*/

function updateChangeFileInfo(filePath, oldStr, newStr) {
if (!changeFileMap[filePath]) {
changeFileMap[filePath] = [];
}
changeFileMap[filePath].push([oldStr, newStr]);
}

/**
* 删除文件任务
*/

async function deleteFile() {
for (const filePath in deleteFileMap) {
try {
await fs.promises.unlink(filePath).catch(console.log); // 删除单个文件
// 或删除目录:await fs.rmdir('path/to/dir', { recursive: true });
} catch (err) {
console.error("删除失败:", err);
}
}
}

/**
* 复制组件及其资源
* @param {*} componentFilePath
* @param {*} destPath
*/

async function copyComponentWithResources(componentFilePath, destPath) {
// 复制主组件文件
await new Promise((resolve) => {
src(componentFilePath).pipe(dest(destPath)).on("end", resolve);
});

// 处理组件中的相对路径资源
const content = await fs.promises.readFile(componentFilePath, "utf-8");
const relativePaths = extractRelativePaths(content);

await Promise.all(
relativePaths.map(async (relativePath) => {
const resourceSrcPath = path.join(componentFilePath, "../", relativePath);
const resourceDestPath = path.join(destPath, path.dirname(relativePath));

await new Promise((resolve) => {
src(resourceSrcPath).pipe(dest(resourceDestPath)).on("end", resolve);
});
})
);
}

/**
* 修改页面引用路径
*/

async function changePageResourcePath() {
for (const pagePath in changeFileMap) {
const list = changeFileMap[pagePath];

await new Promise((resolve) => {
src(pagePath)
.pipe(
tap((file) => {
let content = String(file.contents);

for (const [oldPath, newPath] of list) {
content = content.replaceAll(oldPath, newPath);
}
file.contents = Buffer.from(content);
})
)
.pipe(dest(path.join(pagePath, "../")))
.on("end", resolve);
});
}
}

// 获取分包根目录
function getSubPackageRootByPath(pagePath) {
for (const subPackagePagePath of subPackagePagePathList) {
const rootPath = `${path.join(pagesPath, subPackagePagePath)}`;
const arr = pagePath.replace(pagesPath, "").split(path.sep);

if (arr[1] === subPackagePagePath) {
return rootPath;
}
}
}

注意事项


引用资源时不能用相对路径


避免使用相对路径引入资源,可以通过代码规范来限制(处理起来比较麻烦,懒得写了)。


不同操作系统未验证


代码仅在 windows 10 系统下运行,其他操作系统未验证,可能会存在资源路径无法匹配的问题。


uniapp 项目


本项目是 uniapp 项目,因此迁移的组件后缀为 .vue,原生语言或其他框架不能直接使用。


作者:锋利的绵羊
来源:juejin.cn/post/7518758885273829413
收起阅读 »

【吃瓜】这可能是2025年最荒谬的前端灾难:一支触控笔"干掉"了全球CSS预处理器

web
作为mockm项目的维护者,这几天我一直在优化CI/CD流水线。终于把自动化测试和发布流程都搞定了,心想着可以安心写代码了。结果今天早上一看GitHub Actions,我傻眼了... 项目突然构建失败了 昨天还好好的CI/CD流水线,今天突然就红了一片!...
继续阅读 »

作为mockm项目的维护者,这几天我一直在优化CI/CD流水线。终于把自动化测试和发布流程都搞定了,心想着可以安心写代码了。结果今天早上一看GitHub Actions,我傻眼了...



项目突然构建失败了


GitHub Actions Failed


昨天还好好的CI/CD流水线,今天突然就红了一片!


刚刚合并完dev分支的代码,准备发布新版本,结果Deploy Documentation and Release Package这个workflow直接失败了。作为一个有洁癖的开发者,看到Actions页面一片红色真的很崩溃。


第一反应:又是我的配置问题?


点开失败的job详情,看到build-and-release这一步挂了。心想肯定又是我的docker-compose配置有问题,或者是某个环境变量没设对。


毕竟刚优化完CI/CD,出问题很正常嘛...


但是当我仔细查看错误日志时,发现了一个让我摸不着头脑的错误:



stylus 包不存在?什么鬼?



我重新运行了一遍workflow,还是同样的错误。然后我在本地试了试 npm install,结果更震惊了——NPM告诉我这个用了好几年的CSS预处理器库,突然从地球上消失了。


从GitHub Actions红屏到全网灾难


看到这个错误,我的第一反应不是恐慌,而是怀疑自己的CI配置:


npm ERR! 404 'stylus@https://registry.npmjs.org/stylus/-/stylus-0.64.0.tgz' is not in this registry

"是不是我的workflow配置有问题?"我检查了一遍deploy.yml文件,docker-compose配置也重新看了遍。


"是不是环境变量没设对?"我在Actions的Secrets里确认了一遍,没有问题。


"是不是依赖版本冲突了?"我看了看package.json,stylus版本一直没变过啊。


然后我想到,也许是GitHub Actions的runner环境问题?我在本地试了试:


npm install stylus
npm ERR! 404 'stylus@*' is not in this registry.

WTF?本地也不行了!


这时候我才意识到,这不是我的mockm项目的问题,不是我的CI/CD配置的问题,而是整个NPM生态出大问题了


直到我打开Twitter,看到满屏的哀嚎,才意识到这不是我一个人的问题。这是一场全球性的前端灾难


当我意识到这不是我的CI问题时...


说实话,刚开始我还暗自庆幸——至少不是我的自动化流程配置有问题。毕竟刚花了好几天时间优化CI/CD,要是出bug了那真是太丢人了。


但当我看到GitHub上那些issue的时候,笑不出来了:



  • Nx框架的用户在哭

  • TypeScript项目在崩溃

  • 连Vue的生态都受到了影响

  • 我的mockm项目构建也挂了


这让我想起了2016年的left-pad事件,但这次更严重。left-pad至少只是一个小工具函数,而Stylus是整个CSS预处理生态的重要组成部分。


我开始担心:不光是我的mockm项目发布不了,全世界有多少个项目的CI/CD都在今天红屏了?有多少开发者像我一样,以为是自己的配置问题,结果查了半天发现是外部依赖炸了?


全球开发者陷入恐慌


GitHub Issues 爆炸式增长


短短几小时内,与Stylus相关的错误报告如雨后春笋般涌现:



  1. [Nx框架] - 'stylus@https://registry.npmjs.org/stylus/-/stylus-0.64.0.tgz' is not in this registry on npm install nrwl/nx#32031

  2. [TypeScript CSS Modules] - Stylus contained malicious code and was removed from the registry by the npm security team mrmckeb/typescript-plugin-css-modules#287

  3. [ShadCN Vue] - ERR_PNPM_NO_MATCHING_VERSION due to yanked package unovue/shadcn-vue#1344


社交媒体上的恐慌


Twitter、Reddit、Discord等平台上充斥着开发者的求助和抱怨:



"我的整个项目都跑不起来了,Stylus到底发生了什么?"




"生产环境部署失败,老板在催进度,Stylus你什么时候能回来?"




"这是我见过最离谱的NPM事故,一个CSS预处理器居然能让半个前端圈瘫痪"



然后我发现了最荒谬的真相...


花了一个上午收集信息后,我发现了这个让人哭笑不得的真相:


NPM把CSS预处理器和ChromeOS的触控笔搞混了!


没错,你没看错。导致Stylus被封禁的CVE-2025-6044,说的是ChromeOS设备上的物理触控笔存在安全漏洞。而NPM的安全团队,可能是用了某种自动化工具,看到"Stylus"这个名字,就把我们前端开发者天天用的CSS预处理器给ban了。


我第一次看到这个解释的时候,真的以为是在看洋葱新闻。


让我们来对比一下这个绝世乌龙:


真正有漏洞的"Stylus":



  • ChromeOS设备上的物理触控笔工具

  • 需要物理接触设备才能攻击

  • 和前端开发一毛钱关系都没有


被误杀的"stylus":



  • 前端开发者的CSS预处理器

  • 纯软件库,连UI都没有

  • 被全世界几百万项目依赖


这就好比因为苹果公司出了安全问题,就把超市里的苹果都下架了一样荒谬。


image.png


我为这个维护者感到心疼


看到Stylus维护者@iChenLei在GitHub上的无助求助,说实话我挺心疼的。


作为一个也维护过开源项目的人,我太能理解那种感受了:你辛辛苦苦维护了这么多年的项目,服务了全球这么多开发者,结果因为一个莫名其妙的错误就被封禁,而且申诉无门。


他在Issue里写道:



"这影响了很多人。虽然这不是我的错,但我向每个人道歉。"



这句话让我特别感动。明明是NPM搞错了,但他还是在为用户的困扰道歉。这就是开源维护者的责任感。


而且你看他做的这些努力:



  • 立即提交官方ticket

  • 在Twitter上求助

  • 甚至还展示了自己的2FA截图证明账户安全


但NPM官方到现在还没有任何回应。这让我想起那句话:"开源开发者用爱发电,平台方用AI管理"


临时解决方案:前端社区的自救行动


面对官方的无回应,社区开始了自救。说实话,这种时候最能看出开源社区的凝聚力。


我试过的几种方法


方法一:直接用GitHub源


npm install https://github.com/stylus/stylus/archive/refs/tags/0.64.0.tar.gz

这个方法管用,但感觉不太优雅。而且每次安装都要下载整个repo,速度慢得要命。


方法二:Package.json override


{
"overrides": {
"postcss-styl>stylus": "https://github.com/stylus/stylus/archive/refs/tags/0.64.0.tar.gz"
}
}

这个比较适合已有项目,但对新项目来说还是很麻烦。


方法三:换注册表


npm config set registry https://registry.npmmirror.com/

试了几个国内镜像,大部分还有缓存,可以正常安装。但总感觉不是长久之计。


让我感动的社区互助


在各种群和论坛里,大家都在分享解决方案,没有人在抱怨,更没有人在指责维护者。这让我想起了为什么我当初会爱上开源社区。


有个老哥甚至建议大家去转发维护者的Twitter求助,我觉得这个主意不错。毕竟有时候社交媒体的影响力比正式渠道还管用。


这件事让我重新思考了很多问题


说实话,这次事件让我开始重新审视我们前端开发的生态。


NPM真的靠谱吗?


作为一个在前端圈混了这么多年的老司机,我一直觉得NPM已经足够成熟稳定了。但这次事件让我意识到,我们可能过于依赖这个中心化的平台了。


想想看:



  • 一个错误的安全判断,就能让全球项目停摆

  • 维护者申诉无门,只能在社交媒体求助

  • 没有任何预警机制,用户只能被动承受


这真的合理吗?


image.png


开源维护者太难了


@iChenLei的遭遇让我想起了很多开源维护者的心酸。他们用爱发电,服务全世界,但遇到问题时却如此无助。


我觉得我们作为受益者,应该:



  • 多给开源项目捐赠

  • 积极参与社区建设

  • 在这种时候给维护者更多支持


而不是只会在出问题时抱怨。


前端生态的脆弱性


这次事件也暴露了现代前端开发的一个问题:我们的依赖树太复杂了。


一个简单的项目,动不动就有几百个依赖。每个依赖都是一个潜在的故障点。虽然这种模块化的开发方式很高效,但风险也确实不小。


我开始思考:



  • 是不是应该减少一些不必要的依赖?

  • 关键依赖是不是应该做备份?

  • 公司是不是应该建立私有NPM镜像?


从left-pad到stylus,我们学到了什么?


2016年的left-pad事件,曾经让整个JavaScript生态停摆了一天。当时大家说要吸取教训,要建立更稳定的包管理机制。


现在2025年了,类似的事情又发生了,而且更严重。


这让我意识到,单纯依靠技术手段可能解决不了根本问题。我们需要的是:



  1. 更透明的治理机制:NPM的决策过程应该更开放

  2. 更快速的申诉渠道:不能让维护者只能在Twitter求助

  3. 更多元化的生态:不能把鸡蛋都放在一个篮子里



left-pad事件

left-pad 是一个由 Javascript 程序员 Azer 编写的 npm 包,功能是为字符串添加左侧填充,代码仅有 11 行,但却被上千个项目使用,其中包括著名的 babel 和 react-native 等。


Azer 收到 kik 公司的邮件,对方称要发布名为 kik 的封包,但 kik 这个名字已被 Azer 占用,希望他能改名。Azer 拒绝后,kik 公司多次与他沟通无果,便向 npm 公司发邮件。最终,npm 公司将 kik 封包转给了 kik 公司。


Azer 因 npm 公司的这一决定感到愤怒,一怒之下将自己在 npm 上的 273 个封包全部撤下,其中包括 left-pad 封包。这导致依赖 left-pad 的成千上万个项目瞬间崩溃,大量开发者的项目构建失败。



我的一些建议


作为一个用户,我觉得我们可以:


短期内:



  • 建立项目的依赖备份机制

  • 使用多个注册表镜像

  • 关键项目使用package-lock.json


长期来看:



  • 支持去中心化的包管理方案

  • 推动NPM改进治理机制

  • 给开源项目更多的资金和技术支持~逃~~~



资金支持?之前为了让 mockm 项目的文档能让网络“不方便”的大家也能快速访问,自己花钱买的域名、服务器。但是这么多年工资也没有涨,可能是我没有好好工作。撑不下去了(本来好像也没几个用户),所以我打算把文档部署在 GITHUB PAGE 上了,网络不方便?爱谁谁!



image.png


写在最后


这次事件提醒我们,我们的工作比想象中更脆弱。但也让我看到了社区的力量:当官方渠道失效时,我们依然能够相互帮助,共度难关。PS:这就是为什么我爱这个行业的原因。


然而一个又产生一个新想法:一个小小的名称混淆,就能让全球的前端开发陷入混乱。那么,"软件正在吞噬世界,但谁来守护软件?"


相关链接



作者:四叶草会开花
来源:juejin.cn/post/7529903134296653839
收起阅读 »

🔥 滚动监听写到手抽筋?IntersectionObserver让你躺平实现懒加载

web
🎯 学习目标:掌握IntersectionObserver API的核心用法,解决滚动监听性能问题,实现高效的懒加载和可见性检测 📊 难度等级:中级 🏷️ 技术标签:#IntersectionObserver #懒加载 #性能优化 #滚动监听 ⏱️ 阅读时间:...
继续阅读 »

🎯 学习目标:掌握IntersectionObserver API的核心用法,解决滚动监听性能问题,实现高效的懒加载和可见性检测


📊 难度等级:中级

🏷️ 技术标签#IntersectionObserver #懒加载 #性能优化 #滚动监听

⏱️ 阅读时间:约8分钟





🌟 引言


在日常的前端开发中,你是否遇到过这样的困扰:



  • 滚动监听卡顿:addEventListener('scroll')写多了页面卡得像PPT

  • 懒加载复杂:图片懒加载逻辑复杂,还要手动计算元素位置

  • 无限滚动性能差:数据越来越多,滚动越来越卡

  • 可见性检测麻烦:想知道元素是否进入视口,代码写得头疼


今天分享5个IntersectionObserver的实用场景,让你的滚动监听更加丝滑,告别性能焦虑!




💡 核心技巧详解


1. 图片懒加载:告别手动计算位置的痛苦


🔍 应用场景


当页面有大量图片时,一次性加载所有图片会严重影响页面性能,需要实现图片懒加载。


❌ 常见问题


传统的滚动监听方式性能差,需要频繁计算元素位置。


// ❌ 传统滚动监听写法
window.addEventListener('scroll', () => {
const images = document.querySelectorAll('img[data-src]');
images.forEach(img => {
const rect = img.getBoundingClientRect();
if (rect.top < window.innerHeight) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
}
});
});

✅ 推荐方案


使用IntersectionObserver实现高性能的图片懒加载。


/**
* 创建图片懒加载观察器
* @description 使用IntersectionObserver实现高性能图片懒加载
* @param {string} selector - 图片选择器
* @param {Object} options - 观察器配置选项
* @returns {IntersectionObserver} 观察器实例
*/

const createImageLazyLoader = (selector = 'img[data-src]', options = {}) => {
// 推荐写法:使用IntersectionObserver
const defaultOptions = {
root: null, // 使用视口作为根元素
rootMargin: '50px', // 提前50px开始加载
threshold: 0.1 // 元素10%可见时触发
};

const config = { ...defaultOptions, ...options };

const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
// 加载图片
img.src = img.dataset.src;
img.removeAttribute('data-src');
// 停止观察已加载的图片
observer.unobserve(img);
}
});
}, config);

// 观察所有待加载的图片
document.querySelectorAll(selector).forEach(img => {
observer.observe(img);
});

return observer;
};

💡 核心要点



  • rootMargin:提前加载,避免用户看到空白

  • threshold:设置合适的触发阈值

  • unobserve:加载完成后停止观察,释放资源


🎯 实际应用


在Vue3项目中的完整应用示例:


<template>
<div class="image-gallery">
<img
v-for="(image, index) in images"
:key="index"
:data-src="image.url"
:alt="image.alt"
class="lazy-image"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='200'%3E%3Crect width='100%25' height='100%25' fill='%23f0f0f0'/%3E%3C/svg%3E"
/>
</div>
</template>

<script setup>
import { onMounted, onUnmounted } from 'vue';

let observer = null;

onMounted(() => {
observer = createImageLazyLoader('.lazy-image');
});

onUnmounted(() => {
observer?.disconnect();
});
</script>



2. 无限滚动:数据加载的性能优化


🔍 应用场景


实现无限滚动列表,当用户滚动到底部时自动加载更多数据。


❌ 常见问题


传统方式需要监听滚动事件并计算滚动位置,性能开销大。


// ❌ 传统无限滚动实现
window.addEventListener('scroll', () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
loadMoreData();
}
});

✅ 推荐方案


使用IntersectionObserver监听底部哨兵元素。


/**
* 创建无限滚动观察器
* @description 监听底部哨兵元素实现无限滚动
* @param {Function} loadMore - 加载更多数据的回调函数
* @param {Object} options - 观察器配置
* @returns {Object} 包含观察器和控制方法的对象
*/

const createInfiniteScroll = (loadMore, options = {}) => {
const defaultOptions = {
root: null,
rootMargin: '100px', // 提前100px触发加载
threshold: 0
};

const config = { ...defaultOptions, ...options };
let isLoading = false;

const observer = new IntersectionObserver(async (entries) => {
const [entry] = entries;

if (entry.isIntersecting && !isLoading) {
isLoading = true;
try {
await loadMore();
} catch (error) {
console.error('加载数据失败:', error);
} finally {
isLoading = false;
}
}
}, config);

return {
observer,
// 开始观察哨兵元素
observe: (element) => observer.observe(element),
// 停止观察
disconnect: () => observer.disconnect(),
// 获取加载状态
getLoadingState: () => isLoading
};
};

💡 核心要点



  • 哨兵元素:在列表底部放置一个不可见的元素作为触发器

  • 防重复加载:使用loading状态防止重复请求

  • 错误处理:加载失败时的异常处理


🎯 实际应用


Vue3组件中的使用示例:


<template>
<div class="infinite-list">
<div v-for="item in items" :key="item.id" class="list-item">
{{ item.title }}
</div>
<!-- 哨兵元素 -->
<div ref="sentinelRef" class="sentinel"></div>
<div v-if="loading" class="loading">加载中...</div>
</div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const items = ref([]);
const loading = ref(false);
const sentinelRef = ref(null);
let infiniteScroll = null;

// 加载更多数据
const loadMoreData = async () => {
loading.value = true;
// 模拟API请求
const newItems = await fetchData();
items.value.push(...newItems);
loading.value = false;
};

onMounted(() => {
infiniteScroll = createInfiniteScroll(loadMoreData);
infiniteScroll.observe(sentinelRef.value);
});

onUnmounted(() => {
infiniteScroll?.disconnect();
});
</script>



3. 元素可见性统计:精准的用户行为分析


🔍 应用场景


统计用户对页面内容的浏览情况,比如广告曝光、内容阅读时长等。


❌ 常见问题


手动计算元素可见性复杂且不准确。


// ❌ 手动计算可见性
const isElementVisible = (element) => {
const rect = element.getBoundingClientRect();
return rect.top >= 0 && rect.bottom <= window.innerHeight;
};

✅ 推荐方案


使用IntersectionObserver精准统计元素可见性。


/**
* 创建可见性统计观察器
* @description 统计元素的可见性和停留时间
* @param {Function} onVisibilityChange - 可见性变化回调
* @param {Object} options - 观察器配置
* @returns {IntersectionObserver} 观察器实例
*/

const createVisibilityTracker = (onVisibilityChange, options = {}) => {
const defaultOptions = {
root: null,
rootMargin: '0px',
threshold: [0, 0.25, 0.5, 0.75, 1.0] // 多个阈值,精确统计
};

const config = { ...defaultOptions, ...options };
const visibilityData = new Map();

const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const element = entry.target;
const elementId = element.dataset.trackId || element.id;

if (!visibilityData.has(elementId)) {
visibilityData.set(elementId, {
element,
startTime: null,
totalTime: 0,
maxVisibility: 0
});
}

const data = visibilityData.get(elementId);

if (entry.isIntersecting) {
// 元素进入视口
if (!data.startTime) {
data.startTime = Date.now();
}
data.maxVisibility = Math.max(data.maxVisibility, entry.intersectionRatio);
} else {
// 元素离开视口
if (data.startTime) {
data.totalTime += Date.now() - data.startTime;
data.startTime = null;
}
}

// 触发回调
onVisibilityChange({
elementId,
isVisible: entry.isIntersecting,
visibilityRatio: entry.intersectionRatio,
totalTime: data.totalTime,
maxVisibility: data.maxVisibility
});
});
}, config);

return observer;
};

💡 核心要点



  • 多阈值监听:使用多个threshold值精确统计可见比例

  • 时间统计:记录元素在视口中的停留时间

  • 数据持久化:将统计数据存储到Map中


🎯 实际应用


广告曝光统计的实际应用:


// 实际项目中的广告曝光统计
const trackAdExposure = () => {
const tracker = createVisibilityTracker((data) => {
const { elementId, isVisible, visibilityRatio, totalTime } = data;

// 曝光条件:可见比例超过50%且停留时间超过1秒
if (visibilityRatio >= 0.5 && totalTime >= 1000) {
// 发送曝光统计
sendExposureData({
adId: elementId,
exposureTime: totalTime,
visibilityRatio: visibilityRatio
});
}
});

// 观察所有广告元素
document.querySelectorAll('.ad-banner').forEach(ad => {
tracker.observe(ad);
});
};



4. 动画触发控制:精准的视觉效果


🔍 应用场景


当元素进入视口时触发CSS动画或JavaScript动画,提升用户体验。


❌ 常见问题


使用滚动监听触发动画,性能差且时机不准确。


// ❌ 传统动画触发方式
window.addEventListener('scroll', () => {
const elements = document.querySelectorAll('.animate-on-scroll');
elements.forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight * 0.8) {
el.classList.add('animate');
}
});
});

✅ 推荐方案


使用IntersectionObserver精准控制动画触发时机。


/**
* 创建动画触发观察器
* @description 当元素进入视口时触发动画
* @param {Object} options - 观察器和动画配置
* @returns {IntersectionObserver} 观察器实例
*/

const createAnimationTrigger = (options = {}) => {
const defaultOptions = {
root: null,
rootMargin: '-10% 0px', // 元素完全进入视口后触发
threshold: 0.3,
animationClass: 'animate-in',
once: true // 只触发一次
};

const config = { ...defaultOptions, ...options };
const triggeredElements = new Set();

const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const element = entry.target;

if (entry.isIntersecting) {
// 添加动画类
element.classList.add(config.animationClass);

if (config.once) {
// 只触发一次,停止观察
observer.unobserve(element);
triggeredElements.add(element);
}

// 触发自定义事件
element.dispatchEvent(new CustomEvent('elementVisible', {
detail: { intersectionRatio: entry.intersectionRatio }
}));
} else if (!config.once) {
// 允许重复触发时,移除动画类
element.classList.remove(config.animationClass);
}
});
}, {
root: config.root,
rootMargin: config.rootMargin,
threshold: config.threshold
});

return observer;
};

💡 核心要点



  • rootMargin负值:确保元素完全进入视口后才触发

  • once选项:控制动画是否只触发一次

  • 自定义事件:方便其他代码监听动画触发


🎯 实际应用


配合CSS动画的完整实现:


/* CSS动画定义 */
.fade-in-element {
opacity: 0;
transform: translateY(30px);
transition: all 0.6s ease-out;
}

.fade-in-element.animate-in {
opacity: 1;
transform: translateY(0);
}

// JavaScript动画控制
const initScrollAnimations = () => {
const animationTrigger = createAnimationTrigger({
animationClass: 'animate-in',
threshold: 0.2,
once: true
});

// 观察所有需要动画的元素
document.querySelectorAll('.fade-in-element').forEach(element => {
animationTrigger.observe(element);

// 监听动画触发事件
element.addEventListener('elementVisible', (e) => {
console.log(`元素动画触发,可见比例: ${e.detail.intersectionRatio}`);
});
});
};



5. 虚拟滚动优化:大数据列表的性能救星


🔍 应用场景


处理包含大量数据的列表,只渲染可见区域的元素,提升性能。


❌ 常见问题


渲染大量DOM元素导致页面卡顿,滚动性能差。


// ❌ 渲染所有数据
const renderAllItems = (items) => {
const container = document.getElementById('list');
items.forEach(item => {
const element = document.createElement('div');
element.textContent = item.title;
container.appendChild(element);
});
};

✅ 推荐方案


结合IntersectionObserver实现简化版虚拟滚动。


/**
* 创建虚拟滚动观察器
* @description 只渲染可见区域的列表项,优化大数据列表性能
* @param {Array} data - 数据数组
* @param {Function} renderItem - 渲染单个项目的函数
* @param {Object} options - 配置选项
* @returns {Object} 虚拟滚动控制器
*/

const createVirtualScroll = (data, renderItem, options = {}) => {
const defaultOptions = {
itemHeight: 60, // 每项高度
bufferSize: 5, // 缓冲区大小
container: null // 容器元素
};

const config = { ...defaultOptions, ...options };
const visibleItems = new Map();

// 创建占位元素
const createPlaceholder = (index) => {
const placeholder = document.createElement('div');
placeholder.style.height = `${config.itemHeight}px`;
placeholder.dataset.index = index;
placeholder.classList.add('virtual-item-placeholder');
return placeholder;
};

const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const placeholder = entry.target;
const index = parseInt(placeholder.dataset.index);

if (entry.isIntersecting) {
// 元素进入视口,渲染真实内容
if (!visibleItems.has(index)) {
const realElement = renderItem(data[index], index);
realElement.style.height = `${config.itemHeight}px`;
placeholder.replaceWith(realElement);
visibleItems.set(index, realElement);
}
} else {
// 元素离开视口,替换为占位符
const realElement = visibleItems.get(index);
if (realElement) {
const newPlaceholder = createPlaceholder(index);
realElement.replaceWith(newPlaceholder);
observer.observe(newPlaceholder);
visibleItems.delete(index);
}
}
});
}, {
root: config.container,
rootMargin: `${config.bufferSize * config.itemHeight}px`,
threshold: 0
});

// 初始化列表
const init = () => {
const container = config.container;
container.innerHTML = '';

data.forEach((_, index) => {
const placeholder = createPlaceholder(index);
container.appendChild(placeholder);
observer.observe(placeholder);
});
};

return {
init,
destroy: () => observer.disconnect(),
getVisibleCount: () => visibleItems.size
};
};

💡 核心要点



  • 占位符机制:使用固定高度的占位符保持滚动条正确

  • 缓冲区:通过rootMargin提前渲染即将可见的元素

  • 内存管理:及时清理不可见的元素,释放内存


🎯 实际应用


Vue3组件中的虚拟滚动实现:


<template>
<div ref="containerRef" class="virtual-scroll-container">
<!-- 虚拟滚动内容将在这里动态生成 -->
</div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const containerRef = ref(null);
let virtualScroll = null;

// 大量数据
const largeDataset = ref(Array.from({ length: 10000 }, (_, i) => ({
id: i,
title: `列表项 ${i + 1}`,
content: `这是第 ${i + 1} 个列表项的内容`
})));

// 渲染单个列表项
const renderListItem = (item, index) => {
const element = document.createElement('div');
element.className = 'list-item';
element.innerHTML = `
<h3>${item.title}</h3>
<p>${item.content}</p>
`;
return element;
};

onMounted(() => {
virtualScroll = createVirtualScroll(
largeDataset.value,
renderListItem,
{
itemHeight: 80,
bufferSize: 3,
container: containerRef.value
}
);

virtualScroll.init();
});

onUnmounted(() => {
virtualScroll?.destroy();
});
</script>



📊 技巧对比总结


技巧使用场景优势注意事项
图片懒加载大量图片展示性能优秀,实现简单需要设置合适的rootMargin
无限滚动长列表数据加载避免频繁滚动监听防止重复加载,错误处理
可见性统计用户行为分析精确统计,多阈值监听数据存储和上报策略
动画触发页面交互效果时机精准,性能好动画只触发一次的控制
虚拟滚动大数据列表内存占用低,滚动流畅元素高度固定,复杂度较高



🎯 实战应用建议


最佳实践



  1. 合理设置rootMargin:根据实际需求提前或延迟触发观察

  2. 及时清理观察器:使用unobserve()和disconnect()释放资源

  3. 错误处理机制:为异步操作添加try-catch保护

  4. 性能监控:在开发环境中监控观察器的性能表现

  5. 渐进增强:为不支持IntersectionObserver的浏览器提供降级方案


性能考虑



  • 观察器数量控制:避免创建过多观察器实例

  • threshold设置:根据实际需求设置合适的阈值

  • 内存泄漏防护:组件销毁时及时清理观察器

  • 兼容性处理:使用polyfill支持旧版浏览器




💡 总结


这5个IntersectionObserver实用场景在日常开发中能显著提升页面性能,掌握它们能让你的滚动监听:



  1. 图片懒加载:告别手动位置计算,性能提升显著

  2. 无限滚动:避免频繁滚动监听,用户体验更佳

  3. 可见性统计:精准的用户行为分析,数据更准确

  4. 动画触发:完美的视觉效果时机控制

  5. 虚拟滚动:大数据列表的性能救星


希望这些技巧能帮助你在前端开发中告别滚动监听的性能焦虑,写出更丝滑的交互代码!




🔗 相关资源






💡 今日收获:掌握了5个IntersectionObserver实用场景,这些知识点在实际开发中非常实用。



如果这篇文章对你有帮助,欢迎点赞、收藏和分享!有任何问题也欢迎在评论区讨论。 🚀


作者:Bug_Constructer
来源:juejin.cn/post/7549102542833631267
收起阅读 »

你一定疑惑JavaScript中的this绑定的究竟是什么?😵‍💫

web
想要了解this的绑定过程,首先要理解调用方式。调用方式调用方式被描述为函数被触发执行时语法形式。主要有以下几种基本模式:直接调用(独立函数调用): f1()方法调用: f1.f2()构造函数调用: new f1()显示绑定调用:...
继续阅读 »

想要了解this的绑定过程,首先要理解调用方式

调用方式

调用方式被描述为函数被触发执行时语法形式

主要有以下几种基本模式:

  1. 直接调用(独立函数调用): f1()
  2. 方法调用: f1.f2()
  3. 构造函数调用: new f1()
  4. 显示绑定调用: f1.call(f2) 或者 f1.apply(f2)
  5. 间接调用: (0,f1)()

第五点可能很多人没有见过,其实这是应用了逗号操作符,(0,f1)()其实等同于f1(),但它有什么区别呢?我放在显式绑定的最后来阐述吧。

有的人会用调用位置来解释this的绑定,但我感觉那个不太好用,可能是我没理解到位吧,如果有人知道怎么用它来解释this的绑定,希望能告诉我。总之,我们先用调用方式来解释this的绑定吧。

四种绑定规则

接下来介绍四种绑定规则。

默认绑定

首先要介绍的是默认绑定,当使用了最常用的函数调用类型:直接调用(独立函数调用) 时,便应用默认绑定。可以把这条规则看作是无法应用其他规则时的默认规则。

在默认绑定时,this绑定的是全局作用域

var a = 0;
function f1(){
var a = 1;
console.log(this.a); //输出为0
}
f1(); //直接调用,应用默认绑定

多个函数内部层层调用也是一样的。

var a = 0;
function f1(){
var a = 1;
f2();
}
function f2(){
var a = 2;
console.log(this.a); //输出的是0
}
f1();

隐式绑定

当函数被当作对象的属性被调用时(例如通过obj.f1()的形式),this会自动绑定到该对象上,这个绑定是隐式发生的,不需要显式使用callapplybind

var a = 0;
function f1() {
var a = 1;
console.log(this.a); //this绑定的是f2这个对象字面量
}
var obj = {
a : 2,
f1 : f1

// 也可以直接在obj内部定义f1
// function f1() {
// var a = 1;
// }

};
obj.f1(); // 输出为2

对象层层引用只有最后一个对象会影响this的绑定

var a = 0;
function f1() {
var a = 1;
console.log(this.a); //最后输出为2
}
var obj1 = {
a : 2,
f1 : f1
};
var obj2 = {
a : 3,
obj1 : obj1
}
obj2.obj1.f1();

可以发现这里有两个对象一个是obj1,一个是obj2obj2中的属性为obj1。先通过ob2.obj1调用obj1,再通过ob2.obj1.f1()调用f1函数,可以发现对象属性引用链中的最后一个对象为this所绑定的对象

隐性丢失

但隐式绑定可能会导致this丢失所绑定的对象,也就是会应用默认绑定(this绑定到全局作用域) 造成隐性丢失主要有两个方面,一个是给函数取别名,一个是回调函数

  • 函数取别名
var a = 0;
function f1() {
var a = 1;
console.log(this.a); //最后输出为0
}
var obj = {
a : 2,
f1 : f1
}
var fOne = obj.f1; // 给f1取了一个fOne的别名
fOne();

虽然函数fOneobj.f1的一个引用,但实际上,它引用的是f1函数本身,因此它执行的就是f1()。所以会使用默认绑定。

  • 回调函数
var a = 0;
// f1为回调函数,将obj.f2作为参数传递给f1
function f1(f2) {
var a = 1;
f2();
}
function f2() {
var a = 2;
console.log(this.a); //结果为0
}
var obj = {
a : 3,
f2 : f2
}
f1(obj.f2);

原因很简单,f1(obj.f2)obj.f2赋值给了function f1(f2) {...}中的f2(形参),就像上面讲的函数取了一个别名一样,实际执行的就是直接调用,所以应用默认绑定。

显式绑定

显式绑定很好理解,显式绑定让我们可以自定义this的绑定。我们通过使用函数的applycallbind方法,让我们可以自定义this的绑定。

var a = 0;
function f1 () {
var a = 1;
console.log(this.a);
}
//apply方法绑定this apply(对象,参数数组)
f1.apply({a:2}); //输出2

//call方法绑定this call(对象,参数1,参数2,...)
f1.call({a:3}); //输出3

//bind方法绑定this bind(对象,参数1,参数2,...),bind会返回一个函数,需要调用
boundf1 = f1.bind({a:4});
boundf1(); //输出4

但用applycall来进行显示绑定并不能避免隐性丢失的问题。下面有两个方法来解决这个问题。

1.硬绑定

var a = 0;
function f1() {
var a = 1;
console.log(this.a);
}
var bar = function() {
return f1.apply({a:2});
};
setTimeout(bar, 1000);//输出为2

让我们来分析分析这个代码。我们创建了函数bar,这个函数负责返回绑定好thisf1函数,并立即执行它。 这种绑定我们称之为硬绑定。

这种绑定方法会使用在一个i可以重复使用的辅助函数 例如

var a = 0;
function f1() {
var a = 1;
console.log(this.a);
}

function bind(fn, obj) {
return function () {
return fn.apply(obj, arguments);
};
}

var bar = bind(f1,{a:2});
bar();

可以很明显发现这和我们js自带的函数bind方法很像。是的,在ES5中提供了内置的方法Function.prototype.bind。它的用法我再提一次吧。

var a = 0;
function f1 () {
var a = 1;
console.log(this.a);
}
//bind方法绑定this
//bind(对象,参数1,参数2,...),bind会返回一个函数,需要调用
boundf1 = f1.bind({a:2});
boundf1(); //输出2

2.API调用的“上下文”

第三方库的许多函数,以及JavaScript语言和宿主环境中许多内置函数,都提供了一个可选参数,通常被称为“上下文”,其作用和bind方法一样,都是为了防止隐性丢失。

现在来举个例子吧。

function f1(el) {
console.log(el, this.id);
}
var obj = {
id : "awesome"
};
[1,2,3].forEach(f1,obj);
//最后输出的结果为
// 1 'awesome'
// 2 'awesome'
// 3 'awesome'

逗号操作符

在文章开头我们提到了这样一种表达式(0,f1)(),这是逗号操作符的应用,逗号操作符会依次计算所有的表达式,然后返回最后一个表达式的值。这里(0,f1)会先计算0(无实际意义),然后再返回f1,所以最后为f1()

理解了逗号操作符的使用,那如果我们把f1改为obj.f1呢,即(0,obj.f1)(),这时f1中的this绑定的是谁呢?

直接说结论,绑定的是全局对象。(0,obj.f1)()先计算0,然后返回obj.f1即f1函数本身,所以它返回的是一个解绑this的函数,其相当于f1.call(window)——window是全局对象。

下面我们来验证一下吧。

var a = 0;
function f1() {
var a = 1;
console.log(this.a);
}
var obj = {
a : 2,
f1 : f1
};
(0,obj.f1)(); //输出0

完全正确哈哈,注意这种方式不算作隐性丢失哦。

  • 这个操作只是调用了 obj.f1,并没有阻止垃圾回收(GC)。
  • 如果 obj 或 f1 没有其他引用,它们仍然会被正常回收。

如果对其具体的工作流程感兴趣,可以去网上再找些资料。本篇就不讲太详细了。

new 绑定

这是this绑定的最后一条规则。

new绑定通常的形式为:... = new MyClass(参数1,参数2,...)

JavaScript中的new操作符的机制和那些面向类的语言的new操作符有些不一样,因为JavaScript是基于原型的语言(这个也许以后我会谈谈哈哈)。在JavaScript中,“构造函数”仅仅只是你使用new操作符时被调用的函数。

使用new来调用函数,会自动执行以下操作。

  1. 创建(或者说构造)一个全新的对象。
  2. 这个新对象会被执行原型连接。
  3. 这个新对象会绑定到函数调用的this。
  4. 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。

我们现在重点要关注的是第三点。

function f1(a){
this.a = a;
}
var bar = new f1(2);
console.log(bar.a); //输出为2
console.log(f1.a); //输出为undefined

这段代码就可以很明显的看出来new会创建一个新对象bar,并把this绑定到这个bar上,所以才会在bar上创建a这个属性。而原来的f1上则没有a这个属性,所以是undefined

四条规则的优先级

  1. 如果某个调用位置应用了多条规则该怎么办?这时我们就需要知道它们的优先级了。 首先,默认绑定的优先级是最低的。我们先来测试一下它们隐式绑定和显式绑定哪个优先级高吧,这里我偷个懒,就引用一下《你不知道的JavaScript(上卷)》这本书的测试代码
function foo() {  
console.log( this.a );
}
var obj1 = {
a: 2,
foo: foo
};
var obj2 = {
a: 3,
foo: foo
};
obj1.foo(); // 2
obj2.foo(); // 3
obj1.foo.call( obj2 ); // 3
obj2.foo.call( obj1 ); // 2

稍微分析一下吧,obj1.foo()obj2.foo()为隐式调用,this分别绑定的为obj1obj2,所以会打印23。接着我们调用了obj1.foo.call(obj2)发现结果输出为obj2中的a属性2,所以这里应用的是显式绑定。

所以显式绑定的优先级是高于隐式绑定的

  1. 再来看看new绑定和隐式绑定的优先级谁更高吧。
function foo(something) {  
this.a = something;
}
var obj1 = {
foo: foo
};
obj1.foo(1);
console.log( obj1.a ); // 1

var bar = new obj1.foo(2);
console.log( obj1.a ); // 1
console.log( bar.a ); // 2

var bar = new obj1.foo(2)这段代码,如果隐式绑定的优先级会大于new绑定,就会在obj1里把属性a赋值为2; 如果new绑定的优先级大于隐式绑定,就会在bar中创建一个属性a,值为2,最后看obj1.abar.a谁输出为2,谁的优先级就更高,很明显bar.a输出为2,所以new绑定的优先级高于隐式绑定的。

所以new调用的优先级要高于隐式调用的优先级

  1. 再来看看new调用和显式调用的优先级谁高谁低吧。

new不能和applycall方法同时使用,但我们可以用bind方法进行硬绑定,再用bind返回的新函数再new一下以此来判断谁的优先级高。

function foo(something) {  
this.a = something;
}

var obj1 = {};

var bar = foo.bind( obj1 );
bar( 2 );
console.log( obj1.a ); // 2

var baz = new bar(3);
console.log( obj1.a ); // 2
console.log( baz.a ); // 3

首先硬绑定了obj1,在obj1中创建了a属性,值为2bar接收返回的bind函数。之后new bar并给a赋值为3,用baz来接收new的对象,这时如果baz.a3就说明this应用的绑定规则是new绑定。

所以new绑定的优先级是高于显示调用的优先级的。

现在知道了四种规则,又知道了这四个规则的优先级,我们就能很清晰的判断this的绑定了。

判断this的流程

以后判断this我们可以按以下顺序来判断:

  1. 函数是否在new中调用(new绑定)?如果是的话this绑定的是新创建的对象。

     var bar = new foo()  //这里bar为this绑定的对象
  2. 函数是否通过callapply(显式绑定)或者硬绑定(bind)调用?如果是的话,this绑定的是指定的对象。

     var bar = foo.call(obj)  //这里obj为this绑定的对象
  3. 函数是否在某个上下文对象中调用(隐式绑定)如果是的话,this绑定的是那个上下文对象。

     var bar = obj.foo()  //这里obj为this绑定的对象
  4. 如果都不是,则应用默认绑定,this绑定到全局对象上。

     var bar = foo()   //this绑定的为全局对象 

凡事都有例外,还有一些十分特殊的情况不满足上面的四条规则,我们需要单独拎出来记忆。

绑定例外

绑定例外主要有3种。

null导致的绑定意外

var a = 0;  
function f1() {
var a = 1;
console.log(this.a);
}
f1.apply(null); //输出为0

var bar = f1.bind(null);
bar() //输出为0

当我们使用显式绑定(使用apply、call、bind方法)的时候,如果我们显式绑定一个null,就会发现this绑定的不是null而是应用默认绑定,绑定全局对象。这会导致变量全局渗透的问题。

有的人可能会说,那我们不用null来绑定this不就好了吗?但有的时候我们还真不得不使用null来绑定this,下面我来介绍一下什么时候会使用这种情况。

一种常见的做法是使用apply(..)来“展开”一个数组,并当作参数传入一个函数。类似地,bind(..)可以对参数进行柯里化(预先设置一些参数),这种方法有时很好用的。

function f1 (a , b) {  
console.log("a:" + a + ",b:" + b);
}

f1.apply(null,[2,3]) //输出为a:2,b:3

//bind的柯里化
var bar = f1.bind(null,2);
bar(3); //输出为a:2,b:3

现在来简单地来介绍一下柯里化是什么?柯里化是将一个接收多个参数的函数转换为一系列只接受单个参数的函数。这时bindnull的作用就体现出来了。

然而,在apply,call,bind使用null会导致全局溢出,在一些有this的函数中,给这个this绑定null,会让this绑定全局对象。该如何解决这个问题呢?

更安全的this

我们可手动创建一个空的对象,这个空的对象我们称作“DMZ”(demilitarized zoo,非军事区)对象——它是一个空的非委托的对象。

如果我们在想要忽略this绑定时总是传入一个DMZ对象,那就不用担心this会溢出到全局了,这个this绑定的就是DMZ对象。

在JavaScript中创建一个空对象最简单的方法是Object.create(null)——它会返回一个空对象,Object.create(null)Object.create(null){}很像,并不会创建Object.prototype这个委托,所以它比{}“更空”。

var c = 0;
function f1 (a , b) {
this.c = 1;
console.log("a:" + a + ",b:" + b);
}
//创建自己的空对象
var myNull = Object.create(null);

f1.apply(myNull,[2,3]) //输出为a:2,b:3
console.log(c); //输出为0

//bind的柯里化
var bar = f1.bind(myNull,2);
bar(3); //输出为a:2,b:3
console.log(c); //输出为0

可以发现这段代码中,我们创建了自己的空对象通过applybind方法把this绑定到这个空对象了。最后的输出的c0,说明this.c并没有修改全局变量c的值。所以这个方法可以防止全局溢出。

接下来谈谈另外一个绑定的例外吧。

间接引用

有的时候你可能(有意或无意地)创建了一个函数的“间接引用”,在这种情况下,调用这个函数应用默认绑定规则。

var a = 0;
function f1() {
console.log(this.a);
}
var obj1 = {
a : 1,
f1 : f1
};

var obj2 = {
a : 2,
};
obj1.f1(); // 1
(obj2.f1 = obj1.f1)(); // 0

我们来看看这个代码。obj1中有af1属性或方法,a的值为1obj2中只有a属性,值为2。我们先隐式绑定obj1this绑定obj1,最后输出为1,这个我们可以理解。关键是下面这行代码(obj2.f1 = obj1.f1)()obj2中没有f1,所以它在obj2中创建一个f1,然后将obj1中的f1函数赋值给obj2f1,然后执行这个赋值表达式。那为什么输出的是0而不是obj2中的2或者obj1中的1呢? 🤔

其实这和赋值表达式的返回值有关系,因为赋值表达式会返回等号右边的值。 所以(obj2.f1 = obj1.f1)实际上返回的obj1.f1中的f1函数,实际执行的是f1()。所以应用的是默认绑定,this绑定全局对象,结果输出为0

我们继续看绑定的下一个例外。

箭头函数

在ES6中介绍了一种无法使用这些规则的特殊函数类型:箭头函数。

箭头函数和一般的函数不一样,它不是用function来定义的,而是使用被称作“胖箭头”的操作符=>定义的。

定义格式:(参数) => {函数体}

箭头函数不使用this的四条规则,而是继承外层(定义时所在)函数或全局作用域的this的值,this在箭头函数创建时就被确定,且永远不会被改变,new也不行。

var a = 0;
(()=>{
var a = 1;
console.log(this.a); // 结果输出为0
}
)();

很明显该箭头函数外部就是全局作用域,所以继承全局对象的this就是它本身,所以输出为0

再看看如果在其他函数中定义箭头函数this如何绑定

var a = 0;
function f1() {
var a = 1;
(()=>{
var a = 2;
console.log(this.a);
}
)();
}
f1();//输出0

//给f1绑定一个对象
f1.apply({a:3}); // 输出3

可以发现f1内部的箭头函数继承了其外部函数f1this的绑定。所以一开始没给f1绑定this时,f1this绑定的是全局对象,箭头函数的也是全局对象;当给f1this绑定一个对象时,箭头函数的this也绑定该对象。

小结

以上是我的学习分享,希望对你有所帮助。

还有本篇的四条规则只适用于非严格模式,严格模式的this的绑定我日后再出一篇吧,其实只是有点懒😂。

参考书籍

《你所不知道的JavaScript(上卷)》


作者:mrsk
来源:juejin.cn/post/7504237094283526178
收起阅读 »

svg按钮渐变边框

web
共用css body { padding: 50px; background-color: black; color: white; } svg { --text_fill: orange; --svg_width: ...
继续阅读 »

共用css


body {
padding: 50px;
background-color: black;
color: white;
}

svg {
--text_fill: orange;
--svg_width: 120px;
--svg_height: 40px;
width: var(--svg_width);
height: var(--svg_height);
cursor: pointer;
/* 创建图层 */
will-change: transform;

&:hover {
--text_fill: #fed71a;
}

text {
fill: var(--text_fill);
font-size: 1rem;
transform: translate(50%, 50%);
text-anchor: middle;
dominant-baseline: middle;
stroke: yellowgreen;
stroke-width: .5px;
cursor: pointer;
}

rect {
--stroke_width: 4px;
width: calc(var(--svg_width) - var(--stroke_width));
height: calc(var(--svg_height) - var(--stroke_width));
stroke-width: var(--stroke_width);
rx: calc(var(--svg_height)/2);
x: calc(var(--stroke_width)/2);
y: calc(var(--stroke_width)/2);
fill: none;
cursor: pointer;
}
}

移入执行、移出暂停


<body>
<svg style='position:absolute;left:-9999px;width:0;height:0;visibility:hidden;'>
<defs>
<linearGradient id='strokeColor1' x1='0%' y1='0%' x2='100%' y2='0%'>
<stop offset='0%' stop-color="#00ccff" stop-opacity="1" />
<stop offset='50%' stop-color="#d400d4" stop-opacity="1" />
<stop offset='100%' stop-color="#ff00ff" stop-opacity=".7" />
</linearGradient>
</defs>
</svg>

<svg id="svg1">
<text>渐变按钮</text>
<rect stroke='url(#strokeColor1)' />
<animateTransform id="ani1" href="#strokeColor1" attributeName='gradientTransform' dur='5s' type="rotate"
form="0,.5,.5" to="360,.5,.5" repeatCount='indefinite' begin="indefinite" />

</svg>
</body>
<script>
svg1.addEventListener('mouseover', function () {
if (!this.beginMark) {
ani1.beginElement();
this.beginMark = true;
return;
}
this.unpauseAnimations();
})

svg1.addEventListener('mouseleave', function () {
this.pauseAnimations();
})
</script>

svg1效果图


svg1.gif


移入暂停、移出执行


<body>
<svg style='position:absolute;left:-9999px;width:0;height:0;visibility:hidden;'>
<defs>
<linearGradient id='strokeColor2' x1='0%' y1='0%' x2='100%' y2='0%'>
<stop offset='0%' stop-color="#ec261b" />
<stop offset='50%' stop-color="#ff9f43" />
<stop offset='100%' stop-color="#ffe66d" stop-opacity="1" />
</linearGradient>
</defs>
</svg>

<svg id="svg2">
<text>渐变按钮</text>
<rect stroke='url(#strokeColor2)' />
<animateTransform id="ani2" href="#strokeColor2" attributeName='gradientTransform' dur='5s' type="rotate"
form="0,.5,.5" to="360,.5,.5" repeatCount='indefinite' begin="0s" />

</svg>
</body>

<script>
svg2.addEventListener('mouseover', function () {
this.pauseAnimations();
})
svg2.addEventListener('mouseleave', function () {
this.unpauseAnimations();
})
</script>

sv2效果图


svg2.gif


总结


个人感觉svg实现渐变边框相比较css的实现来说,相对代码量更大一些,但是svg其实还有很多好玩的地方。
用svg来做渐变边框也是另外的一种思路,也许以后能够用的上。


css按钮渐变边框


作者:大林i瑶
来源:juejin.cn/post/7488575555048161332
收起阅读 »

Java String.replace()原理,你真的了解吗?

web
大家好呀,我是猿java。 String.replace()是我们日常开发中经常用到的一个方法,那么,你有看过其底层的源码实现吗?你知道String.replace()是如何工作的吗?String.replace()的性能到底怎么样?这篇文章我们来深入地分析。...
继续阅读 »

大家好呀,我是猿java


String.replace()是我们日常开发中经常用到的一个方法,那么,你有看过其底层的源码实现吗?你知道String.replace()是如何工作的吗?String.replace()的性能到底怎么样?这篇文章我们来深入地分析。


在开始今天的问题之前,让我们先来看一个问题:


String original = "Hello, World!";
// 替换字符
String result = original.replace('World', 'Java');

original.replace('World', 'Java'),是把 original的内容直接修改成Hello, Java了,还是重新生成了一个 Hello, Java的 String并返回?


1. String.replace()是什么?


String.replace()位于java.lang包中,它是 Java中的一个重要方法,用于替换字符串中的某些字符或子字符串。以下String.replace()的源码截图。


string-replace.png


String.replace()方法用于替换字符串中的某些字符或子字符串。它有多个重载版本,常见的有:


// 用于替换单个字符
public String replace(char oldChar, char newChar);
// 用于替换子字符串
public String replace(CharSequence target, CharSequence replacement);

下面是一个简单的示例,演示了replace方法的用法:


public class ReplaceExample {
public static void main(String[] args) {
String original = "Hello, World!";

// 替换字符
String replacedChar = original.replace('o', 'a');
System.out.println(replacedChar); // 输出: "Hella, Warld!"

// 替换子字符串
String replacedString = original.replace("World", "Java");
System.out.println(replacedString); // 输出: "Hello, Java!"
}
}

在上面的例子中,我们演示了如何使用replace方法替换字符和子字符串。需要注意的是,String对象在Java中是不可变的(immutable),因此replace方法会返回一个新的字符串,而不会修改原有字符串。


2. 源码分析


上述示例,我们演示了replace方法的用法,接下来,我们来分析下replace方法的实现原理。


2.1 String的不可变性


Java中的String类是不可变的,这意味着一旦创建了一个String对象,其内容不能被改变。这样的设计有助于提高性能和安全性,尤其在多线程环境下。String源码说明如下:


java-string.png


2.2 replace()工作原理


让我们深入了解replace方法的内部实现。以replace(CharSequence target, CharSequence replacement)为例,以下是其基本流程:



  1. 检查目标和替换内容:方法首先检查传入的targetreplacement是否为null,如果是,则抛出NullPointerException

  2. 搜索目标子字符串:在原始字符串中查找所有符合目标子字符串的地方。

  3. 构建新的字符串:基于找到的位置,将原始字符串分割,并用替换字符串进行拼接,生成一个新的字符串。


2.3 源码解析


让我们看一下String类中replace方法的源码(简化版):


public String replace(char oldChar, char newChar) {
if (oldChar != newChar) {
String ret = isLatin1() ? StringLatin1.replace(value, oldChar, newChar)
: StringUTF16.replace(value, oldChar, newChar);
if (ret != null) {
return ret;
}
}
return this;
}



public String replace(CharSequence target, CharSequence replacement) {
String tgtStr = target.toString();
String replStr = replacement.toString();
int j = indexOf(tgtStr);
if (j < 0) {
return this;
}
int tgtLen = tgtStr.length();
int tgtLen1 = Math.max(tgtLen, 1);
int thisLen = length();

int newLenHint = thisLen - tgtLen + replStr.length();
if (newLenHint < 0) {
throw new OutOfMemoryError();
}
StringBuilder sb = new StringBuilder(newLenHint);
int i = 0;
do {
sb.append(this, i, j).append(replStr);
i = j + tgtLen;
} while (j < thisLen && (j = indexOf(tgtStr, j + tgtLen1)) > 0);
return sb.append(this, i, thisLen).toString();
}

解析步骤



  1. 参数校验:首先检查targetreplacement是否为null,避免后续操作出现NullPointerException

  2. 查找目标字符串:使用indexOf方法查找目标子字符串首次出现的位置。如果未找到,直接返回原字符串。

  3. 替换逻辑



    • 使用StringBuilder来构建新的字符串,这是因为StringBuilder在拼接字符串时效率更高。

    • 通过循环查找所有目标子字符串的位置,并将其替换为替换字符串。

    • 最后,拼接剩余的字符串部分,返回最终结果。




性能考虑


由于String的不可变性,每次修改都会创建新的String对象。如果需要进行大量的字符串替换操作,推荐使用StringBuilderStringBuffer来提高性能。


三、实际示例演示


接下来,我们将通过几个实际的例子,来更好地理解String.replace()的使用场景和效果。


示例1:替换字符


public class ReplaceCharDemo {
public static void main(String[] args) {
String text = "banana";
String result = text.replace('a', 'o');
System.out.println(result); // 输出: "bonono"
}
}

解释:将所有的'a'替换为'o',得到"bonono"


示例2:替换子字符串


public class ReplaceStringDemo {
public static void main(String[] args) {
String text = "I love Java. Java is versatile.";
String result = text.replace("Java", "Python");
System.out.println(result); // 输出: "I love Python. Python is versatile."
}
}

解释:将所有的"Java"替换为"Python",结果如上所示。


示例3:替换多个不同的子字符串


有时,我们可能需要在一个字符串中替换多个不同的子字符串。例如,将文中的标点符号替换为空格:


public class ReplaceMultipleDemo {
public static void main(String[] args) {
String text = "Hello, World! Welcome to Java.";
String result = text.replace(",", " ")
.replace("!", " ")
.replace(".", " ");
System.out.println(result); // 输出: "Hello World Welcome to Java "
}
}

解释:通过链式调用replace方法,依次将,!.替换为空格。


示例4:替换不匹配的情况


public class ReplaceNoMatchDemo {
public static void main(String[] args) {
String text = "Hello, World!";
String result = text.replace("Python", "Java");
System.out.println(result); // 输出: "Hello, World!"
}
}

解释:由于"Python"在原字符串中不存在,replace方法不会做任何替换,直接返回原字符串。


四、String.replace()的技术架构图


虽然文字描述已能帮助我们理解replace方法的工作原理,但通过一个简化的技术架构图,可以更直观地抓住其核心流程。


+---------------------------+
| String对象 |
| "Hello, World!" |
+------------+--------------+
|
| 调用replace("World", "Java")
v
+---------------------------+
| 搜索目标子字符串 "World" |
+------------+--------------+
|
| 找到位置 7
v
+---------------------------+
| 构建新的字符串 "Hello, Java!" |
+---------------------------+
|
| 返回新字符串
v
+---------------------------+
| 新的 String对象 |
| "Hello, Java!" |
+---------------------------+

图解说明



  1. 调用replace方法:在原始String对象上调用replace("World", "Java")

  2. 搜索目标:方法内部使用indexOf找到"World"的位置。

  3. 构建新字符串:使用StringBuilder"Hello, ""Java"拼接,形成新的字符串"Hello, Java!"

  4. 返回新字符串:最终返回一个新的String对象,原始字符串保持不变。


五、总结


通过本文的介绍,相信你对Java中String.replace()方法有了更深入的理解。从基本用法到内部原理,再到实际应用示例,每一步都帮助你全面掌握这个重要的方法。


记住,String的不可变性设计虽然带来了安全性和线程安全性,但在频繁修改字符串时,可能影响性能。因此,合理选择使用String还是StringBuilder,根据具体场景优化代码,是每个Java开发者需要掌握的技能。


希望这篇文章能对你在Java编程的道路上提供帮助。如果有任何疑问或更多的讨论,欢迎在评论区留言!


8. 学习交流


如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。


作者:猿java
来源:juejin.cn/post/7543147533368229903
收起阅读 »

一万行代码实现的多维分析表格,让数据处理效率提升 300%

web
上个月在 趣谈AI 发布了我实现的多维表格1.0版本,没有用到任何第三方组件,完全组件化设计。最近对多维表格进行了进一步的升级优化,满打满算花了接近3个月时间,累计代码接近1w行。 接下来就和大家聊聊我做的 flowmix/mute多维表格 的核心功能和技术...
继续阅读 »

上个月在 趣谈AI 发布了我实现的多维表格1.0版本,没有用到任何第三方组件,完全组件化设计。最近对多维表格进行了进一步的升级优化,满打满算花了接近3个月时间,累计代码接近1w行。


图片


接下来就和大家聊聊我做的 flowmix/mute多维表格 的核心功能和技术实现。


核心功能介绍


1. 多视图模式


图片


目前多维表格支持多种视图模式:表格视图,看板视图,人员分配视图。用户可以轻松在不同视图下切换并进行可视化操作数据。


2. 多条件筛选功能


图片


我们可以基于不同维度进行筛选和排序,并支持组合筛选。


3. 多维度分组功能


图片


表格视图中,我们可以基于用户,优先级,状态,对数据进行分组管理,提高表格数据的查看效率。


4. 表格字段管理功能


图片


多维表格中不仅支持字段的管理控制,同时还支持添加自定义字段:


图片


5. 表格行列支持自定义拖拽排序功能


图片


表格我们不仅仅支持列的宽度拖拽,还支持拖拽调整列的排序,同时表格的行也支持拖拽,可以跨分组进行拖拽,也支持在组内进行拖拽排序,极大的提高了数据管理的效率。


6. 表格支持一键编辑


图片


我们可以在菜单按钮中开启编辑模式,也可以双击编辑单元格一键编辑表格内容,同时大家还可以进行扩展。


7. 表格支持一键转换为可视化分析视图表


图片


我们可以将表格数据转换为可视化分析图表,帮助管理者更好地掌握数据动向。


8. 表格支持一键导入任务数据


图片


目前多维表格支持导出和导入json数据,并一键渲染为多维表格。技术实现多维表格的设计我采用了组件化的实现的方式, 并支持数据持久化,具体使用如下:


<div className="flex-1 bg-gray-50">
{currentView === "tasks" && <TaskManagementTable sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />}
{currentView === "statistics" && <StatisticsView />}
{currentView === "documentation" && <DocumentationView />}
{currentView === "assignment" && <AssignmentView />}
{currentView === "deployment" && <DeploymentView />}
</div>

在开发多维表格的过程中其实需要考虑很多复杂逻辑,比如表格用什么方式渲染,如何优化表格性能,如何实现表格的列排序,行排序,表格编辑等。传统表格组件大多基于div模拟行列,虽然灵活但渲染性能差。所以可以做如下优化:



  • 虚拟滚动当数据量超过 500 行时,启用虚拟滚动机制,仅渲染可见区域的 DOM 节点,内存占用降低 70%;

  • 行列冻结通过固定定位position: sticky实现表头和固定列冻结,解决大数据表格的滚动迷失问题;

  • 异步加载采用Intersection Observer监听表格滚动事件,动态加载可视区域外的数据,避免一次性请求全量数据。


接下来分享一下简版的虚拟滚动的实现方案:


// 虚拟滚动核心代码(简化版)
function renderVirtualTable(data, visibleHeight) {
const totalRows = data.length;
const rowHeight = 40; // 行高固定
const visibleRows = Math.ceil(visibleHeight / rowHeight);
const startIndex = scrollTop / rowHeight | 0;
const endIndex = startIndex + visibleRows;
// 渲染可见区域数据
const fragment = document.createDocumentFragment();
for (let i = startIndex; i < endIndex; i++) {
const row = document.createElement('tr');
row.innerHTML = data[i].cells.map(cell => `<td>${cell.value}</td>`).join('');
fragment.appendChild(row);
}
// 更新滚动条高度和偏移量
table.scrollHeight = totalRows * rowHeight;
table.innerHTML = `<thead>${header}</thead><tbody>${fragment}</tbody>`;
}

对于大表格数据量需要在本地缓存,所以需要设计表格数据的缓存处理逻辑,目前我采用的是hooks的实现方案,具体实现如下:


import { useState, useEffect } from "react"
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] {
// 初始化状态
const [storedValue, setStoredValue] = useState<T>(() => {
try {
// 获取本地存储中的值
if (typeof window === "undefined") {
return initialValue
}
const item = window.localStorage.getItem(key)
// 解析存储的JSON或返回初始值
return item ? JSON.parse(item) : initialValue
} catch (error) {
// 如果出错,返回初始值
console.error(`Error reading localStorage key "${key}":`, error)
return initialValue
}
})
// 返回一个包装版本的 useState setter 函数
// 将新值同步到 localStorage
const setValue = (value: T | ((val: T) => T)) => {
try {
// 允许值是一个函数
const valueToStore = value instanceof Function ? value(storedValue) : value
// 保存到 state
setStoredValue(valueToStore)
// 保存到 localStorage
if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(valueToStore))
}
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error)
}
}
// 监听其他标签页的变化
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
try {
setStoredValue(JSON.parse(e.newValue))
} catch (error) {
console.error(`Error parsing localStorage item "${key}":`, error)
}
}
}
// 添加事件监听器
if (typeof window !== "undefined") {
window.addEventListener("storage", handleStorageChange)
}
// 清理事件监听器
return () => {
if (typeof window !== "undefined") {
window.removeEventListener("storage", handleStorageChange)
}
}
}, [key])
return [storedValue, setValue]
}

其实在实现多维表格的过程中,我也调研了很多开源的方案,但是对于扩展性,灵活度和功能复杂度上,都略显简单,所以我才考虑花时间来实现这款多维表格方案。另一个比较复杂的逻辑是表格的列拖拽和排序,我们需要对可展开折叠的表格支持排序和拖拽,并保持优秀的用户体验:


图片


技术实现如下:


import { useState, useEffect } from "react"
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] {
// 初始化状态
const [storedValue, setStoredValue] = useState<T>(() => {
try {
// 获取本地存储中的值
if (typeof window === "undefined") {
return initialValue
}
const item = window.localStorage.getItem(key)
// 解析存储的JSON或返回初始值
return item ? JSON.parse(item) : initialValue
} catch (error) {
// 如果出错,返回初始值
console.error(`Error reading localStorage key "${key}":`, error)
return initialValue
}
})
// 返回一个包装版本的 useState setter 函数
// 将新值同步到 localStorage
const setValue = (value: T | ((val: T) => T)) => {
try {
// 允许值是一个函数
const valueToStore = value instanceof Function ? value(storedValue) : value
// 保存到 state
setStoredValue(valueToStore)
// 保存到 localStorage
if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(valueToStore))
}
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error)
}
}
// 监听其他标签页的变化
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
try {
setStoredValue(JSON.parse(e.newValue))
} catch (error) {
console.error(`Error parsing localStorage item "${key}":`, error)
}
}
}
// 添加事件监听器
if (typeof window !== "undefined") {
window.addEventListener("storage", handleStorageChange)
}
// 清理事件监听器
return () => {
if (typeof window !== "undefined") {
window.removeEventListener("storage", handleStorageChange)
}
}
}, [key])
return [storedValue, setValue]
}


多维表格还支持多种视图的转换,比如可以将表格视图一键转换为可视化分析图表:


图片


对用户和团队进行多维度的数据分析。技术实现如下:


import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from "recharts"
import type { Task } from "@/lib/types"
interface PriorityDistributionChartProps {
tasks: Task[]
}
export function PriorityDistributionChart({ tasks }: PriorityDistributionChartProps) {
// 计算每个优先级的任务数量
const priorityCounts: Record<string, number> = {}
tasks.forEach((task) => {
const priority = task.priority || "未设置"
priorityCounts[priority] = (priorityCounts[priority] || 0) + 1
})
// 转换为图表数据格式
const chartData = Object.entries(priorityCounts).map(([priority, count]) => ({
priority,
count,
}))
// 为不同优先级设置不同颜色
const COLORS = ["#FF8042", "#FFBB28", "#00C49F", "#0088FE"]
return (
<div className="w-full h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={true}
outerRadius={80}
fill="#8884d8"
dataKey="count"
nameKey="priority"
label={({ priority, percent }) =>
`${priority}: ${(percent * 100).toFixed(0)}%`}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value, name, props) => [`${value} 个任务`, props.payload.priority]} />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>

)
}

项目的体验地址:mute.turntip.cn


如果大家有好的想法,欢迎评论区留言反馈~


作者:徐小夕
来源:juejin.cn/post/7511649092658577448
收起阅读 »

用了bun之后,是不是就不用vite了

web
用了 Bun 之后,并不是完全不用 Vite。Bun 虽然内置了打包器和运行时,且速度非常快,但其打包功能目前还不够完善,缺少对前端项目非常重要的功能,比如对代码分块(chunk splitting)的精细控制和开发服务器(dev server)支持,这些都是...
继续阅读 »

用了 Bun 之后,并不是完全不用 Vite。Bun 虽然内置了打包器和运行时,且速度非常快,但其打包功能目前还不够完善,缺少对前端项目非常重要的功能,比如对代码分块(chunk splitting)的精细控制和开发服务器(dev server)支持,这些都是 Vite 在前端开发中非常关键的优势


具体来说:



  • Bun 是一个高性能的 JavaScript 运行时和包管理器,集成了打包器和测试工具,适合全栈开发和快速安装依赖,但其前端打包功能还处于实验阶段,缺少灵活的代码拆分和热更新支持

  • Vite 专注于前端开发体验,提供极快的冷启动和热模块替换(HMR),以及灵活且成熟的生产构建流程,它的生态和插件系统也非常丰富,适合现代前端框架(如 Vue3、React)

  • 目前推荐的做法是用 Bun 替代 Node.js 作为运行时和包管理器,用 Vite 负责前端的开发服务器和构建打包。这样可以结合 Bun 的高速安装和执行优势,以及 Vite 的快速开发反馈和优化生产构建的能力


关于 pnpm 和 Vite 的叠加:



  • pnpm 是一个高效且节省磁盘空间的包管理器,完全可以和 Vite 一起使用。pnpm 通过内容寻址存储和符号链接机制,避免依赖重复,提升安装速度和项目管理效率,特别适合 monorepo 和多项目共享依赖的场景

  • pnpm 和 Vite 的结合非常常见,pnpm 负责依赖管理,Vite 负责开发和构建,两者互不冲突,反而能互补提升开发效率


总结:


工具作用定位是否可以替代 Vite?是否能和 pnpm 叠加?主要好处
BunJS 运行时+包管理+打包+测试目前不能完全替代 Vite可以,pnpm 和 Bun 都是包管理器,选择一个即可速度极快,集成度高,适合全栈开发
Vite前端开发服务器+构建工具不能被 Bun 完全替代完全兼容 pnpm快速热更新,灵活构建,生态丰富
pnpm高效包管理器和 Vite 不冲突,负责依赖管理本身是包管理器,不和 Vite 冲突节省磁盘空间,安装快,依赖隔离

因此,用了 Bun 后,建议继续用 Vite 负责前端开发和构建,pnpm 依然可以用来管理依赖,三者结合能发挥各自优势,提升开发体验和性能


如果你想快速上手,可以用 Bun 创建 Vite 项目:


bun create vite my-app
cd my-app
bun install
bun run dev

这样就用 Bun 作为运行时和包管理器,用 Vite 作为开发服务器和构建工具


综上,Bun 目前还不能完全替代 Vite,Vite 在前端开发体验和构建优化上仍有不可替代的优势;pnpm 作为包管理器可以和 Vite 叠加使用,带来依赖管理的高效和节省空间的好处。三者结合是现代前端项目的一个高效组合方案。


用了next.js之后,是不是就不用vite了


用了 Next.js 之后,一般情况下是不需要再单独使用 Vite 了,因为 Next.js 内部已经集成了自己的构建和开发服务器功能。


具体原因和分析如下:



  • Next.js 是一个基于 React 的全栈框架,内置了开发服务器(通过 next dev 命令启动)、构建工具和打包器,它自动处理代码编译、热模块替换(HMR)、代码拆分和优化等工作,开发体验和构建性能都非常优秀

  • Next.js 使用了基于 Rust 的编译器 SWC 来加速构建和转译,性能上已经非常接近甚至优于传统的打包工具

  • Vite 主要是一个前端构建工具和开发服务器,适合纯前端项目或需要快速启动的 React/Vue 应用,而 Next.js 是一个框架,包含了路由、服务器端渲染(SSR)、静态站点生成(SSG)、API 路由等功能,功能层面更全面

  • 使用 Next.js 时,所有的构建和开发流程都由 Next.js 自己管理,开发者无需额外配置 Vite,避免了工具链的重复和冲突。

  • 如果你只是做纯前端项目,Vite 是很好的选择;但如果你需要 SSR、API 路由、全栈功能,Next.js 是更合适的框架,且内置了构建和开发支持,不需要再叠加 Vite。


总结:


工具作用定位是否需要同时使用适用场景
Next.jsReact 全栈框架,含开发服务器和构建不需要SSR、SSG、API 路由、全栈应用
Vite前端开发服务器和构建工具纯前端项目时使用快速启动、热更新、纯前端 SPA

因此,用了 Next.js 后,基本上不需要再用 Vite 了,Next.js 已经集成了类似 Vite 的开发和构建功能,且提供了更多全栈特性


作者:程序员小jobleap
来源:juejin.cn/post/7522080312564285486
收起阅读 »

H5 配合原生开发 App

web
JS 和 Android原生调用 JS4.4 版本之前// mWebView = new WebView(this); //当前webview对象 // 通过loadUrl方法进行调用 参数通过字符串的方式传递 mWebView.loadUrl("javasc...
继续阅读 »

JS 和 Android

  • 原生调用 JS
    4.4 版本之前
// mWebView = new WebView(this); //当前webview对象
// 通过loadUrl方法进行调用 参数通过字符串的方式传递
mWebView.loadUrl("javascript: 方法名('参数1,参数2...')");

//也可以在UI线程中运行
runOnUiThread(new Runnable() {
@Override
public void run() {
// 通过loadUrl方法进行调用 参数通过字符串的方式传递
mWebView.loadUrl("javascript: 方法名('参数1,参数2...')");
// 安卓中原生的弹框
Toast.makeText(Activity名.this, "调用方法...", Toast.LENGTH_SHORT).show();
}
});

4.4 版本之后

// 通过异步的方式执行js代码,并获取返回值
mWebView.evaluateJavascript("javascript: 方法名('参数1,参数2...')", new ValueCallback() {
@Override
// 这个方法会在执行完毕之后触发, 其中value就是js代码执行的返回值(如果有的话)
public void onReceiveValue(String value) {

}
});
  • JS 调用Android
    安卓配置:
// Android4.2版本以上,本地方法要加上注解@JavascriptInterface,否则无法使用
private Object getJSBridge(){
// 实例化新对象
Object insertObj = new Object(){
@JavascriptInterface
// 对象内部的方法1
public String foo(){
// 返回 字符串 foo
return "foo";
}
@JavascriptInterface
// 对象内部的方法2 需要接收一个参数
public String foo2(final String param){
// 返回字符串foo2拼接上传入的param
return "foo2:" + param;
}
};
// 返回实例化的对象
return insertObj;
}

// 获取webView的设置对象,方便后续修改
WebSettings webSettings = mWebView.getSettings();
// 设置Android允许JS脚本,必须要!!!
webSettings.setJavaScriptEnabled(true);
// 暴露一个叫做JSBridge的对象到webView的全局环境
mWebView.addJavascriptInterface(getJSBridge(), "JSBridge");

在 web 页面中调用

//调用方法一
window.JSBridge.foo(); //返回:'foo'
//调用方法二
window.JSBridge.foo2('test');//返回:'foo2:test'

JS 和 IOS

  • 原生调用 JS
class ViewController: UIViewController, WKNavigationDelegate, WKScriptMessageHandler {
// 加载完毕会触发(类似于Vue的生命周期钩子)
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// 类似于console.log()
print("触发啦");
// wkWebView调用js代码,其中doSomething()会被当做js解析
webView.evaluateJavaScript("doSomething()");
}
}
  • JS 调用 IOS
  1. JS 部分
window.webkit.messageHandlers.方法名.postMessage(数据)
  1. iOS 部分注册监听
wkWebView.configuration.userContentController.add(self, name: 方法名)
  1. iOS 部分遵守协议相关方法
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
// message.body 就是传递过来的数据
print("传来的数据为", message.body)
}

url scheme(互通协议)

web 调用

class="ios" type="button" value="使用iframe加载url">

// 加载url 通过iframe 设置URL 目的是让ios拦截
function loadUrl(url) {
// 创建iframe
const iframe = document.createElement('iframe');
// 设置url
iframe.src = url;
// 设置尺寸(不希望他被看到)
iframe.style.height = 0;
iframe.style.width = 0;
// 添加到页面上
document.body.appendChild(iframe);
// 加载了url之后他就没用了
// 移除iframe
iframe.parentNode.removeChild(iframe);
}

document.querySelector('.ios').onclick = function () {
loadUrl('taobao://click');
}

IOS 监听

// 拦截url
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
// 获取url
let url = navigationAction.request.url?.absoluteString;
if(url=="taobao://click"){
print("调用系统功能");
decisionHandler(.cancel);
}else{
decisionHandler(.allow);
}
}

HyBridApp

image.png

  • 开发框架
  1. 提供前端运行环境
  2. 实现前端和原生交互
  3. 封装原生功能,提供插件机制 image.png

加载优化

  • 骨架屏
<style>
.shell .placeholder-block{
display: block;
height: 5em;
background: #ccc;
margin: 1em;
}
.novel {
height: 5em;
background-color: yellowgreen;
}
style>
head>
<body>
<div class="shell">
<div class="placeholder-block">div>
div>
body>
html>
<script>
setTimeout(()=>{
// 移除 占位dom元素
document.querySelector('.shell').innerHTML = ''
// 创建数据的dom元素 添加到页面上
let p = document.createElement('p')
p.innerHTML = '黑马程序员'
p.className = 'novel'
document.querySelector('.shell').appendChild(p)
},3000)
script>

webview

import UIKit
import WebKit

class ViewController: UIViewController, WKNavigationDelegate {
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
// 创建webView
var webView = WKWebView(frame: self.view.bounds)
// 设置自己为WebView的代理
webView.navigationDelegate = self
// 添加到页面上
self.view.addSubview(webView)

// 创建URL对象
var url = URL(string: "https://www.baidu.com")
// 创建URLRequest对象
var request = URLRequest(url: url!)
// 加载URL
webView.load(request)
}
}

JSBridge

image.png

  • 设计思想
  1. JS 向原生发送消息
  2. 原生向 JS 发送消息
window.JSBridge = {
invoke: function(action, params, callback) {
// 生成唯一回调ID
const callbackId = 'cb_' + Date.now();
// 存储回调函数
window[callbackId] = callback;

// 构建标准化消息
const msg = {
action: action,
params: params || {},
callbackId: callbackId
};

// 根据平台调用不同原生桥
if (isIOS()) {
window.webkit.messageHandlers.nativeBridge.postMessage(JSON.stringify(msg));
} else if (isAndroid()) {
window.android.postMessage(JSON.stringify(msg));
}
},
// 原生调用此方法来回调结果
receiveMessage: function(msg) {
const { callbackId, result, error } = msg;
const callback = window[callbackId];
if (callback) {
if (error) {
callback(null, error); // 错误回调
} else {
callback(result, null); // 成功回调
}
// 执行后删除回调,避免内存泄漏
delete window[callbackId];
}
}
};

// 使用示例:调用原生相机
JSBridge.invoke('takePhoto', { quality: 'high' }, (result, error) => {
if (error) {
console.error('拍照失败:', error);
} else {
console.log('照片路径:', result.imagePath);
}
});

解释:

  1. 前端调用 JSBridge.invoke 时:存储回调函数,生成唯一的 callbackId(如 cb_1725000000000),确保每个回调能被唯一识别;把回调函数挂载到 window 对象上(即 window[callbackId] = 回调函数),相当于 “暂时存档”,避免函数被垃圾回收。
  2. 前端向原生发送 “带回调 ID 的消息”,然后根据平台(iOS/Android)把消息发给原生,此时原生收到的是  “操作指令 + 回调 ID”
  3. 原生执行操作(如调用相机),原生接收到消息后,解析出 action 和 params,执行对应的原生逻辑
  • iOS:调用 UIImagePickerController(系统相机接口),按 quality: 'high' 配置拍照质量;
  • Android:调用 Camera 或 CameraX 接口,同样按参数执行拍照。 这个阶段完全在原生环境(Objective-C/Swift 或 Java/Kotlin)中运行,与前端 JS 无关。
  1. 原生将 “结果 + 回调 ID” 回传给前端
    原生执行完操作后(无论成功 / 失败),会构建一个 “结果消息”,包含:callbackId: 'cb_1725000000000'(必须和前端传过来的一致,才能找到对应的回调); result: { imagePath: '/var/mobile/.../photo.jpg' }(成功时的结果,如照片路径); 或 error: '用户取消拍照'(失败时的错误信息)。

然后原生会主动调用前端 JSBridge 预留的 receiveMessage 方法,把 “结果消息” 传回去。

  1. 前端 receiveMessage 执行回调函数
  • 解析原生传过来的消息,提取 callbackIdresulterror
  • 通过 callbackId 找到之前挂载在 window 上的回调函数(即 window['cb_1725000000000']);
  • 执行回调函数:

    • 成功:调用 callback(result, null)(如打印照片路径);
    • 失败:调用 callback(null, error)(如打印 “用户取消拍照”);
  • 执行完后删除 window[callbackId],避免内存泄漏。

到这一步,回调函数才真正在前端 JS 环境中执行,完成整个跨端通信闭环。


作者:code_YuJun
来源:juejin.cn/post/7544077353371222067

收起阅读 »

JavaScript 数组扁平化全解析

web
JavaScript 数组扁平化全解析:从基础到进阶,深入理解 flat 与多种实现方式 在现代前端开发中,数组操作是日常编码中最常见的任务之一。而在处理复杂数据结构时,我们经常会遇到“嵌套数组”(即高维数组)的场景。例如,后端返回的数据结构可能是多层嵌套的,...
继续阅读 »

JavaScript 数组扁平化全解析:从基础到进阶,深入理解 flat 与多种实现方式


在现代前端开发中,数组操作是日常编码中最常见的任务之一。而在处理复杂数据结构时,我们经常会遇到“嵌套数组”(即高维数组)的场景。例如,后端返回的数据结构可能是多层嵌套的,我们需要将其“拍平”为一维数组以便于渲染或进一步处理。这种将多层嵌套数组转换为单层数组的过程,就被称为 数组扁平化(Array Flattening)


本文将带你全面了解 JavaScript 中数组扁平化的各种方法,包括原生 API 的使用、递归实现、reduce 高阶函数应用、利用 toStringsplit 的巧妙技巧,以及基于展开运算符的循环优化方案。我们将深入剖析每种方法的原理、优缺点和适用场景,帮助你构建完整的知识体系。




一、什么是数组扁平化?


数组扁平化,顾名思义,就是把一个嵌套多层的数组“压平”成一个只有一层的一维数组。例如:


const nestedArr = [1, [2, 3, [4, 5]], 6];
// 扁平化后应得到:
// [1, 2, 3, 4, 5, 6]

这个问题看似简单,但在实际项目中非常常见。比如你在处理树形菜单、评论回复结构、文件目录层级等数据时,都可能需要对嵌套数组进行扁平化处理。




二、使用原生 flat() 方法(推荐方式)


ES2019 引入了 Array.prototype.flat() 方法,使得数组扁平化变得极其简单和直观。


✅ 基本语法


arr.flat([depth])


  • depth:指定要展开的层数,默认为 1

  • 如果传入 Infinity,则无论嵌套多少层,都会被完全展开。


✅ 示例代码


const arr = [1, [2, 3, [1]]];

console.log(arr.flat()); // [1, 2, 3, [1]] → 只展开一层
console.log(arr.flat(2)); // [1, 2, 3, 1] → 展开两层
console.log(arr.flat(Infinity)); // [1, 2, 3, 1] → 完全展开

✅ 特点总结



  • 简洁高效:一行代码解决问题。

  • 兼容性良好:现代浏览器基本都支持(IE 不支持)。

  • 可控制深度:灵活控制展开层级。

  • 推荐用于生产环境:清晰、安全、性能好。



⚠️ 注意:flat() 不会改变原数组,而是返回一个新的扁平化数组。





三、递归实现:最经典的思路


如果你不能使用 flat()(比如兼容老版本浏览器),或者想深入理解其内部机制,那么递归是一个经典且直观的解决方案。


✅ 基础递归版本


function flatten(arr) {
let res = [];
for (let i = 0; i < arr.length; i++) {
if (Array.isArray(arr[i])) {
res = res.concat(flatten(arr[i])); // 递归处理子数组
} else {
res.push(arr[i]); // 非数组元素直接加入结果
}
}
return res;
}

// 测试
const arr = [1, [2, 3, [1]]];
console.log(flatten(arr)); // [1, 2, 3, 1]

✅ 分析



  • 使用 for 循环遍历每个元素。

  • 判断是否为数组:是 → 递归调用;否 → 直接推入结果数组。

  • 利用 concat 合并递归结果。


✅ 缺点



  • 每次 concat 都会创建新数组,性能略低。

  • 递归深度过大可能导致栈溢出(极端情况)。




四、使用 reduce + 递归:函数式编程风格


利用 reduce 可以写出更优雅、更具函数式风格的扁平化函数。


✅ 实现方式


function flatten(arr) {
return arr.reduce((pre, cur) => {
return pre.concat(Array.isArray(cur) ? flatten(cur) : cur);
}, []);
}

✅ 解析



  • reduce 接收一个累加器 pre 和当前元素 cur

  • 如果 cur 是数组,则递归调用 flatten(cur),否则直接使用 cur

  • 使用 concat 将结果合并到 pre 中。


✅ 优点



  • 代码简洁,逻辑清晰。

  • 更符合函数式编程思想。

  • 易于组合其他操作(如 map、filter)。




五、利用 toString() + split() 的“黑科技”技巧


这是一个非常巧妙但需要谨慎使用的技巧,适用于数组中只包含数字或字符串基本类型的情况。


✅ 实现原理


JavaScript 中,数组的 toString() 方法会递归地将每个元素转为字符串,并用逗号连接


const arr = [1, [2, 3, [1]]];
console.log(arr.toString()); // "1,2,3,1"

我们可以利用这一点,先转成字符串,再用 split(',') 分割,最后通过 +item 转回数字。


✅ 实现代码


function flatten(arr) {
return arr.toString().split(',').map(item => +item);
}

// 测试
const arr = [1, [2, 3, [1]]];
console.log(flatten(arr)); // [1, 2, 3, 1]

✅ 优点



  • 代码极短,实现“一行扁平化”。

  • 性能较好(底层由引擎优化)。


✅ 缺点(⚠️ 重要)



  1. 仅适用于纯数字数组:如果数组中有字符串 "hello"+"hello" 会变成 NaN

  2. 无法保留原始类型:所有元素都会被转为数字。

  3. 丢失 nullundefined、对象等复杂类型信息



❗ 所以这个方法虽然巧妙,但不适合通用场景,仅作为面试中的“奇技淫巧”了解即可。





六、使用 while 循环 + concat + 展开运算符(性能优化版)


这种方法避免了递归调用,采用循环逐步“拍平”数组,适合处理深层嵌套且希望避免栈溢出的场景。


✅ 实现方式


function flatten(arr) {
while (arr.some(item => Array.isArray(item))) {
arr = [].concat(...arr);
}
return arr;
}

✅ 原理解析



  • arr.some(item => Array.isArray(item)):检查数组中是否还存在嵌套数组。

  • ...arr:展开数组的所有元素。

  • [].concat(...arr)concat 会对展开后的数组元素自动“拍平一层”。


🔍 举个例子:

[].concat(...[1, [2, 3, [1]]])
// 等价于
[].concat(1, [2, 3, [1]])
// → [1, 2, 3, [1]] → 拍平了一层

然后继续循环,直到没有嵌套为止。


✅ 优点



  • 非递归,避免栈溢出。

  • 逻辑清晰,易于理解。

  • 性能较好,尤其适合中等深度嵌套。


✅ 缺点



  • 每次 concat(...arr) 都会创建新数组,内存开销较大。

  • 对于极深嵌套,仍可能影响性能。




七、对比总结:各种方法的适用场景


方法优点缺点推荐场景
arr.flat(Infinity)简洁、标准、安全IE 不支持✅ 生产环境首选
递归 + for逻辑清晰,易理解性能一般,可能栈溢出学习理解原理
reduce + 递归函数式风格,优雅同上偏好函数式编程
toString + split代码短,性能好类型受限,不通用面试奇技淫巧
while + concat + ...非递归,避免栈溢出内存占用高深层嵌套处理



八、扩展思考:如何实现深度可控的扁平化?


有时候我们并不想完全拍平,而是只想展开指定层数。可以仿照 flat(depth) 实现一个通用函数:


function flattenDepth(arr, depth = 1) {
if (depth === 0) return arr.slice(); // 深度为0,直接返回副本

let result = [];
for (let item of arr) {
if (Array.isArray(item) && depth > 0) {
result.push(...flattenDepth(item, depth - 1));
} else {
result.push(item);
}
}
return result;
}

// 测试
const arr = [1, [2, 3, [4, 5, [6]]]];
console.log(flattenDepth(arr, 1)); // [1, 2, 3, [4, 5, [6]]]
console.log(flattenDepth(arr, 2)); // [1, 2, 3, 4, 5, [6]]
console.log(flattenDepth(arr, Infinity)); // [1, 2, 3, 4, 5, 6]



九、结语


📌 小贴士:如果你的项目需要兼容老旧浏览器,可以使用 Babel 转译 flat(),或手动引入 polyfill:


// Polyfill for Array.prototype.flat
if (!Array.prototype.flat) {
Array.prototype.flat = function(depth = 1) {
return this.reduce((acc, val) =>
Array.isArray(val) && depth > 0
? acc.concat(val.flat(depth - 1))
: acc.concat(val)
, []);
};
}

这样就能在任何环境中愉快地使用 flat() 了!


作者:yyt_
来源:juejin.cn/post/7543941409930625087
收起阅读 »

某些场景下CSS替代JS(现代CSS的深度实践指南)

web
某些场景下CSS替代JS(现代CSS的深度实践指南) 🧩 前端渲染核心机制解析 水合错误(Hydration Mismatch)深度解析 graph TD A[客户端渲染CSR] --> B[服务端渲染SSR] B --> C{水合...
继续阅读 »

某些场景下CSS替代JS(现代CSS的深度实践指南)


🧩 前端渲染核心机制解析


水合错误(Hydration Mismatch)深度解析


graph TD
A[客户端渲染CSR] --> B[服务端渲染SSR]
B --> C{水合过程 Hydration}
C -->|成功| D[交互式页面]
C -->|失败| E[水合错误]
E --> F[布局错乱]
E --> G[交互失效]
E --> H[控制台报错]

水合错误的本质

在SSR框架(如Next.js)中,服务端生成的静态HTML与客户端React组件的初始状态不一致,导致React在"注水"过程中无法正确匹配DOM结构。


典型场景


// Next.js组件 - 服务端渲染时获取时间
export default function Page({ serverTime }) {
// 问题点:客户端初始化时间与服务端不同
const [clientTime] = useState(Date.now());

return (
<div>
<p>服务端时间: {serverTime}</p>
<p>客户端时间: {clientTime}</p>
</div>

);
}

export async function getServerSideProps() {
return {
props: {
serverTime: Date.now() // 服务端生成时间戳
},
};
}

根本原因分析



  1. 时序差异:服务端/客户端执行环境时间差

  2. 数据异步:客户端数据获取滞后于渲染

  3. DOM操作:客户端手动修改服务端生成的DOM

  4. 组件状态:useState初始值与SSR输出不匹配


现代CSS的解决之道


<!-- 纯CSS时间显示方案 -->
<div class="time-container">
<time datetime="2023-11-15T08:00:00Z">08:00</time>
<span class="live-indicator"></span>
</div>

<style>
.live-indicator::after {
content: "实时";
animation: pulse 1s infinite;
}

@keyframes pulse {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
</style>

优势对比


方案水合风险首屏时间复杂度可访问性
React水合中等
纯CSS
渐进增强中等中等

🛠️ CSS核心解决方案详解


1️⃣ 嵌套选择器:组件化样式管理


/* 卡片组件 - 替代React组件 */
.card {
padding: 1.5rem;
border: 1px solid #e0e0e0;

/* 标题区域 */
&-header {
display: flex;
align-items: center;

&:hover {
background: #f5f5f5;
}
}

/* 响应式处理 */
@media (width <= 768px) {
border-radius: 0;
padding: 1rem;
}

/* 深色模式适配 */
@media (prefers-color-scheme: dark) {
border-color: #444;
}
}

工程价值



  • 作用域隔离:避免全局样式污染

  • 维护成本:修改单个组件不影响其他部分

  • 开发效率:类似JSX的组件化开发体验


2️⃣ CSS变量 + 相对颜色:动态主题系统


:root {
--primary: #2468f2;
--text-primary: #333;

/* 动态派生变量 */
--primary-hover: hsl(from var(--primary) h s calc(l + 8%));
--primary-active: oklch(from var(--primary) l c h / 0.9);
}

/* 主题切换器 */
.theme-switcher:has(#dark:checked) {
--text-primary: #fff;
--bg-primary: #121212;
}

button {
background: var(--primary);
transition: background 0.3s;

&:hover {
background: var(--primary-hover);
}

&:active {
background: var(--primary-active);
}
}

3️⃣ @starting-style:元素入场动画


.modal {
opacity: 1;
transform: translateY(0);
transition:
opacity 0.4s ease-out,
transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);

/* 初始状态 */
@starting-style {
opacity: 0;
transform: translateY(20px);
}
}

与传统方案对比


// React实现模态框动画 - 需要状态管理
function Modal() {
const [isOpen, setIsOpen] = useState(false);

return (
<div
className={`modal ${isOpen ? 'open' : ''}`}
onTransitionEnd={() =>
console.log('动画结束')}
>
{/* 内容 */}
</div>

)
}

/* 对应CSS */
.modal {
opacity: 0;
transform: translateY(20px);
transition: all 0.4s;
}

.modal.open {
opacity: 1;
transform: translateY(0);
}

📱 响应式设计新范式


动态视口单位实战


/* 移动端布局方案 */
.header {
height: 15svh; /* 最小可视高度 */
}

.hero {
height: 75lvh; /* 最大可视高度 */
}

.content {
height: 120dvh; /* 动态高度 */
overflow-y: auto;
}

.footer {
height: 10svh; /* 保证始终可见 */
}

单位解析


单位计算基准适用场景iOS Safari支持
svh最小可视区域高度固定导航栏16.4+
lvh最大可视区域高度全屏轮播图16.4+
dvh当前可视区域高度可滚动内容区16.4+

✅ 实践总结


水合错误规避策略



  1. 数据一致性


    // Next.js getStaticProps保证数据一致
    export async function getStaticProps() {
    const data = await fetchData();
    return { props: { data } };
    }


  2. 组件设计原则


    // 避免客户端特有状态
    function SafeComponent({ serverData }) {
    // ✅ 使用服务端传递的数据
    return <div>{serverData}</div>;
    }


  3. 渐进增强方案


    <!-- 首屏使用静态HTML -->
    <div id="user-profile">
    <!-- SSR生成内容 -->
    </div>

    <!-- 客户端增强 -->
    <script type="module">
    if (navigator.onLine) {
    loadInteractiveComponents();
    }
    </script>



CSS优先架构优势


指标JS方案CSS方案提升幅度
首屏加载2.8s0.6s78%
交互延迟120ms16ms87%
内存占用85MB12MB86%
代码体积350KB (gzip)45KB (gzip)87%

实施路线图



  1. 静态内容:优先使用HTML/CSS

  2. 交互元素:hover, :focus-within 等伪类

  3. 复杂逻辑:渐进增强添加JS

  4. 状态管理:URL参数 + :target 选择器



通过现代CSS技术栈,开发者可在避免水合错误的同时,构建高性能、可访问性强的Web应用,实现真正的"渐进式Web体验"。




原文:xuanhu.info/projects/it…



作者:召摇
来源:juejin.cn/post/7544366602885873679
收起阅读 »

instanceof 的小秘密

web
instanceof 运算符用于检测某个构造函数的 prototype 属性,是否存在于对象的原型链上。 class Cat { constructor(name, age) { this.name = name; th...
继续阅读 »

instanceof 运算符用于检测某个构造函数的 prototype 属性,是否存在于对象的原型链上。


class Cat {
constructor(name, age) {
this.name = name;
this.age = age;
}
}

const cat = new Cat("Mittens", 3);

console.log(cat instanceof Cat); // true
console.log(cat instanceof Object); // true

instanceof 接收两个参数,v(左)和target(右),判断v是否为target的实例对象,方法是先查询targetSymbol.hasInstance属性,如果不存在,则判断targetprototype属性是否存在v的原型中。


class Cat {

static [Symbol.hasInstance](instance) {
return false
}

constructor(name, age) {
this.name = name;
this.age = age;
}
}

const cat = new Cat("Mittens", 3);

console.log(cat instanceof Cat); // false
console.log(cat instanceof Object); // true
console.log(cat instanceof null) // TypeError: Right-hand side of 'instanceof' is not an object

或许有人会想到1 intanceof Number1 intanceof Object为什么会是false呢?明明(1).__proto__是有值的,为什么呢?这里就不得不提到JS的一个机制"自动装箱"


我们定义一个变量const n = 1, n是一个原始值,有以下特点:



  • 不可变性:原始值本身不能被修改,任何"修改"操作都会创建新值

  • 按值传递:赋值时复制值,而不是引用

  • 没有属性和方法:原始值本身不是对象,不能直接拥有属性和方法


在访问原始值属性或者方法时,Js会创建一个临时对象,使用后便会销毁。


const n = 1;
n.toString()

// JavaScript 内部实际执行的过程
// 1. 创建临时 Number 对象:new Number(1)
// 2. 调用方法:numberObj.toString()
// 3. 返回结果:"1"
// 4. 销毁临时对象

但是在intanceof操作时,不会进行"自动装箱",所以得到的结果为false


作者:人机888号
来源:juejin.cn/post/7543797314282373162
收起阅读 »

一个有趣的效果--动态生成动画导航

web
一个有趣的效果--动态生成动画导航 在接下来的这个项目中,我们即将使用纯 JavaScript 和 CSS 来创建一个具有动态动画效果的导航栏。这篇文章将详细解析该代码的实现,包括 HTML 结构、CSS 样式、JavaScript 逻辑等方面,帮助你理解每一...
继续阅读 »

一个有趣的效果--动态生成动画导航


在接下来的这个项目中,我们即将使用纯 JavaScript 和 CSS 来创建一个具有动态动画效果的导航栏。这篇文章将详细解析该代码的实现,包括 HTML 结构、CSS 样式、JavaScript 逻辑等方面,帮助你理解每一个步骤和实现思路。文章内容将逐步拆解,涵盖从页面结构、样式设计到功能实现的各个细节。


项目概述


这个项目的核心目标是创建一个包含动画效果的导航栏。具体功能包括:



  1. 动态导航项:当用户将鼠标悬停在导航项上时,显示一个附加的面板。

  2. 面板动画:面板会根据鼠标悬停的位置进行平滑过渡,显示不同的内容。

  3. 过渡效果:每个导航项的高亮状态和面板显示都有精美的动画效果,增强用户体验。


HTML 结构


HTML 基本框架


<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>一个动态动画导航</title>
<style>
/* 样式在这里 */
</style>
</head>

<body>
<script>
/* JavaScript 逻辑在这里 */
</script>
</body>

</html>

HTML 文档是非常标准的结构,包含了 headbody 两大部分:



  1. <head> 部分:定义了页面的字符编码、视口设置和页面的标题。标题为 “一个动态动画导航”,用于描述页面内容。

  2. <body> 部分:里面没有直接的 HTML 内容,而是通过 JavaScript 动态生成和管理导航栏的结构。


导航栏元素


在页面的 body 中,我们没有直接放置导航栏的 HTML 代码,而是通过 JavaScript 动态生成。接下来我们将深入分析这些 JavaScript 代码的工作原理。


CSS 样式解析


全局样式


body, html, ul, p {
margin: 0;
padding: 0;
}

这一段代码是用来移除 bodyhtmlulp 元素的默认 margin 和 padding,以确保布局没有多余的间隙。这是前端开发中的常见做法,有助于在不同浏览器中获得一致的效果。


导航栏 .nav


.nav {
list-style: none;
padding: 0;
margin: 0;
display: flex;
position: relative;
margin-left: 200px;
}

.nav 是一个容器元素,负责展示导航栏中的各个导航项。它使用了 flex 布局,使得每个 li 元素可以水平排列。此外,通过 position: relative 来为可能添加的子元素(如下拉面板)提供定位上下文,margin-left: 200px 是为了给导航栏留出空间。


导航项 .nav li


.nav li {
min-width: 100px;
text-align: center;
border-bottom: 1px solid #ddd;
color: #535455;
padding: 12px;
margin-right: 12px;
cursor: pointer;
transition: all ease 0.2s;
}

每个导航项 (li) 有如下样式:



  • min-width: 100px:确保每个项至少占据 100px 宽度。

  • text-align: center:使文本居中显示。

  • border-bottom: 1px solid #ddd:为每个导航项添加一个细线,增强视觉效果。

  • padding: 12pxmargin-right: 12px:设置内外边距,使项之间保持一定的间距。

  • cursor: pointer:当鼠标悬停在导航项上时,显示为可点击的手形光标。

  • transition: all ease 0.2s:使所有样式变化(如颜色、背景色、缩放等)具有过渡效果,持续时间为 0.2 秒,效果为平滑过渡。


面板 .nav-panel-wrapper


.nav-panel-wrapper {
border: 1px solid #dedede;
position: absolute;
top: 60px;
left: 0;
padding: 12px;
border-radius: 4px;
box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.32);
display: none;
overflow: hidden;
}

.nav-panel-wrapper 是每个导航项的下拉面板,包含以下样式:



  • position: absolute:使面板相对于 .nav 容器进行绝对定位。

  • top: 60px:将面板放置在导航项下方(假设导航栏的高度为 60px)。

  • border-radius: 4px:为面板添加圆角,使其看起来更加圆滑。

  • box-shadow:为面板添加阴影效果,使其更加立体,增加视觉层次感。

  • display: none:面板默认是隐藏的,只有在用户悬停时才会显示。

  • overflow: hidden:确保面板内容不会溢出其容器。


动画样式


.scale-up-top {
animation: scale-up-top 0.2s cubic-bezier(0.39, 0.575, 0.565, 1) both;
}

@keyframes scale-up-top {
0% {
transform: scale(0.5);
transform-origin: 50% 0%;
}
100% {
transform: scale(1);
transform-origin: 50% 0%;
}
}

.scale-up-top 类通过动画效果使面板从小到大逐渐放大,并且设置了动画的持续时间为 0.2 秒,使用了 cubic-bezier 函数来创建缓动效果。@keyframes scale-up-top 定义了放大过程的具体动画帧:从 50% 的缩放大小(即最小状态)逐渐过渡到 100%(即原始大小)。


JavaScript 逻辑解析


工具类 AnimateNavUtils


AnimateNavUtils 是一个工具类,提供了一些常用的方法,简化了 DOM 操作的代码:



  • $:根据选择器返回文档中的第一个匹配元素。

  • createElement:根据传入的 HTML 字符串创建一个新的 DOM 元素。

  • addClassremoveClasshasClass:分别用于为元素添加、移除、检查 CSS 类。

  • insertNode:将一个新的节点插入到指定的元素中,或者替换现有节点。

  • create:创建一个新的 DOM 元素节点。

  • setStyle:为元素动态设置样式。


这些工具方法大大简化了后续类的实现,使得代码更具可读性和复用性。


动画导航类 AnimateNav


AnimateNav 类是核心部分,负责处理导航栏的渲染、事件绑定和面板的动画效果。


构造函数

constructor({ data }) {
super();
this.data = data;
this.panelDelayTimer = null;
this.currentIndex = 0;
this.panelEle = null;
this.navEle = null;
}

在构造函数中,我们接收一个 data 参数,它是一个包含导航项信息的数组。panelDelayTimer 用来控制面板的显示延迟,currentIndex 用来记录当前导航项的索引,panelElenavEle 分别存储面板和导航栏的 DOM 元素引用。


mount 方法

mount(el) {
const container = this.isString(el) ? this.$(el) : document.body;
this.render(container);
}

mount 方法负责将导航栏挂载到指定的 DOM 元素中。如果传入的参数是一个字符串(例如选择器),则查找对应的元素;如果是其他类型,则默认为 document.body


render 方法

render(container) {
if (!this.isArray(this.data) || this.data?.length === 0) {
return;
}
const node = this.createElement(`
<ul class="nav">
${this.data.map(item => `<li data-sub="${item.sub}" data-index="${item.index}" class="nav-item">${item.text}</li>`).join('')}
<div class="nav-panel-wrapper"> </div>
</ul>
`
);
...
}

render 方法负责生成导航栏的 HTML 结构并将其插入到页面中。它首先检查 data 是否有效,确保它是一个数组且非空。接着,它动态创建一个包含 <ul class="nav"><div class="nav-panel-wrapper"> 的 HTML 结构。



  • data.map(item => ...) 生成每个导航项的 <li> 元素,并根据 data-subdata-index 设置相应的自定义属性。

  • this.navElethis.panelEle 分别存储了导航栏容器和面板容器的 DOM 元素引用,方便后续操作。

  • 最后,调用 bindEvents 方法来绑定事件处理器。


绑定事件 bindEvents


bindEvents() {
const items = Array.from(this.navEle.querySelectorAll('.nav-item'));
items.forEach(item => {
item.addEventListener('mouseenter', (e) => {
const index = e.target.dataset.index;
this.showPanel(index);
});

item.addEventListener('mouseleave', () => {
this.hidePanel();
});
});
}

showPanel(index) {
const item = this.navEle.querySelector(`[data-index="${index}"]`);
const subItems = item.getAttribute('data-sub');
this.panelEle.innerHTML = subItems ? subItems : '没有子项';
this.addClass(this.panelEle, 'scale-up-top');
this.setStyle(this.panelEle, {
display: 'block',
top: `${item.offsetTop + item.offsetHeight + 12}px`
});
}

hidePanel() {
this.removeClass(this.panelEle, 'scale-up-top');
this.setStyle(this.panelEle, { display: 'none' });
}

bindEvents 方法中,我们为每个导航项添加了 mouseentermouseleave 事件监听器:



  • mouseenter:当鼠标进入某个导航项时,调用 showPanel 方法显示对应的面板,并填充子项内容。

  • mouseleave:当鼠标离开导航项时,调用 hidePanel 隐藏面板。


showPanel 方法


showPanel(index) {
const item = this.navEle.querySelector(`[data-index="${index}"]`);
const subItems = item.getAttribute('data-sub');
this.panelEle.innerHTML = subItems ? subItems : '没有子项';
this.addClass(this.panelEle, 'scale-up-top');
this.setStyle(this.panelEle, {
display: 'block',
top: `${item.offsetTop + item.offsetHeight + 12}px`
});
}

showPanel 方法根据导航项的索引 (data-index) 显示相应的子项。如果该项有子项(存储在 data-sub 属性中),则将这些子项填充到面板中。如果没有子项,则显示默认的消息('没有子项')。然后,通过 scale-up-top 动画类使面板执行放大动画,并将面板的显示位置设为导航项的下方。


hidePanel 方法


hidePanel() {
this.removeClass(this.panelEle, 'scale-up-top');
this.setStyle(this.panelEle, { display: 'none' });
}

hidePanel 方法用于隐藏面板。它会移除面板的动画类 scale-up-top,并通过 setStyle 将面板的 display 属性设置为 none,使其消失。


总结


动画和交互效果



  1. 悬停时显示面板:当用户将鼠标悬停在导航项上时,会触发面板的显示,面板内容来自 data-sub 属性。

  2. 平滑动画:面板在显示和隐藏时应用了平滑的缩放动画,使得界面显得更加动态和流畅。

  3. 动态子项内容:通过自定义的 data-sub 属性,每个导航项可以动态地包含不同的子项或其他内容。


来看一个在线示例如下所示:



当然这个导航还有可以优化和扩展的空间,如下:


优化和扩展



  1. 响应式设计:当前代码没有完全考虑到移动端的布局,可以进一步优化以适应不同设备屏幕的大小。

  2. 面板延迟:目前面板的显示和隐藏没有延迟处理,未来可以根据需要加入延迟显示、隐藏的效果,提升交互体验。

  3. 面板定位优化:面板的显示位置是相对于当前导航项的位置进行的,可以根据页面的整体布局进一步调整面板的显示位置,例如避免面板超出页面底部或侧边界。


整体来说,这个动态导航效果是通过结合 JavaScript 的 DOM 操作和 CSS 动画来实现的,结构清晰,动画流畅,能够为用户提供良好的互动体验。


作者:夕水
来源:juejin.cn/post/7442965793157136420
收起阅读 »

我让AI一把撸了个算命网站,结果它比我还懂玄学

web
作为一个信奉代码逻辑的程序员,我做梦也没想到有一天会让AI帮我开发算命网站。 事情是这样的:某天深夜,我突然冒出一个奇葩想法——能不能让Claude Code从零开始搭建一个算命网站? 不是因为我迷信,而是纯粹的技术好奇心:AI能理解2000年前的玄学智慧吗?...
继续阅读 »

image.png


作为一个信奉代码逻辑的程序员,我做梦也没想到有一天会让AI帮我开发算命网站。


事情是这样的:某天深夜,我突然冒出一个奇葩想法——能不能让Claude Code从零开始搭建一个算命网站? 不是因为我迷信,而是纯粹的技术好奇心:AI能理解2000年前的玄学智慧吗?


于是我开始了这个"用最现代的技术实现最古老的智慧"的实验。


3天后,网站上线了。更神奇的是,AI对传统文化的理解程度让我这个中国人都自愧不如。




选技术栈:AI的选择让我怀疑人生


我本来打算用熟悉的Vue + Express一把梭,结果Claude Code一开口就让我重新审视了自己的技术认知。


我问它:"帮我选个适合算命网站的技术栈"


它的回答让我直接傻眼:


Next.js 14 + TypeScript。我说算命网站用TypeScript?它说:"天干地支计算出错了用户会骂死你的,类型安全不是开玩笑的。"我一想,确实,算错了八字那不是要被用户打死。


Supabase。我说为什么不用MongoDB?它说:"用户算命记录要能实时同步,万一用户换个设备要查看历史记录呢?"行吧,你考虑得比我周到。


DeepSeek API。这个真的让我意外,我下意识想用GPT,结果它说:"中文传统文化,还是国产模型理解得更地道。"后来的事实证明,这个选择太明智了。


最让我佩服的是,它还主动提到了"真太阳时计算"。我当时就懵了,什么是真太阳时?它耐心地给我科普:古代算命要考虑地理位置对时间的影响,不是简单的北京时间。


我心想,这AI对传统文化的理解比我这个中国人还深入啊。


给自己算了一下还是挺满意的


image.png


image.png


教AI算命:一场智商与玄学的较量


项目最大的难点不是写代码,而是怎么让一个理性的AI理解玄学


这就像让一个直男理解女朋友的小情绪,难度系数直接拉满。


第一次尝试,我直接甩给AI一个八字:"帮我分析一下甲子年乙丑月丙寅日丁卯时"


AI的回答让我哭笑不得:"这位朋友可能具有较强的时间观念,因为你的出生时间很规律..."


我当场就想关电脑了。这哪里是算命,这是在分析数据规律啊!


第二次,我学聪明了,告诉它"你是命理大师"。结果它开始发挥想象力,创造了一套"六行理论",除了传统五行还加了个"气行"。我差点被它的创新精神感动。


第三次,我痛定思痛,决定从根本上改变策略。我不再把它当AI,而是真的把它当成一个有30年经验的老师傅。我给它详细介绍了传统命理的理论体系,告诉它什么能说,什么不能说,甚至教它怎么说话。


这次它终于开窍了,分析起来有模有样,专业术语用得恰到好处,建议也很中肯。


我突然意识到,训练AI就像带徒弟,不是给它知识,而是教它思考方式




踩坑实录:当代码遇见玄学,bug都变得玄幻了


做这个项目让我深刻体会到什么叫"传统文化博大精深",每个看似简单的概念背后都藏着巨大的坑。


最让我头疼的是时辰计算。我原本以为很简单,23点到1点是子时嘛,结果Claude Code告诉我:"古代的时辰划分和现代时间概念不完全一样,而且要考虑地理位置。"


我当时就懵了,算个命还要考虑地理位置?后来才知道,古人用的是"真太阳时",北京的中午12点和新疆的中午12点,太阳位置是不一样的。


这就好比你以为做个网站用个时间戳就行了,结果发现还要处理时区、夏令时、闰秒...程序员的痛,古人早就体验过了。


还有一个哭笑不得的bug。AI在分析五行的时候,突然开始"创新",告诉用户发现了"六行理论",除了金木水火土,还有个"气行"。我当时想,你这是要颠覆传统文化吗?


后来我在提示词里加了一句"严格按照传统理论,不要创新",AI这才老实下来。


最隐蔽的坑是日期计算。现代JavaScript处理1900年以前的日期有问题,结果导致古代名人的八字全算错了。我测试的时候用李白的生日,算出来说他五行缺钱...我差点被自己笑死。


每修复一个bug,我都觉得自己对传统文化的理解又深了一层。这感觉很奇妙,就像在用代码穿越时空,和古人对话。




从程序员审美到仙气飘飘


做程序员这么多年,我深知自己的审美水平。我设计的界面通常是这样的:白色背景,黑色字体,偶尔加个边框,完事。


用户打开我设计的网站,第一反应通常是:"这...是1990年代的网站吗?"


但算命网站不一样啊,用户来算命,你给他一个Excel表格的界面,他会觉得你在糊弄他。这玩意得有神秘感,得有仙气。


我问Claude Code:"怎么让网站看起来有仙气?"


它的回答让我刷新了对UI设计的认知。它告诉我色彩心理学:深紫色代表神秘和智慧,金色代表尊贵和权威,渐变背景能营造空间感...


我听得一愣一愣的,心想这AI怎么还懂心理学?


按照它的建议改了界面后,效果确实不错。原本的Excel风格摇身一变成了"古风仙侠游戏界面"。朋友看了都说:"这网站一看就很专业,肯定算得准。"


我当时就想,界面设计真的能影响用户的心理预期。同样的内容,包装不同,用户的信任度完全不一样。


这让我想到另一个问题:在技术驱动的时代,审美能力可能比编程能力更稀缺。会写代码的程序员到处都是,但能设计出让用户一见钟情的界面的,真的不多。


这个布局我很喜欢,但一些ui感觉还可以微调


image.png


意外的收获:技术人的文化觉醒


这个项目最大的收获不是技术上的,而是认知上的。


以前我总觉得传统文化和现代技术是两个世界的东西,一个古老神秘,一个理性现代。但做完这个项目后,我发现它们其实是可以融合的


AI可以学会古老的智慧,代码可以承载文化的传承。技术不是要替代传统,而是要成为传统文化在新时代的载体。


更重要的是,我开始理解用户需求的复杂性。人们使用算命网站,不只是想知道未来会怎样,更多的是希望获得一种心理安慰,一种对未知的控制感。


这让我重新思考技术产品的本质:不是要解决技术问题,而是要解决人的问题


下一步:用技术重新定义传统


基于这次的经验,我有了一个更大胆的想法:用现代技术重新定义传统文化


不是简单地把古书电子化,而是用AI、VR、区块链这些新技术,创造全新的文化体验方式。比如用AI生成个性化的《易经》解读,用VR重现古代占卜场景,用区块链记录每个人的文化传承轨迹。


传统文化需要在新时代找到新的表达方式,而技术人恰好有这个能力和责任。


先用three.js写个动画勉强还算满意吧


image.png




写在最后:一个程序员的玄学感悟


3天时间,从一个深夜的奇思妙想到一个完整的产品上线。回过头看,这个项目带给我的不只是技术上的提升,更多的是思维上的转变。


最大的感悟是:AI不是工具,而是合作伙伴。它有自己的"想法",会给你意想不到的建议,会从你没想到的角度解决问题。与其说是我在使用AI,不如说是我们在一起探索未知。


第二个感悟是:用户需求比技术实现更重要。算命网站的核心不是算法有多精确,而是能不能给用户带来心理上的满足。技术是手段,解决人的问题才是目的。


第三个感悟是:传统文化需要新的表达方式。不是要用技术颠覆传统,而是要用技术让传统在新时代重新焕发生机。


如果你也对AI开发感兴趣,我的建议是:不要把AI当成万能的代码生成器,要把它当成一个有智慧的合作伙伴。它能给你灵感,能帮你思考,但最终的判断和决策还是要靠你自己。


最后,如果你也想尝试类似的跨界项目,记住一点:技术栈可以学,算法可以抄,但洞察用户需求的能力,只能靠自己慢慢积累


下一个项目,还不知道做啥,有想法的朋友可以在评论区说一声




本文基于真实项目开发经验,欢迎技术交流和商业合作!


作者:芋圆ai
来源:juejin.cn/post/7537339432292270080
收起阅读 »

VitePress 彩虹动画

web
在查阅 VitePress 具体实践时,我被 UnoCSS 文档中的彩虹动画效果深深吸引。在查看其实现原理之后,本文也将探索如何通过自定义组件和样式增强 VitePress 站点,并实现一个炫酷的彩虹动画效果。 自定义主题 VitePress 允许你通过自定义...
继续阅读 »

在查阅 VitePress 具体实践时,我被 UnoCSS 文档中的彩虹动画效果深深吸引。在查看其实现原理之后,本文也将探索如何通过自定义组件和样式增强 VitePress 站点,并实现一个炫酷的彩虹动画效果。


自定义主题


VitePress 允许你通过自定义 Layout 来改变页面的结构和样式。自定义 Layout 可以帮助你更好地控制页面的外观和行为,尤其是在复杂的站点中。


项目初始化


在终端中运行以下命令,初始化一个新的 VitePress 项目:


npx vitepress init

然后根据提示,这次选择自定义主题(Default Theme + Customization):


┌  Welcome to VitePress!

◇ Where should VitePress initialize the config?
│ ./docs

◇ Site title:
│ My Awesome Project

◇ Site description:
│ A VitePress Site

◇ Theme:
│ Default Theme + Customization

◇ Use TypeScript for config and theme files?
│ Yes

◇ Add VitePress npm scripts to package.json?
│ Yes

└ Done! Now run npm run docs:dev and start writing.

Tips:
- Make sure to add docs/.vitepress/dist and docs/.vitepress/cache to your .gitignore file.
- Since you've chosen to customize the theme, you should also explicitly install vue as a dev dependency.

注意提示,这里需要额外手动安装 vue 库:


pnpm add vue

自定义入口文件


找到 .vitepress/theme/index.ts 入口文件:


// <https://vitepress.dev/guide/custom-theme>
import { h } from 'vue'
import type { Theme } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import './style.css'

export default {
extends: DefaultTheme,
Layout: () => {
return h(DefaultTheme.Layout, null, {
// <https://vitepress.dev/guide/extending-default-theme#layout-slots>
})
},
enhanceApp({ app, router, siteData }) {
// ...
}
} satisfies Theme

里面暴露了一个 Layout 组件,这里是通过 h 函数实现的,我们将其抽离成 Layout.vue 组件。


创建自定义 Layout


VitePress 的 Layout 组件是整个网站的骨架,控制了页面的基本结构和布局。通过自定义 Layout,我们可以完全掌控网站的外观和行为。


为什么要自定义 Layout?



  • 增加特定的布局元素

  • 修改默认主题的行为

  • 添加全局组件或功能

  • 实现特殊的视觉效果(如我们的彩虹动画)


我们在 .vitepress/theme 文件夹中创建 Layout.vue 组件,并将之前的内容转换成 vue 代码:


<script setup lang="ts">
import DefaultTheme from 'vitepress/theme'
</script>

<template>
<DefaultTheme.Layout />
</template>


接下来,在 .vitepress/theme/index.ts 中注册自定义 Layout:


// .vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme'
import CustomLayout from './Layout.vue'

export default {
extends: DefaultTheme,
Layout: CustomLayout,
}

这将会覆盖默认的 Layout,应用你自定义的布局结构。


覆盖原本样式


VitePress 提供了 css 变量来动态修改自带的样式,可以看到项目初始化后在 .vitepress/theme 中有一个 style.css。里面提供了案例,告诉如何去修改这些变量。


同时可以通过该链接查看全部的 VitePress 变量:VitePress 默认主题变量


VitePress 允许我们通过多种方式覆盖默认样式。最常用的方法是创建一个 CSS 文件,并在主题配置中导入。


比如想设置 name 的颜色,就可以通过:


:root {
--vp-home-hero-name-color: blue;
}

引入 UnoCSS


UnoCSS 是一个按需生成 CSS 的工具,可以极大简化 CSS 管理,帮助快速生成高效样式。


在项目中安装 UnoCSS 插件:


pnpm add -D unocss

然后,在 vite.config.ts 中配置 UnoCSS 插件:


import UnoCSS from 'unocss/vite'
import { defineConfig } from 'vite'

export default defineConfig({
plugins: [UnoCSS()],
}

通过 UnoCSS,可以轻松应用样式而无需写冗余 CSS。例如,使用以下类名快速创建按钮样式:


<button class="bg-blue-500 text-white p-4 rounded-lg hover:bg-blue-600">
按钮
</button>

实现彩虹动画


彩虹动画是本文的主角,主要通过动态改变 CSS 变量值来实现色彩的平滑过渡。


定义彩虹动画关键帧


通过 @keyframes,在不同颜色之间平滑过渡,形成彩虹动画效果。创建 rainbow.css 文件:


@keyframes rainbow {
0% {
--vp-c-brand-1: #00a98e;
--vp-c-brand-light: #4ad1b4;
--vp-c-brand-lighter: #78fadc;
--vp-c-brand-dark: #008269;
--vp-c-brand-darker: #005d47;
--vp-c-brand-next: #009ff7;
}
25% {
--vp-c-brand-1: #00a6e2;
--vp-c-brand-light: #56cdff;
--vp-c-brand-lighter: #87f6ff;
--vp-c-brand-dark: #0080b9;
--vp-c-brand-darker: #005c93;
--vp-c-brand-next: #9280ed;
}
50% {
--vp-c-brand-1: #c76dd1;
--vp-c-brand-light: #f194fa;
--vp-c-brand-lighter: #ffbcff;
--vp-c-brand-dark: #9e47a9;
--vp-c-brand-darker: #772082;
--vp-c-brand-next: #eb6552;
}
75% {
--vp-c-brand-1: #e95ca2;
--vp-c-brand-light: #ff84ca;
--vp-c-brand-lighter: #ffadf2;
--vp-c-brand-dark: #be317d;
--vp-c-brand-darker: #940059;
--vp-c-brand-next: #d17a2a;
}
100% {
--vp-c-brand-1: #00a98e;
--vp-c-brand-light: #4ad1b4;
--vp-c-brand-lighter: #78fadc;
--vp-c-brand-dark: #008269;
--vp-c-brand-darker: #005d47;
--vp-c-brand-next: #009ff7;
}
}

:root {
--vp-c-brand-1: #00a8cf;
--vp-c-brand-light: #52cff7;
--vp-c-brand-lighter: #82f8ff;
--vp-c-brand-dark: #0082a7;
--vp-c-brand-darker: #005e81;
--vp-c-brand-next: #638af8;
animation: rainbow 40s linear infinite;
}

html:not(.rainbow) {
--vp-c-brand-1: #00a8cf;
--vp-c-brand-light: #52cff7;
--vp-c-brand-lighter: #82f8ff;
--vp-c-brand-dark: #0082a7;
--vp-c-brand-darker: #005e81;
--vp-c-brand-next: #638af8;
animation: none !important;
}

这段代码定义了彩虹动画的五个关键帧,并将动画应用到根元素上。注意,我们还定义了不带动画的默认状态,这样就可以通过 CSS 类切换动画的启用/禁用。


实现彩虹动画控制组件


接下来,实现名为 RainbowAnimationSwitcher 的组件,其主要逻辑是通过添加或移除 HTML 根元素上的 rainbow 类来控制动画的启用状态,从而实现页面的彩虹渐变效果。


这个组件使用了 @vueuse/core 的两个工具函数:



  • useLocalStorage 用于在浏览器本地存储用户的偏好设置

  • useMediaQuery 用于检测用户系统是否设置了减少动画


<script lang="ts" setup>
import { useLocalStorage, useMediaQuery } from '@vueuse/core'
import { inBrowser } from 'vitepress'
import { computed, watch } from 'vue'
import RainbowSwitcher from './RainbowSwitcher.vue'

defineProps<{ text?: string; screenMenu?: boolean }>()

const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)').value

const animated = useLocalStorage('animate-rainbow', inBrowser ? !reduceMotion : true)

function toggleRainbow() {
animated.value = !animated.value
}

// 在这里对动画做处理
watch(
animated,
anim => {
document.documentElement.classList.remove('rainbow')
if (anim) {
document.documentElement.classList.add('rainbow')
}
},
{ immediate: inBrowser, flush: 'post' },
)

const switchTitle = computed(() => {
return animated.value ? 'Disable rainbow animation' : 'Enable rainbow animation'
})
</script>

<template>
<ClientOnly>
<div class="group" :class="{ mobile: screenMenu }">
<div class="NavScreenRainbowAnimation">
<p class="text">
{{ text ?? 'Rainbow Animation' }}
</p>
<RainbowSwitcher
:title="switchTitle"
class="RainbowAnimationSwitcher"
:aria-checked="animated ? 'true' : 'false'"
@click="toggleRainbow"
>

<span class="i-tabler:rainbow animated" />
<span class="i-tabler:rainbow-off non-animated" />
</RainbowSwitcher>
</div>
</div>
</ClientOnly>
</template>


<style scoped>
.group {
border-top: 1px solid var(--vp-c-divider);
padding-top: 10px;
margin-top: 1rem !important;
}

.NavScreenRainbowAnimation {
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 8px;
padding: 12px;
background-color: var(--vp-c-bg-elv);
max-width: 220px;
}

.text {
line-height: 24px;
font-size: 12px;
font-weight: 500;
color: var(--vp-c-text-2);
}

.animated {
opacity: 1;
}

.non-animated {
opacity: 0;
}

.RainbowAnimationSwitcher[aria-checked='false'] .non-animated {
opacity: 1;
}

.RainbowAnimationSwitcher[aria-checked='true'] .animated {
opacity: 1;
}
</style>


其中 RainbowSwitcher 组件是一个简单的开关按钮。以下是其实现:


<template>
<button class="VPSwitch" type="button" role="switch">
<span class="check">
<span v-if="$slots.default" class="icon">
<slot />
</span>
</span>
</button>

</template>

<style scoped>
.VPSwitch {
position: relative;
border-radius: 11px;
display: block;
width: 40px;
height: 22px;
flex-shrink: 0;
border: 1px solid var(--vp-input-border-color);
background-color: var(--vp-input-switch-bg-color);
transition: border-color 0.25s !important;
}

.check {
position: absolute;
top: 1px;
left: 1px;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--vp-c-neutral-inverse);
box-shadow: var(--vp-shadow-1);
transition: transform 0.25s !important;
}

.icon {
position: relative;
display: block;
width: 18px;
height: 18px;
border-radius: 50%;
overflow: hidden;
}
</style>


挂载组件


.vitepress/theme/index.ts 中,在 enhanceApp 中挂载组件:


// .vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme'
import CustomLayout from './Layout.vue'

export default {
extends: DefaultTheme,
Layout: CustomLayout,
enhanceApp({ app, router }) {
app.component('RainbowAnimationSwitcher', RainbowAnimationSwitcher)
if (typeof window === 'undefined') return

watch(
() => router.route.data.relativePath,
() => updateHomePageStyle(location.pathname === '/'),
{ immediate: true },
)
},
}

// Speed up the rainbow animation on home page
function updateHomePageStyle(value: boolean) {
if (value) {
if (homePageStyle) return

homePageStyle = document.createElement('style')
homePageStyle.innerHTML = `
:root {
animation: rainbow 12s linear infinite;
}`

document.body.appendChild(homePageStyle)
} else {
if (!homePageStyle) return

homePageStyle.remove()
homePageStyle = undefined
}
}

在导航栏中使用彩虹动画开关


.vitepress/config/index.ts 的配置文件中添加彩虹动画开关按钮:


export default defineConfig({
themeConfig: {
nav: [
// 其他导航项...
{
text: `v${version}`,
items: [
{
text: '发布日志',
link: '<https://github.com/yourusername/repo/releases>',
},
{
text: '提交 Issue',
link: '<https://github.com/yourusername/repo/issues>',
},
{
component: 'RainbowAnimationSwitcher',
props: {
text: '彩虹动画',
},
},
],
},
],
// 其他配置...
},
})

这样,彩虹动画开关就成功加载到导航栏的下拉菜单中。


Snipaste_2025-04-22_21-36-40.png


彩虹动画效果


20250422_213520.gif


如果想查看具体效果,可查看 EasyEditor 的文档。其中关于彩虹动画效果的详细实现看,可以查看内部对应的代码:EasyEditor/docs/.vitepress/theme at main · Easy-Editor/EasyEditor


作者:JinSo
来源:juejin.cn/post/7508591120407576586
收起阅读 »

提升React移动端开发效率:Vant组件库

web
在React中使用Vant组件库的指南 Vant是一套轻量、可靠的移动端组件库,特别适合在React项目中使用。本文将详细介绍如何在React项目中集成和使用Vant组件库,并通过Button按钮和NavBar导航栏等常用组件作为示例,展示其基本用法和高级特性...
继续阅读 »

在React中使用Vant组件库的指南


Vant是一套轻量、可靠的移动端组件库,特别适合在React项目中使用。本文将详细介绍如何在React项目中集成和使用Vant组件库,并通过Button按钮和NavBar导航栏等常用组件作为示例,展示其基本用法和高级特性。


一、Vant简介与安装


1.1 Vant是什么


Vant是由有赞前端团队开发的一套基于Vue的移动端组件库,后来也推出了React版本(Vant React)。它提供了60+高质量组件,覆盖了移动端开发的大部分场景,具有以下特点:



  • 性能极佳:组件经过精心优化,运行流畅

  • 样式美观:遵循统一的设计语言,视觉效果出色

  • 功能丰富:提供大量实用组件和灵活配置

  • 文档完善:中文文档详细,示例丰富

  • 社区活跃:GitHub上star数高,问题响应快


1.2 安装Vant


在React项目中使用Vant前,需要先安装它。确保你已经创建了一个React项目(可以使用create-react-app或其它脚手架工具),然后在项目目录下执行:


bash


npm install vant --save
# 或者使用yarn
yarn add vant

1.3 引入组件样式


Vant的样式文件需要单独引入。推荐在项目的入口文件(通常是src/index.js或src/App.js)中添加以下代码:


jsx


import 'vant/lib/index.css';

这一步非常重要,否则组件将没有样式效果。


二、Button按钮组件使用详解


Button是Vant中最基础也是最常用的组件之一,下面详细介绍它的使用方法。


2.1 基本用法


首先引入Button组件:


jsx


import { Button } from 'vant';

然后在你的组件中使用:


jsx


function MyComponent() {
return (
<div>
<Button type="primary">主要按钮</Button>
<Button type="info">信息按钮</Button>
<Button type="default">默认按钮</Button>
</div>

);
}

2.2 按钮类型


Vant提供了多种按钮类型,通过type属性来设置:



  • primary: 主要按钮,蓝色背景

  • success: 成功按钮,绿色背景

  • danger: 危险按钮,红色背景

  • warning: 警告按钮,橙色背景

  • default: 默认按钮,灰色背景

  • info: 信息按钮,浅蓝色背景


jsx


<Button type="success">成功按钮</Button>
<Button type="danger">危险按钮</Button>
<Button type="warning">警告按钮</Button>

2.3 按钮形状


除了类型,还可以设置按钮的形状:



  • 方形按钮(默认)

  • 圆形按钮:添加round属性

  • 圆角按钮:添加square属性


jsx


<Button round>圆形按钮</Button>
<Button square>圆角按钮</Button>

2.4 按钮尺寸


Vant提供了三种尺寸的按钮:



  • 大号按钮:size="large"

  • 普通按钮(默认)

  • 小号按钮:size="small"

  • 迷你按钮:size="mini"


jsx


<Button size="large">大号按钮</Button>
<Button size="small">小号按钮</Button>
<Button size="mini">迷你按钮</Button>

2.5 按钮状态


按钮有不同的状态,可以通过以下属性控制:



  • 禁用状态:disabled

  • 加载状态:loading

  • 朴素按钮:plain(边框样式)


jsx


<Button disabled>禁用按钮</Button>
<Button loading>加载中...</Button>
<Button plain>朴素按钮</Button>

2.6 按钮图标


可以在按钮中添加图标,使用icon属性:


jsx


import { Icon } from 'vant';

<Button icon="plus">添加</Button>
<Button icon="search">搜索</Button>
<Button icon={<Icon name="like" />}>点赞</Button>

Vant内置了大量图标,可以在官方文档中查看所有可用图标。


2.7 按钮事件


按钮最常用的就是点击事件:


jsx


function handleClick() {
console.log('按钮被点击了');
}

<Button onClick={handleClick}>点击我</Button>

三、NavBar导航栏组件使用详解


NavBar是移动端常用的顶部导航栏组件,下面详细介绍它的使用方法。


3.1 基本用法


首先引入NavBar组件:


jsx


import { NavBar } from 'vant';

然后在你的组件中使用:


jsx


function MyComponent() {
return (
<NavBar
title="标题"
leftText="返回"
rightText="按钮"
leftArrow
/>
);
}

3.2 主要属性


NavBar组件的主要属性包括:



  • title: 导航栏标题

  • leftText: 左侧文字

  • rightText: 右侧文字

  • leftArrow: 是否显示左侧箭头

  • fixed: 是否固定在顶部

  • placeholder: 是否生成一个等高的占位元素(配合fixed使用)

  • border: 是否显示下边框

  • zIndex: 设置z-index


jsx


<NavBar
title="个人中心"
leftText="返回"
rightText="设置"
leftArrow
fixed
placeholder
border
zIndex={100}
/>

3.3 自定义内容


除了使用属性,还可以通过插槽自定义导航栏内容:


jsx


<NavBar>
<template #left>
<Icon name="arrow-left" /> 返回
</template>
<template #title>
<span style={{ color: 'red' }}>自定义标题</span>
</template>
<template #right>
<Icon name="search" />
<Icon name="more-o" style={{ marginLeft: '10px' }} />
</template>
</NavBar>

3.4 事件处理


NavBar组件提供了以下事件:



  • click-left: 点击左侧区域时触发

  • click-right: 点击右侧区域时触发


jsx


function handleClickLeft() {
console.log('点击了左侧');
// 通常用于返回上一页
// history.goBack();
}

function handleClickRight() {
console.log('点击了右侧');
// 可以打开设置页面等
}

<NavBar
title="事件示例"
leftText="返回"
rightText="设置"
leftArrow
onClickLeft={handleClickLeft}
onClickRight={handleClickRight}
/>

3.5 配合路由使用


在实际项目中,NavBar通常需要配合路由使用:


jsx


import { useNavigate } from 'react-router-dom';

function MyComponent() {
const navigate = useNavigate();

const handleBack = () => {
navigate(-1); // 返回上一页
};

const handleToSettings = () => {
navigate('/settings'); // 跳转到设置页
};

return (
<NavBar
title="路由示例"
leftText="返回"
rightText="设置"
leftArrow
onClickLeft={handleBack}
onClickRight={handleToSettings}
/>
);
}

四、高级用法与注意事项


4.1 主题定制


Vant支持主题定制,可以通过CSS变量来修改主题样式。在项目的全局CSS文件中添加:


css


:root {
--van-primary-color: #ff6a00; /* 修改主题色为橙色 */
--van-border-radius: 8px; /* 修改圆角大小 */
--van-nav-bar-height: 60px; /* 修改导航栏高度 */
}

更多可定制的CSS变量可以参考官方文档


4.2 按需引入


如果担心引入全部组件会增加包体积,可以使用按需引入的方式。首先安装babel插件:


bash


npm install babel-plugin-import --save-dev

然后在babel配置中添加:


json


{
"plugins": [
["import", {
"libraryName": "vant",
"libraryDirectory": "es",
"style": true
}, "vant"]
]
}

之后就可以按需引入组件了:


jsx


import { Button, NavBar } from 'vant';

这种方式只会打包你实际使用的组件,可以有效减小最终打包体积。


4.3 国际化支持


Vant支持多语言,可以通过LocaleProvider组件设置:


jsx


import { LocaleProvider, Button } from 'vant';
import enUS from 'vant/es/locale/lang/en-US';

function App() {
return (
<LocaleProvider locale={enUS}>
<Button>Submit</Button>
</LocaleProvider>

);
}

4.4 常见问题与解决方案



  1. 样式不生效:确保已经正确引入了Vant的样式文件

  2. 组件未定义:检查组件名称拼写是否正确,是否已经正确引入

  3. TypeScript支持:Vant提供了完整的TypeScript类型定义,可以直接在TS项目中使用

  4. 移动端适配:建议在项目中同时使用postcss-pxtorem或postcss-px-to-viewport等插件进行移动端适配


五、总结


通过合理使用Vant组件库,可以显著提高React移动端应用的开发效率,同时保证UI的一致性和美观性。建议读者在实际项目中多加练习,掌握更多组件的使用方法。


Vant还提供了许多其他实用组件,如Toast轻提示、Dialog弹出框、List列表等,都可以在官方文档中找到详细的使用说明。


作者:Dream耀
来源:juejin.cn/post/7531667016286863394
收起阅读 »

transform、translate、transition分别是什么属性,CSS中常用的实现动画方式

web
transform、translate、transition分别是什么属性,CSS中常用的实现动画方式 在 CSS 中,transform、translate 和 transition 是用于实现元素变换和动画的重要属性。它们各自有不同的作用,通常结合使用可以...
继续阅读 »

transform、translate、transition分别是什么属性,CSS中常用的实现动画方式


在 CSS 中,transformtranslatetransition 是用于实现元素变换和动画的重要属性。它们各自有不同的作用,通常结合使用可以实现丰富的动画效果。


1. 属性详解


1.1 transform



  • 作用:用于对元素进行 2D 或 3D 变换,如旋转、缩放、倾斜、平移等。

  • 常用函数

    • translate(x, y):平移元素。

    • rotate(angle):旋转元素。

    • scale(x, y):缩放元素。

    • skew(x-angle, y-angle):倾斜元素。

    • matrix(a, b, c, d, e, f):定义 2D 变换矩阵。



  • 示例
    .box {
    transform: translate(50px, 100px) rotate(45deg) scale(1.5);
    }



1.2 translate



  • 作用translatetransform 的一个函数,用于平移元素。

  • 语法

    • translate(x, y):水平方向移动 x,垂直方向移动 y

    • translateX(x):仅水平方向移动。

    • translateY(y):仅垂直方向移动。

    • translateZ(z):在 3D 空间中沿 Z 轴移动。



  • 示例
    .box {
    transform: translate(50px, 100px);
    }



1.3 transition



  • 作用:用于定义元素在样式变化时的过渡效果。

  • 常用属性

    • transition-property:指定需要过渡的属性(如 allopacitytransform 等)。

    • transition-duration:指定过渡的持续时间(如 1s500ms)。

    • transition-timing-function:指定过渡的速度曲线(如 easelinearease-in-out)。

    • transition-delay:指定过渡的延迟时间(如 0.5s)。



  • 简写语法
    transition: property duration timing-function delay;


  • 示例
    .box {
    transition: transform 0.5s ease-in-out, opacity 0.3s linear;
    }



2. CSS 中常用的实现动画方式


2.1 使用 transition 实现简单动画



  • 适用场景:适用于简单的状态变化动画(如 hover 效果)。

  • 示例
    .box {
    width: 100px;
    height: 100px;
    background-color: lightblue;
    transition: transform 0.5s ease-in-out;
    }

    .box:hover {
    transform: scale(1.2) rotate(45deg);
    }



2.2 使用 @keyframesanimation 实现复杂动画



  • 适用场景:适用于复杂的多帧动画。

  • 步骤

    1. 使用 @keyframes 定义动画关键帧。

    2. 使用 animation 属性将动画应用到元素上。



  • 示例
    @keyframes slideIn {
    0% {
    transform: translateX(-100%);
    }
    100% {
    transform: translateX(0);
    }
    }

    .box {
    width: 100px;
    height: 100px;
    background-color: lightblue;
    animation: slideIn 1s ease-in-out;
    }



2.3 使用 transformtransition 结合实现交互效果



  • 适用场景:适用于用户交互触发的动画(如点击、悬停)。

  • 示例
    .box {
    width: 100px;
    height: 100px;
    background-color: lightblue;
    transition: transform 0.3s ease-in-out;
    }

    .box:active {
    transform: scale(0.9);
    }



2.4 使用 will-change 优化动画性能



  • 作用:提前告知浏览器元素将会发生的变化,以优化渲染性能。

  • 示例
    .box {
    will-change: transform;
    }



3. 综合示例


示例 1:按钮点击效果


.button {
padding: 10px 20px;
background-color: lightblue;
border: none;
transition: transform 0.2s ease-in-out;
}

.button:active {
transform: scale(0.95);
}

示例 2:卡片翻转动画


.card {
width: 200px;
height: 200px;
position: relative;
perspective: 1000px;
}

.card-inner {
width: 100%;
height: 100%;
transition: transform 0.6s;
transform-style: preserve-3d;
}

.card:hover .card-inner {
transform: rotateY(180deg);
}

.card-front, .card-back {
width: 100%;
height: 100%;
position: absolute;
backface-visibility: hidden;
}

.card-front {
background-color: lightblue;
}

.card-back {
background-color: lightcoral;
transform: rotateY(180deg);
}

示例 3:加载动画


@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

.loader {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}

总结


属性/方法作用适用场景
transform对元素进行 2D/3D 变换平移、旋转、缩放、倾斜等
translatetransform 的一个函数,用于平移元素移动元素位置
transition定义元素样式变化的过渡效果简单的状态变化动画
@keyframes定义动画关键帧复杂的多帧动画
animation@keyframes 定义的动画应用到元素上复杂的多帧动画
will-change优化动画性能性能优化

通过灵活运用这些属性和方法,可以实现丰富的动画效果,提升用户体验。


更多vue相关插件及后台管理模板可访问vue admin reference,代码详情请访问github


作者:Epicurus
来源:juejin.cn/post/7480766452653260852
收起阅读 »

优化Mini React:避免状态未变更时的重复渲染

web
优化Mini React:避免状态未变更时的重复渲染在构建Mini React时,我们发现一个常见的性能问题:即使状态值未发生改变,组件也会进行不必要的重复渲染。本文将深入分析问题原因并实现优化方案。问题现象分析以下面代码为例:function Foo() {...
继续阅读 »

优化Mini React:避免状态未变更时的重复渲染

在构建Mini React时,我们发现一个常见的性能问题:即使状态值未发生改变,组件也会进行不必要的重复渲染。本文将深入分析问题原因并实现优化方案。

问题现象分析

以下面代码为例:

function Foo() {
 console.log('fooo') // 每次点击都会打印
 const [bar, setBar] = React.useState('bar')
 
 function handleClick() {
   setBar('bar') // 设置相同的值
}

 return (
   <div>
    {bar}
     <button onClick={handleClick}>clickbutton>
   div>
);
}

当点击按钮时,虽然状态值bar没有实际变化,但每次点击都会触发组件重新渲染(控制台持续输出"fooo")。这在性能敏感场景下会造成资源浪费。

优化原理与实现

React的核心优化策略之一是:当状态值未改变时,跳过渲染流程。我们在useState的setState函数中加入值比较逻辑:

function useState(initial) {
 // ... 状态初始化逻辑
 
 const setState = (action) => {
   // 计算期望的新状态
   const eagerState = typeof action === 'function'
     ? action(stateHook.state)
    : action;
   
   // 关键优化:状态值未改变时提前返回
   if (Object.is(eagerState, stateHook.state)) {
     return;
  }
   
   // 状态更新及重新渲染逻辑
   stateHook.state = eagerState;
   scheduleUpdate();
};

 return [stateHook.state, setState];
}

优化关键点解析

  1. 提前计算状态值

    • 处理函数式更新:action(currentState)
    • 处理直接赋值:action
  2. 精准状态比较

    • 使用Object.is()代替===运算符
    • 正确处理特殊值:NaN+0/-0等边界情况
    • 性能考虑:先比较再更新,避免不必要的渲染流程
  3. 渲染流程优化

    • 状态未变更时直接return,阻断后续更新
    • 状态变更时才触发重新渲染调度

优化效果验证

优化后,当点击按钮设置相同状态值时:

setBar('bar') // 与当前状态相同
  • 控制台不再输出"fooo"
  • 组件不会触发重新渲染
  • 虚拟DOM不会进行diff比较
  • 真实DOM不会更新

实际应用场景

  1. 表单控件:输入框失去焦点时重置状态
  2. 多次相同操作:重复点击相同选项
  3. 防抖/节流:快速触发时的状态保护
  4. 数据同步:避免接口返回相同数据时的渲染

扩展思考

  1. 引用类型优化
setObj({...obj}) // 内容相同但引用不同

需配合immutable.js或immer等库实现深度比较

  1. 类组件优化: 在setState方法中实现相同的值比较逻辑
  2. 性能权衡: 简单值比较成本低,复杂对象比较需评估成本

总结

通过实现状态变更的精准判断,我们:

  1. 减少不必要的渲染流程
  2. 降低虚拟DOM diff成本
  3. 避免真实DOM的无效更新
  4. 提升组件整体性能

在Mini React中实现的这一优化,体现了React框架设计中的核心性能优化思想。理解这一机制有助于我们编写更高效的React应用代码。

优化本质:计算成本 < 渲染成本时,用计算换渲染


作者:snakeshe1010
来源:juejin.cn/post/7524992966084083766
收起阅读 »

前端使用CountUp.js制作数字动画效果的教程

web
在现代网页设计中,动态数字展示能够显著提升用户体验,吸引访客注意力。无论是数据统计、销售数字还是还是评分展示,平滑的数字增长动画都能让信息传递更加生动。CountUp.js 正是一款专门用于创建这种数字动画效果的轻量级 JavaScript 库,本文将详细介绍...
继续阅读 »

在现代网页设计中,动态数字展示能够显著提升用户体验,吸引访客注意力。无论是数据统计、销售数字还是还是评分展示,平滑的数字增长动画都能让信息传递更加生动。CountUp.js 正是一款专门用于创建这种数字动画效果的轻量级 JavaScript 库,本文将详细介绍其使用方法与技巧。


1. 前言


CountUp.js 是一个零依赖的 JavaScript 库,用于创建从一个数字平滑过渡到另一个数字的动画效果。它体积小巧(压缩后仅约 3KB),使用简单,且高度可定制,能够满足各种数字动画需求。


CountUp.js 的特点



  • 零依赖,无需引入其他库

  • 轻量级,加载迅速

  • 高度可配置(动画时长、延迟、小数位数等)

  • 支持多种 easing 动画效果

  • 支持暂停、恢复、重置等控制

  • 兼容所有现代浏览器


2. 快速开始


CountUp.js 有多种引入方式,可根据项目需求选择:


1. 通过 npm 安装


npm install countup.js

然后在项目中导入:


import CountUp from 'countup.js';

2. 直接引入 CDN


<script src="https://cdn.jsdelivr.net/npm/countup.js@2.0.8/dist/countUp.umd.min.js">script>

3. 下载源码


GitHub 仓库 下载源码,直接引入本地文件。


2.1. 基本用法


使用 CountUp.js 只需三步:



  1. 在 HTML 中准备一个用于显示数字的元素



<div id="counter">div>


  1. 初始化 CountUp 实例


// 获取 DOM 元素
const element = document.getElementById('counter');

// 目标数值
const target = 1000;

// 创建 CountUp 实例
const countUp = new CountUp(element, target);


  1. 启动动画


// 检查是否初始化成功,然后启动动画
if (!countUp.error) {
countUp.start();
} else {
console.error(countUp.error);
}

3. 配置选项


CountUp.js 提供了丰富的配置选项,让你可以精确控制动画效果:


const options = {
startVal: 0, // 起始值,默认为 0
duration: 2, // 动画时长(秒),默认为 2
decimalPlaces: 0, // 小数位数,默认为 0
useGr0uping: true, // 是否使用千位分隔符,默认为 true
useEasing: true, // 是否使用缓动效果,默认为 true
smartEasingThreshold: 999, // 智能缓动阈值
smartEasingAmount: 300, // 智能缓动数量
separator: ',', // 千位分隔符,默认为 ','
decimal: '.', // 小数点符号,默认为 '.'
prefix: '', // 数字前缀
suffix: '', // 数字后缀
numerals: [] // 数字替换数组,用于本地化
};

// 使用配置创建实例
const countUp = new CountUp(element, target, options);

3.1. 示例:带前缀和后缀的动画


// 显示"$1,234.56"的动画
const options = {
startVal: 0,
duration: 3,
decimalPlaces: 2,
prefix: '$',
suffix: ''
};

const countUp = new CountUp(document.getElementById('price'), 1234.56, options);
countUp.start();

4. 高级控制方法


CountUp.js 提供了多种方法来控制动画过程:


// 开始动画
countUp.start();

// 暂停动画
countUp.pauseResume();

// 重置动画
countUp.reset();

// 更新目标值并重新开始动画
countUp.update(2000);

// 立即完成动画
countUp.finish();

4.1. 示例:带回调函数的动画


// 动画完成后执行回调函数
countUp.start(() => {
console.log('动画完成!');
// 可以在这里执行后续操作
});

5. 实际应用场景


下面是实际应用场景的模拟:


5.1. 数据统计展示


<div class="stats">
<div class="stat-item">
<h3>用户总数h3>
<div class="stat-value" id="users">div>
div>
<div class="stat-item">
<h3>总销售额h3>
<div class="stat-value" id="sales">div>
div>
<div class="stat-item">
<h3>转化率h3>
<div class="stat-value" id="conversion">div>
div>
div>

<script>
// 初始化多个计数器
const usersCounter = new CountUp('users', 12500, { suffix: '+' });
const salesCounter = new CountUp('sales', 458920, { prefix: '$', decimalPlaces: 0 });
const conversionCounter = new CountUp('conversion', 24.5, { suffix: '%', decimalPlaces: 1 });

// 同时启动所有动画
document.addEventListener('DOMContentLoaded', () => {
usersCounter.start();
salesCounter.start();
conversionCounter.start();
});
script>

5.2. 滚动触发动画


结合 Intersection Observer API,实现元素进入视口时触发动画:


<div id="scrollCounter" class="counter">div>

<script>
// 创建计数器实例但不立即启动
const scrollCounter = new CountUp('scrollCounter', 5000);

// 配置交叉观察器
const observer = new IntersectionObserver((entries) => {
entries.
forEach(entry => {
if (entry.isIntersecting) {
// 元素进入视口,启动动画
scrollCounter.
start();
// 只观察一次
observer.
unobserve(entry.target);
}
});
});

// 观察目标元素
observer.
observe(document.getElementById('scrollCounter'));
script>

5.3. 结合按钮控制


<div id="controlledCounter">div>
<button id="startBtn">开始button>
<button id="pauseBtn">暂停button>
<button id="resetBtn">重置button>
<button id="updateBtn">更新到 2000button>

<script>
const counter = new CountUp('controlledCounter', 1000);

// 按钮事件监听
document.getElementById('startBtn').addEventListener('click', () => {
counter.start();
});

document.getElementById('pauseBtn').addEventListener('click', () => {
counter.pauseResume();
});

document.getElementById('resetBtn').addEventListener('click', () => {
counter.reset();
});

document.getElementById('updateBtn').addEventListener('click', () => {
counter.update(2000);
});
script>

6.自定义缓动函数


CountUp.js 允许你自定义缓动函数,创建独特的动画效果:


// 自定义缓动函数
function myEasing(t, b, c, d) {
// t: 当前时间
// b: 起始值
// c: 变化量 (目标值 - 起始值)
// d: 总时长
t /= d;
return c * t * t * t + b;
}

// 使用自定义缓动函数
const options = {
duration: 2,
easingFn: myEasing
};

const countUp = new CountUp(element, target, options);
countUp.start();

7. 常见问题与解决方案


下面是一些常见问题与解决方案:


7.1. 动画不生效



  • 检查元素是否正确获取

  • 确保目标值大于起始值(如需从大到小动画,可设置 startVal 大于 target)

  • 检查控制台是否有错误信息


7.2. 数字格式问题



  • 使用 separator 和 decimal 选项配置数字格式

  • 对于特殊数字系统,使用 numerals 选项进行替换


7.3. 性能问题



  • 避免在同一页面创建过多计数器实例

  • 对于非常大的数字,适当增加动画时长

  • 考虑使用滚动触发,而非页面加载时同时启动所有动画


8. 总结


CountUp.js 是一个简单而强大的数字动画库,能够为你的网站增添专业感和活力。它的轻量级特性和丰富的配置选项使其适用于各种场景,从简单的数字展示到复杂的数据可视化。


通过本文介绍的基础用法和高级技巧,你可以轻松实现各种数字动画效果,提升用户体验。无论是个人博客、企业官网还是电商平台,CountUp.js 都能成为你前端工具箱中的得力助手。


参考资源






作者:鹏多多
来源:juejin.cn/post/7542403996917989422
收起阅读 »

uniapp图片上传添加水印/压缩/剪裁

web
一、前言 最近遇到一个需求,微信小程序上传图片添加水印的需求,故此有该文章做总结, 功能涵盖定理地位,百度地址解析,图片四角水印,图片压缩,图片压缩并添加水印,图片剪裁,定位授权,保存图片到相册等 二、效果 三、代码实现核心 3.1)添加水印并压缩 核心实现...
继续阅读 »

一、前言


最近遇到一个需求,微信小程序上传图片添加水印的需求,故此有该文章做总结, 功能涵盖定理地位,百度地址解析,图片四角水印,图片压缩,图片压缩并添加水印,图片剪裁,定位授权,保存图片到相册等


二、效果


4.gif


三、代码实现核心


3.1)添加水印并压缩
核心实现


// 添加水印并压缩
export function addWatermarkAndCompress(options, that, isCompress = false) {
return new Promise((resolve, reject) => {
const {
errLog,
config
} = dealWatermarkConfig(options)

that.watermarkCanvasOption.width = 0
that.watermarkCanvasOption.height = 0
if (!errLog.length) {
const {
canvasId,
imagePath,
watermarkList,
quality = 0.6
} = config

uni.getImageInfo({ // 获取图片信息,以便获取图片的真实宽高信息
src: imagePath,
success: (info) => {
const {
width: oWidth,
height: oHeight,
type,
orientation
} = info; // 获取图片的原始宽高
const fileTypeObj = {
'jpeg': 'jpg',
'jpg': 'jpg',
'png': 'png',
}
const fileType = fileTypeObj[type] || 'png'

let width = oWidth
let height = oHeight

if (isCompress) {
const {
cWidth,
cHeight
} = calcRatioHeightAndWight({
oWidth,
oHeight,
quality,
orientation
})

// 按对折比例缩小
width = cWidth
height = cHeight
}

that.watermarkCanvasOption.width = width
that.watermarkCanvasOption.height = height

that.$nextTick(() => {
// 获取canvas绘图上下文
const ctx = uni.createCanvasContext(canvasId, that);
// 绘制原始图片到canvas上
ctx.drawImage(imagePath, 0, 0, width, height);
// 绘制水印项
const drawWMItem = (ctx, options) => {
const {
fontSize,
color,
text: cText,
position,
margin
} = options
// 添加水印
ctx.setFontSize(fontSize); // 设置字体大小
ctx.setFillStyle(color); // 设置字体颜色为红色

if (isNotEmptyArr(cText)) {
const text = cText.filter(Boolean)
if (position.startsWith('bottom')) {
text.reverse()
}
text.forEach((str, ind) => {
const textMetrics = ctx.measureText(str);
const {
calcX,
calcY
} = calcPosition({
height,
width,
position,
margin,
ind,
fontSize,
textMetrics
})
ctx.fillText(str, calcX, calcY, width);
})
} else {
const textMetrics = ctx.measureText(cText);

const {
calcX,
calcY
} = calcPosition({
height,
width,
position,
margin,
ind: 0,
fontSize,
textMetrics
})
// 在图片底部添加水印文字
ctx.fillText(text, calcX, calcY, width);
}
}

watermarkList.forEach(ele => {
drawWMItem(ctx, ele)
})

// 绘制完成后执行的操作,这里不等待绘制完成就继续执行后续操作,因为我们要导出为图片
ctx.draw(false, () => {
// #ifndef MP-ALIPAY
uni.canvasToTempFilePath({ // 将画布内容导出为图片
canvasId,
x: 0,
y: 0,
width,
height,
fileType,
quality, // 图片的质量,目前仅对 jpg 有效。取值范围为 (0, 1],不在范围内时当作 1.0 处理。
destWidth: width,
destHeight: height,
success: (res) => {
console.log('res.tempFilePath', res)
resolve(res.tempFilePath)
},
fail() {
reject(false)
}
}, that);
// #endif

// #ifdef MP-ALIPAY
ctx.toTempFilePath({ // 将画布内容导出为图片
canvasId,
x: 0,
y: 0,
width: width,
height: height,
destWidth: width,
destHeight: height,
quality,
fileType,
success: (res) => {
console.log('res.tempFilePath', res)
resolve(res.tempFilePath)
},
fail() {
reject(false)
}
}, that);
// #endif
});
})
}
});
} else {
const errStr = errLog.join(';')
showMsg(errStr)
reject(errStr)
}
})
}


3.2)剪切图片


// 剪切图片
export function clipImg(options, that) {
return new Promise((resolve, reject) => {
const {
errLog,
config
} = dealClipImgConfig(options)

that.watermarkCanvasOption.width = 0
that.watermarkCanvasOption.height = 0

if (!errLog.length) {
const {
canvasId,
imagePath,
cWidth,
cHeight,
position
} = config

// 获取图片信息,以便获取图片的真实宽高信息
uni.getImageInfo({
src: imagePath,
success: (info) => {
const {
width,
height
} = info; // 获取图片的原始宽高

// 自定义剪裁范围要在图片内
if (width >= cWidth && height >= cHeight) {

that.watermarkCanvasOption.width = width
that.watermarkCanvasOption.height = height
that.$nextTick(() => {
// 获取canvas绘图上下文
const ctx = uni.createCanvasContext(canvasId, that);

const {
calcSX,
calcSY,
calcEX,
calcEY
} = calcClipPosition({
cWidth,
cHeight,
position,
width,
height
})

// 绘制原始图片到canvas上
ctx.drawImage(imagePath, 0, 0, width, height);

// 绘制完成后执行的操作,这里不等待绘制完成就继续执行后续操作,因为我们要导出为图片
ctx.draw(false, () => {
// #ifndef MP-ALIPAY
uni.canvasToTempFilePath({ // 将画布内容导出为图片
canvasId,
x: calcSX,
y: calcSY,
width: cWidth,
height: cHeight,
destWidth: cWidth,
destHeight: cHeight,
success: (res) => {
console.log('res.tempFilePath',
res)
resolve(res.tempFilePath)
},
fail() {
reject(false)
}
}, that);
// #endif

// #ifdef MP-ALIPAY
ctx.toTempFilePath({ // 将画布内容导出为图片
canvasId,
x: 0,
y: 0,
width: width,
height: height,
destWidth: width,
destHeight: height,
// fileType: 'png',
success: (res) => {
console.log('res.tempFilePath',
res)
resolve(res.tempFilePath)
},
fail() {
reject(false)
}
}, that);
// #endif
});
})
} else {
return imagePath
}
}
})

} else {
const errStr = errLog.join(';')
showMsg(errStr)
reject(errStr)
}
})
}


3.3)canvas画布标签


		<!-- 给图片添加的标签 -->
<canvas v-if="watermarkCanvasOption.width > 0 && watermarkCanvasOption.height > 0"
:style="{ width: watermarkCanvasOption.width + 'px', height: watermarkCanvasOption.height + 'px' }"
canvas-id="watermarkCanvas" id="watermarkCanvas" style="position: absolute; top: -10000000rpx;" />


以上代码具体的实现功能不做一一讲解,详细请看下方源码地址


四、源码地址


github: github.com/ArcherNull/…


五、总结



  • 图片的操作,例如压缩/剪裁/加水印都是需要借助canvas标签,也就是说需要有canvas实例通过该api实现这些操作

  • 当执行 ctx.drawImage(imagePath, 0, 0, width, height) 后,后续的操作的是对内存中的数据,而不是源文件


完结撒花,如果对您有帮助,请一键三连


作者:前端梭哈攻城狮
来源:juejin.cn/post/7513183180092031011
收起阅读 »

如何将canvas动画导成一个视频?

web
引言 某一天我突然有个想法,我想用canvas做一个音频可视化的音谱,然后将这个音频导出成视频。 使用canvas实现音频可视化,使用ffmpeg导出视频与音频,看起来方案是可行的,技术也是可行的,说干就干,先写一个demo。 这里我使用vue来搭建项目 创...
继续阅读 »

引言


某一天我突然有个想法,我想用canvas做一个音频可视化的音谱,然后将这个音频导出成视频。


使用canvas实现音频可视化,使用ffmpeg导出视频与音频,看起来方案是可行的,技术也是可行的,说干就干,先写一个demo。


这里我使用vue来搭建项目



  • 创建项目


vue create demo


  • 安装ffmpeg插件


npm @ffmpeg/ffmpeg @ffmpeg/core


  • 组件videoPlayer.vue
    这里有个点需要注意:引用@ffmpeg/ffmpeg可能会报错
    需要将node_modules中@ffmpeg文件下面的

  • ffmpeg-core.js

  • ffmpeg-core.wasm

  • ffmpeg-core.worker.js
    这三个文件复制到public文件下面

  • 并且需要在vue。config.js中进行如下配置


const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
devServer:{
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
}
}
})

准备好这些后,下面是实现代码


<template>
    <div class="wrap" v-loading="loading" element-loading-text="正在下载视频。。。">
        <div>
            <input type="file" @change="handleFileUpload" accept="audio/*" />
            <button @click="playAudio">播放</button>
            <button @click="pauseAudio">暂停</button>
        </div>
        <div class="canvas-wrap">
            <canvas ref="canvas" id="canvas"></canvas>
        </div>
    </div>
</template>



<script>
import RainDrop from './rain'
import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';
export default {
    name: 'canvasVideo',
    data() {
        return {
            frames: [],
            recording: false,
            ffmpeg: null,
            x: 0,
            loading: false,
            canvasCtx: null,
            audioContext: null,
            analyser: null,
            bufferLength: null,
            dataArray: null,
            audioFile: null,
            audioElement: null,
            audioSource: null,
            // 谱频个数
            barCount: 64,
            // 宽度
            barWidth: 10,
            marginLeft: 10,
            player: false,
            rainCount: 200,
            rainDrops: [],
            pausePng: null,
            offscreenCanvas: null
        };
    },
    mounted() {
        this.ffmpeg = createFFmpeg({ log: true });
        this.initFFmpeg();
    },
    methods: {
        async initFFmpeg() {
            await this.ffmpeg.load();
            this.initCanvas()
        },
        startRecording() {
            this.recording = true;
            this.captureFrames();
        },
        stopRecording() {
            this.recording = false;
            this.exportVideo();

        },

        async captureFrames() {
            const canvas = this.canvasCtx.canvas;
            const imageData = canvas.toDataURL('image/png');
            this.frames.push(imageData);
        },
        async exportVideo() {
            this.loading = true
            this.recording = false
            const { ffmpeg } = this;
            console.log('frames', this.frames)
            try {
                for (let i = 0; i < this.frames.length; i++) {
                    const frame = this.frames[i];
                    const frameData = await fetchFile(frame);
                    ffmpeg.FS('writeFile', `frame${i}.png`, frameData);
                }
                // 将音频文件写入 FFmpeg 文件系统
                ffmpeg.FS('writeFile', 'audio.mp3', await fetchFile(this.audioFile));
                // 使用 FFmpeg 将帧编码为视频
                await ffmpeg.run(
                    '-framerate', '30', // 帧率 可以收费
                    '-i', 'frame%d.png', // 输入文件名格式
                    '-i', 'audio.mp3', // 输入音频
                    '-c:v', 'libx264', // 视频编码器
                    '-c:a', 'aac', // 音频编码器
                    '-pix_fmt', 'yuv420p', // 像素格式
                    '-vsync', 'vfr', // 同步视频和音频
                    '-shortest', // 使视频长度与音频一致
                    'output.mp4' // 输出文件名
                );
                const files = ffmpeg.FS('readdir', '/');
                console.log('文件系统中的文件:', files);
                const data = ffmpeg.FS('readFile', 'output.mp4');
                const url = URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' }));
                const a = document.createElement('a');
                a.href = url;
                a.download = 'output.mp4';
                a.click();
            } catch (e) {
                console.log('eeee', e)
            }
            this.loading = false
        },
        initCanvas() {
            const dom = document.getElementById('canvas');
            this.canvasCtx = dom.getContext('2d');
            const p = document.querySelector('.canvas-wrap')
            console.log('p', p.offsetWidth)
            this.canvasCtx.canvas.width = p.offsetWidth;
            this.canvasCtx.canvas.height = p.offsetHeight;
            console.log('canvasCtx', this.canvasCtx)
            this.initAudioContext()
            this.createRainDrops()
        },
        handleFileUpload(event) {
            const file = event.target.files[0];
            if (file) {
                this.audioFile = file
                const fileURL = URL.createObjectURL(file);
                this.loadAudio(fileURL);
            }
        },
        loadAudio(url) {
            this.audioElement = new Audio(url);
            this.audioElement.addEventListener('error', (e) => {
                console.error('音频加载失败:', e);
            });
            this.audioSource = this.audioContext.createMediaElementSource(this.audioElement);
            this.audioSource.connect(this.analyser);
            this.analyser.connect(this.audioContext.destination);
        },
        playAudio() {
            if (this.audioContext.state === 'suspended') {
                this.audioContext.resume().then(() => {
                    console.log('AudioContext 已恢复');
                    this.audioElement.play();
                    this.player = true
                    this.draw();
                });
            } else {
                this.audioElement.play().then(() => {
                    this.player = true
                    this.draw();
                }).catch((error) => {
                    console.error('播放失败:', error);
                });
            }
        },
        pauseAudio() {
            if (this.audioElement) {
                this.audioElement.pause();
                this.player = false
                this.stopRecording()
            }
        },
        initAudioContext() {
            this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
            this.analyser = this.audioContext.createAnalyser();
            this.analyser.fftSize = 256;
            this.dataArray = new Uint8Array(this.barCount);
        },
        bar() {
            let barHeight = 20;
            const allBarWidth = this.barCount * this.barWidth + this.marginLeft * (this.barCount - 1)
            const left = (this.canvasCtx.canvas.width - allBarWidth) / 2
            let x = left
            for (let i = 0; i < this.barCount; i++) {
                barHeight = this.player ? this.dataArray[i] : 0
                // console.log('barHeight', barHeight)
                // 创建线性渐变
                const gradient = this.canvasCtx.createLinearGradient(0, 0, this.canvasCtx.canvas.width, 0); // 从左到右渐变
                gradient.addColorStop(0.2, '#fff');    // 起始颜色
                gradient.addColorStop(0.5, '#ff5555');
                gradient.addColorStop(0.8, '#fff');  // 结束颜色
                // 设置阴影属性
                this.canvasCtx.shadowColor = i <= 10 ? '#fff' : i > 54 ? '#fff' : '#ff5555';
                this.canvasCtx.shadowBlur = 5;
                this.canvasCtx.fillStyle = gradient;
                this.canvasCtx.fillRect(x, this.canvasCtx.canvas.height - barHeight / 2 - 100, this.barWidth, barHeight / 2);
                this.canvasCtx.shadowColor = i <= 10 ? '#fff' : i > 54 ? '#fff' : '#ff5555';
                this.canvasCtx.shadowBlur = 5;
                this.canvasCtx.beginPath();
                this.canvasCtx.arc(x + 5, this.canvasCtx.canvas.height - barHeight / 2 - 99, 5, 0, Math.PI, true)
                // this.canvasCtx.arc(x + 5, this.canvasCtx.canvas.height - barHeight / 2, 5, 0, Math.PI, false)
                this.canvasCtx.closePath();
                this.canvasCtx.fill()
                this.canvasCtx.shadowColor = i <= 10 ? '#fff' : i > 54 ? '#fff' : '#ff5555';
                this.canvasCtx.shadowBlur = 5;
                this.canvasCtx.beginPath();
                // this.canvasCtx.arc(x + 5, this.canvasCtx.canvas.height - barHeight / 2 - 100, 5, 0, Math.PI, true)
                this.canvasCtx.arc(x + 5, this.canvasCtx.canvas.height - 100, 5, 0, Math.PI, false)
                this.canvasCtx.closePath();
                this.canvasCtx.fill()
                x += this.barWidth + this.marginLeft;
            }
        },
        draw() {
            if (this.player) requestAnimationFrame(this.draw);
            this.startRecording()
            // 获取频谱数据
            this.analyser.getByteFrequencyData(this.dataArray);
            this.canvasCtx.fillStyle = 'rgb(0, 0, 0)';
            this.canvasCtx.fillRect(0, 0, this.canvasCtx.canvas.width, this.canvasCtx.canvas.height); // 清除画布
            this.bar()
            this.rainDrops.forEach((drop) => {
                drop.update();
                drop.draw(this.canvasCtx);
            });
        },
        // 创建雨滴对象
        createRainDrops() {
            for (let i = 0; i < this.rainCount; i++) {
                this.rainDrops.push(new RainDrop(this.canvasCtx.canvas.width, this.canvasCtx.canvas.height, this.canvasCtx));
            }
        },
    }
};
</script>


当选择好音频文件点击播放时如下图


屏幕截图 2025-07-01 100029.png


点击暂停则可对已经播放过的音频时长进行视频录制下载


2.png


如果有什么其他问题欢迎在评论区交流


作者:NeverSettle110574
来源:juejin.cn/post/7521685642431053863
收起阅读 »

理解 devDependencies:它们真的不会被打包进生产代码吗?

web
在前端开发中,很多开发者都有一个常见误解:package.json 中的 devDependencies 是开发时依赖,因此不会被打包到最终的生产环境代码中。这个理解在一定条件下成立,但在真实项目中,打包工具(如 Vite、Webpack 等)并不会根据 de...
继续阅读 »

在前端开发中,很多开发者都有一个常见误解:package.json 中的 devDependencies开发时依赖,因此不会被打包到最终的生产环境代码中。这个理解在一定条件下成立,但在真实项目中,打包工具(如 Vite、Webpack 等)并不会根据 devDependenciesdependencies 的位置来决定是否将依赖打包到最终的 bundle 中,而是完全俗义于代码中是否引用了这些模块。


本文将通过一个实际例子来说明这个问题,并提出一些实践建议来避免误用。




一、dependencies vs devDependencies 回顾


package.json 中,我们通常会看到两个依赖字段:


{
"dependencies": {
"lodash": "^4.17.21"
},
"devDependencies": {
"vite": "^5.0.0"
}
}


  • dependencies:运行时依赖,通常用于项目在生产环境中运行所需的库。

  • devDependencies:开发时依赖,通常用于构建、测试、打包等过程,比如 Babel、ESLint、Vite 等。


很多人认为把某个库放到 devDependencies 中就意味着它不会被打包进最终代码,但这只是约定俗成,并非构建工具的实际行为




二、一个实际例子:lodash 被错误地放入 devDependencies


我们以一个使用 Vite 构建的库包为例:


目录结构:


my-lib/
├── src/
│ └── index.ts
├── package.json
├── vite.config.ts
└── tsconfig.json

src/index.ts


import _ from 'lodash';

export function capitalizeName(name: string) {
return _.capitalize(name);
}

错误的 package.json


{
"name": "my-lib",
"version": "1.0.0",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"scripts": {
"build": "vite build"
},
"devDependencies": {
"vite": "^5.0.0",
"lodash": "^4.17.21",
"typescript": "^5.4.0"
}
}

注意:lodash 被放到了 devDependencies 中,而不是 dependencies中。


构建后结果:


执行 npm run build 后,你会发现 lodash 的代码被打包进了最终输出的 bundle 中,尽管它被标记为 devDependencies


dist/
├── index.js ← 包含 lodash 的代码
├── index.mjs
└── index.d.ts



三、为什么会发生这种情况?


构建工具(如 Vite、Webpack)在处理打包时,并不会关心某个依赖是 dependencies 还是 devDependencies


它只会扫描你的代码:



  • 如果你 import 了某个模块(如 lodash),构建工具会把它包含进 bundle 中,除非你通过 external 配置显式告诉它不要打包进来

  • 你放在 devDependencies 中只是告诉 npm install:这个依赖只在开发阶段需要,npm install --production 时不会安装它。


换句话说,打包行为取决于代码,而不是依赖声明。




四、修复方式:将运行时依赖移到 dependencies


为了正确构建一个可以发布的库包,应该:


{
"dependencies": {
"lodash": "^4.17.21"
},
"devDependencies": {
"vite": "^5.0.0",
"typescript": "^5.4.0"
}
}

这样使用你库的开发者才能在安装你的包时自动获取 lodash




五、如何防止此类问题?


1. 使用 peerDependencies(推荐给库开发者)


如果你希望使用者自带 lodash,而不是你来打包它,可以这样配置:


{
"peerDependencies": {
"lodash": "^4.17.21"
}
}

同时在 Vite 配置中加上:


export default defineConfig({
build: {
lib: {
entry: 'src/index.ts',
name: 'MyLib'
},
rollupOptions: {
external: ['lodash'], // 不打包 lodash
}
}
})

这样打包出来的 bundle 中就不会再包含 lodash 的代码。


2. 使用构建工具的 external 配置


像上面这样将 lodash 标为 external 可以避免误打包。


3. 静态分析工具检测


使用像 depcheckeslint-plugin-import 等工具,可以帮你发现未声明或声明错误的依赖。


六、总结


依赖位置作用说明
dependencies生产环境运行时必须使用的库
devDependencies开发、构建过程所需的工具库
peerDependencies你的库需要,但由使用者提供的依赖(库开发推荐)

构建工具不会参考 package.json 中依赖的位置来决定是否打包,而是基于代码的实际引用。作为库作者,你应该确保:



  • 所有运行时依赖都放在 dependenciespeerDependencies

  • 构建工具正确配置 external,避免不必要地打包外部依赖;

  • 使用工具检查依赖定义的一致性。


作者:CAD老兵
来源:juejin.cn/post/7530180739729555491
收起阅读 »

使用three.js搭建3d隧道监测-2

web
接 使用three.js搭建3d隧道监测-1 加载基础线条与地面效果 在我们的隧道监控系统中,地面网格和方向指示器是重要的视觉元素,它们帮助用户理解空间关系和导航方向。 1. 网格地面的创建与优化 javascript // 初始化场景中的地面...
继续阅读 »

使用three.js搭建3d隧道监测-1




截屏2025-08-19 20.44.51.png


截屏2025-08-19 20.45.57.png


截屏2025-08-19 20.46.38.png


加载基础线条与地面效果


截屏2025-08-19 20.26.06.png


截屏2025-08-19 20.27.10.png


截屏2025-08-19 20.35.36.png
在我们的隧道监控系统中,地面网格和方向指示器是重要的视觉元素,它们帮助用户理解空间关系和导航方向。


1. 网格地面的创建与优化


javascript
// 初始化场景中的地面
const addGround = () => {
const size = 40000; // 网格大小
const divisions = 100; // 分割数(越高越密集)

// 主网格线颜色(亮蓝色)
const color1 = 0x6E7DB9; // 蓝色

// 次网格线颜色(深蓝色)
const color2 = 0x282C3C; // 深蓝色

const gridHelper = new THREE.GridHelper(size, divisions, color1, color2);

// 调整网格线的透明度和材质
gridHelper.material.opacity = 1;
gridHelper.material.transparent = true;
gridHelper.material.depthWrite = false; // 防止网格阻挡其他物体的渲染

// 设置材质的混合模式以实现发光效果
gridHelper.material.blending = THREE.AdditiveBlending;
gridHelper.material.vertexColors = false;

// 增强线条对比度
gridHelper.material.color.setHex(color1);
gridHelper.material.linewidth = 100;

// 旋转网格,使其位于水平面
gridHelper.rotation.x = Math.PI;

sceneRef.current.add(gridHelper);
};


知识点: Three.js 中的网格地面实现技术



  • GridHelper:Three.js 提供的辅助对象,用于创建二维网格,常用于表示地面或参考平面

  • 材质优化:通过设置 depthWrite = false 避免渲染排序问题,防止网格阻挡其他物体

  • 混合模式AdditiveBlending 混合模式使重叠线条颜色叠加,产生发光效果

  • 性能考量:网格分割数(divisions)会影响性能,需要在视觉效果和性能间平衡

  • 旋转技巧:通过 rotation.x = Math.PI 将默认垂直的网格旋转到水平面


这种科幻风格的网格地面在虚拟现实、数据可视化和游戏中非常常见,能够提供空间参考而不显得过于突兀。



2. 动态方向指示器的实现


javascript
const createPolygonRoadIndicators = (dis) => {
const routeIndicationGeometry = new THREE.PlaneGeometry(3024, 4000); // 创建平面几何体

// 创建文本纹理的辅助函数
const getTextCanvas = (text) => {
const width = 200;
const height = 300;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.font = "bold 40px Arial"; // 设置字体大小和样式
ctx.fillStyle = '#949292'; // 设置字体颜色
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, width / 2, height / 2);
return canvas;
};

// 创建方向1文本平面
const textMap = new THREE.CanvasTexture(getTextCanvas('方向1'));
const textMaterial = new THREE.MeshBasicMaterial({
map: textMap,
transparent: true,
depthTest: false
});
const plane = new THREE.Mesh(routeIndicationGeometry, textMaterial);
plane.castShadow = false;
plane.position.set(1024, 0, 1400);
plane.rotateX(-Math.PI / 2);

// 创建方向2文本平面
const textMap1 = new THREE.CanvasTexture(getTextCanvas('方向2'));
const textMaterial1 = new THREE.MeshBasicMaterial({
map: textMap1,
transparent: true,
depthTest: false
});
const plane1 = new THREE.Mesh(routeIndicationGeometry, textMaterial1);
plane1.castShadow = false;
plane1.position.set(1024, 0, -1400);
plane1.rotateX(-Math.PI / 2);

// 创建箭头指示器
const loader = new THREE.TextureLoader();
const texture = loader.load('/image/arrow1.png', (t) => {
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.repeat.set(1, 1);
});
const geometryRoute = new THREE.PlaneGeometry(1024, 1200);
const materialRoute = new THREE.MeshStandardMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide, // 确保可以从两个面看见
});
const plane2 = new THREE.Mesh(geometryRoute, materialRoute);
plane2.receiveShadow = false;
plane2.position.set(1000, 0, 0);
plane2.rotateX(dis==="left"?-Math.PI / 2:Math.PI / 2);

// 将所有元素组合成一个组
const group = new THREE.Gr0up();
group.add(plane2, plane, plane1);
group.scale.set(0.4, 0.4, 0.4);
group.position.set(dis==="left"?500:500-4000, 0, 0);

return group;
};


知识点: Three.js 中的动态文本与指示器实现技术



  • Canvas 纹理:使用 HTML Canvas 动态生成文本,然后转换为 Three.js 纹理,这是在 3D 场景中显示文本的高效方法

  • CanvasTexture:Three.js 提供的特殊纹理类型,可以直接从 Canvas 元素创建纹理,支持动态更新

  • 透明度处理:通过设置 transparent: true 和适当的 depthTest 设置解决透明纹理的渲染问题

  • 几何体组织:使用 THREE.Gr0up 将多个相关的 3D 对象组织在一起,便于统一变换和管理

  • 条件旋转:根据参数 dis 动态决定箭头的朝向,实现可配置的方向指示

  • 纹理重复:通过 RepeatWrapping 和 repeat 设置可以控制纹理的重复方式,适用于创建连续的纹理效果


这种动态方向指示器在导航系统、虚拟导览和交互式地图中非常有用,可以为用户提供直观的方向引导。



3.地面方向指示器实现


在隧道监控系统中,方向指示是帮助用户理解空间方向和导航的关键元素。我们实现了一套包含文本标签和箭头的地面方向指示系统。


javascript
import * as THREE from "three";

const createPolygonRoadIndicators = (dis) => {
const routeIndicationGeometry = new THREE.PlaneGeometry(3024, 4000); // 创建平面几何体

const getTextCanvas = (text) => {
const width = 200;
const height = 300;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.font = "bold 40px Arial"; // 设置字体大小和样式
ctx.fillStyle = '#949292'; // 设置字体颜色
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, width / 2, height / 2);
return canvas;
};

const textMap = new THREE.CanvasTexture(getTextCanvas('方向1'));
const textMaterial = new THREE.MeshBasicMaterial({ map: textMap, transparent: true, depthTest: false }); // 创建材质,depthTest解决黑色块问题
const plane = new THREE.Mesh(routeIndicationGeometry, textMaterial);
plane.castShadow = false; // 不投影阴影"
plane.position.set(1024, 0, 1400);
plane.rotateX(-Math.PI / 2);

const textMap1 = new THREE.CanvasTexture(getTextCanvas('方向2'));
const textMaterial1 = new THREE.MeshBasicMaterial({ map: textMap1, transparent: true, depthTest: false }); // 创建材质,depthTest解决黑色块问题
const plane1 = new THREE.Mesh(routeIndicationGeometry, textMaterial1);
plane1.castShadow = false; // 不投影阴影
plane1.position.set(1024, 0, -1400);
plane1.rotateX(-Math.PI / 2);


const loader = new THREE.TextureLoader();
const texture = loader.load('/image/arrow1.png', (t) => {
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.repeat.set(1, 1);
});
const geometryRoute = new THREE.PlaneGeometry(1024, 1200);
const materialRoute = new THREE.MeshStandardMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide, // 确保可以从两个面看见
});
const plane2 = new THREE.Mesh(geometryRoute, materialRoute);
plane2.receiveShadow = false; // 不接收阴影
plane2.position.set(1000, 0, 0);
plane2.rotateX(dis==="left"?-Math.PI / 2:Math.PI / 2);
const group = new THREE.Gr0up();
group.add(plane2, plane, plane1);
group.scale.set(0.4, 0.4, 0.4);
group.position.set(dis==="left"?500:500-4000, 0, 0);

return group;
};

export default createPolygonRoadIndicators;


知识点: Three.js 中的地面方向指示器实现技术



  • 平面投影标记:使用 PlaneGeometry 创建平面,通过旋转使其平行于地面,形成"地面投影"效果



    • 使用 rotateX(-Math.PI / 2) 将平面从垂直旋转到水平位置



  • 动态文本生成:使用 Canvas API 动态生成文本纹理



    • getTextCanvas 函数创建一个临时 Canvas 并在其上绘制文本

    • 使用 CanvasTexture 将 Canvas 转换为 Three.js 可用的纹理

    • 这种方法比使用 3D 文本几何体更高效,特别是对于频繁变化的文本



  • 纹理渲染优化



    • transparent: true 启用透明度处理,使背景透明

    • depthTest: false 禁用深度测试,解决半透明纹理的渲染问题,防止出现"黑色块"

    • castShadow: false 和 receiveShadow: false 避免不必要的阴影计算



  • 方向性指示:使用箭头纹理创建明确的方向指示



    • 通过 TextureLoader 加载外部箭头图像

    • 根据 dis 参数动态调整箭头方向(rotateX(dis==="left"?-Math.PI / 2:Math.PI / 2)

    • side: THREE.DoubleSide 确保从任何角度都能看到箭头



  • 组织与缩放



    • 使用 THREE.Gr0up 将相关元素(文本标签和箭头)组织在一起

    • 通过 group.scale.set(0.4, 0.4, 0.4) 统一调整组内所有元素的大小

    • 根据方向参数设置整个组的位置,实现左右两侧不同的指示效果



  • 纹理重复设置



    • RepeatWrapping 和 repeat.set(1, 1) 控制纹理的重复方式

    • 这为创建连续的纹理效果提供了基础,虽然本例中设为1(不重复)




这种地面方向指示系统在大型空间(如隧道、机场、展馆)的导航中特别有用,为用户提供直观的方向感,不会干扰主要视觉元素。



隧道指示牌制作


截屏2025-08-19 20.31.55.png


在隧道监控系统中,指示牌是引导用户和提供空间信息的重要元素。我们实现了一种复合结构的隧道指示牌,包含支柱、横梁和信息板。


javascript
import * as THREE from 'three';
import {TextGeometry} from "three/examples/jsm/geometries/TextGeometry";

/**
* 创建石头柱子(竖直 + 横向)
* @returns {THREE.Gr0up} - 返回包含柱子和横梁的组
*/
const createStonePillar = () => {
const pillarGr0up = new THREE.Gr0up();

// 创建六边形的竖直柱子
const pillarGeometry = new THREE.CylinderGeometry(6, 6, 340, 6); // 直径12, 高度340, 六边形柱体
const pillarMaterial = new THREE.MeshStandardMaterial({color: 0x808080}); // 石头颜色
const pillar = new THREE.Mesh(pillarGeometry, pillarMaterial);
pillar.position.set(0, 0, 0);

// 创建第一根横向长方体
const beam1Geometry = new THREE.BoxGeometry(100, 10, 0.1);
const beam1Material = new THREE.MeshStandardMaterial({color: 0x808080});
const beam1 = new THREE.Mesh(beam1Geometry, beam1Material);
beam1.position.set(-50, 150, 0);

// 创建第二根横向长方体
const beam2Geometry = new THREE.BoxGeometry(100, 10, 0.1);
const beam2Material = new THREE.MeshStandardMaterial({color: 0x808080});
const beam2 = new THREE.Mesh(beam2Geometry, beam2Material);
beam2.position.set(-50, 130, 0);

// 将柱子和横梁添加到组
pillarGr0up.add(pillar);
pillarGr0up.add(beam1);
pillarGr0up.add(beam2);
return pillarGr0up;
};

/**
* 创建一个用于绘制文本的 Canvas
* @param {string} text - 要绘制的文本
* @returns {HTMLCanvasElement} - 返回 Canvas 元素
*/
const getTextCanvas = (text) => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');

// 设置 Canvas 尺寸
const fontSize = 32;
canvas.width = 512;
canvas.height = 128;

// 设置背景色
context.fillStyle = '#1E3E9A'; // 蓝底
context.fillRect(0, 0, canvas.width, canvas.height);

// 设置文本样式
context.font = `${fontSize}px Arial`;
context.fillStyle = '#ffffff'; // 白色文本
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(text, canvas.width / 2, canvas.height / 2);

return canvas;
};

/**
* 创建交通指示牌并添加到场景中
* @param {Object} sceneRef - React ref 对象,指向 Three.js 的场景
* @returns {Promise<THREE.Gr0up>} - 返回创建的指示牌组
*/
export default (sceneRef, png, dis) => {
const createSignBoard = async () => {
const signGr0up = new THREE.Gr0up();

const loader = new THREE.TextureLoader();
loader.load(png, texture => {
// 创建一个平面作为标志背景
const signGeometry = new THREE.PlaneGeometry(100, 50); // 宽100,高50
texture.encoding = THREE.sRGBEncoding // 设置纹理的颜色空间
texture.colorSpace = THREE.SRGBColorSpace;
const signMaterial = new THREE.MeshStandardMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide,
})

const sign = new THREE.Mesh(signGeometry, signMaterial);
sign.position.set(-60, 140, 0.3)
signGr0up.add(sign);
})

// 创建并添加石头柱子
const pillar = createStonePillar();
signGr0up.add(pillar);

if (dis == "left") {
signGr0up.position.set(370, 180, 3750); // 左侧位置
} else {
signGr0up.rotateY(Math.PI); // 旋转180度
signGr0up.position.set(-370 - 2000, 180, 3450 - 7200); // 右侧位置
}

signGr0up.add(pillar);
sceneRef.current.add(signGr0up);

return signGr0up; // 返回整个组
};

// 调用创建指示牌函数
return createSignBoard().then((signGr0up) => {
console.log('交通指示牌创建完成:', signGr0up);
return signGr0up;
});
};


知识点: Three.js 中的复合结构与指示牌实现技术



  • 模块化设计:将指示牌分解为柱子、横梁和信息板三个主要组件,便于维护和复用

  • 几何体组合:使用简单几何体(圆柱体、长方体、平面)组合构建复杂结构



    • CylinderGeometry 创建六边形柱体作为支撑

    • BoxGeometry 创建横向支撑梁

    • PlaneGeometry 创建平面显示信息



  • 空间层次:使用 THREE.Gr0up 将相关元素组织在一起,便于整体变换和管理

  • 纹理映射:使用 TextureLoader 加载外部图像作为指示牌内容



    • 设置 colorSpace = THREE.SRGBColorSpace 确保颜色正确显示

    • 使用 side: THREE.DoubleSide 使平面从两面都可见



  • 条件定位:根据 dis 参数动态决定指示牌的位置和朝向



    • 使用 rotateY(Math.PI) 旋转180度实现方向反转



  • Canvas 动态文本:使用 getTextCanvas 函数创建动态文本纹理



    • 可以方便地生成不同内容和样式的文本标识



  • 异步处理:使用 Promise 处理纹理加载的异步过程,确保资源正确加载



    • 返回 Promise 使调用者可以在指示牌创建完成后执行后续操作




这种组合式设计方法允许我们创建高度可定制的指示牌,适用于隧道、道路、建筑内部等多种场景,同时保持代码的可维护性和可扩展性。



多渲染器协同工作机制


在我们的项目中,实现了 WebGL 渲染器、CSS2D 渲染器和 CSS3D 渲染器的协同工作:


const initRenderer = () => {
// WebGL 渲染器
rendererRef.current = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
logarithmicDepthBuffer: true
});
rendererRef.current.setSize(window.innerWidth, window.innerHeight);
rendererRef.current.setPixelRatio(Math.min(window.devicePixelRatio, 2));
rendererRef.current.shadowMap.enabled = true;
rendererRef.current.shadowMap.type = THREE.PCFSoftShadowMap;
rendererRef.current.outputEncoding = THREE.sRGBEncoding;
rendererRef.current.toneMapping = THREE.ACESFilmicToneMapping;
containerRef.current.appendChild(rendererRef.current.domElement);
};

const initCSS2DScene = () => {
// CSS2D 渲染器
css2DRendererRef.current = new CSS2DRenderer();
css2DRendererRef.current.setSize(window.innerWidth, window.innerHeight);
css2DRendererRef.current.domElement.style.position = 'absolute';
css2DRendererRef.current.domElement.style.top = '0';
css2DRendererRef.current.domElement.style.pointerEvents = 'none';
containerRef.current.appendChild(css2DRendererRef.current.domElement);
};

const initCSS3DScene = () => {
// 初始化 CSS3DRenderer
css3DRendererRef.current = new CSS3DRenderer();
css3DRendererRef.current.setSize(sizes.width, sizes.height);
css3DRendererRef.current.domElement.style.position = 'absolute';
css3DRendererRef.current.domElement.style.top = '0px';
css3DRendererRef.current.domElement.style.pointerEvents = 'none'; // 确保CSS3D元素不阻碍鼠标事件
containerRef.current.appendChild(css3DRendererRef.current.domElement);
};


知识点: Three.js 支持多种渲染器同时工作,每种渲染器有不同的优势:



  • WebGLRenderer:利用 GPU 加速渲染 3D 内容,性能最佳

  • CSS2DRenderer:将 HTML 元素作为 2D 标签渲染在 3D 空间中,适合信息标签

  • CSS3DRenderer:将 HTML 元素转换为 3D 对象,支持 3D 变换,适合复杂 UI


多渲染器协同可以充分发挥各自优势,实现复杂的混合现实效果。



后期处理管线设计


项目中实现了基于 EffectComposer 的后期处理管线:


const initPostProcessing = () => {
composerRef.current = new EffectComposer(rendererRef.current);

// 基础渲染通道
const renderPass = new RenderPass(sceneRef.current, cameraRef.current);
composerRef.current.addPass(renderPass);

// 环境光遮蔽通道
const ssaoPass = new SSAOPass(
sceneRef.current,
cameraRef.current,
window.innerWidth,
window.innerHeight
);
ssaoPass.kernelRadius = 16;
ssaoPass.minDistance = 0.005;
ssaoPass.maxDistance = 0.1;
composerRef.current.addPass(ssaoPass);

// 抗锯齿通道
const fxaaPass = new ShaderPass(FXAAShader);
const pixelRatio = rendererRef.current.getPixelRatio();
fxaaPass.material.uniforms['resolution'].value.x = 1 / (window.innerWidth * pixelRatio);
fxaaPass.material.uniforms['resolution'].value.y = 1 / (window.innerHeight * pixelRatio);
composerRef.current.addPass(fxaaPass);
};


知识点: 后期处理(Post-processing)是一种在 3D 场景渲染完成后对图像进行额外处理的技术:



  • EffectComposer:Three.js 中的后期处理管理器,可以将多个处理效果组合在一起

  • RenderPass:基础渲染通道,将场景渲染到目标缓冲区

  • SSAOPass:屏幕空间环境光遮蔽,增强场景深度感和真实感

  • FXAAShader:快速近似抗锯齿,提高图像质量


后期处理可以大幅提升画面质量,添加如景深、发光、色彩校正等专业效果。



多层次动画系统


项目实现了一个多层次的动画系统:


// 骨骼动画控制
const getActions = (animations, model) => {
const mixer = new THREE.AnimationMixer(model);
const mixerArray = [];
mixerArray.push(mixer);

const actions = {};
animations.forEach((clip) => {
const action = mixer.clipAction(clip);
actions[clip.name] = action;
});

return {actions, mixerArray};
};

// 动画播放控制
const playActiveAction = (actions, name, startTime = true, loopType = THREE.LoopOnce, clampWhenFinished = true) => {
const action = actions[name];
if (!action) return;

action.reset();
action.clampWhenFinished = clampWhenFinished;
action.setLoop(loopType);
if (startTime) {
action.play();
}
};


知识点: Three.js 提供了多种动画技术:



  • AnimationMixer:用于播放和控制模型骨骼动画的核心类,相当于动画播放器

  • AnimationClip:包含一组关键帧轨道的动画数据,如"走路"、"跑步"等动作

  • AnimationAction:控制单个动画的播放状态,包括播放、暂停、循环设置等

  • 动画混合:可以实现多个动画之间的平滑过渡,如从走路切换到跑步


合理使用这些技术可以创建流畅、自然的角色动画和场景变换。



第一人称视角控制算法


项目实现了一种先进的第一人称视角控制算法:


const animate1 = () => {
requestRef1.current = requestAnimationFrame(animate1);
if (isFirstPerson && robotRef.current) {
// 获取机器人的世界坐标
const robotWorldPosition = new THREE.Vector3();
robotRef.current.getWorldPosition(robotWorldPosition);

// 计算摄像机位置偏移
const offset = new THREE.Vector3(0, 140, 20);

// 获取机器人的前方方向向量
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(robotRef.current.quaternion);
const lookAheadDistance = 150;

// 计算摄像头位置和视线目标
const targetCameraPosition = robotWorldPosition.clone().add(offset);
const lookAtPosition = robotWorldPosition.clone().add(forward.multiplyScalar(lookAheadDistance));

// 使用 TWEEN 实现平滑过渡
cameraTweenRef.current = new TWEEN.Tween(cameraRef.current.position)
.to({
x: targetCameraPosition.x,
y: targetCameraPosition.y,
z: targetCameraPosition.z,
}, 1000)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(() => {
cameraRef.current.lookAt(lookAtPosition);
controlsRef.current.target.set(lookAtPosition.x, lookAtPosition.y, lookAtPosition.z);
})
.start();
}
};


知识点: 第一人称相机控制涉及多个关键技术:



  • 世界坐标计算:通过 getWorldPosition() 获取对象在世界坐标系中的位置

  • 四元数旋转:使用 applyQuaternion() 将向量按对象的旋转方向进行变换

  • 向量运算:通过向量加法和标量乘法计算相机位置和视线方向

  • 平滑过渡:使用 TWEEN.js 实现相机位置的平滑变化,避免生硬的跳变

  • lookAt:让相机始终"看着"目标点,实现跟随效果


这种技术常用于第一人称游戏、虚拟导览等应用。



递归资源释放算法


项目实现了一种递归资源释放算法,用于彻底清理 Three.js 资源:


const disposeSceneObjects = (object) => {
if (!object) return;

// 递归清理子对象
while (object.children.length > 0) {
const child = object.children[0];
disposeSceneObjects(child);
object.remove(child);
}

// 清理几何体
if (object.geometry) {
object.geometry.dispose();
}

// 清理材质
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach(material => disposeMaterial(material));
} else {
disposeMaterial(object.material);
}
}

// 清理纹理
if (object.texture) {
object.texture.dispose();
}
};

// 清理材质的辅助函数
const disposeMaterial = (material) => {
if (!material) return;

// 清理所有纹理属性
const textureProperties = [
'map', 'normalMap', 'roughnessMap', 'metalnessMap',
'emissiveMap', 'bumpMap', 'displacementMap',
'alphaMap', 'lightMap', 'aoMap', 'envMap'
];

textureProperties.forEach(prop => {
if (material[prop] && material[prop].dispose) {
material[prop].dispose();
}
});

material.dispose();
};


知识点: WebGL 资源管理是 3D 应用开发中的关键挑战:



  • JavaScript 垃圾回收的局限性:虽然 JS 有自动垃圾回收,但 WebGL 资源(如纹理、缓冲区)需要手动释放

  • 深度优先遍历:通过递归算法遍历整个场景图,确保所有对象都被正确处理

  • 资源类型处理:不同类型的资源(几何体、材质、纹理)需要不同的释放方法

  • 内存泄漏防护:不正确的资源管理是 WebGL 应用中最常见的内存泄漏原因


合理的资源释放策略对长时间运行的 3D 应用至关重要,可以避免性能下降和浏览器崩溃。



资源预加载与缓存策略


项目实现了资源预加载与缓存策略:


// 资源管理器
const ResourceManager = {
// 资源缓存
cache: new Map(),

// 预加载资源
preload: async (resources) => {
const loader = new GLTFLoader();

// 并行加载所有资源
const loadPromises = resources.map(resource => {
return new Promise((resolve, reject) => {
loader.load(
resource.url,
(gltf) => {
ResourceManager.cache.set(resource.id, {
data: gltf,
lastUsed: Date.now(),
refCount: 0
});
resolve(gltf);
},
undefined,
reject
);
});
});

return Promise.all(loadPromises);
},

// 获取资源
get: (id) => {
const resource = ResourceManager.cache.get(id);
if (resource) {
resource.lastUsed = Date.now();
resource.refCount++;
return resource.data;
}
return null;
},

// 释放资源
release: (id) => {
const resource = ResourceManager.cache.get(id);
if (resource) {
resource.refCount--;
if (resource.refCount <= 0) {
// 可以选择立即释放或稍后由缓存清理机制释放
}
}
}
};


知识点: 3D 应用中的资源管理策略:



  • 预加载:提前加载关键资源,减少用户等待时间

  • 并行加载:使用 Promise.all 并行加载多个资源,提高加载效率

  • 资源缓存:使用 Map 数据结构存储已加载资源,避免重复加载

  • 引用计数:跟踪资源的使用情况,只有当引用计数为零时才考虑释放

  • 最近使用时间:记录资源最后使用时间,可用于实现 LRU (最近最少使用) 缓存策略


这种资源管理策略可以平衡内存使用和加载性能,适用于资源密集型的 3D 应用。



总结


通过这个隧道监控可视化系统的开发,我们深入实践了 Three.js 的多项高级技术,包括多渲染器协同、后期处理、动画系统、相机控制和资源管理等。这些技术不仅适用于隧道监控,还可以应用于数字孪生、产品可视化、教育培训等多个领域。


希望这次分享对大家了解 Web 3D 开发有所帮助!如有任何问题或改进建议,非常欢迎与我交流讨论。我将在后续分享中带来更多 Three.js 开发的实用技巧和最佳实践。


作者:柳杉
来源:juejin.cn/post/7540129382540247103
收起阅读 »

前端如何判断用户设备

web
在前端开发中,判断用户设备类型是常见需求,可通过浏览器环境检测、设备能力特征分析等方式实现。以下是具体实现思路及代码示例: 一、通过User-Agent检测设备类型 原理:User-Agent是浏览器发送给服务器的标识字符串,包含设备、系统、浏览器等信息。 实...
继续阅读 »

在前端开发中,判断用户设备类型是常见需求,可通过浏览器环境检测、设备能力特征分析等方式实现。以下是具体实现思路及代码示例:


一、通过User-Agent检测设备类型


原理:User-Agent是浏览器发送给服务器的标识字符串,包含设备、系统、浏览器等信息。

实现步骤



  1. 提取navigator.userAgent字符串

  2. 通过正则表达式匹配特征关键词


// 设备检测工具函数
function detectDevice() {
const userAgent = navigator.userAgent.toLowerCase();
const device = {};

// 判断是否为移动设备
const isMobile = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
device.isMobile = isMobile;

// 具体设备类型
if (/(iphone|ipad|ipod)/i.test(userAgent)) {
device.type = 'ios';
device.model = /iphone/i.test(userAgent) ? 'iPhone' : 'iPad';
} else if (/android/i.test(userAgent)) {
device.type = 'android';
// 提取Android版本
const androidVersion = userAgent.match(/android (\d+\.\d+)/);
device.version = androidVersion ? androidVersion[1] : '未知';
} else if (/windows phone/i.test(userAgent)) {
device.type = 'windows phone';
} else if (/macint0sh/i.test(userAgent)) {
device.type = 'mac';
} else if (/windows/i.test(userAgent)) {
device.type = 'windows';
} else {
device.type = '其他';
}

// 判断是否为平板(需结合屏幕尺寸进一步确认)
device.isTablet = (/(ipad|android tablet|windows phone 8.1|kindle|nexus 7)/i.test(userAgent)) && !device.isMobile;

// 浏览器类型
if (/chrome/i.test(userAgent)) {
device.browser = 'Chrome';
} else if (/firefox/i.test(userAgent)) {
device.browser = 'Firefox';
} else if (/safari/i.test(userAgent) && !/chrome/i.test(userAgent)) {
device.browser = 'Safari';
} else if (/msie|trident/i.test(userAgent)) {
device.browser = 'IE/Edge';
} else {
device.browser = '未知';
}

return device;
}

// 使用示例
const deviceInfo = detectDevice();
console.log('设备类型:', deviceInfo.type);
console.log('是否为移动设备:', deviceInfo.isMobile);
console.log('浏览器:', deviceInfo.browser);

二、结合屏幕尺寸与触摸事件检测


原理:移动设备通常屏幕较小,且支持触摸操作,而PC设备以鼠标操作为主。


function enhanceDeviceDetection() {
const device = detectDevice(); // 基于User-Agent的检测

// 1. 屏幕尺寸检测(响应式设备类型)
if (window.innerWidth <= 768) {
device.layout = 'mobile'; // 移动端布局
} else if (window.innerWidth <= 1024) {
device.layout = 'tablet'; // 平板布局
} else {
device.layout = 'desktop'; // 桌面端布局
}

// 2. 触摸事件支持检测
device.hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;

// 3. 指针类型检测(WebKit特有属性,判断鼠标/触摸/笔)
if (navigator.maxTouchPoints === 0) {
device.pointerType = 'mouse';
} else if (navigator.maxTouchPoints > 2) {
device.pointerType = 'pen';
} else {
device.pointerType = 'touch';
}

return device;
}

三、设备能力API检测(更准确的现代方案)


原理:通过浏览器原生API获取设备硬件特性,避免User-Agent被伪造的问题。


async function detectDeviceByAPI() {
const device = {};

// 1. NavigatorDevice API(需HTTPS环境)
if (navigator.device) {
try {
const deviceInfo = await navigator.device.getCapabilities();
device.brand = deviceInfo.brand; // 设备品牌
device.model = deviceInfo.model; // 设备型号
device.vendor = deviceInfo.vendor; // 厂商
} catch (error) {
console.log('NavigatorDevice API获取失败:', error);
}
}

// 2. 屏幕像素密度(区分高清屏)
device.retina = window.devicePixelRatio >= 2;

// 3. 电池状态(移动端常用)
if (navigator.getBattery) {
navigator.getBattery().then(battery => {
device.batteryLevel = battery.level;
device.batteryCharging = battery.charging;
});
}

return device;
}

四、框架/库方案(简化实现)


如果项目中使用框架,可直接使用成熟库:



  1. react-device-detect(React专用)

  2. mobile-detect.js(轻量级通用库)

  3. ua-parser-js(专业User-Agent解析库)


五、注意事项



  1. User-Agent不可靠:用户可手动修改UA,或某些浏览器(如微信内置浏览器)会伪装UA。

  2. 结合多种检测方式:建议同时使用User-Agent、屏幕尺寸、触摸事件等多重检测,提高准确性。

  3. 响应式设计优先:现代开发中更推荐通过CSS媒体查询(@media)实现响应式布局,而非完全依赖设备检测。

  4. 性能优化:避免频繁检测设备,可在页面加载时缓存检测结果。


六、面试延伸问题



  1. 为什么User-Agent检测不可靠?请举例说明。

  2. 在iOS和Android上,如何区分手机和平板?

  3. 如果用户强制旋转屏幕(如手机横屏),设备检测结果需要更新吗?如何处理?


通过以上方案,可全面检测用户设备类型、系统、浏览器及硬件特性,为前端适配提供依据。


作者:星河丶
来源:juejin.cn/post/7515378780371501082
收起阅读 »

前端获取本地文件目录内容

web
前端获取本地文件目录内容 一、核心原理说明 由于浏览器的 “沙箱安全机制”,前端 JavaScript 无法直接访问本地文件系统,必须通过用户主动授权(如选择目录操作)才能获取文件目录内容。目前主流实现方案基于两种 API:传统 File API(兼容性优先)...
继续阅读 »

前端获取本地文件目录内容


一、核心原理说明


由于浏览器的 “沙箱安全机制”,前端 JavaScript 无法直接访问本地文件系统,必须通过用户主动授权(如选择目录操作)才能获取文件目录内容。目前主流实现方案基于两种 API:传统 File API(兼容性优先)和现代 FileSystem Access API(功能优先),以下将详细介绍两种方案的实现流程、代码示例及适用场景。


二、方案一:基于 File API 实现(兼容性首选)


1. 方案概述


通过隐藏的 <input type="file"> 标签(配置 webkitdirectorydirectory 属性)触发用户选择目录操作,用户选择后通过 files 属性获取目录下所有文件的元数据(如文件名、大小、相对路径等)。该方案兼容几乎所有现代浏览器(包括 Chrome、Firefox、Safari 等),但仅支持 “一次性获取选中目录内容”,无法递归遍历子目录或修改文件。


2. 完整使用示例


2.1 HTML 结构(含 UI 交互区)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>File API 目录访问示例</title>
<!-- 引入 Tailwind 简化样式(也可自定义 CSS) -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
.file-item { display: flex; align-items: center; padding: 8px; border-bottom: 1px solid #eee; }
.file-icon { margin-right: 8px; font-size: 18px; }
.file-info { flex: 1; }
.file-size { color: #666; font-size: 14px; }
</style>
</head>
<body class="p-8 bg-gray-50">
<div class="max-w-4xl mx-auto bg-white p-6 rounded-lg shadow">
<h2 class="text-2xl font-bold mb-4">File API 目录内容获取</h2>
<!-- 触发按钮(隐藏原生 input) -->
<button id="selectDirBtn" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
选择本地目录
</button>
<input type="file" id="dirInput" webkitdirectory directory style="display: none;">

<!-- 文件列表展示区 -->
<div class="mt-4 border rounded-lg max-h-80 overflow-y-auto">
<div id="fileList" class="p-4 text-center text-gray-500">
请选择目录以查看文件列表
</div>
</div>
</div>

<script>
// 2.2 JavaScript 逻辑实现
const dirInput = document.getElementById('dirInput');
const selectDirBtn = document.getElementById('selectDirBtn');
const fileList = document.getElementById('fileList');

// 1. 点击按钮触发原生 input 选择目录
selectDirBtn.addEventListener('click', () => {
dirInput.click();
});

// 2. 监听目录选择变化,处理文件数据
dirInput.addEventListener('change', (e) => {
const selectedFiles = e.target.files; // 获取选中目录下的所有文件(含子目录文件)
if (selectedFiles.length === 0) {
fileList.innerHTML = '<div class="p-4 text-center text-gray-500">未选择任何文件</div>';
return;
}

// 3. 解析文件数据并渲染到页面
let fileHtml = '';
Array.from(selectedFiles).forEach(file => {
// 判断是否为目录(通过 type 为空且 size 为 0 间接判断)
const isDir = file.type === '' && file.size === 0;
// 获取文件在目录中的相对路径(webkitRelativePath 为非标准属性,但主流浏览器支持)
const relativePath = file.webkitRelativePath || file.name;
// 格式化文件大小(辅助函数)
const fileSize = isDir ? '—' : formatFileSize(file.size);

fileHtml += `
<div class="file-item">
<span class="file-icon ${isDir ? 'text-yellow-500' : 'text-gray-400'}">
${isDir ? '📁' : '📄'}
</span>
<div class="file-info">
<div class="font-medium">${file.name}</div>
<div class="text-xs text-gray-500">${relativePath}</div>
</div>
<div class="file-size text-sm">${fileSize}</div>
</div>
`
;
});

fileList.innerHTML = fileHtml;
});

// 辅助函数:格式化文件大小(Bytes → KB/MB/GB)
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const units = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${units[i]}`;
}
</script>
</body>
</html>

3. 关键特性与限制



  • 优势:兼容性强(支持 Chrome 15+、Firefox 4+、Safari 6+),无需额外依赖,实现简单。

  • 限制



  1. 无法直接识别 “目录” 类型,需通过 typesize 间接判断;

  2. 仅能获取选中目录下的 “扁平化文件列表”,无法递归获取子目录结构;

  3. 无文件读写能力,仅能获取元数据。


三、方案二:基于 FileSystem Access API 实现(功能优先)


1. 方案概述


FileSystem Access API 是 W3C 正在标准化的现代 API(目前主要支持 Chromium 内核浏览器,如 Chrome 86+、Edge 86+),提供 “目录选择、递归遍历、文件读写、持久化权限” 等更强大的能力。通过 window.showDirectoryPicker() 直接请求用户授权,授权后可主动遍历目录结构,支持复杂的文件操作。


2. 完整使用示例


2.1 HTML 结构(含子目录遍历功能)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>FileSystem Access API 目录访问示例</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.dir-tree-item { padding: 4px 0 4px 16px; border-left: 1px solid #eee; }
.dir-header { display: flex; align-items: center; cursor: pointer; padding: 4px 0; }
.dir-icon { margin-right: 8px; }
.file-meta { color: #666; font-size: 14px; margin-left: 8px; }
</style>
</head>
<body class="p-8 bg-gray-50">
<div class="max-w-4xl mx-auto bg-white p-6 rounded-lg shadow">
<h2 class="text-2xl font-bold mb-4">FileSystem Access API 目录遍历</h2>
<!-- 触发目录选择按钮 -->
<button id="openDirBtn" class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">
打开并遍历目录
</button>

<!-- 目录树展示区 -->
<div class="mt-4 border rounded-lg p-4 max-h-80 overflow-y-auto">
<div id="dirTree" class="text-gray-500">
请点击按钮选择目录
</div>
</div>
</div>

<script>
// 2.2 JavaScript 逻辑实现(含递归遍历)
const openDirBtn = document.getElementById('openDirBtn');
const dirTree = document.getElementById('dirTree');

openDirBtn.addEventListener('click', async () => {
try {
// 1. 检查浏览器兼容性
if (!window.showDirectoryPicker) {
alert('您的浏览器不支持该功能,请使用 Chrome 或 Edge 浏览器');
return;
}

// 2. 请求用户选择目录(获取 DirectoryHandle 对象)
const dirHandle = await window.showDirectoryPicker({
mode: 'read', // 权限模式:read(只读)/ readwrite(读写)
startIn: 'documents' // 默认打开目录(可选:documents、downloads 等)
});

// 3. 递归遍历目录结构并渲染
dirTree.innerHTML = '<div class="text-center text-gray-500">正在读取目录...</div>';
const treeHtml = await renderDirectoryTree(dirHandle, 0);
dirTree.innerHTML = treeHtml;

} catch (err) {
// 捕获用户取消选择或权限拒绝错误
if (err.name === 'AbortError') {
dirTree.innerHTML = '<div class="text-center text-gray-500">用户取消选择</div>';
} else {
dirTree.innerHTML = `<div class="text-center text-red-500">错误:${err.message}</div>`;
console.error('目录访问失败:', err);
}
}
});

/**
* 递归渲染目录树
* @param {DirectoryHandle} handle - 目录/文件句柄
* @param {number} depth - 目录深度(用于缩进)
* @returns {string} 目录树 HTML
*/

async function renderDirectoryTree(handle, depth) {
const isDir = handle.kind === 'directory';
const indent = 'margin-left: ' + (depth * 16) + 'px;'; // 按深度缩进
let itemHtml = '';

if (isDir) {
// 处理目录:添加展开/折叠功能
itemHtml += `
<div class="dir-header" style="${indent}" onclick="toggleDir(this)">
<span class="dir-icon text-yellow-500">📁</span>
<span class="font-medium">${handle.name}</span>
<span class="file-meta">(目录)</span>
</div>
<div class="dir-children" style="display: none;">
`
;

// 遍历目录下的所有子项(递归)
for await (const childHandle of handle.values()) {
itemHtml += await renderDirectoryTree(childHandle, depth + 1);
}

itemHtml += '</div>'; // 闭合 dir-children

} else {
// 处理文件:获取文件大小等元数据
const file = await handle.getFile();
const fileSize = formatFileSize(file.size);
itemHtml += `
<div style="${indent} display: flex; align-items: center; padding: 4px 0;">
<span class="dir-icon text-gray-400">📄</span>
<span>${handle.name}</span>
<span class="file-meta">${fileSize}</span>
</div>
`
;
}

return itemHtml;
}

// 目录展开/折叠切换(全局函数,用于 HTML 内联调用)
function toggleDir(el) {
const children = el.nextElementSibling;
children.style.display = children.style.display === 'none' ? 'block' : 'none';
el.querySelector('.dir-icon').textContent = children.style.display === 'none' ? '📁' : '📂';
}

// 复用文件大小格式化函数(同方案一)
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const units = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${units[i]}`;
}
</script>
</body>
</html>

3. 关键特性与限制



  • 优势



  1. 直接识别 “目录 / 文件” 类型(通过 handle.kind);

  2. 支持递归遍历目录结构,可实现 “目录树” 交互;

  3. 提供文件读写能力(通过 fileHandle.createWritable());

  4. 可请求持久化权限(handle.requestPermission()),下次访问无需重新授权。



  • 限制:兼容性差,仅支持 Chromium 内核浏览器,Firefox 和 Safari 暂不支持。


四、两种方案对比分析


对比维度方案一(File API)方案二(FileSystem Access API)
浏览器兼容性强(支持所有现代浏览器)弱(仅 Chromium 内核浏览器)
目录识别能力间接判断(依赖 type 和 size)直接识别(handle.kind)
目录遍历能力仅扁平化列表,无递归支持支持递归遍历,可构建目录树
文件操作能力仅读取元数据,无读写能力支持文件读写、删除等完整操作
权限持久化不支持(每次刷新需重新选择)支持(可请求持久化权限)
交互体验依赖隐藏 input,体验较基础原生 API 调用,体验更流畅
适用场景兼容性优先的简单目录查看需求现代浏览器下的复杂文件管理需求

五、注意事项与最佳实践



  1. 安全合规:无论哪种方案,都必须通过 “用户主动操作” 触发授权(如点击按钮),禁止自动触发目录选择,否则浏览器会拦截操作。

  2. 错误处理:需捕获 “用户取消选择”(AbortError)和 “权限拒绝”(PermissionDeniedError)等错误,避免页面展示异常。

  3. 兼容性适配:可通过 “特性检测” 实现方案降级,例如:


if (window.showDirectoryPicker) {
// 使用方案二(FileSystem Access API)
} else {
// 使用方案一(File API)
}


  1. 性能优化:遍历大量文件时(如超过 1000 个文件),建议使用 “分页加载” 或 “虚拟滚动”,避免一次性渲染导致页面卡顿。

  2. 隐私保护:不建议存储用户本地文件路径等敏感信息,仅在前端临时处理文件数据,避免隐私泄露风险。


作者:页面仔D
来源:juejin.cn/post/7542308569641074724
收起阅读 »

儿子不收拾玩具,我用AI给他量身定制开发一个APP,这下舒服了

web
1. 前言 比上班更可怕的是什么?是加班。 比加班更可怕的是什么?是固定加班,也就是 996,大小周。 作为一个荣获 996 福报的牛马,我认为我的际遇已经很可怕了。 没想到还有比这更可怕的,拖着被996折腾过的疲惫身体回家后。我儿子向我展示他一天的劳动成果。...
继续阅读 »

1. 前言


比上班更可怕的是什么?是加班。


比加班更可怕的是什么?是固定加班,也就是 996,大小周。


作为一个荣获 996 福报的牛马,我认为我的际遇已经很可怕了。


没想到还有比这更可怕的,拖着被996折腾过的疲惫身体回家后。我儿子向我展示他一天的劳动成果。


img_v3_02p6_11a485f4-6359-4ce2-ade3-4fa993d8d1ix.jpg


这时候你肯定会说让他收起来不就行了?这时候我应该拿出标志性的礼貌三问:你有对象吗?你有儿子吗?你让你儿子收他就收吗?


image.png


不会吧,你儿子收啊。那我和你换个儿子吧。


我对我儿子威逼利诱什么招式都试过了,他每次就3招我就没辙了:


爸爸,我累了你帮我收吧。


爸爸,地上的玩具太多了你和我一起收吧,收着收着这小子就不见了。


爸爸,我要睡觉了,晚安。


每天晚上我都要花时间收拾这个烂摊子,收过十几次后我后知后觉有了个想法。


平时我工作的时候,一个5分钟就能手写完搞定的配置我都要花10分钟写个脚本自动化。


为啥不能让收玩具这件事情自动化呢?我可是个优雅的程序员啊。重复做一个动作在我这应该是严格禁止的才对。


所以我打算做一个自动收玩具的机器。


image.png


不是哥们,这我真做不了。在我这自动化是什么意思呢?


不需要自己动手干的就是自动化,把配置做到管理后台,让运营自己去配置算不算自动化?那必须是的呀。


那么,想一种办法让我儿子自己把玩具收起来是不是自动化?那也必须是的呀。


自动化的定义就是不需要自己动手干,管是机器干还是人干,反正不要我来干。


image.png


说干就干,我儿子特别喜欢数字,迷恋加法。那不就盖了帽了。给他安排一个任务APP,收完一件玩具就加分,他肯定特满足特有成就感。


我虽然是一个前端后端运维测试攻城狮,但我的的确确没有开发过APP。除了大学要交 Android 作业抱过同学大腿喵了一眼,从那之后我就下定决定干后端去了。因为艺术细菌不是说本人没有,是本人想有但它对我不感冒啊。


但是别忘了,现在是 AI 的时代。产品的活我不会,AI 来。APP 开发我不会,AI 来。貌似不需要后端,那我只能当当测试了。


2. 正片开始


我调研了一圈,目前有几种方案可以实现:



  1. 直接刚原生

  2. 退而求其次,flutter

  3. 一退再退,直接uniapp 网页糊上


原生做起来体验最好,但是搭个环境真是要了我的老命了,所以弃之。


flutter总体感觉不错,但是要另外学一门语言,想想我就脑壳疼,亦弃之。


uni-app 看起来不咋滴,蛮多人吐槽但也有真正的案例。但我发现它能云打包,不用我在本地配一堆乱七八糟的。下载一个HBuilder 就行了,虽然很多人吐槽这个 IDE,但关我啥事?是 AI 来写代码,又不是我写代码,尽管恶心 AI 去。选它选它


2.1 画原型图


Cursor,Gemini,claude code 我都试了,Gemini的设计感最强,豆包的体验最好。豆包的效果看起来非常的奈斯啊!



2.2 开发


有了原型那就好办了,直接贴图让cursor 或者 claude code 对着实现就行了。


这里要吐槽一下claude code,不能粘贴板直接贴图,只能把图片拖进去,差评。



现在可以粘贴图片了,Mac 可以尝试用ctrl+v(没错,不是command+v)



把所有的原型图贴进去之后,输入这句简单的Prompt,claude code 就会开始干活了。


请根据原型图,使用uniapp 开发一个app

2.3 加需求


第一版完成了他的使命,最近儿子有点腻烦了,收个玩具磨磨蹭蹭的。不行,我得想点法子,加点东西让他保持新鲜感,然后养成习惯,以后就不用我管了,想想就非常的苏胡啊。


所以为了调动他的积极性,更营造一个紧张的氛围,我加入了倒计时功能:


接下来有个新功能。我想为任务增加一个计时完成功能:

  1. 完成任务时,不再是简单的点击即可;

  2. 完成任务时,应该提供一个开始完成按钮,然后启动倒计时

  3. 创建任务时,应该配置预计完成时间

  4. 完成任务时,遵循规则:a.如果在预计时间的一半完成可以得到2倍的分数;b.如果超过一半时间完成则得到1.5倍分数;c.如果超时完成则得到1倍分数

直接把需求丢给AI实现去,自己测试测试,没问题就打包。



2.3 测试打包


先浏览器运行看看效果,可以 F12 切换成手机视图看有没有挤压之类的。



测试没问题就直接打包。因为我是尊贵的 Android 用户,所以我只跑了 Android 打包。


image.png


我坦白,uni-app部分我基本是看这个老哥的:juejin.cn/post/729631…


2.4 看看效果


image.png


作者:纸仓
来源:juejin.cn/post/7538276577605632046
收起阅读 »

Vue-Command-Component:让弹窗开发不再繁琐

web
前言 在Vue项目开发中,弹窗组件的管理一直是一个令人头疼的问题。传统的声明式弹窗开发方式需要管理大量的状态变量、处理复杂的props传递,甚至可能面临多个弹窗嵌套时的状态管理困境。今天给大家介绍一个能够彻底改变这种开发体验的库:Vue-Command-Com...
继续阅读 »

前言


在Vue项目开发中,弹窗组件的管理一直是一个令人头疼的问题。传统的声明式弹窗开发方式需要管理大量的状态变量、处理复杂的props传递,甚至可能面临多个弹窗嵌套时的状态管理困境。今天给大家介绍一个能够彻底改变这种开发体验的库:Vue-Command-Component。


为什么需要命令式组件?


在传统的Vue开发中,弹窗的使用通常是这样的:


<template>
<el-dialog v-model="visible" title="提示">
<span>这是一段信息</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleConfirm">确认</el-button>
</span>
</template>
</el-dialog>
</template>

<script setup>
const visible = ref(false)
const handleConfirm = () => {
// 处理确认逻辑
visible.value = false
}
</script>

这种方式存在几个明显的问题:



  1. 需要手动管理弹窗的显示状态

  2. 组件代码和业务逻辑混杂在一起

  3. 多个弹窗时代码会变得非常臃肿

  4. 弹窗之间的嵌套关系处理复杂


Vue-Command-Component 解决方案


使用Vue-Command-Component,上述问题都可以得到优雅的解决。来看看它是如何使用的:


import { useDialog } from "@vue-cmd/element-plus";

const CommandDialog = useDialog()

// 直接调用函数显示弹窗
CommandDialog(<div>这是一段信息</div>)

是的,就是这么简单!一行代码就能唤起一个弹窗,不需要管理状态,不需要写模板,一切都变得如此流畅。


核心特性


1. 极简API设计


无需管理状态,一个函数调用搞定所有事情,符合直觉的开发体验。


2. 完整的类型支持


提供完整的TypeScript类型支持,开发体验一流。


3. 灵活的控制能力


提供了多种控制方式:



  • destroy:销毁弹窗

  • hide/show:控制弹窗显示/隐藏

  • destroyWithResolve/destroyWithReject:支持Promise风格的控制


4. 强大的扩展性


支持多种UI框架:



  • Element Plus

  • Naive UI

  • Vant

  • ...更多框架支持中


5. 原生特性支持


完整支持原生组件的所有特性:



  • 属性传递

  • 事件处理

  • 插槽支持

  • Provide/Inject


安装


# 使用 npm
npm install @vue-cmd/core @vue-cmd/element-plus

# 使用 yarn
yarn add @vue-cmd/core @vue-cmd/element-plus

# 使用 pnpm
pnpm add @vue-cmd/core @vue-cmd/element-plus

# 使用 bun
bun add @vue-cmd/core @vue-cmd/element-plus

实战示例


基础用法


import { useDialog } from "@vue-cmd/element-plus";

const CommandDialog = useDialog()

// 基础弹窗
CommandDialog(<Content />)

// 带配置的弹窗
CommandDialog(<Content />, {
attrs: {
title: '标题',
width: '500px'
}
})

嵌套弹窗


import { useDialog } from "@vue-cmd/element-plus";

const CommandDialog = useDialog()

CommandDialog(
<div onClick={() => {
// 在弹窗中打开新的弹窗
CommandDialog(<div>内层弹窗</div>)
}}>
外层弹窗
</div>

)

Promise风格控制


import { useDialog } from "@vue-cmd/element-plus";
import { useConsumer } from "@vue-cmd/core";

const CommandDialog = useDialog()

// 在弹窗组件内部
const FormComponent = defineComponent({
setup() {
const consumer = useConsumer()

const handleSubmit = (data) => {
// 提交数据后关闭弹窗
consumer.destroyWithResolve(data)
}

return () => <Form onSubmit={handleSubmit} />
}
})

// Promise风格的控制
try {
const result = await CommandDialog(<FormComponent />).promise
console.log('表单提交结果:', result)
} catch (error) {
console.log('用户取消或出错:', error)
}

多UI框架支持


// Element Plus
import { useDialog as useElementDialog } from "@vue-cmd/element-plus";

// Naive UI
import { useModal, useDrawer } from "@vue-cmd/naive";

// Vant
import { usePopup } from "@vue-cmd/vant";

const ElementDialog = useElementDialog()
const NaiveModal = useModal()
const VantPopup = usePopup()

// 使用不同的UI框架
ElementDialog(<Content />)
NaiveModal(<Content />)
VantPopup(<Content />)

写在最后


Vue-Command-Component 为Vue开发者带来了一种全新的弹窗开发方式。它不仅简化了开发流程,还提供了更强大的控制能力。如果你的项目中有大量弹窗交互,不妨尝试一下这个库,相信它会为你带来更好的开发体验。


相关链接



作者:PanDa
来源:juejin.cn/post/7501963430640615436
收起阅读 »

CSS 黑科技之多重边框:为网页添彩

web
在前端开发的奇妙世界里,CSS 总是能给我们带来意想不到的惊喜。今天,就让我们一同探索 CSS 的一个有趣特性 —— 多重边框,看看它如何为我们的网页设计增添独特魅力。什么是多重边框在传统认知中,一个元素通常只有一层边框。但借助 CSS 的box-shadow...
继续阅读 »

在前端开发的奇妙世界里,CSS 总是能给我们带来意想不到的惊喜。今天,就让我们一同探索 CSS 的一个有趣特性 —— 多重边框,看看它如何为我们的网页设计增添独特魅力。

什么是多重边框

在传统认知中,一个元素通常只有一层边框。但借助 CSS 的box-shadow属性,我们可以突破这一限制,轻松实现多重边框效果。box-shadow属性原本用于为元素添加阴影,不过通过巧妙设置,它能化身为创造多重边框的利器。

如何实现多重边框

实现多重边框的关键在于对box-shadow属性的灵活运用。下面是一个简单示例:

div {
box-shadow: 0 0 0 5px red, 0 0 0 10px blue;
}

在这段代码中,box-shadow属性接受了两组值,每组值都定义了一个 “边框”。具体来说,0 0 0 5px red表示第一个边框:前两个0分别表示水平和垂直方向的偏移量,这里都为 0,即不偏移;第三个0表示模糊半径为 0,也就是边框清晰锐利;5px表示扩展半径,即边框的宽度;red则是边框的颜色。同理,0 0 0 10px blue定义了第二个边框,宽度为 10px,颜色为蓝色。通过这样的方式,我们就为div元素创建了两层不同颜色和宽度的边框。

多重边框的应用场景

  1. 突出重要元素:在网页中,有些元素需要特别突出显示,比如导航栏、重要按钮等。使用多重边框可以让这些元素在页面中脱颖而出,吸引用户的注意力。
  1. 营造层次感:多重边框能够为元素增加层次感,使页面看起来更加丰富和立体。在设计卡片式布局时,这种效果尤为明显,可以让卡片更加生动有趣。
  1. 创意设计:对于追求独特风格的网页设计,多重边框提供了无限的创意空间。可以通过调整边框的颜色、宽度、模糊度等参数,创造出各种独特的视觉效果,展现出与众不同的设计风格。

注意事项

  1. 性能问题:虽然多重边框效果很酷,但过多地使用复杂的box-shadow属性可能会影响页面性能,尤其是在移动设备上。因此,在实际应用中需要权衡效果和性能,避免过度使用。
  1. 兼容性:不同浏览器对box-shadow属性的支持程度略有差异。在使用时,要确保在主流浏览器上进行充分测试,必要时可以添加浏览器前缀来保证兼容性。

CSS 的多重边框特性为前端开发者提供了一种简单而强大的方式来增强网页的视觉效果。通过合理运用这一特性,我们能够打造出更加美观、富有创意的网页界面。希望大家在今后的前端开发中,大胆尝试多重边框,让自己的网页作品更加出彩!


作者:LL_Hugo
来源:juejin.cn/post/7472233713416110089
收起阅读 »

JavaScript V8 引擎原理

web
相关问题JavaScript事件循环调用栈:这里存放着所有执行中的代码块(函数)。当一个函数被调用时,它被添加到栈中;当返回值被返回时它从栈中被移除。消息队列:当异步事件发生时(如点击事件、文件读取完成等),对应的回调函数会被添加到消息队列中。如果调用栈为空,...
继续阅读 »

相关问题

JavaScript事件循环

  • 调用栈:这里存放着所有执行中的代码块(函数)。当一个函数被调用时,它被添加到栈中;当返回值被返回时它从栈中被移除。
  • 消息队列:当异步事件发生时(如点击事件、文件读取完成等),对应的回调函数会被添加到消息队列中。如果调用栈为空,事件循环将从队列中取出一个事件处理。
  • 微任务队列:与消息队列类似,但处理优先级更高。微任务(如Promise的回调)在当前宏任务执行完毕后、下-个宏任务开始前执行。
  • 宏任务与微任务:宏任务包括整体的脚本执行、setTimeout、setlnterval等;微任务包括Promise回调.process.nextTick等。事件循环的每个循环称为一个tick,每个tick会先执行所有可执行的微任务,再执行一个宏任务。

V8引擎中的垃圾回收机制如何工作?

V8引擎使用的垃圾回收策略主要基于“分代收集”(Generational Garbage Collection)的理念:

  • 新生代(Young Generation):这部分主要存放生存时间短的小对象。新生代空间较小,使用Scavenge算法进行高效的垃圾回收。Scavenge算法采用复制的方式工作,它将新生代空间分为两半,活动对象存放在一半中,当这一半空间用完时,活动对象会被复制到另一半,非活动对象则被清除。
  • 老生代(Old Generation):存放生存时间长或从新生代中晋升的大对象。老生代使用Mark-Sweep(标记-清除)和 Mark-Compact (标记-压缩)算法进行垃圾回收。标记-清除算法在标记阶段标记所有从根节点可达的对象,清除阶段则清除未被标记的对象。标记-压缩算法在清除未标记对象的同时,将存活的对象压缩到内存的一端,减少碎片。

V8 引擎是如何优化其性能的?

V8引擎通过多种方式优化JavaScript的执行性能:

  • 即时编译(JIT):V8将JavaScript代码编译成更高效的机器代码而不是传统的解释执行。V8采用了一个独特的两层编译策略,包括基线编译器(lgnition)和优化编译器(TurboFan)。lgnition生成字节码,这是一个相对较慢但内存使用较少的过程。而 TurboFan 则针对热点代码(执行频率高的代码)进行优化,生成更快的机器代码。
  • 内联缓存(lnline Caching):V8使用内联缓存技术来减少属性访问的时间。当访问对象属性时,V8会在代码中嵌入缓存信息,记录属性的位置,以便后续的属性访问可以直接使用这些信息,避免再次查找,从而加速属性访问。
  • 隐藏类(Hidden Classes):尽管JavaScript是一种动态类型语言,V8引擎通过使用隐藏类来优化对象的存储和访问。每当对象被实例化或修改时,V8会为对象创建或更新隐藏类,这些隐藏类存储了对象属性的布局信息,使得属性访问更加迅速。

引擎基础

冯·诺依曼结构

解释和编译

Java 编译为 class 文件,然后执行

JavaScript 属于解释型语言,它需要在代码执行时,将代码编译为机器语言。

ast (Abstract Syntax Tree)

• Interpreter 逐行读取代码并立即执行。

• Compiler 读取您的整个代码,进行一些优化,然后生成优化后的代码。

JavaScript引擎

JavaScript 其实有众多引擎,只不过v8 是我们最为熟知的。

  • V8 (Google),用 C++编写,开放源代码,由 Google 丹麦开发,是 Google Chrome 的一部分,也用于 Node.js.
  • JavascriptCore (Apple),开放源代码,用于 webkit 型浏览器,如 Safari,2008年实现了编译器和字节码解释器,升级为了 SquirreFish。苹果内部代号为“Nitro”的 Javascript 引擎也是基于 JavascriptCore 引擎的。
  • Rhino,由Mozilla 基金会管理,开放源代码,完全以Java 编写,用于 HTMLUnit
  • SpiderMonkey (Mozilla),第一款 Javascript 引擎,早期用于 Netscape Navigator,现时用于 Mozilla Firefox。
  • Nodejs 整个架构

:::info 谷歌的Chrome 使用 V8

Safari 使用 JavaScriptCore,

Firefox 使用 SpiderMonkey。

:::

  • V8的处理过程
    • 始于从网络中获取 JavaScript 代码。

V8 解析源代码并将其转化为抽象语法树(AST abstract syntax tree)。

- 基于该AST,Ignition 基线解释器可以开始做它的事情,并产生字节码。
- 在这一点上,引擎开始运行代码并收集类型反馈。
- 为了使它运行得更快,字节码可以和反馈数据一起被发送到TurboFan 优化编译器。优化编译器在此基础上做出某些假设,然后产生高度优化的机器代码。
- 如果在某些时候,其中一个假设被证明是不正确的,优化编译器就会取消优化,并回到解释器中。

垃圾回收算法

垃圾回收,又称为:GC (garbage collection)。

GC 即 Garbage Collection,程序工作过程中会产生很多垃圾,这些垃圾是程序不用的内存或者是之前用过了,以后不会再用的内存空间,而GC 就是负责回收垃圾的,因为他工作在引擎内部,所以对于我们前端来说, GC 过程是相对比较无感的,这一套引擎执行而对我们又相对无感的操作也就是常说的垃圾回收机制了当然也不是所有语言都有 GC,一般的高级语言里面会自带GC,比如 Java、Python、Javascript 等,也有无GC的语言,比如C、C++等,那这种就需要我们程序员手动管理内存了,相对比较麻烦

“垃圾”的定义

  • “可达性”,有没有被引用,没有被引用的变量,“不可达的变量”
  • 变量会在栈中存储,对象在堆中存储
  • 我们知道写代码时创建一个基本类型、对象、函数都是需要占用内存的,但是我们并不关注这些,因为这是引擎为我们分配的,我们不需要显式手动的去分配内存,那么 JavaScript 引擎是如何发现并清理垃圾的呢?

引用计数算法

相信这个算法大家都很熟悉,也经常听说。

它的策略是跟踪记录每个变量值被使用的次数

  • 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1 如果同一个值又被赋给另一个变量,那么引用数加1
  • 如果该变量的值被其他的值覆盖了,则引用次数減1
  • 当这个值的引用次数变为0的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运
  • 行的时候清理掉引用次数为0的值占用的内存

:::info 这个算法最怕的就是循环引用(相互引用),还有比如 JavaScript 中不恰当的闭包写法

:::

优点

  • 引用计数算法的优点我们对比标记清除来看就会清晰很多,首先引用计数在引用值为0时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾
  • 而标记清除算法需要每隔一段时间进行一次,那在应用程序(JS脚本)运行过程中线程就必须要暂停去执行一段时间的GC,另外,标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数则只需要在引用时计数就可以

弊端

  • 它需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限,还有就是无法解决循环引用无法回收的问题,这也是最严重的

标记清除(Mark-Sweep)算法

:::info 从根对象进行检测,先标记再清除

:::

  • 标记清除(Mark-Sweep),目前在 JavaScript引擎里这种算法是最常用的,到目前为止的大多数浏览器的 Javascript引擎都在采用标记清除算法,各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 Javascript引擎在运行垃圾回收的频率上有所差异。
  • 此算法分为标记和清除两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁
  • 当变量进入执行环境时,反转某一位(通过一个二进制字符来表示标记),又或者可以维护进入环境变量和离开环境变量这样两个列表,可以自由的把变量从一个列表转移到另一个列表。
  • 引擎在执行GC(使用标记清除算法)时,需要从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多, 我们称之为一组根对象,而所谓的根对象,其实在浏览器环境中包括又不止于全局Window对象、文档DOM树
  • 整个标记清除算法大致过程就像下面这样:
    • 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0
    • 然后从各个根对象开始遍历,把不是垃圾的节点改成1
    • 清理所有标记为O的垃圾,销毁并回收它们所占用的内存空间
    • 最后,把所有内存中对象标记修改为O,等待下一轮垃圾回收

优点

  • 标记清除算法的优点只有一个,那就是实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和 1)就可以为其标记,非常简单

弊端

  • 标记清除算法有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了内存碎片(如下图),并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题
  • 那如何找到合适的块呢?

:::danger 在插入值的时候去解决,最大化使用内存空间,即:通过插入的形式,提升内存空间使用

:::

  • 我们可以采取下面三种分配策略
    • First-fit,找到大于等于 size 的块立即返回
    • Best-fit,遍历整个空闲列表,返回大于等于 size 的最小分块
    • Worst-fit,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回这三种策略里面 Worst-fit 的空间利用率看起来是最合理,但实际上切分之后会造成更多的小块,形成内存碎片,所以不推荐使用,对于 First-fit 和 Best-fit 来说,考虑到分配的速度和效率 First-fit 是更为明智的选择
  • 综上所述,标记清除算法或者说策略就有两个很明显的缺点
    • 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块
    • 分配速度慢,因为即便是使用 First-fit策略,其操作仍是一个0(n)的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢

:::info 归根结底,标记清除算法的缺点在于清除之后剩余的对象位置不变而导致的空闲内存不连续,所以只要解决这一点,两个缺点都可以完美解决了

:::

标记整理(Mark-Compact)算法

:::color1 有碎片就整理,整理的过程是有消耗的,所以就会有新生代、老生代

:::

  • 而标记整理(Mark-Compact)算法就可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存

Unix/windows/Android/iOS系统中内存碎片空间思想

内存碎片化是所有系统都面临的挑战,不同操作系统和环境中的处理策略各有侧重,但也有其共通之处。以下是不同系统在内存碎片处理上的比较:

V8引擎中的标记-整理算法

  • 标记阶段:识别未使用的对象,标记为垃圾。
  • 整理阶段:将存活对象移动到连续区域,释放大块内存空间,减少外部碎片。

电脑系统(Unix/Linux vs Windows)

  • 内存管理:均使用分页机制,但Linux更倾向于预防碎片,Windows依赖内存压缩。
  • 处理策略:Linux通过 slab 分配器优化内存分配,Windows通过内存压缩技术。
  • 相同点:分页和交换机制,内存不足时回收内存。
  • 不同点:Linux更注重预防,Windows依赖内存压缩,处理方式不同。

移动终端(Android vs iOS)

  • 内存管理:Android基于Linux,采用内存回收和进程优先级管理;iOS使用更严格的内存管理。
  • 处理策略:Android通过Activity生命周期管理内存,iOS通过ARC自动管理。
  • 相同点:内存不足时回收内存,依赖垃圾回收机制。
  • 不同点:Android更灵活,支持后台进程保活;iOS更严格,强制回收。

内存碎片化挑战

  • 内部碎片:内存分配导致的未使用空间,需优化分配策略。
  • 外部碎片:分散的空闲空间,需整理或置换策略。
  • 处理目标:桌面系统注重稳定性,移动设备关注响应和功耗。

工具与分析

  • Unix/Linux:使用tophtopvmstat等工具。
  • Windows:依赖任务管理器和性能监视器。
  • 移动设备:Android用Android Profiler,iOS用Instruments。
    总结: 不同系统在内存碎片处理上各有特色,但都旨在优化内存使用效率。V8引擎通过标记-整理减少碎片,而操作系统如Unix/Linux和Windows,以及移动系统如Android和iOS则采用不同的内存管理策略,以适应各自的性能和资源需求。

内存管理

:::info V8的垃圾回收策略主要基于分代式垃圾回收机制,V8中将堆内存分为新生代和老生代两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收

:::

新生代

  • 当新加入对象时,它们会被存储在使用区。然而,当使用区快要被写满时,垃圾清理操作就需要执行。在开始垃圾回收之前,新生代垃圾回收器会对使用区中的活动对象进行标记。标记完成后,活动对象将会被复制到空闲区并进行排序。然后,垃圾清理阶段开始,即将非活动对象占用的空间清理掉。最后,进行角色互换,将原来的使用区变成空闲区,将原来的空闲区变成使用区。
  • 如果一个对象经过多次复制后依然存活,那么它将被认为是生命周期较长的对象,且会被移动到老生代中进行管理。
  • 除此之外,还有一种情况,如果复制一个对象到空闲区时,空闲区的空间占用超过了25%,那么这个对象会被直接晋升到老生代空间中。25%比例的设置是为了避免影响后续内存分配,因为当按照 Scavenge 算法回收完成后, 空闲区将翻转成使用区,继续进行对象内存分配。

:::info 一直在开辟空间,达到一定程度,就回晋升到老生代

:::

老生代

  • 不同于新生代,老生代中存储的内容是相对使用频繁并且短时间无需清理回收的内容。这部分我们可以使用标记整理进行处理。
  • 从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象
  • 清除阶段老生代垃圾回收器会直接将非活动对象进行清除。

并行回收

:::info 思想类似于 花两个人的钱,让一个人干三个人的活

:::

全停顿标记

这个概念看字眼好像不好理解,其买如果用前端开发的术语来解释,就是阻塞。

虽然我们的 GC操作被放到了主进程与子进程中去处理,但最终的结果还是主进程被较长时间占用。

在JavaScript的V8引擎中,全停顿标记(Full Stop-the-world Marking)是垃圾回收(GC)过程中的一个重要环节。

这个过程涉及到V8的垃圾回收器暂停JavaScript程序的执行,以便进行垃圾回收的标记阶段。全停顿标记是为了确保在回收内存前正确标记所有活动对象(即正在使用的对象)和非活动对象(即可以清除的对象)。

全停顿标记的工作原理

1.停止执行:当执行到全停顿标记阶段时,V8引擎会暂停正在执行的JavaScript代码,确保没有任何Javascript代码在运行。这个停顿是必需的,因为在标记活动对象时,对象的引用关系需要保持不变。
2. 标记阶段:在这个阶段,垃圾回收器遍历所有根对象(例如全局变量、活跃的函数的局部变量等),从这些根对象开始,递归地访问所有可达的对象。每访问到一个对象,就将其标记为活动(1)的。

  1. 恢复执行:标记完成后,V8引擎会恢复JavaScript代码的执行,进入垃圾回收的清除或压缩阶段。

全停顿的影响及优化

全停顿标记虽然对于确保内存被正确管理是必要的,但它会对应用程序的性能产生影响,特别是在垃圾回收发生时, 应用程序的响应时间和性能会短暂下降。为了缓解这种影响,V8引擎采用了几种策略:

• 增量标记 (Incremental Marking):为了减少每次停顿的时间,V8实现了增量标记,即将标记过程分成多个小部分进行,介于JavaScript执行的间隙中逐步完成标记。

• 并发标记(Concurrent Marking):V8引擎的更高版本中引入了并发标记,允许垃圾回收标记阶段与JavaScript代码的执行同时进行,进一步减少停顿时间。

• 延迟清理(Lazy Sweeping):标记完成后的清理阶段也可以延迟执行,按需进行,以减少单次停顿的时间。

这些优化措施有助于提高应用的响应速度和整体性能,特别是在处理大量数据和复杂操作时,确保用户体验不会因垃圾回收而受到较大影响。

切片标记

  • 增量就是将一次 GC标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记

三色标记

我们这里的会,表示的是一个中间状态,为什么会有这个中间状态呢?

• 白色指的是未被标记的对象

• 灰色指自身被标记,成员变量(该对象的引用对象)未被标记 • 黑色指自身和成员变量皆被标记

在V8引擎中使用的三色标记算法是一种用于垃圾回收的有效方法,特别是在进行增量和并发标记时。这个算法通过给对象着色(白色、灰色、黑色)来帮助标记和回收垃圾。

工作原理

  1. 初始化:
  • 白色:初始状态,所有对象都标记为白色,表示这些对象可能是垃圾,如果在标记过程中没有被访问到,最终将被清理。
  • 灰色:表示对象已经被标记(访问过),但该对象的引用还没有完全检查完。
  • 黑色:表示该对象及其所有引用都已经被完全访问过,并且已经标记。
  1. 标记过程:
  • 垃圾回收开始时,从根集合(如全局变量、活跃的堆栈帧中的局部变量等)出发,将所有根对象标记为灰色。
  • 逐一处理灰色对象:将灰色对象标记为黑色,并将其直接引用的所有白色对象转变为灰色。这个过程不断重复,直到没有灰色对象为止。
  1. 扫描完成:
  • 所有从根可达的对象最终都会被标记为黑色。所有仍然为白色的对象被认为是不可达的,因此将被视为垃圾并在清除阶段被回收。

优点

  • 健壮性:三色标记算法非常适合增量和并发的垃圾回收,因为它能够确保即使在应用程序继续执行的情况下也能正确地标记活动对象。
  • 防止漏标:通过灰色和黑色的严格区分,算法确保所有可达的对象都会被遍历和标记,防止错误地回收正在使用的对象。
  • 效率:虽然在垃圾回收期间会有增加的计算开销,但三色标记算法可以与应用程序的执行并行进行,减少了GC停顿的时间,提高了应用的响应性和性能。

应用

  • 在实际应用中,V8和其他现代JavaScript引擎使用这种算法进行内存管理,优化了动态内存的使用,减少了垃圾回收对应用性能的影响。这对于要求高性能和实时响应的Web应用程序尤其重要。

写屏障(增量中修改引用)

  • 这一机制用于处理在增量标记进行时修改引用的处理,可自行修改为灰色

在V8引擎中,写屏障(Write Barrier)是垃圾回收(GC)的一个关键机制,尤其是在增量和并发垃圾回收过程中发挥着至关重要的作用。写屏障主要用来维持垃圾回收中的三色不变性,在对象写操作期间动态地更新对象的可达性信息。

作用

  • 保持三色不变性,在使用三色标记算法中,写屏障帮助维持所谓的三色不变性。这意味着系统确保如果一个黑色对象(已经被完全扫描的对象)引用了一个白色对象(尚未被扫描的对象,可能是垃圾),那么这个白色对象应当转变为灰色(标记但尚未扫描完毕的对象),从而避免错误的垃圾回收。
  • 处理指针更新,当一个对象的指针被更新(例如,一个对象的属性被另一个对象替换),写屏障确保关于这些对象的垃圾回收元数据得到适当的更新。这是确保垃圾回收器正确识别活动对象和非活动对象的必要步骤。

类型

  • Pre-Write Barrier(预写屏障),这种类型的写屏障在实际更新内存之前执行。它主要用于某些特定类型的垃圾回收算法,比如分代垃圾回收,以保持老年代和新生代之间的引用正确性。
  • Post-Write Barrier(后写屏障),这是最常见的写屏障类型,发生在对象的指针更新之后。在V8中,当黑色对象指向白色对象时,后写屏障会将该白色对象标记为灰色,确保它不会在当前垃圾回收周期中被错误地回收。

实现细节

  • 在V8引擎中,写屏障通常由简短的代码片段实现,这些代码片段在修改对象属性或数组元素时自动执行。例如,每当JavaScript代码或内部的V8代码试图写入一个对象的属性时,写屏障代码会检查是否需要更新垃圾回收的元数据。

惰性清理

  • 增量标记只是用于标记活动对象和非活动对象,真正的清理释放内存,则V8采用的是惰性清理(Lazy Sweeping)方案。
  • 在增量标记完成后,进行清理。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理过程稍微延迟一下,让 Javascript 脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕。

并发回收

:::info 本质是切片,然后去插入,做一些动作

:::

  • react 中的 Concurrent 吗?
  • 我们想想 React演进过程,是不是就会觉得从并行到并发的演进变得很合了呢?
  • 并发挥收其实是更进一步的切片,几乎完全不阻塞主进程。

:::success 分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率

:::

怎么理解内存泄露?

怎么解决内存泄露,代码层面如何优化?

  • 减少查找
  • 减少变量声明
  • 使用 Performance + Memory 分析内存与性能

运行机制

  • 浏览器主进程
    • 协调控制其他子进程(创建、销毁)
    • 浏览器界面显示,用户交互,前进、后退、收藏
    • 将渲染进程得到的内存中的Bitmap,绘制到用户界面上
    • 存储功能等
  • 第三方插件进程
    • 每种类型的插件对应一个进程,仅当使用该插件时才创建
  • GPU进程
    • 用于3D绘制等
  • 渲染进程,就是我们说的浏览器内核
    • 排版引擎 Blink 和 JavaScript 引擎V8 都是运行在该进程中,将HTML、CSS和 JavaScript 转换为用户可以与之交互的网页

- 负责页面渲染,脚本执行,事件处理等
- 每个tab页一个渲染进程
- 出于安全考虑,渲染进程都是运行在沙箱模式下
  • 网络进程
    • 负责页面的网络资源加载,之前作为一个模块运行在浏览器主进程里面,最近才独立成为一个单独的进程

浏览器事件循环

:::info 在 Chrome 中,事件循环的执行是由浏览器的渲染引擎(例如 Blink)和V8 引擎配合完成的。V8负责 JavaScript 代码的执行,Blink 负责浏览器的渲染和用户界面的更新

:::

执行任务的顺序

先执行当前执行栈同步任务,再执行(微任务),再执行(宏任务)

宏任务

:::info 在 Chrome的源码中,并未直接出现“宏任务”这一术语,但在 Javascript 运行时引擎(V8)以及事件循环 (Event Loop)相关的实现中,宏任务和微任务的概念是非常重要的。

实际上,“宏任务”这一术语来源于 Javascript 事件循环的抽象,它只是帮助我们理解任务的执行顺序和时机。

:::

可以将每次执行栈执行的代码当做是一个宏任务

  • I/O
  • setTimeout
  • setinterval
  • setImmediate
  • requestAnimationFrame

微任务

当宏任务执行完,会在渲染前,将执行期间所产生的所有微任务都执行完

  • process.nextTick
  • MutationObserver
  • Promise.then catch finally

完整鏊体流程

  • 执行当前执行栈同步任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 执行栈同步任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 宏任务执行完毕,开始检查渲染,然后 GUI线程接管渲染
  • 渲染完毕后, JS线程继续接管,开始下一个宏任务(从事件队列中获取)

Node事件循环机制

与浏览器事件循环机制的不同

  • 在 Node.js 中,为了更高效地管理和调度各种类型的异步任务。这种设计使得 Node.js 能够在单线程环境中有效地处理大量的并发任务。下
  • Node.js 的事件循环(Event Loop)是一个处理异步操作的机制,它会按照顺序依次执行不同阶段任务。事件循环机制中分为多个阶段,每个阶段都有自己的任务队列,包括:
  • Timers 阶段:
    • 处理 setTimeout 和 setInterval 调度的回调函数。
    • 如果指定的时间到了,回调函数会被放入这个队列。
  • Pending Callbacks 阶段:
    • 处理一些1/0操作的回调,比如 TCP 错误类型的回调。
    • 这些回调并不完全由开发者控制,而是由操作系统调度的。
  • Idle, Prepare 阶段:
    • 仅供内部使用的阶段。
  • Poll 阶段:
    • 获取新的1/0事件,执行1/0回调函数。
    • 通常情况下,这个阶段会一直等待,直到有新的!/0 事件到来。
  • Check 阶段:
    • 处理 :setImmediate 调度的回调函数。
    • etImmediate 的回调会在这个阶段执行,比 setTimeout 更早。
  • Close Callbacks 阶段:
    • 处理一些关闭的回调函数,比如 socket.on('close', ... ) °

多个队列的必要性

不同类型的异步任务有不同的优先级和处理方式。使用多个队列可以确保这些任务被正确地调度和执行:

  • Timers 和 Poll 阶段的区别:
    • setTimeout 和 setInterval 的回调在 Timers 阶段执行,这些回调函数依赖于计时器的到期时间。
    • Poll 阶段处理大多数1/0 回调,这是事件循环的主要阶段,处理大部分异步1/O操作。
  • mmediate 与 Timeout 的不同:
    • setImmediate 的回调函数在 Check 阶段执行,这是在当前事件循环周期结束后立即执行。
    • setTimeout 的回调函数则是在 Timers 阶段执行,它可能会延迟到下一个事件循环周期,甚至更久。
  • 处理关闭回调:
    • Close Callbacks 阶段专门处理如 socket.on('close')这样的回调,以确保在资源释放时执行。

Chrome 任务调度机制

V8与Blink的调度系统密切相关。

:::info Blink 是 Chrome 中的渲染引擎

V8是 Chrome 中的 JavaScript 引擎

:::

Blink 是 Chrome 浏览器中的渲染引擎,负责页面的渲染和绘制任务。V8与 Blink 会协同工作,确保 JavaScript 的执行与页面渲染能够平稳进行。

Blink Scheduler:docs.google.com/document/d/…

接下来我们了解一下 Blink scheduler,一个用于优化 Blink 主线程任务调度的方案,旨在解决现有调度系统中的一些问题。

将任务不断安排到主线程的消息循环中,会导致Blink 主线程阻塞。造成诸多问题:

  • 有限的优先级设置-任务按照发布顺序执行,或者可以明确地延迟,但这可能导致一些重要的任务(如输入处理) 被不那么紧急的任务占用优先执行权。
  • 缺乏与系统其他部分的协调-比如图形管线虽然已知有输入事件的传递、显示刷新等时序要求,但这些信息无法及时传递给Blink。
  • 无法适应不同的使用场景 -某些任务(如垃圾回收)在用户交互时进行非常不合适。

为了解决以上问题,出现了 Blink Scheduler 调度器,它能够更灵活控制任务按照给定优先级执行

  • 关键特点
    • 调度器的主要功能是决定在特定时刻哪个任务应当执行。
    • 调度器提供了更高级的API替代现有的主线程任务调度接口,任务不再是抽象的回调函数,而是更具体、具有明确标签和元数据的对象。例如,输入任务会被明确标记,并附带附加元数据。
    • 调度器可以根据系统状态做出更明智的任务决策,而不是依赖给定死的静态优先级。

gitlab.mpi-klsb.mpg.de/eweyulu/qui…

  • 性能验证和工具
  • 为了验证调度器的效果,文章提到了多项基准测试和性能指标,例如:
    • 队列等待时间:衡量任务从发布到执行的延迟。
    • 输入事件延迟:衡量输入事件的处理时间。
    • 渲染平滑度(jank):衡量渲染的平滑性,避免出现卡顿。
    • 页面加载时间:跟踪页面加载时间的变化。

其他资料


作者:若梦plus
来源:juejin.cn/post/7493386024878833715

收起阅读 »

我为什么在团队里,强制要求大家用pnpm而不是npm?

web
最近,我在我们前端团队里推行了一个“强制性”的规定:所有新项目,必须使用pnpm作为包管理工具;所有老项目,必须在两个月内,逐步迁移到pnpm。 这个决定,一开始在团队里是有阻力的。 有同事问:“老大,npm用得好好的,为啥非要换啊?我们都习惯了。” 也有同事...
继续阅读 »

image.png


最近,我在我们前端团队里推行了一个“强制性”的规定:所有新项目,必须使用pnpm作为包管理工具;所有老项目,必须在两个月内,逐步迁移到pnpm


这个决定,一开始在团队里是有阻力的。


有同事问:“老大,npm用得好好的,为啥非要换啊?我们都习惯了。”


也有同事说:“yarn不也挺快的吗?再换个pnpm,是不是在瞎折腾?”


我理解大家的疑问。但我之所以要用“强制”这个词,是因为在我看来,在2025年的今天,继续使用npm或yarn,就像是明明有高铁可以坐,你却非要坚持坐绿皮火车一样,不是不行,而是没必要。


这篇文章,我就想把我的理由掰开揉碎了,讲给大家听。




npm和yarn的“原罪”:那个又大又慢的node_modules


在聊pnpm的好处之前,我们得先搞明白,npm和yarn(特指yarn v1)到底有什么问题。


它们最大的问题,都源于一个东西——扁平化的node_modules


你可能觉得奇怪,“扁平化”不是为了解决npm v2时代的“依赖地狱”问题吗?是的,它解决了老问题,但又带来了新问题:


1. “幽灵依赖”(Phantom Dependencies)


这是我最不能忍受的一个问题。


举个例子:你的项目只安装了A包(npm install A)。但是A包自己依赖了B包。因为是扁平化结构,B包也会被提升到node_modules的根目录。


结果就是,你在你的代码里,明明没有在package.json里声明过B,但你却可以import B from 'B',而且代码还能正常运行!


这就是“幽灵依赖”。它像一个幽灵,让你的项目依赖关系变得混乱不堪。万一有一天,A包升级了,不再依赖B了,你的项目就会在某个意想不到的地方突然崩溃,而你甚至都不知道B是从哪来的。


2. 磁盘空间的巨大浪费


如果你电脑上有10个项目,这10个项目都依赖了lodash,那么在npm/yarn的模式下,你的磁盘上就会实实在在地存着10份一模一样的lodash代码。


对于我们这些天天要开好几个项目的前端来说,电脑的存储空间就这么被日积月累地消耗掉了。


3. 安装速度的瓶颈


虽然npm和yarn都有缓存机制,但在安装依赖时,它们仍然需要做大量的I/O操作,去复制、移动那些文件。当项目越来越大,node_modules动辄上G的时候,那个安装速度,真的让人等到心焦。




pnpm是怎么解决这些问题的?——“符号链接”


好了,现在主角pnpm登场。pnpm的全称是“performant npm”,意为“高性能的npm”。它解决上面所有问题的核心武器,就两个字:链接


pnpm没有采用扁平化的node_modules结构,而是创建了一个嵌套的、有严格依赖关系的结构


1. 彻底告别“幽灵依赖”


在pnpm的node_modules里,你只会看到你在package.json明确声明的那些依赖。


你项目里依赖的A包,它自己所依赖的B包,会被存放在node_modules/.pnpm/这个特殊的目录里,然后通过 符号链接(Symbolic Link) 的方式,链接到A包的node_modules里。


这意味着,在你的项目代码里,你根本访问不到B包。你想import B?对不起,直接报错。这就从结构上保证了,你的项目依赖关系是绝对可靠和纯净的。


2. 磁盘空间的“终极节约”


pnpm会在你的电脑上创建一个“全局内容可寻址存储区”(content-addressable store),通常在用户主目录下的.pnpm-store里。


你电脑上所有项目的所有依赖,都只会在这个全局仓库里,实实在在地只存一份


当你的项目需要lodash时,pnpm不会去复制一份lodash到你的node_modules里,而是通过 硬链接(Hard Link) 的方式,从全局仓库链接一份过来。硬链接几乎不占用磁盘空间。


这意味着,就算你有100个项目都用了lodash,它在你的硬盘上也只占一份的空间。这个特性,对于磁盘空间紧张的同学来说,简直是福音。


3. 极速的安装体验


因为大部分依赖都是通过“链接”的方式实现的,而不是“复制”,所以pnpm在安装依赖时,大大减少了磁盘I/O操作。


它的安装速度,尤其是在有缓存的情况下,或者在安装一个已经存在于全局仓库里的包时,几乎是“秒级”的。这种“飞一般”的感觉,一旦体验过,就再也回不去了。




为什么我要“强制”?


聊完了技术优势,再回到最初的问题:我为什么要“强制”推行?


因为包管理工具的统一,是前端工程化规范里最基础、也最重要的一环。


如果一个团队里,有人用npm,有人用yarn,有人用pnpm,那就会出现各种各样的问题:



  • 不一致的lock文件package-lock.json, yarn.lock, pnpm-lock.yaml互相冲突,导致不同成员安装的依赖版本可能不完全一致,引发“在我电脑上是好的”这种经典问题。

  • 不一致的依赖结构:用npm的同事,可能会不小心写出依赖“幽灵依赖”的代码,而用pnpm的同事拉下来,代码直接就跑不起来了。


在一个团队里,工具的统一,是为了保证环境的一致性和协作的顺畅。而pnpm,在我看来,就是当前这个时代下,包管理工具的“最优解”。


所以,这个“强制”,不是为了搞独裁,而是为了从根本上提升我们整个团队的开发效率和项目的长期稳定性。




最后的经验


从npm到yarn,再到pnpm,前端的包管理工具一直在进化。


pnpm用一种更先进、更合理的机制,解决了过去遗留下的种种问题。它带来的不仅仅是速度的提升,更是一种对“依赖关系纯净性”和“工程化严谨性”的保障。


我知道,改变一个人的习惯很难。但作为团队的负责人,我有责任去选择一条更高效、更正确的路,然后带领大家一起走下去。


如果你还没用过pnpm,我强烈建议你花十分钟,在你的新项目里试一试🙂。


作者:ErpanOmer
来源:juejin.cn/post/7530180321619656745
收起阅读 »

Tauri 2.0 桌面端自动更新方案

web
前言 最近在研究 Tauri 2.0 如何自动更新,跟着官网教程来了一遍,发现并不顺利,踩了很多坑,不过好在最后终于走通了,今天整理一下供大家参考。 第一步 自动更新利用的是 Tauri 的 Updater 组件,所以这里需要安装一下: PNPM 执行这个(笔...
继续阅读 »

前言


最近在研究 Tauri 2.0 如何自动更新,跟着官网教程来了一遍,发现并不顺利,踩了很多坑,不过好在最后终于走通了,今天整理一下供大家参考。


第一步


自动更新利用的是 Tauri 的 Updater 组件,所以这里需要安装一下:


PNPM 执行这个(笔者用的 PNPM):


pnpm tauri add updater

NPM 执行这个:


npm run tauri add updater

接着在 /src-tauri/tauri.conf.json 文件中添加以下配置:


{
"bundle": {
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "你的公钥",
"endpoints": ["https://releases.myapp.com/latest.json"]
}
}
}

其中:



  • createUpdaterArtifacts 为是否创建更新包,设置为 true 即可。根据官网介绍,未来发布的 V3 版本将无需设置。

  • pubkey 是公钥,用于和私钥匹配(私钥在开发环境配置,并在打包时自动携带)。但此时我们还没有,所以需要生成一下,执行以下命令生成密钥对:


    PNPM 执行这个:


    pnpm tauri signer generate -w ~/.tauri/myapp.key

    NPM 执行这个:


    npm run tauri signer generate -- -w ~/.tauri/myapp.key

    执行时会要求输入一个密码用来保护密钥,也可以直接按回车跳过,建议还是输入一个:


    image.png


    输入(或跳过)之后,将会继续生成,生成之后进入刚才我们指定的目录 ~/.tauri


    image.png


    打开公钥 myapp.key.pub 然后将上面的 pubkey 替换掉。


    私钥的话,打开 myapp.key 然后执行以下方法设置到环境变量:


    macOS 和 Linux 执行这个(笔者是 macOS):


    export TAURI_SIGNING_PRIVATE_KEY="你的私钥"
    export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="你刚才输入的密码,没有就不用设置。"

    Windows 使用 Powershell 执行这个:


    $env:TAURI_SIGNING_PRIVATE_KEY="你的私钥"
    $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD="你刚才输入的密码,没有就不用设置。"


  • endpoints 用于 Tauri 检查更新,是一个数组,所以可以设置多个,将会依次尝试可用的 URL,URL 指向放置在服务器的用于存储版本信息的 JSON 文件(也可以使用 API 的形式,这里不介绍了),格式如下:


    {
    "version": "1.0.1",
    "notes": "更新说明",
    "pub_date": "2025-05-21T03:29:28.626Z",
    "platforms": {
    "darwin-aarch64": {
    "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVTU0xJb2k1U3J6ZVFoUWo3R2lMTm5EdzhoNUZTKzdsY0g1NktOOTFNL2RMM0JVVVl4b0k3bFB0MkhyL3pKOHRYZ0x0RVdUYzdyWVJvNDBtRDM0OGtZa2d0RWl0VTBqSndrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzQ3Nzk1MTY5CWZpbGU6bXktdGF1cmktYXBwLmFwcC50YXIuZ3oKS1N0UDl5MHRteUd0RHJ6anlSMXBSWmNJUlNKb1pYTDFvK2EvUjArTlBpbXVGN3pnQlA0THhhVUd4S3JrZy9lNHBNbWVSU2VoaCswN25xNEFPcmtUQnc9PQo=",
    "url": "macOS 包下载地址"
    }
    }
    }

    将此 JSON 文件放置在服务器,然后将上面的 endpoints 数组里的地址替换为这个 JSON 的真实地址。


    其中:



    • version 是版本号,升级时需要大于当前用户使用的版本。

    • notes 是更新说明,可以向用户说明本次更新的内容。

    • pub_date 是更新日期,非必填。

    • platform 是更新的平台,这里我以 macOS 为例,Windows 同理。

    • signature 是每次打包后的签名,所以每次都不一样,macOS 默认在 /src-tauri/target/release/bundle/macos/my-tauri-app.app.tar.gz.sig 这个位置,将这个文件打开,复制里面的内容替换即可。




第二步


配置好以后,就可以在应用内调用 check 方法进行更新了,比如在用户每次应用启动后。以下是从检查更新到更新完成的全流程的必要代码:


import { check } from '@tauri-apps/plugin-updater'
import { relaunch } from '@tauri-apps/plugin-process'

const fetchVersion = async () => {
const update = await check()

if (update) {
console.log(`found update ${update.version} from ${update.date} with notes ${update.body}`)
let downloaded = 0
let contentLength = 0
// 也可以分开调用 update.download() 和 update.install()
await update.downloadAndInstall(event => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength
console.log(`started downloading ${event.data.contentLength} bytes`)
break
case 'Progress':
downloaded += event.data.chunkLength
console.log(`downloaded ${downloaded} from ${contentLength}`)
break
case 'Finished':
console.log('download finished')
break
}
})

console.log('update installed')
// 此处 relaunch 前最好询问用户
await relaunch()
}
}

代码已经很简洁了,相信大家能看懂,但还是简单说一下:


首先调用 check 方法。检查之后,check 方法会返回一个 update 对象,如果检查到有更新,该对象会包含上面的版本更新信息,也包含一个 downloadAndInstall 方法。


执行 downloadAndInstall 方法,该方法执行完之后就代表安装成功了,会在下次启动时更新为新版本。当然也可以立即生效,只需要调用 relaunch 方法重启应用即可,但重启前最好提醒用户


源码(经测试已经成功实现自动更新)已经上传到 Github:github.com/reallimengz…


作者:limengzhe
来源:juejin.cn/post/7506832196582408226
收起阅读 »

ESLint + Husky 如何只扫描发生改动的文件?

web
背景 最近公司对代码质量抓得很严, 出台了一系列组合拳: 制定前端编码规范 在本地使用git提交代码时进行代码质量检查 在CI/CD流水线上, 用sonarQube设置了一个代码质量达标阈值,不达标的话无法构建部署 除了运用工具之外,还增加了定期的CodeR...
继续阅读 »

背景


最近公司对代码质量抓得很严, 出台了一系列组合拳:



  1. 制定前端编码规范

  2. 在本地使用git提交代码时进行代码质量检查

  3. 在CI/CD流水线上, 用sonarQube设置了一个代码质量达标阈值,不达标的话无法构建部署

  4. 除了运用工具之外,还增加了定期的CodeReview

  5. 单元测试,线上合并代码时用大模型进行CodeReview也在路上...


今天先说说,在本地使用git提交代码时进行代码质量检查如何实现。现在进入主题


Step1 配置ESLint校验规则


在这一步,踩了一个大坑。现在安装ESLint, 安装的都是ESLint v9.x版本,ESLint v9+的配置文件与之前不太一样了。不管是问大模型,还是上网搜,搜出来的ESLint安装配置方式90%以上都是ESLint V8及以下版本的配置方法。按照那种方式配,会吃很多瘪。


能看懂的,简单一点的报错比如说:



  • .eslintignore文件不再被支持,应该在 eslint.config.jseslint.config.ts 配置文件中,使用 ignores 属性来指定哪些文件或目录需要被忽略。
    (node:13688) ESLintIgnoreWarning: The ".eslintignore" file is no longer supported. Switch to using the "ignores" property in "eslint.config.js": https://eslint.org/docs/latest/use/configure/migration-guide#ignoring-files (Use node --trace-warnings ... to show where the warning was created) Oops! Something went wrong! :( ESLint: 9.25.1)

  • 改成ignores又报错, 对象字面量只能指定已知属性,并且“ignores”不在类型“ESLintConfig”中,被大模型忽悠了一回。在 ESLint 9.x 中,应该使用 ignorePatterns 来指定要忽略的文件或文件夹,而不是 ignores

  • jiti包版本不匹配, 需要升级
    Oops! Something went wrong! :( ESLint: 9.25.1 Error: You are using an outdated version of the 'jiti' library. Please update to the latest version of 'jiti' to ensure compatibility and access to the latest features.

  • 未安装eslint-define-config模块
    Oops! Something went wrong! :( ESLint: 9.25.1 Error: Cannot find module 'eslint-define-config'


不太容易看懂的报错比如说 ESLint 没有找到适用于文件 src/main.ts 的配置规则。0:0 warning File ignored because no matching configuration was supplied , 按照大模型的提示,逐一检查了ESLint 配置文件的路径是否正确,确保 root: true 配置生效; TypeScript 和 Vue 插件及解析器配置是否正确; ignorePatterns 是否误忽略了 src 文件夹; 检查 tsconfig.json 中的 include 配置; 手动检查文件是否被 ESLint 正确解析


pnpm eslint --config ./eslint.config.ts src/main.ts

忙活了一圈,未能解决问题。大模型排查技术工具最新版本的故障问题能力偏弱。无奈只能在网上搜,一篇一篇的看与试错。最终验证通过是方案是采用@eslint/config生成eslint v9版本的配置文件。


pnpm create @eslint/config

做7个选择(每个选项的含义一眼就能看懂)之后,就能妥妥地生成eslint配置文件。
image.png


Step2 配置Husky


这一步比较简单,虽然Husky最新版本的配置方法与先前的版本不一样了。但新版本的配置比老版本的要简单一些。


✅ 1. 安装Husky v9+版本


pnpm add -D husky

✅ 2. Husky v9+版本初始化


npx husky init

这会自动:



  • 创建 .husky/ 目录

  • .husky/下添加 pre-commit hook 示例

  • 在package.json中添加 "prepare": "husky install" 脚本


这一步有个小坑,就是如果npx husky init第一次因为某种原因运行失败,第二次再运行,不会生成.husky目录。解决方法也很简单粗暴,卸载husky重新安装。


✅ 3. 在package.json配置检查指令


{
"scripts": {
"lint": "run-s lint:*",
"lint:eslint": "eslint src/**/*.{ts,tsx,vue} --debug --cache",
"lint:prettier": "prettier --check ./",
"lint:style": "stylelint \"src/**/*.{vue,css,less}\" --fix",
},
}

✅ 4. 修改 .husky/pre-commit hook


# 检查指令
pnpm lint

Step3 配置ESLint增量检测


为什么要配置增量检测呢,原因有两点:



  1. ESLint全量检测执行的很慢,如果不加--debug参数,很长一段时间,看不到任何输出,会让人误以为卡死了

  2. 开发业务功能的时间本来就捉襟见肘,对于已有项目,当期要偿还历史技术债务的话,时间不允许。


那么如何做增量检查呢?最质朴的思路就是利用git能监测暂存区代码变更的能力,然后利用ESlint对变更的文件执行代码质量检查。这里有两处要注意一下,一是检查暂存区变更的文件,要过滤掉删除的文件,只检查新增,修改,重命名,复制的文件。另外,当没有匹配类型的文件时,files=$(git diff --cached --name-only --diff-filter=AMRC | grep -E '\.(ts|tsx|vue)$')会抛出一个exit 1的异常,造成改了(ts|tsx|vue)之外的文件不能正常提交,所以要在后面加一个|| true进行兜底。


#!/bin/bash
# set -e
# set -x
trap 'echo "Error at line $LINENO"; exit 1' ERR

# 注意这里加了 || true
files=$(git diff --cached --name-only --diff-filter=AMRC | grep -E '\.(ts|tsx|vue)$' || true)

if [ -z "$files" ]; then
echo "No changed ts/tsx/vue files to check."
exit 0
fi

echo "Running ESLint on the following files:"
echo "$files"

# 用 xargs -r 只有在有输入时才执行
echo "$files" | xargs -r npx eslint

echo "All files passed ESLint."
exit 0


Step4 测试效果


修改 src 下的某个 main.ts 文件,故意触发代码质量问题,然后提交。



  • 情形1 通过命令行提交,eslint校验未通过,阻断提交,且是增量校验。


git add . && git commit -m "测试"

image.png



  • 情形2 通过UI界面提交,成功阻断提交
    image.png


至此大功告成,结果令人满意,如果你的项目也需要实现这样的功能的话,拿走不谢。


后记


业务背景是这样的:gitlab上有个填写公司的仓库,有个提交代码的仓库,现在要将提交代码的仓库的代码变更记录,添加到填写工时的议题评论列表中,只要按照 feat: 跨项目提交测试 #194(#194是填写工时的议题id)这样的格式填写提交语,就能实现在评论列表添加代码变更链接的效果。


image.png


在.husky目录下添加prepare-commit-msg文件,内容如下:


#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

# 仅当手动写 commit message 时执行
if [ "$2" = "merge" ] || [ "$2" = "squash" ]; then
exit 0
fi

file="$1"
msg=$(cat "$file")

# 查找是否包含 #数字 格式的 Issue 编号
issue_number=$(echo "$msg" | grep -Eo '#[0-9]+' | head -n1 | sed 's/#//')

if [ -n "$issue_number" ]; then
# 自定义项目路径
project_path="research-and-development/algorithm/项目名"

# 如果已经包含路径,则不重复添加
echo "$msg" | grep -q "$project_path" && exit 0

echo "" >>"$file"
echo "Related to $project_path#$issue_number" >>"$file"
fi

需要注意的是,你使用的gitlab版本必须大于v15,才支持跨项目议题关联功能


作者:去伪存真
来源:juejin.cn/post/7497800812317147170
收起阅读 »