阅读视图

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

「译文」我买不起奔驰 🚗,但是奔驰买得起 Oxlint ⚓️ 和 Rolldown ⚡️!

前言

今天刷推特看到的一篇博客,作者是奔驰技术团队!

尤雨溪转发的推特

往期精彩推荐

正文

在当今快速发展的 JavaScript 生态系统中,现代工具发挥着关键作用,不仅影响我们的编码方式,还影响我们花费在编码上的时间和金钱。

JavaScript 生态系统中工作时,我们可以利用各种工具来提升质量、一致性和效率。今天,我们将重点介绍两款出色的工具,它们在实现重大里程碑的同时,仍然是大多数用例的直接替代品。

我们将讨论 捆绑器代码检查器,因为它们可能会成为开发或生产构建的瓶颈。

JavaScript 开发中,捆绑器 采用单个代码模块(无论是以 ESM 还是 CommonJS 编写),并将它们组装成一个有凝聚力的、更大的输出,例如完整的应用程序或可重用的库。

对于 Web 应用而言,即使使用 HTTP/2,此过程也能提升加载速度和运行时效率。对于库而言,提前打包可以减少使用方项目重新打包的需要,并提升执行性能。

相反,JavaScript 中的 linting 涉及检查代码以捕获潜在的错误、风格不一致以及不良的编码习惯。linter 是一种根据一组定义的规则或标准扫描代码库的工具,它有助于确保代码一致性、增强可读性,并在常见错误成为实际问题之前将其预防。

它是如何变化的?

多年来,各种构建工具层出不穷,包括 WebpackTurbopackRspackVite。其中,Vite 凭借其灵活性、用户友好性以及与各种项目的无缝兼容性,在 JavaScript 社区中得到了广泛的采用。

Vite 底层使用了两个打包器:esbuildRollup。您可以在这里找到更多详细信息,因此我们在此不再赘述。Vite 优秀的开源团队在 VoidZero 的支持下,推出了一款基于 Rust 的全新打包器,旨在取代 esbuildRollup。这款新的打包器保留了 Rollup 的 API,同时显著提升了性能并进行了许多其他改进。

在代码检查方面,ESLint 一直以来都是 JavaScript 代码中发现问题的首选工具。然而,VoidZero 也推出了一款基于 Rust 的替代方案 Oxlint,它的性能得到了提升,运行速度比 ESLint 快 50 到 100 倍。

补充一点,需要记住的是,仅仅迁移到 Rust 并不会自动提升速度。许多此类工具也借此机会考察了其他开源项目的架构,找出了设计瓶颈,并根据我们目前所了解的现实情况,为未来做出更可持续的决策。

它对我们有何影响?

这些新工具更令人印象深刻的是,它们可以直接作为替代品。无需重构代码,也无需花时间思考如何集成所有功能。

Mercedes-Benz.io,我们的前端本质上是微前端,所以我们无法仅仅测试完整构建版本并查看其改进程度。尽管如此,我仍然好奇这些工具会给我们带来多少时间和金钱上的影响,而金钱不一定是机器时间,主要是工程时间。

我从一些团队中挑选了几个代码库,一些团队拥有较多的微前端,而另一些团队拥有较少的微前端,并运行了 4 种类型的测试:

  1. 使用 Rolldown 进行直接替换
  2. 使用 SASS-EmbeddedRolldown 进行直接替换
  3. 使用 SASS-Embedded + Lightning CSSRolldown 进行直接替换
  4. 使用 Oxlint 替代 ESLint

由于我已经在测试它,所以我决定采用两个可用于生产的替代品,如上所示。

Sass-EmbeddedSass 包的替代方案。它支持与 Sass 相同的 JS API,并且由同一团队维护。然而,Sass-Embedded 是一个 JavaScript 封装器,封装了原生 Dart 可执行文件。这意味着它通常速度更快,尤其是在处理大型 Sass 编译时。

LightningCSS 是一款用 Rust 编写的超快速 CSS 解析器、转换器、打包器和压缩器。它是一款能够编译、优化和转换 CSS 的工具,旨在比其他传统工具更快、更高效。

让我们来看看数字

在您测试的所有代码库中,平均结果显示:

  1. 仅使用 Rolldown,构建时间减少了 23%,最高可达 33%。
  2. 使用 Rolldown + Sass-Embedded,构建时间减少了 25%,最高可达 33%。
  3. 使用 Rolldown + Sass-Embedded + Lightning CSS,构建时间减少了 34%,最高可达 38%。

在 linting 方面,转向 Oxlint 后,平均减少了 71%,最高减少了 97%。

这只是这些工具的开始。Rolldown 仍在开发中,但已经经过社区的大量测试,而 Oxlint 目前处于 Beta 阶段。这两款工具都计划进行更多改进。

这真是太神奇了:我们谈论的不是重构代码,也不是花时间理解和尝试适应新工具。我们谈论的是一些可以让我们的 CI 流程减少 64% 的嵌入式替换(仅指构建 + linting)。

假设大约有 100 个项目,每个项目平均每年有 600 次提交,每次提交都需要构建和 lint。这意味着每年可以节省大约 500 个小时,差不多 21 天。由于机器成本难以计算,因此不计入机器成本,我们可以想象工程成本,包括生产力、专注度,以及能否顺利进入流程。

这种节省不仅能显著提高开发效率,还能让团队有更多时间专注于功能开发和创新,从而提升整体项目的质量和响应速度。

最后

原文地址:www.mercedes-benz.io/blog/2025-0…

原推特地址:x.com/boshen_c/st…

往期精彩推荐

一次痛苦的内存泄露排查经历

前言

这阵子接手的一个需求就是排查项目是否存在内存泄露问题,找到并解决它

目前是发现了四处问题,一处是 el-select 组件存在泄露问题,这个有做出挣扎解决,但是最终并没有彻底解决,无果;一初是项目封装的 v-delegate 指令存在闭包问题,这个最后是组长帮忙发现的,问题很隐蔽;另一个是有个 timer 没有 clear。最后一个还是闭包,这个就是本篇要讲的,排查过程比较煎熬

前端排查内存泄漏还是非常痛苦的,尤其是面对复杂项目

组里的项目是 黑盒语音 客户端,就是大家熟知的小黑盒旗下的一款语音产品,语音项目本身就对内存占用要求比较苛刻

其实排查内存泄漏,定位到是哪些交互其实还好,痛苦的是找到了交互过后,如何定位到具体代码,这次就是一个异步 + 闭包导致的内存泄露问题,开篇前先介绍下如何利用 performancememory 选项卡定位可疑的交互

如何排查(先 performancememory

该项目是 electron + vue2

比如这次碰到的问题是,语聊房的房间设置页面存在多个 tab,我针对所有 tab 依次往下点击然后回到最初的第一个 tab 看 performance 是否存在内存上涨

这里最好记得先把所有的 tab 点击加载一遍,防止有动态加载的组件存在,或者是缓存,异步函数等等。总之最好第一次先点击加载消除某些不可控的影响

这里提一嘴,排查内存泄漏最好是打包之后,或者保证自己的项目不存在 console.log(感觉手动剔除也不现实) ,因为你开启了 console 台后,log 会保存你的变量,这些变量会留在 windows 中导致内存泄露,打包之后一般 tree-shaking 会帮我们将项目的 log 给自动删除

好,现在我们来利用 performance 进行排查

从 第一个 tab 开始切换下面的所有 tab,然后回到第一个 tab

1.gif

在录制之前保证点击了所有的 tab 后,开始录制时记得勾选 memory ,这个就是我们要看的 内存 信息,交互前先点击 🧹 图标,最后回到第一个 tab 后结束录制前也记得 点击 🧹 图标

最后我们来注意整个交互区间的 Nodes 上下浮动范围,从图中可以看出,节点从最初的 7000 个增长到了 8000 个左右,明显有内存泄露问题

这也就意味着这么多 tab ,存在一个或者多个导致了内存泄漏,正常来讲我回到页面最初起点,页面的 dom 数量也只会是最初的,增长了也就意味着 dom 可能被某些数据引用了,成了游离 dom,前端内存泄漏最大的问题其实就是游离 dom

接下来的分析方向就比较清晰了,我们需要排查究竟是哪个 tab 导致了内存泄露,后面的定位会比较繁琐,因为你要挨个排查,挨个排查你就得控制好变量,比如我怀疑第一个 tab,那么我就需要将其余 tab 的组件代码的 代码(template + script) 清空,然后针对第一个 tab 来回切换

中间的步骤这里不会展示,这里直接说结论了,就是第一个 tab 有问题,为了再次证明是这个 tab 的问题,我们接下来可以利用 memory 选项卡进行内存快照分析

为了有一个 tab 可以辅助切换,但是又不能有这个 tab 的影响,我就需要将其 template + Script 部分代码置空,比如这里我将 第二个 tab 代码置空

我们现在进行 memory 分析

2.gif

可以看到第一个tab在与第二个空tab来回频繁切换 10 次后,js heap 上涨了将近 20M,这还是非常恐怖的数据

这个时候我们就可以去定位到是第一个 tab 这个组件的问题所在了,当然我们其实还是可以去继续留意 memory 的变化

1.png

我们会发现有两个 很奇怪的 constructor 增长了,一个 t 一个 a,我将这个项目放到 web 上去观察反而没有这两个变量,我们随机展开一个 t 看内部结构

2.png

会发现这应该是 vnode,这应该是因为 electron 跑的是代码压缩后的结果,还有个 a 应该是 VueComponent

其实内存快照的 comparison 这里也只有 t 好去分析,也就是 vnode,因为一个 vnode 会对应一个 dom 节点,我们可以看看究竟是哪里多出的 游离节点

其实当我们定位到某个组件的时候,我们还需要进一步分析,因为有些组件可能是由多个封装组件进一步封装的,或者会有多个同级 div,这个时候我们又需要去做一个控制变量分析,依次保留当前组件的某部分 template 然后去拍 内存快照,有时候可能是 js 问题,那又要控制 js 代码。

因为第一个 tab 组件比较复杂,所以这一步废了挺多时间去排查某个具体部分

其实定位到组件的时候,我们可以先目测观察下当前组件是否存在一些没有 off 掉的事件或者没有 clear 的定时器,然而事实却是 on 的事件都有对应的 off ,定时器也都有 clear,这就加大了排查难度

最后是定位到了一个 异步 methods,这个 methods 大致如下

3.png

其实这个 initData 中间还有很多逻辑,这里只展示了重点。乍这么一看好像也没啥问题,最后我定位的过程中,发现就是 judge 有问题,当我在 judge 中直接 return true 时没问题,只要一引用了 vuex 的值就会有泄露

这里的 channel_list 就是一个 vuexstore 值,我若是切换切得很快,这个 异步函数 在组件卸载时可能还没有执行完毕,后面的 initChannels 就会排队执行,这个 initChannels 里面的 judge 又是个 闭包函数,并且通过 vuex 引用了 thisvuex 本身就是全局唯一的状态管理库,这个值若牵扯到了 this,也就是 vue 实例,就会引起内存泄漏问题

所以怎么解决这个问题呢,我们可以在 await 后添加一个 逻辑,若组件卸载了就直接 return,不让继续执行后面的逻辑

4.png

这个泄露 bug 排查最后还是组长点醒我的

其实后面排查的过程中,因为用了组件库 element-ui,其中还有个 tab 用到了 el-select 组件,这个组件也存在内存泄露问题,好像 element-ui 但凡涉及到 popover 的组件都存在内存泄露问题,大家使用这个库的时候还是谨慎点

最后

当我们使用 performance 或者 memory 选项卡定位到了某个组件存在内存泄漏问题时,首先应该去判断组件是否存在某些事件没有清除,或者定时器没有 clear,这个是最重要的,若肉眼难以看出来,那就进一步去怀疑是否存在闭包导致的内存泄漏,然后去通过注释代码的方式去验证想法,过程还是非常麻烦的

再也不怕接口格式变来变去!用自定义请求钩子封死后端奇葩接口

在日常中后台开发中,我们经常需要实现各种数据列表页,包括查询条件表单、数据表格、分页等功能。然而,不同业务的列表页虽然样式和交互相似,但背后的后端接口格式可能千差万别。一旦后端接口格式发生变化,前端列表页的代码就可能需要大幅修改,给维护带来不少麻烦。本文将介绍一套通用的可复用列表页组件方案,通过灵活的配置和技巧来应对各种“奇葩”后端接口。

为什么需要通用的列表页组件?

在中后台项目中,列表页通常由查询条件表单和数据列表展示组成,是重复率很高的功能模块。如果每个列表页都各自实现,会产生大量重复代码,也不利于统一维护。构建一个通用的列表页组件可以带来诸多好处:

  • 减少重复代码:将列表页的通用逻辑(如分页处理、表格渲染、查询表单)封装起来,在不同页面复用,避免每次从零编写。
  • 统一交互与样式:统一列表页的查询交互(如“展开/收起”高级查询)、表格样式和空数据提示等,提升系统的一致性。
  • 应对需求变更:当需要对列表页功能做修改(比如新增导出按钮、调整分页逻辑)时,只需在组件内部修改一次,所有使用该组件的页面都会同步更新。
  • 屏蔽后端差异:通过配置来适配不同后端接口的请求和响应格式,列表组件内部消化这些差异,页面使用方无需感知接口的特殊性。

综上,封装通用列表页组件既是工程复用的需要,也是提高开发效率和代码健壮性的有效手段。

requestConfig.buildPayload:适配不同后端接口格式

后端的列表接口往往有不同的请求入参规范。例如,有的接口期望请求体直接提供查询条件,有的则要求将查询条件嵌套在 modelpageBean.model 下,还有的分页参数字段名各不相同。如果我们在每个页面手动拼装不同格式的请求,无疑增加了重复劳动和出错概率。

解决方案:在通用列表页组件中引入 requestConfig.buildPayload() 钩子,用于根据统一的查询参数对象构建不同格式的请求载荷。组件对外暴露 requestConfig 配置,使用方可以传入自定义的 buildPayload 函数来自定义请求格式:

// 使用通用列表组件时传入配置
<CommonList
  :requestConfig="{
    url: '/api/getListData',
    method: 'POST',
    // 自定义请求载荷构建逻辑
    buildPayload: (queryParams, pagination) => {
      // 例:将查询参数包裹在 pageBean.model 中,并添加分页信息
      return {
        pageBean: {
          page: pagination.currentPage,
          size: pagination.pageSize,
          model: queryParams
        }
      }
    }
  }"
  ... />

在组件内部,每当需要发起列表请求时,会调用 requestConfig.buildPayload(formData, pagination) 来获取最终的请求体。例如:

  • 直接使用 model 包装:某些接口希望所有查询条件都放在 model 字段下,那么 buildPayload 可以返回 { pageNum, pageSize, model: { ...查询条件 } }
  • 嵌套在 pageBean.model:对于要求分页信息和查询条件一起封装的接口,则返回 { pageBean: { page, size, model: { ...查询条件 } } }
  • 无特殊包装:如果后端直接接受平铺的查询参数,那么 buildPayload 直接返回 { page, size, ...查询条件 } 即可。

通过这种钩子机制,我们实现了请求格式的适配层。无论后端接口多么“奇葩”,我们都能在不改动组件核心代码的前提下,通过定制 buildPayload 轻松应对。这极大提升了组件的适应性,也让接口变更对前端的影响降到最低。

优雅实现查询表单的展开/收起

高级查询条件往往很多,我们通常提供“展开/收起”按钮来在界面上隐藏部分条件。当用户点击“展开”时显示所有字段,“收起”则只显示基础字段。如何实现这个功能,同时保证表单字段的状态不丢失,是我们要解决的关键问题。

常规做法的问题

一些常见但不理想的实现方式包括:

  • 替换表单规则:通过切换不同的表单字段列表(form rule)来控制显示哪些字段。例如收起时使用一套精简字段数组,展开时替换成完整字段数组。然而这样做会导致组件的销毁和重建,已填写的数据会丢失。尤其是在使用表单生成器如 form-create 时,动态增删字段会重置部分已选值。频繁切换规则也增加了实现复杂度。
  • v-if 条件渲染:对每个可收起字段加上 v-if="showAll" 来决定渲染与否。这种方式同样会在收起时移除 DOM 元素,字段状态可能会丢,而且需要在展开时重新挂载组件。类似地,表单验证状态也会被重置。

用隐藏属性控制显示

推荐做法是利用隐藏属性来控制字段显隐,而非移除节点或替换整个规则。在 form-create 中,我们有两种隐藏方式:

  • 隐藏字段(无 DOM) :通过调用 fApi.hidden(true, fieldName) 可以隐藏指定字段,隐藏后完全不渲染对应的组件,DOM 节点将移除。这样做适合初始就不需要渲染大量高级字段的场景,减少 DOM 开销。但要注意,字段隐藏后表单验证也不会触发。
  • 隐藏组件(保留 DOM) :调用 fApi.display(false, fieldName) 则会将组件通过 CSS 隐藏(display:none),但组件实例仍然保留在 DOM 中。优点是字段的绑定值和验证状态都不会丢失,再次显示时能保持原有状态。

在实际实现中,我们可以结合两种方式。例如初始进入页面时将高级字段使用 hidden 隐藏以减轻渲染负担;当用户点击“展开”按钮时,再将这些字段用 display 显示出来。收起时,则仅隐藏(display:none)而不销毁组件,以便保留用户可能已输入的内容。

具体代码逻辑示例:

data() {
  return {
    showAll: false,              // 控制展开/收起的状态
    advancedFields: ['age', 'address', 'company']  // 需要隐藏的高级查询字段name列表
  }
},
methods: {
  toggleFields() {
    this.showAll = !this.showAll;
    if (this.showAll) {
      // 展开:显示所有字段
      this.fApi.display(true, this.advancedFields);
    } else {
      // 收起:隐藏高级字段(保留其值和状态)
      this.fApi.display(false, this.advancedFields);
    }
  }
}

通过这种方式,“展开/收起”查询表单非常流畅:组件状态不重建不重置,用户在高级字段中已输入的值在收起后虽然不可见,但再次展开时还能看到,避免了反复输入。同时,隐藏的字段也不会影响布局,表单其余部分不会因为移除节点而闪烁。

隐藏字段的查询与重置处理

实现字段隐藏后,还需要处理两个细节问题:查询时隐藏字段不参与、重置时隐藏字段也要处理。否则可能出现隐藏字段的值误参与查询,或重置操作无法清空隐藏字段等情况。

跳过隐藏字段参与查询

当用户收起高级查询后,再点击查询按钮时,我们不应将隐藏字段的值提交给后端,否则会造成意外的筛选。即使之前用户在高级字段填过值,收起状态下也应视为暂不使用。为此,可以在构造请求参数时过滤掉所有当前隐藏的字段:

const formData = this.fApi.formData();  // 获取表单所有字段的当前值
for (const field of this.advancedFields) {
  if (!this.showAll) {
    // 收起状态下,直接移除高级字段的参数
    delete formData[field];
  }
}
const payload = this.requestConfig.buildPayload(formData, this.pagination);

如上,我们利用 fApi.formData() 获取所有字段的值,然后根据 showAll 状态剔除 advancedFields 列表中的字段。这样生成的查询参数就只包含可见的查询条件,保证后台只按用户期望的条件筛选数据。

当然,更严谨的做法是利用 form-create 提供的 hiddenStatus 接口动态判断字段是否隐藏:

Object.keys(formData).forEach(field => {
  if (this.fApi.hiddenStatus(field)) {
    delete formData[field];  // 隐藏状态则剔除
  }
});

这在多处使用隐藏字段的场景下更加通用。

确保重置清空所有字段

点击“重置”按钮或执行表单清空时,我们期望所有查询条件都被清空,包括那些当前隐藏的高级字段。然而,如果直接使用 Element UI 提供的 this.$refs.form.resetFields() 或 form-create 的 fApi.resetFields(),需要注意默认行为是否覆盖隐藏字段。

在 form-create 中,fApi.resetFields() 会重置表单的所有字段值(也可以选择特定字段)。但是这里的“重置”往往是指恢复初始值:如果某些字段设置了初始值,reset 后会回到初始值而非空。因此,为实现“彻底清空”,我们可能需要做额外处理:

  • 未设置初始值的字段:reset 后本来就是空的,可直接使用 resetFields() 清空。
  • 有默认初始值的字段:reset 会回到默认值,而我们希望清空为“无”。对于这类字段,可以在重置后调用 setValue 将其设为空字符串或空数组等。
  • 隐藏字段:确保在重置时也包含隐藏字段。一种简单方式是直接调用 fApi.resetFields() 不传参,让它重置所有字段。如果我们之前对隐藏字段做了剔除查询等操作,resetFields 仍会把它们复位到初始值。若想完全清除其值,可以在 resetFields 之后再主动将高级字段对应的值设置为 ''undefined

综合考虑,最佳实践是封装一个清空方法,既使用 resetFields 恢复默认,又针对有默认值或特殊需求的字段做定制处理。例如:

resetAllFields() {
  // 重置所有字段到初始值
  this.fApi.resetFields();
  // 清空隐藏字段的值(覆盖默认初始值的情况)
  this.advancedFields.forEach(field => {
    this.fApi.setValue(field, '');  // 将值设为空(或相应类型的初始空值)
  });
}

这样,无论字段当前是否显示,我们都能确保查询条件彻底被清空,不会遗留上次的状态。

internalRule:避免直接改动组件属性

在使用 form-create 构建动态表单时,我们经常需要更新字段的属性或状态,例如切换字段的 disabled、修改占位提示文字等。如果不借助正确的方法,直接修改 form-create 内部生成的组件属性,可能导致不可预期的结果,甚至丢失字段状态。我们通过一个内部规则(internalRule)机制来避免这些问题。

避免直接修改表单项属性

直接操作表单项的 props 可能遇到以下坑:

  • 修改不生效:form-create 对传入的规则进行了封装,直接更改 rule.props.x 有时不会触发视图更新,因为内部可能没有观测这些深层变化。
  • 状态丢失或重置:粗暴地替换整个规则对象会导致对应字段被重新创建,之前填写的值或校验状态丢失。这和前文提到的增删规则类似,会清空已有输入。

一个典型案例是,动态增删字段或修改其属性后,需要保留用户已填写的数据。form-create 官方建议使用其提供的 API 方法来操作,例如使用 fApi.setValue() 给字段赋值,以及避免直接操作规则数组。正如前述,当我们调用 this.rule.push()新增规则时,其他字段可能重置;而使用 fApi.append()prepend()等方法就能避免这种情况。

internalRule 思路

所谓 internalRule,指的是在组件内部维护一份表单规则的源数据副本或额外的配置,用于记录和控制字段属性变化。要点如下:

  • 初始规则克隆:在创建表单时,将传入的 formRule 深拷贝一份保存在组件内部(例如 this.internalRule = deepClone(props.formRule))。后续所有对表单结构的调整,都基于 internalRule 来进行。
  • 统一通过 API 更新:当需要修改字段属性(比如隐藏、禁用某字段)时,不直接操作 props.formRule,而是通过 fApi 提供的方法或更新 internalRule 来实现。例如,要禁用名为 "status" 的字段,我们优先选择 this.fApi.disabled(true, 'status'),这会由 form-create 内部去处理 DOM 和状态同步,而不是手动改 rule.props.disabled
  • 确保状态不丢失:由于我们保留了 internalRule,哪怕外部传入的规则在父组件因条件变化而重新计算,我们仍可以根据 internalRule 判断哪些字段之前的状态需要恢复。比如在展开高级查询时,我们知道哪些字段应该处于什么状态,而不依赖于外部重新给我们的规则(因为外部可能不知道用户中途对字段的改动)。

举个例子,假设我们的组件接受一个 formRule 列表作为 Prop。我们在内部保存 internalRule,并使用它生成 form-create 表单。当父组件可能出于某些原因重新传入一个新 formRule 时,我们可以智能对比 newProps 和 internalRule,仅对差异部分更新,而用户在界面上交互产生的状态(选中的值、隐藏显示状态等)在 internalRule 中有记录,不会无故被覆盖。

通过 internalRule,我们相当于构建了一个单一数据源来管理表单结构和状态的变化,避免了外部频繁调整导致的冲突。这种模式下,组件内部对 form-create 有完全的掌控力,确保了字段状态的稳定与延续。

彻底清空 vs. resetFields:清理查询条件的最佳实践

在实际项目中,用户有时希望“一键清空”所有查询条件,恢复到一个完全空白的初始状态(可能与默认初始值不同)。结合我们前面的讨论,总结出几条最佳实践建议:

  • 使用组件方法优于手动操作 DOM:无论是隐藏字段还是清空表单,都应优先使用 form-create 提供的 API(如 hidden/displayresetFieldssetValue 等)来操作。避免通过操作 DOM 或组件实例属性的方式清理数据,这样更稳健也更易维护。
  • 区分重置和清空:resetFields倾向于恢复初始值,而“清空”通常指把用户填写的数据全部清除。根据需求选择合适的方法,必要时组合多种手段。例如先 reset 恢复默认,再二次清理默认值字段。
  • 隐藏字段特殊处理:对于暂时隐藏的字段,查询时过滤、清空时仍要重置。这保证了隐藏即忽略,但一旦显现又是干净的新状态,不会把收起时残留的数据误用到下一次查询。
  • 保存用户输入体验:在展开/收起切换中,不轻易销毁用户已经输入的内容,而通过隐藏来软控制。这给了用户更好的体验(展开后还能找回之前输入的条件),同时对开发来说也减少了状态管理的复杂度。

最后,经过这套方案改造后的列表页组件,无论面对怎样的后端接口格式变更或需求调整,都能从容应对——后端奇葩接口再也无法威胁我们的前端代码稳定

总结:构建通用列表页组件时,既要考虑适配各种接口格式(通过 buildPayload 等钩子灵活封装),又要注重前端交互细节(如查询表单的展开收起实现)。利用 form-create 等工具的特性,我们可以优雅地隐藏和显示表单项,避免直接改 DOM 或规则造成的数据丢失。同时,要善用其 API 进行状态管理和表单重置,确保每次查询和重置都符合预期。按照以上最佳实践,就能封死各种后端奇葩接口对前端的影响,稳健地提升列表页的可维护性和用户体验。

Three.js 完全学习指南(二)场景、相机、渲染器基础

场景、相机、渲染器基础

场景(Scene)详解

场景是 Three.js 中所有 3D 对象的容器,它定义了整个 3D 空间。让我们深入了解场景的配置和使用。

场景示例

图 2.1: 包含多个几何体的场景示例

1. 场景基础配置

import * as THREE from 'three';

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

// 设置背景色
scene.background = new THREE.Color(0x000000); // 黑色背景

// 添加雾效果
scene.fog = new THREE.Fog(0x000000, 10, 100); // 颜色、近平面、远平面

// 设置场景环境
scene.environment = new THREE.CubeTextureLoader().load([
    'px.jpg', 'nx.jpg',
    'py.jpg', 'ny.jpg',
    'pz.jpg', 'nz.jpg'
]);

场景环境贴图

图 2.2: 使用环境贴图的场景效果

2. 场景管理

// 添加对象到场景
scene.add(mesh);

// 从场景中移除对象
scene.remove(mesh);

// 获取场景中的所有对象
const objects = scene.children;

// 遍历场景中的对象
scene.traverse((object) => {
    if (object.isMesh) {
        // 处理网格对象
    }
});
graph TD
    A[场景] --> B[几何体]
    A --> C[光源]
    A --> D[相机]
    A --> E[辅助对象]
    B --> F[网格]
    B --> G[线条]
    C --> H[环境光]
    C --> I[平行光]
    E --> J[网格辅助]
    E --> K[坐标轴]

图 2.3: 场景对象层级结构

3. 场景优化

// 设置场景自动更新
scene.autoUpdate = true;

// 手动更新场景
scene.updateMatrixWorld(true);

// 清理场景
function disposeScene() {
    scene.traverse((object) => {
        if (object.geometry) {
            object.geometry.dispose();
        }
        if (object.material) {
            if (Array.isArray(object.material)) {
                object.material.forEach(material => material.dispose());
            } else {
                object.material.dispose();
            }
        }
    });
}

相机(Camera)详解

Three.js 提供了多种相机类型,每种类型都有其特定的用途。

1. 透视相机(PerspectiveCamera)

透视相机模拟人眼视角,是最常用的相机类型。

透视相机效果

图 2.4: 透视相机的渲染效果

// 创建透视相机
const camera = new THREE.PerspectiveCamera(
    75, // 视角(FOV)
    window.innerWidth / window.innerHeight, // 宽高比
    0.1, // 近平面
    1000 // 远平面
);

// 设置相机位置
camera.position.set(0, 5, 10);

// 设置相机朝向
camera.lookAt(0, 0, 0);

// 更新相机参数
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();

2. 正交相机(OrthographicCamera)

正交相机没有透视效果,适合用于 2D 场景或等距视图。

正交相机效果转存失败,建议直接上传图片文件

图 2.5: 正交相机的渲染效果

// 创建正交相机
const camera = new THREE.OrthographicCamera(
    -10, // 左
    10,  // 右
    10,  // 上
    -10, // 下
    0.1, // 近平面
    1000 // 远平面
);

// 设置相机位置
camera.position.set(0, 0, 10);
camera.lookAt(0, 0, 0);

3. 相机控制

使用 OrbitControls 实现相机控制:

相机控制效果

图 2.6: 使用 OrbitControls 的相机控制效果

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';

// 创建控制器
const controls = new OrbitControls(camera, renderer.domElement);

// 配置控制器
controls.enableDamping = true; // 启用阻尼效果
controls.dampingFactor = 0.05;
controls.screenSpacePanning = false;
controls.minDistance = 10;
controls.maxDistance = 500;
controls.maxPolarAngle = Math.PI / 2;

// 在动画循环中更新控制器
function animate() {
    requestAnimationFrame(animate);
    controls.update();
    renderer.render(scene, camera);
}

渲染器(Renderer)详解

渲染器负责将场景和相机的内容绘制到屏幕上。

1. 基础配置

// 创建渲染器
const renderer = new THREE.WebGLRenderer({
    antialias: true, // 抗锯齿
    alpha: true,     // 透明背景
    precision: 'highp' // 精度
});

// 设置渲染器尺寸
renderer.setSize(window.innerWidth, window.innerHeight);

// 设置像素比
renderer.setPixelRatio(window.devicePixelRatio);

// 设置输出编码
renderer.outputEncoding = THREE.sRGBEncoding;

// 启用阴影
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

渲染器效果转存失败,建议直接上传图片文件

图 2.7: 不同渲染效果的对比

2. 高级配置

// 设置渲染器参数
renderer.setClearColor(0x000000, 1); // 设置清除颜色
renderer.setClearAlpha(1); // 设置清除透明度

// 配置阴影
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

// 配置后期处理
const composer = new THREE.EffectComposer(renderer);
const renderPass = new THREE.RenderPass(scene, camera);
composer.addPass(renderPass);

后期处理效果

图 2.8: 使用后期处理的效果

3. 性能优化

// 设置渲染器参数
renderer.powerPreference = 'high-performance';
renderer.precision = 'highp';

// 自动清理
function disposeRenderer() {
    renderer.dispose();
    renderer.forceContextLoss();
    renderer.domElement.remove();
}

// 处理窗口大小变化
window.addEventListener('resize', () => {
    const width = window.innerWidth;
    const height = window.innerHeight;

    camera.aspect = width / height;
    camera.updateProjectionMatrix();

    renderer.setSize(width, height);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});

实战:创建一个完整的 3D 场景

让我们结合以上知识,创建一个完整的 3D 场景:

完整场景示例

图 2.9: 完整的 3D 场景示例

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';

// 创建场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);

// 创建相机
const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
);
camera.position.set(5, 5, 5);
camera.lookAt(0, 0, 0);

// 创建渲染器
const renderer = new THREE.WebGLRenderer({
    antialias: true,
    alpha: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
document.getElementById('app').appendChild(renderer.domElement);

// 创建控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;

// 添加网格
const gridHelper = new THREE.GridHelper(10, 10);
scene.add(gridHelper);

// 添加环境光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);

// 添加平行光
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight.position.set(5, 5, 5);
directionalLight.castShadow = true;
scene.add(directionalLight);

// 创建一个立方体
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({
    color: 0x00ff00,
    metalness: 0.3,
    roughness: 0.4
});
const cube = new THREE.Mesh(geometry, material);
cube.castShadow = true;
cube.receiveShadow = true;
scene.add(cube);

// 创建地面
const planeGeometry = new THREE.PlaneGeometry(10, 10);
const planeMaterial = new THREE.MeshStandardMaterial({
    color: 0x808080,
    side: THREE.DoubleSide
});
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
plane.rotation.x = -Math.PI / 2;
plane.position.y = -0.5;
plane.receiveShadow = true;
scene.add(plane);

// 动画循环
function animate() {
    requestAnimationFrame(animate);

    // 更新控制器
    controls.update();

    // 旋转立方体
    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;

    // 渲染场景
    renderer.render(scene, camera);
}

// 开始动画
animate();

性能优化建议

  1. 场景优化

    • 使用适当的几何体复杂度
    • 及时清理不需要的对象
    • 使用对象池管理频繁创建的对象
  2. 相机优化

    • 设置合适的视锥体范围
    • 使用适当的相机类型
    • 优化控制器参数
  3. 渲染器优化

    • 使用适当的像素比
    • 启用必要的渲染特性
    • 及时释放资源

练习

  1. 实现相机的自动旋转
  2. 添加多个光源并观察效果
  3. 实现场景的昼夜变化
  4. 添加后期处理效果

下一步学习

在下一章中,我们将学习:

  • 几何体的创建和使用
  • 材质系统的详细配置
  • 纹理的应用
  • 对象的变换和组合

JavaScript作用域和作用域链

在JavaScript中,作用域和作用域链是理解代码执行和变量访问的关键概念。它们决定了变量和函数在代码中的可见性和生命周期。

一、作用域(Scope)

(一)什么是作用域?

作用域是在运行时代码中的某些特定部分中变量、函数和对象的可访问性。换句话说,作用域决定了代码区块中变量和其他资源的可见性。

作用域的主要作用是隔离变量,防止不同作用域下的同名变量发生冲突。例如:

function outFun2() {
    var inVariable = "内层变量2";
}
outFun2();
console.log(inVariable); // Uncaught ReferenceError: inVariable is not defined

在上面的例子中,变量inVariable在全局作用域中没有声明,因此在全局作用域下访问它会报错。

(二)全局作用域和函数作用域

1. 全局作用域

全局作用域是指在代码中任何地方都能访问到的对象。以下几种情形拥有全局作用域:

  • 最外层函数和在最外层函数外面定义的变量拥有全局作用域。
  • 所有未定义直接赋值的变量自动声明为拥有全局作用域。
  • 所有window对象的属性拥有全局作用域。
var outVariable = "我是最外层变量"; // 最外层变量
function outFun() { // 最外层函数
    var inVariable = "内层变量";
    function innerFun() { // 内层函数
        console.log(inVariable);
    }
    innerFun();
}
console.log(outVariable); // 我是最外层变量
outFun(); // 内层变量
console.log(inVariable); // inVariable is not defined

全局作用域的弊端是容易污染全局命名空间,引起命名冲突。因此,通常建议将代码封装在函数中,避免全局变量的滥用。

2. 函数作用域

函数作用域是指声明在函数内部的变量,这些变量只能在函数内部访问。例如:

function doSomething() {
    var stuName = "zhangsan";
    function innerSay() {
        console.log(stuName);
    }
    innerSay();
}
console.log(stuName); // 脚本错误
innerSay(); // 脚本错误

函数作用域的一个重要特点是内层作用域可以访问外层作用域的变量,但外层作用域不能访问内层作用域的变量。

(三)块级作用域

ES6引入了块级作用域,通过letconst关键字声明的变量具有块级作用域。块级作用域在以下情况被创建:

  1. 在一个函数内部。
  2. 在一个代码块(由一对花括号包裹)内部。

块级作用域的特点包括:

  • 声明变量不会提升到代码块顶部。
  • 禁止重复声明。
  • 循环中的绑定块作用域的妙用。
for (let i = 0; i < 10; i++) {
    console.log(i); // i 在循环内部有效
}
console.log(i); // ReferenceError: i is not defined

二、作用域链

(一)什么是自由变量?

自由变量是指在当前作用域中没有定义的变量。例如:

var a = 100;
function fn() {
    var b = 200;
    console.log(a); // 这里的 a 是一个自由变量
    console.log(b);
}
fn();

fn函数中,a是一个自由变量,因为它在fn函数的作用域中没有定义。

(二)什么是作用域链?

作用域链是指当访问一个变量时,编译器会从当前作用域开始,逐层向上查找,直到找到该变量或到达全局作用域。例如:

var a = 100;
function f1() {
    var b = 200;
    function f2() {
        var c = 300;
        console.log(a); // 100
        console.log(b); // 200
        console.log(c); // 300
    }
    f2();
}
f1();

f2函数中,ab是自由变量,它们的值通过作用域链从外层作用域中获取。

(三)关于自由变量的取值

自由变量的值是在函数定义时确定的,而不是在函数调用时确定的。例如:

var x = 10;
function fn() {
    console.log(x);
}
function show(f) {
    var x = 20;
    (function () {
        f(); // 输出 10,而不是 20
    })();
}
show(fn);

fn函数中,x的值是在fn函数定义时确定的,因此输出的是全局作用域中的x,而不是show函数中的x

三、作用域与执行上下文

许多开发人员经常混淆作用域和执行上下文的概念。虽然它们都与变量的访问和函数的执行有关,但它们是不同的概念。

  • 作用域:作用域是在函数定义时确定的,它决定了变量的可见性和生命周期。
  • 执行上下文:执行上下文是在函数执行时创建的,它包括变量对象、作用域链和this的指向。

(一)执行上下文的生命周期

执行上下文的生命周期分为两个阶段:

  1. 创建阶段:当代码执行进入一个环境时,会创建一个执行上下文。在这个阶段,执行上下文会进行以下操作:

    • 创建变量对象(Variable Object,VO):包括函数的形参、arguments对象、函数声明和变量声明。
    • 确定this的指向。
    • 确定作用域链。
  2. 执行阶段:在执行阶段,代码开始执行,变量被赋值,函数被调用,其他代码按顺序执行。

四、总结

理解作用域和作用域链的工作原理和实际应用,可以帮助你更好地理解代码的执行流程和变量的访问机制。如果你对本文的内容有任何疑问或补充,欢迎在评论区留言讨论。

Flutter核心机制图解说明

一、三棵树协作流程详解

1. 架构关系示意图
[用户代码] → Widget树(声明式配置)
       ↓ 创建
Element树(生命周期管理)
       ↓ 绑定
RenderObject树(布局/绘制)
2. 协作流程步骤
  1. 初始化阶段

    • runApp() 触发根Widget创建
    • 生成对应的根Element(RenderObjectElement
    • Element创建关联的RenderObject
  2. 构建阶段

    • Widget树通过build()方法递归构建
    • Element树通过inflateWidget方法逐层创建子元素
    • RenderObject树执行createRenderObject初始化渲染对象
  3. 更新阶段

    • 当Widget发生变更时: a. Element树对比新旧Widget类型 b. 类型相同 → 更新现有Element配置(update()) c. 类型不同 → 销毁旧Element,创建新Element
  4. 布局阶段

    • RenderObject执行layout()方法
    • 父节点向子节点传递约束条件(Constraints)
    • 子节点返回布局尺寸(Size)
  5. 绘制阶段

    • 生成Layer树提交给Skia引擎
    • 通过OpenGL/Vulkan进行GPU渲染

二、Platform Channel架构解析

1. 通信层级结构
[Flutter层] - Dart代码
   │
   ├── MethodChannel (方法调用)
   ├── EventChannel (事件流)
   └── BasicMessageChannel (基础消息)
           │
           |
[Native层] - 平台原生代码
   │
   ├── Android (Java/Kotlin)
   └── iOS (Objective-C/Swift)
2. 数据流向示意图
Flutter → 序列化为二进制 → 平台通道 → 反序列化为原生类型 → Native处理
       ← 序列化返回数据 ←          ← 原生返回结果 ←
3. 核心组件说明表
组件 功能特点 典型使用场景
MethodChannel 支持异步方法调用与返回值 调用相机/获取地理位置
EventChannel 建立持续事件流(类似观察者模式) 传感器数据监听/实时定位更新
BasicMessageChannel 基础消息传递(支持自定义编解码器) 简单数据交换/二进制传输

三、Key机制工作原理图示

1. LocalKey复用逻辑
Widget树重建前:
Item1(Key:A) - Item2(Key:B) - Item3(Key:C)

Widget树重建后:
Item2(Key:B) - Item3(Key:C) - Item1(Key:A)

Element树保持:
ElementB ↔ ElementC ↔ ElementA(仅位置变化)
2. GlobalKey定位原理
           ┌───────────┐
           │ GlobalKey │
           └─────┬─────┘
                 │
           ┌─────▼─────┐
           │ Element树  │
           └─────┬─────┘
                 │
           ┌─────▼─────┐
           │ 获取RenderObject │
           └───────────┘

四、状态管理数据流模型

1. Provider架构模型
[ChangeNotifier] ← 数据更新
       │
       ├─── notifyListeners()
       │
[Consumer] → 局部刷新
       │
[Selector] → 精准刷新
2. GetX响应式流程
[Rx变量] → 数据变更
       │
       ├─── 自动触发更新
       │
[Obx组件] → 重建依赖部件
       │
[GetBuilder] → 手动控制刷新

五、混合开发通信时序图

1. MethodChannel调用流程
Flutter端               Native端
  │                        │
  │  invokeMethod('getInfo')│
  │───────────────────────>│
  │                        ├── 执行原生代码
  │                        │
  │     result(data)       │
  │<───────────────────────│
  │                        │
2. EventChannel事件流
Flutter端               Native端
  │                        │
  │   receiveBroadcast()   │
  │───────────────────────>│
  │                        ├── 注册监听器
  │                        │
  │     event(data)        │
  │<───────────────────────│(持续推送)
  │                        │

六、性能优化关键路径

1. 渲染优化路线
减少Widget重建 → 优化Element复用 → 降低RenderObject计算 → 精简Layer树
      ↑               ↑                  ↑
   const构造      Key精准控制        布局边界标记(RepaintBoundary)
2. 内存管理策略
图片缓存控制 → 及时销毁监听 → 避免闭包泄漏 → 使用Isolate计算
   ↑               ↑              ↑             ↑
LRU策略       dispose()清理    DevTools检测   compute()函数

通过以上文字图解,开发者可以建立清晰的架构认知:

  1. 三棵树机制:理解声明式UI的核心工作原理
  2. 平台交互:掌握混合开发的数据通信脉络
  3. 状态管理:构建可维护的响应式架构
  4. 性能优化:定位关键瓶颈实施精准优化

建议结合Flutter DevTools的以下功能进行验证:

  • Widget Inspector:实时查看三棵树状态
  • Timeline:分析渲染流水线性能
  • Memory:检测内存泄漏与溢出

跟着文档学VUE3(四)- 类与样式绑定

Class 与 Style 的绑定技巧

关键词:Vue3、class 绑定、style 绑定、动态样式、响应式 UI

在 Vue 开发中,我们经常需要根据组件状态动态地控制元素的类名和内联样式。Vue 提供了非常强大的 :class:style 指令,不仅可以绑定字符串,还能绑定对象或数组,实现灵活的 UI 样式切换。

🧱 一、绑定 class 的多种方式

💡 基本原理:

  • class 是 HTML 元素的一个 attribute。

  • 在 Vue 中,使用 v-bind:class 或简写为 :class 来动态绑定 class。

  • Vue 对 class 绑定做了增强,支持传入:

    • 字符串
    • 对象
    • 数组

✅ 1. 使用对象绑定 class

适用于需要根据布尔值来切换 class 的场景。

// 直接绑定一个对象
<div :class="classObject"></div>
// 内联字面量
<div
  class="static
  :class="{ active: isActive, 'text-danger': hasError }"
></div>

data() {
  return {
    isActive: true,
    hasError: false,
    classObject: { active: true, 'text-danger': false }
  }
}

💡 小贴士:
  • 如果类名是烤串命名(kebab-case),需要用引号包裹,如 'text-danger'
  • 可以将对象提取成一个计算属性,提升可读性和复用性

✅ 2. 使用数组绑定 class

适用于需要绑定多个 class 名字的情况。

📌 动态条件渲染?

可以结合三元表达式或嵌套对象来实现:

<div :class="[isActive ? activeClass : '', errorClass]"></div>
<div :class="[{ [activeClass]: isActive }, errorClass]"></div>

data() {
  return {
    activeClass: 'active',
    errorClass: 'text-danger',
    isActive: true,
  }
}

// 🧾渲染结果
<div class="active text-danger"></div>

✅ 3. 在组件上使用 class

单根元素组件:

当你给组件添加 class 时,这些类会自动合并到组件的根元素上。

多根元素组件:

如果组件有多个根元素,则需显式指定哪个元素接收 class

示例代码
<!-- 子组件模板 -->
<p :class="$attrs.class">Hi!</p>
<span>This is a child component</span>
<!-- 在使用组件时 -->
<MyComponent class="baz boo" />

<p class="baz boo">Hi!</p>

🎨 二、绑定 style 内联样式的高级用法

💡 基本原理:

  • :style 支持绑定 JavaScript 对象,用于动态设置内联样式。
  • 支持 camelCase 和 kebab-case 的 key 写法。

✅ 1. 使用对象绑定 style

// 推荐使用 camelCase, 如果是kebab-cased,则key需要对应它在css中的实际名称
<div :style="{ 'font-size': fontSize + 'px' }"></div>

// 也可以直接绑定一个对象
<div :style="styleObject"></div>
data() { 
    return { 
        styleObject: { color: 'red', fontSize: '13px' } 
        } 
 }

✅ 2. 使用数组绑定多个样式对象

可以传入多个样式对象,后面的样式会覆盖前面的:

 
// 也可以绑定一个包含多个样式对象的数组
<div :style="[baseStyles, overridingStyles]"></div>

✅ 3. 自动处理浏览器前缀

  • 如果在css中使用了需要浏览器特殊前缀的css,Vue在运行时会检查该属性是否支持在当前浏览器中使用,如果不支持,会自动加前缀
  • 可以提供多个不同前缀的值,只会渲染浏览器支持的最后一个值
// 不需要特别前缀的浏览器,最后会渲染为display: flex
<div :style="{ display: ['-webkit-box', '-ms-flexbox', 'flex'] }"></div>

【CodeBuddy】一句话开发一个完整项目之:响应式栅格布局生成器

前言

在响应式网页设计领域,栅格系统是构建页面骨架的核心工具。传统开发中需手动编写重复的CSS代码,本项目通过实现可视化栅格生成器,将布局配置转化为可交互操作,提升前端开发效率。工具采用原生HTML/CSS/JavaScript技术栈,展现基础技术的组合威力。

以下是实际操作中的开发界面与最终呈现效果:

Snipaste_2025-05-19_17-27-00.png

Snipaste_2025-05-19_17-27-36.png

bandicam 2025-05-19 17-29-57-790 00_00_00-00_00_30.gif

应用场景

  1. 教学演示 - 直观展示栅格参数变化对布局的影响
  2. 原型设计 - 快速生成基础布局代码框架
  3. 样式调试 - 实时预览不同间距/列数的视觉效果
  4. 主题定制 - 通过色彩变量快速匹配品牌风格

核心功能实现

1. 动态栅格控制

  • 通过<input type="range">控件绑定columnsgutter参数
  • updateGrid()函数动态重建gridPreview容器:
function updateGrid() {
  gridPreview.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
  // 动态创建${columns}个gridItem...
}

2. 实时样式同步

  • 颜色选择器使用<input type="color">控件
  • 通过document.documentElement.style.setProperty更新CSS变量:
colorInput.addEventListener('input', () => {
  document.documentElement.style.setProperty('--primary-color', color);
});

3. CSS代码生成

  • generateCSS()方法拼接模板字符串生成可复用代码
  • 输出结果包含动态插值变量:
const css = `.grid-container {
  grid-template-columns: repeat(${columns}, 1fr);
  gap: ${gutter}px;
}`;

过程难点

  1. 实时响应同步
    需处理多个输入控件的input事件,通过函数节流优化性能,保证高频操作下的流畅体验。
  2. 跨浏览器兼容
  • 统一滑块控件样式:重写::-webkit-slider-thumb伪元素
  • 颜色输入控件降级方案:保留原生type="color"的同时提供备选说明
  1. 代码复制体验
    采用document.execCommand('copy')实现剪贴板交互,配合视觉反馈提升操作感知:
copyBtn.addEventListener('click', () => {
  // 创建Range对象选择代码文本...
  this.textContent = '已复制!';
});

总结感悟

  1. 技术收获
  • CSS变量在动态主题中的应用价值
  • 原生API实现复杂交互的可能性
  • 响应式布局的核心原理实践
  1. 优化方向
  • 增加断点媒体查询配置
  • 添加布局模板预设
  • 支持SCSS/LESS格式导出
  1. 开发启示
    工具类项目应遵循"所见即所得"原则,通过即时反馈降低用户认知成本。在追求新技术的同时,不应忽视原生技术的潜力挖掘。



🌟 让技术经验流动起来

▌▍▎▏ 你的每个互动都在为技术社区蓄能 ▏▎▍▌
点赞 → 让优质经验被更多人看见
📥 收藏 → 构建你的专属知识库
🔄 转发 → 与技术伙伴共享避坑指南

点赞 ➕ 收藏 ➕ 转发,助力更多小伙伴一起成长!💪

💌 深度连接
点击 「头像」→「+关注」
每周解锁:
🔥 一线架构实录 | 💡 故障排查手册 | 🚀 效能提升秘籍

uniapp(微信小程序、App)中获取音频文件时长duration

1.需求介绍

  • 在uniapp中获取上传的音频文件的时长,根据时长来校验提交规则。

2.实现

2.1 在微信小程序中获取音频文件时长。

根据 uni.createInnerAudioContext()来获取音频文化时长,点击可直达该api的官方文档说明。

2.1.1 不兼容的写法
  • 基于 uni.createInnerAudioContext() 封装的方法
  • ios微信小程序中可以获取到音频文件时长,在android微信小程序中就获取不到音频文件时长。
  const getAudioDurationByWx = (filePath) => {
    return new Promise((resolve, reject) => {
        // 创建并返回内部 audio 上下文 `innerAudioContext` 对象。
      const audioContext = uni.createInnerAudioContext();
      audioContext.src = filePath

      let retryCount = 0 // 当前重试获取音频文件时长的次数
      const maxRetries = 3 // 最大次数重试获取音频文件时长的次数

      const checkDuration = () => {
        if (audioContext.duration > 0) {
          const duration = audioContext.duration // 获取音频文件的时长
          audioContext.destroy() // 获取到音频文件的时长后, 直接销毁 `innerAudioContext`对象
          resolve(duration) // 音频文件的时长返回出去,供外面使用。
        } else if (retryCount < maxRetries) { // 重新试着获取时长。
          retryCount++
          setTimeout(checkDuration, 500) // 每500ms检查一次
        } else {
          audioContext.destroy(); // 超过了最大重试获取音频文件时长的次数后,直接销毁 `innerAudioContext`对象
          reject(new Error('无法获取音频时长'))
        }
      }

      // 等待一段时间后开始获取时长。
      setTimeout(() => {
        checkDuration()
      }, 100)

    // 监听`innerAudioContext`对象的错误回调方法
      audioContext.onError((err) => {
        audioContext.destroy()
        reject(err)
      })
    })
  }
2.1.2 微信小程序中的兼容写法
  • 参考博客,点击也可直达文档。
  • 如下列出代码
const getAudioDurationByWx = (filePath) => {
  return new Promise((resolve, reject) => {
    const audioContext = uni.createInnerAudioContext()
    audioContext.volume = 0; // 音量0
    audioContext.autoplay = true; // 自动播放
    audioContext.src = filePath

    audioContext.onTimeUpdate(() => {
      if (audioContext.duration > 0) {
        const duration = audioContext.duration
        audioContext.destroy()
        resolve(duration)
      } else {
        audioContext.destroy()
        reject('无法获取音频时长')
      }
      audioContext.destroy(); // 销毁实例
    })

    audioContext.onError((err) => {
      audioContext.destroy()
      reject(err)
    })
  })
}

2.2 在Android中获取音频文件时长

2.2.1 在Android中如下获取

  • 注意: 因为是音频文件上传,记得要去manifest.json中配置文件上传权限
const getAudioDurationByApp = (filePath) => {
  return new Promise((resolve, reject) => {
    try {
      if(plus.os.name.toLowerCase() === 'android') {
        const MediaMetadataRetriever = plus.android.importClass('android.media.MediaMetadataRetriever')
        const retriever = new MediaMetadataRetriever()

        retriever.setDataSource(filePath)
        const duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
        const durationInSeconds = Math.floor(parseInt(duration) / 1000)

        retriever.release()
        resolve(durationInSeconds)
      }
    } catch (e) {
      console.error('获取音频时长失败:', e)
      reject(e)
    }
  })
}

最后

  • 如有不正之处,请佬不吝赐教,万分感谢。
  • 祝大家向上!

前端表单提交请求的跨域机制深度解析:从浏览器同源策略到实战解决方案

一、同源策略的本质:浏览器安全体系的基石

1.1 同源策略的诞生背景与核心目标

1995 年,随着 Netscape Navigator 浏览器的普及,网页交互场景逐渐复杂。为防止恶意网页通过脚本窃取用户敏感信息(如 Cookie、Session 数据),同源策略(Same Origin Policy)作为浏览器安全模型的核心机制被正式确立。其核心目标是隔离不同源的文档或脚本,限制从一个源加载的文档或脚本如何与来自另一个源的资源进行交互

同源的判定标准

  • 协议(Protocol) :HTTP 与 HTTPS 视为不同源
  • 域名(Domain)example.comapi.example.com视为同源(子域名默认不同源,需配置 document.domain)
  • 端口(Port) :80 端口与 443 端口视为不同源

典型受同源策略限制的场景

  • AJAX/Fetch 跨域请求
  • DOM 对象的跨窗口访问(如 iframe 间通信)
  • Cookie、LocalStorage 的跨域读取

1.2 表单提交在同源策略中的特殊地位

传统 HTML 表单(<form>标签)的设计初衷是实现页面跳转与数据提交的原子操作,其工作流程独立于 JavaScript 引擎。因此,浏览器对表单提交的跨域行为采取了与 AJAX 完全不同的处理逻辑

  • 不阻塞请求发送:无论目标 URL 是否跨域,表单数据都会被正常发送
  • 不拦截响应内容:服务器返回的响应会触发页面跳转,而非被浏览器安全策略拦截
  • 不暴露响应数据:JavaScript 无法通过事件监听获取跨域表单提交的响应内容(仅能通过 302 重定向到同源 URL 后获取)

二、表单提交的跨域行为分析:请求生命周期全拆解

2.1 表单提交的三种核心方式

2.1.1 传统 HTML 表单提交(GET/POST)

<form 
  action="https://api.example.com/submit" 
  method="POST" 
  enctype="application/x-www-form-urlencoded"
>
  <input type="text" name="username">
  <button type="submit">提交</button>
</form>

跨域行为特点

  • 请求阶段:浏览器生成 Form Data 并发送 POST 请求至跨域 URL,无任何前置校验

  • 响应阶段

    • 若服务器返回 HTML 页面:浏览器直接渲染新页面
    • 若服务器返回 JSON 数据:浏览器显示原始 JSON 文本(无法被前端 JS 捕获)
  • 安全性:依赖服务器端校验(如 CSRF Token),前端无防护能力

2.1.2 AJAX 模拟表单提交(Fetch/XMLHttpRequest)

const formData = new FormData(document.querySelector('form'));
fetch('https://api.example.com/submit', {
  method: 'POST',
  body: formData,
  headers: {
    // 若设置自定义请求头,需服务器开启CORS预flight(OPTIONS请求)
    'X-Custom-Header': 'value'
  }
})
.then(response => response.json())
.catch(error => console.error('跨域错误:', error));

跨域行为特点

  • 受同源策略严格限制

    • 若未配置 CORS:浏览器直接拦截响应,控制台抛出Access to fetch at '...' from origin '...' has been blocked by CORS policy
    • 若配置 CORS 但缺少必要响应头(如Access-Control-Allow-Headers):预 flight 请求失败
  • 优势:支持异步处理、响应数据解析、错误捕获

2.1.3 表单提交与 IFRAME 结合(跨域通信 hack)

<iframe id="crossFrame" src="about:blank" style="display:none;"></iframe>
<form 
  action="https://api.example.com/submit" 
  method="POST" 
  target="crossFrame"
>
  <!-- 表单元素 -->
</form>

跨域行为特点

  • 利用 IFRAME 的隔离环境:表单提交的目标设为隐藏 IFRAME,服务器响应可通过onload事件监听

  • 数据获取限制

    • 若响应为 HTML 页面:可通过iframe.contentDocument获取,但受限于同源策略(仅当响应 URL 与当前页面同源时有效)
    • 若响应为 JSON 数据:无法直接解析,需通过 URL 参数传递(如 302 重定向到同源 URL?data=xxx)
  • 历史用途:早期前端跨域上传文件的常用方案(如 Flash 替代方案)

2.2 跨域表单提交的安全风险与防御

2.2.1 CSRF 攻击(跨站请求伪造)

攻击原理
攻击者诱导用户访问恶意页面,利用用户已登录的同源站点 Cookie,通过隐藏表单自动提交敏感操作(如转账、修改密码)。

防御措施

  1. SameSite Cookie 属性

Set-Cookie: sessionId=xxx; SameSite=Strict
  • Strict:完全禁止跨站请求携带 Cookie

  • Lax:允许 GET 请求跨站携带(如<a>标签、img 标签),POST 等危险方法禁止

  1. CSRF Token 验证

  • 前端:每次表单提交时携带随机 Token(存储于 Session 或 LocalStorage)

  • 服务器:校验请求中的 Token 与用户会话是否一致

  1. Referer 校验:检查请求来源是否为可信域名(存在隐私泄露风险,逐渐被弃用)

2.2.2 跨域数据泄露

风险场景
若服务器将敏感数据直接返回给跨域表单提交的响应页面,且未限制页面访问权限,攻击者可通过诱导用户访问该页面获取数据。

防御措施

  • 敏感数据接口强制要求同源请求(通过Origin请求头校验)
  • 重要操作添加二次验证(如短信验证码、密码确认)

三、跨域场景下的表单处理方案:从传统到现代的技术演进

3.1 服务器端 CORS 配置:最标准的跨域解决方案

3.1.1 简单请求(Simple Requests)与预 flight 请求(Preflight Requests)

请求类型 条件 浏览器行为
简单请求 - 方法:GET/POST/HEAD - 头信息:仅 Content-Type 为 application/x-www-form-urlencoded、multipart/form-data、text/plain 直接发送请求,无需 OPTIONS 预检
预 flight 请求 不符合简单请求条件(如使用 PUT 方法、自定义请求头) 先发送 OPTIONS 请求校验权限

3.1.2 完整 CORS 响应头配置示例(Node.js Express)

app.use((req, res, next) => {
  const origin = req.headers.origin || '*';
  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Custom-Header');
  res.setHeader('Access-Control-Allow-Credentials', 'true'); // 允许携带Cookie
  // 处理预flight请求
  if (req.method === 'OPTIONS') {
    return res.status(200).end();
  }
  next();
});

3.1.3 表单提交与 CORS 的结合场景

  • 传统表单 + AJAX 处理响应

    <form id="myForm" action="about:blank" method="POST">
      <!-- 表单元素 -->
    </form>
    <script>
      document.getElementById('myForm').addEventListener('submit', async (e) => {
        e.preventDefault(); // 阻止默认提交
        const formData = new FormData(e.target);
        // 使用Fetch发送跨域请求并处理响应
        const response = await fetch('https://api.example.com/submit', {
          method: 'POST',
          body: formData,
          credentials: 'include' // 携带Cookie
        });
        const data = await response.json();
        console.log('提交结果:', data);
      });
    </script>
    

    关键点:通过e.preventDefault()阻止传统表单提交,改用 AJAX 发送请求,需服务器配置 CORS 允许跨域。

3.2 代理服务器:前端开发环境的跨域解决方案

3.2.1 开发环境代理配置(Webpack/Vite)

Webpack 配置示例(vue.config.js)

module.exports = {
  devServer: {
    proxy: {
      '/api': {
        target: 'https://api.example.com', // 目标API地址
        changeOrigin: true, // 模拟同源请求
        pathRewrite: {
          '^/api': '' // 路径重写,去除请求路径中的/api前缀
        }
      }
    }
  }
};

使用方式

// 前端请求URL
fetch('/api/submit', { method: 'POST', body: formData });
// 实际发送至:https://api.example.com/submit

3.2.2 生产环境代理部署(Nginx)

server {
  listen 80;
  server_name your-domain.com;

  location /api/ {
    proxy_pass https://api.example.com/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}

适用场景

  • 前后端分离项目的开发与部署
  • 需要统一处理请求前缀、认证头的场景

3.3 JSONP:历史方案的局限性与替代方案

3.3.1 JSONP 原理与实现

核心思想:利用<script>标签无跨域限制的特性,动态加载跨域 JS 文件,通过回调函数传递数据。

示例代码

<script>
  function handleResponse(data) {
    console.log('JSONP响应:', data);
  }
</script>
<script src="https://api.example.com/jsonp?callback=handleResponse"></script>

服务器端返回

handleResponse({ "status": "success", "data": "hello" });

3.3.2 局限性与替代方案

局限性 现代替代方案
仅支持 GET 请求 Fetch + CORS
存在 XSS 风险 严格校验回调函数名称
无法设置请求头 代理服务器
错误处理困难 Fetch 的 AbortController

四、前沿场景:跨域表单提交的未来技术趋势

4.1 PostMessage API:安全的跨窗口通信机制

使用场景:主窗口与 IFRAME 间的跨域表单状态同步

// 主窗口
const iframe = document.querySelector('iframe');
iframe.contentWindow.postMessage({ type: 'getFormData' }, 'https://iframe-domain.com');

// IFRAME页面
window.addEventListener('message', (event) => {
  if (event.origin === 'https://main-domain.com' && event.data.type === 'getFormData') {
    const formData = new FormData(document.querySelector('form'));
    event.source.postMessage({ formData }, event.origin);
  }
});

安全要点

  • 严格校验event.origin,避免接收任意域名的消息
  • 限制消息类型,仅传递必要数据

4.2 跨域资源共享增强(CORS 2.0 提案)

4.2.1 Private Network Access(PNA)

浏览器即将引入的 PNA 机制,将限制跨域请求访问私有网络(如企业内网 API)。前端需通过Access-Control-Request-Private-Network: true请求头声明,并由服务器通过Access-Control-Allow-Private-Network: true响应头授权。

4.2.2 Early Hints(HTTP 103 状态码)

允许服务器在正式响应前发送包含 CORS 头的 Early Hints,提前告知浏览器是否允许跨域,优化预 flight 请求流程。

五、实践指南:不同业务场景下的最优方案选择

5.1 场景一:传统表单提交至跨域服务器,无需前端处理响应

需求:用户提交表单后跳转到第三方支付页面(如支付宝、微信支付)
方案:直接使用 HTML 表单提交

<form action="https://pay.example.com/pay" method="POST">
  <input type="hidden" name="orderId" value="123456">
  <button type="submit">去支付</button>
</form>

优势:简单直接,无需任何跨域配置
注意事项:确保第三方回调 URL 可处理跨域请求(如通过 302 重定向回同源页面)

5.2 场景二:AJAX 表单提交至跨域 API,需处理 JSON 响应

需求:用户提交表单后,前端需根据 API 返回的 JSON 数据显示提交结果(如成功 / 失败提示)
方案:Fetch + CORS
步骤

  1. 服务器配置 CORS 响应头

  2. 前端使用.preventDefault () 阻止默认提交,通过 Fetch 发送请求

  3. 处理响应数据并更新页面

async function submitForm() {
  const form = document.getElementById('myForm');
  const formData = new FormData(form);
  try {
    const response = await fetch('https://api.example.com/submit', {
      method: 'POST',
      body: formData,
      credentials: 'include' // 若需携带Cookie
    });
    if (!response.ok) throw new Error('提交失败');
    const data = await response.json();
    alert(`提交成功:${data.message}`);
  } catch (error) {
    alert('提交失败,请重试');
  }
}

5.3 场景三:多域环境下的表单数据同步(如中台系统)

需求:主站(a.com)与子站(b.com)需共享表单数据
方案

  1. 主站与子站通过 PostMessage API 通信

  2. 表单数据存储于同源服务器(如a.com的 API)

  3. 子站通过代理服务器转发请求

// 子站(b.com)代码
const proxyUrl = 'https://a.com/proxy/submit'; // a.com的代理接口
fetch(proxyUrl, { method: 'POST', body: formData })
.then(response => response.json());

六、性能优化:跨域表单提交的效率提升策略

6.1 减少预 flight 请求次数

  • 合并自定义请求头:避免多次发送包含不同头信息的请求

  • 使用Access-Control-Max-Age:服务器设置预 flight 结果的缓存时间(单位:秒)

6.2 表单数据压缩与分块传输

  • 启用 gzip 压缩:服务器配置Content-Encoding: gzip
  • 分块提交大文件:使用multipart/form-data结合Content-Range头实现断点续传
Access-Control-Max-Age: 86400 // 缓存1天

七、总结:跨域机制的本质与工程实践原则

7.1 核心结论

  • 表单提交的跨域特殊性:传统表单提交不受同源策略拦截,但无法在前端获取跨域响应数据;AJAX 提交受 CORS 严格限制,需服务器配合

  • 安全第一原则:任何跨域交互必须优先考虑 CSRF、XSS 等安全风险,避免信任客户端数据

  • 技术选型逻辑

    • 简单页面跳转:直接使用 HTML 表单
    • 复杂交互场景:AJAX + CORS / 代理
    • 多域通信:PostMessage + 同源服务器

7.2 未来发展方向

随着浏览器安全策略的不断升级(如 SameSite Cookie 成为默认配置、Private Network Access 限制),跨域交互将更加依赖服务器端配置与标准化方案(如 CORS)。前端开发者需深入理解同源策略的底层原理,在灵活性与安全性之间找到最佳平衡点,同时关注 Web 平台新特性(如 Early Hints、Secure Context)对跨域机制的影响。

通过对表单提交跨域行为的全面解析,我们可以更清晰地认识到:浏览器的安全限制并非阻碍创新,而是为了构建更可靠的网络环境。合理利用跨域解决方案,既能满足业务需求,又能保障用户数据安全,这正是现代 Web 开发的核心挑战与价值所在。

🧠 面试官:一行 v-model 背后发生了什么?你真知道吗?

面试官推了推眼镜:“我们项目用的是 Vue,对吧?”

“是的,我们主要用 Vue 3。”

“那我问个简单点的,v-model 是怎么实现的?”

“呃,它是语法糖吧……就是帮我们绑定 value 和监听 input……”

“那你知道 Vue 3 的 v-model 和 Vue 2 有什么不同?它做了什么?组件里又该怎么用?”

你愣了一下,心里咯噔:这事儿我还真没深究过……

很多人对 v-model 的印象还停留在“它帮我们双向绑定”,但到底怎么帮的、帮了哪些事、Vue 3 做了哪些变化、为什么要变化,真正能讲清楚的人并不多。

今天我们就深入拆解一下,一行 v-model 背后到底发生了什么事


🕰️ Vue 2:v-model 的“前世”

先来看 Vue 2:

<Child v-model="form.name" />

这个语法糖等价于:

<Child :value="form.name" @input="val => form.name = val" />

你看到这里可能会点点头:“对,就是绑定 value,然后监听 input 事件。”

确实没错,但:

组件里怎么接这个 valueinput

在 Vue 2 的组件里这么写:

// Child.vue
export default {
  props: ['value'],
  methods: {
    updateValue(newVal) {
      this.$emit('input', newVal)
    }
  }
}

组件内部要接收一个叫 value 的 prop,并触发一个叫 input 的事件。

这种方式虽然约定俗成,但也有明显的缺陷:

  • value 容易和其他 prop 名冲突
  • 所有 v-model 都只能绑定一个字段
  • 不支持多个 v-model(比如分别绑定 titlecontent

于是,Vue 3 来了。

🌱 Vue 3:v-model 的“今生”

在 Vue 3 中,还是这行代码:

<Child v-model="form.name" />

它被编译成了下面这样:

<Child modelValue="form.name" @update:modelValue="val => form.name = val" />

这就是 Vue 3 对 v-model 的标准行为:

  • 传值:通过 modelValue prop
  • 回传:通过 update:modelValue 事件

对应组件内部这样写:

// Child.vue
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

function update(val) {
  emit('update:modelValue', val)
}

优势是啥?

  • 组件更好维护,避免了 value 的语义冲突
  • 支持多个 v-model 绑定
  • 结构更清晰,统一使用 modelValue 开头

Vue 3 用 modelValue 替代 value,避免了跟表单原生属性冲突,增强组件语义的明确性。具名 v-model 使得多字段双向绑定更灵活。

小Tip

直接通过 modelValue 这个 prop 绑定,不用 v-model:

<Child :modelValue="xxx" @update:modelValue="yyy" />

但应该没人会这么干,毕竟v-model 这么简洁好用。

🧩 进阶 v-model:多个绑定 + 自定义命名

接下来我们来看看,当你需要更灵活地绑定多个属性或命名时,Vue 3 又是如何优雅应对的。

Vue 3 为组件通信带来了更多灵活性,其中一个重要升级就是:支持多个 v-model自定义绑定的 prop 名称

多个字段:一个组件绑定多个 prop

比如我们有个弹窗组件,既要绑定标题,又要绑定内容:

<Modal v-model:title="form.title" v-model:content="form.content" />

这行代码最终会被编译成:

<Modal 
  :title="form.title" 
  :content="form.content" 
  @update:title="val => form.title = val" 
  @update:content="val => form.content = val"
/>

在子组件内部:

// Modal.vue
defineProps(['title', 'content'])
const emit = defineEmits(['update:title', 'update:content'])

function updateTitle(val) {
  emit('update:title', val)
}
function updateContent(val) {
  emit('update:content', val)
}

这是不是有点 React “受控组件” 的味道?

多个字段独立传参、独立更新,逻辑更加清晰、组件更易维护,相比 Vue 2 的单字段绑定,灵活度提升明显。


自定义名称:不再被 modelValue 限制

你可能还想问:

v-model 一定只能绑定到 modelValue 吗?我能不能自定义名字?

当然可以。

Vue 3 的做法是使用具名 v-model 替代 Vue 2 中的 model 配置项,不再推荐如下写法:

// Vue 2 时代的写法
export default {
  model: {
    prop: 'checked',
    event: 'change'
  },
  props: ['checked'],
  emits: ['change']
}

现在我们直接用具名绑定:

<MySwitch v-model:checked="isOn" />

组件内部写法:

defineProps(['checked'])
const emit = defineEmits(['update:checked'])

function toggle() {
  emit('update:checked', !props.checked)
}

绑定字段名和事件名一目了然,不局限于 modelValue,也无需写死绑定逻辑。

✨ Vue 3.3 新特性:defineModel 语法糖

Vue 3.3 新增了 defineModel,主要解决了手动声明 modelValue prop 的冗余和类型声明繁琐问题。它让响应式状态声明更简洁,尤其对 TypeScript 友好。

它的用法很简单,帮你自动声明了 modelValue(或者你指定的其他名称)的 prop,且支持类型推断。

示例:

<script setup lang="ts">
const model = defineModel({
  name: 'modelValue',
  type: String
})

const emit = defineEmits(['update:modelValue'])

function onInput(e: Event) {
  const target = e.target as HTMLInputElement
  emit('update:modelValue', target.value)
}
</script>

<template>
  <input :value="model" @input="onInput" />
</template>

这里:

  • defineModel 自动帮你定义了 modelValue 这个 prop
  • 变量 model 就是传入的值
  • 你仍然需要手动用 emit('update:modelValue', value) 来通知父组件

🧩 原理图解:这样理解最简单

可以把 v-model 看作是下面这个转换器:

[v-model="xxx"] 
===> 
:propName="xxx" 
@update:propName="val => xxx = val"

111.png

默认情况下,propName = modelValue

如果使用具名绑定 v-model:title,那 propName = title

只要记住这几步步,你就能秒懂任何形式的 v-model

⚠️ 需要注意的小细节

1. 在组件中使用 v-model,需要手动 emit

如果你只是传了 modelValue,却忘了触发 update:modelValue,那页面是不会更新的:

function onInput(e) {
  emit('update:modelValue', e.target.value)
}

Vue 没有帮你做双向绑定,它只是做了“语法转译”。

2. 不要在组件中直接修改 modelValue

// 错误写法
props.modelValue = 'newValue' // Vue 会报警告

要改只能 emit,保持数据流是从父向子传,子通过事件通知父。

🛠️ Vue 2 vs Vue 3 一眼对比

对比点 Vue 2 Vue 3
默认 prop 名 value modelValue
默认事件名 input update:modelValue
多个 v-model 不支持 ✅ 支持具名 v-model:xxx
自定义字段名 model 配置项 直接具名 v-model:xxx
类型推断 手动声明,类型弱 ✅ 支持 defineModel 语法糖
数据更新方式 手动 emit 同样手动 emit,无自动双绑

🎯 小总结

v-model 看似一行代码,背后其实做了三件事:

  1. v-model="x" 转为 :modelValue="x"
  2. 添加 @update:modelValue 监听;
  3. 要求子组件手动 emit 更新。

Vue 3 把 “value + input” 升级为更语义化的 “modelValue + update:modelValue”,不仅避免了命名冲突,还支持多个字段绑定和自定义名称。 但需要注意:

它只是“语法糖”,真正的双向更新还是得你在组件里手动触发事件。

掌握这几点,面试官再问你 v-model,你就能从容不迫地答出底层原理!

如果你觉得这篇文章对你有帮助,欢迎点赞 👍、收藏 ⭐、评论 💬 让我知道你在看! 后续我也会持续输出更多 前端打怪笔记系列文章,敬请期待!❤️

Nuxt 安装 tailwindcss 4.1

但是按照Nuxt模块安装,我的项目打包的时候会有冲突,@nuxtjs/tailwindcss 下的一个依赖库,与我的项目中的一个其他依赖库有版本冲突,然后我就换了一种方式安装tailwind,如下

用tailwind4.1官网指导

tailwindcss.com/docs/instal…

然后我发现以前的v3的一些用法和配置 v4.1 不一样了,参考文档:

[1]. tailwind.nodejs.cn/docs/theme#…

[2]. tailwind.nodejs.cn/docs/upgrad…

比如配置:

// 原来的使用@nuxtjs/tailwindcss
// tailwind.config.js
module.exports= {
    darkMode: 'class',
    theme:{
        screens:{
            xs:"614pX"
            sm:"1002px"
            md:"1022px"
            lg:"1092px"
            xl:"1280px"
        },
    extend:{
        colors:{
            dim:{
                50:"#5F99F7",
                100:"#5F99F7",
                200:"#38444d",
                300:"#202e3a",
                400:"#253341",
                500:"#5F99F7",
                600:"#5F99F7",
                700:"#192734",
                800:"#162d40",
                900:"#15202b",
            },
        },
        
    plugins:[
        require('@tailwindcss/forms'),
    ]
}


// 现在4.1, 不会寻找tailwind.config.js的配置了
// 可以在 /assets/css/tailwind.css中配置
@import "tailwindcss";

@theme {
    --color-dim-50: #5F99F7;
    --color-dim-100: #5F99F7;
    --color-dim-200: #38444D;
    --color-dim-300: #202E3A;
    --color-dim-400: #253341;
    --color-dim-500: #5F99F7;
    --color-dim-600: #5F99F7;
    --color-dim-700: #192734;
    --color-dim-800: #162D40;
    --color-dim-900: #15202B;


    --breakpoint-xs: 38.375rem;
    --breakpoint-sm: 62.625rem;
    --breakpoint-md: 63.875rem;
    --breakpoint-lg: 68.25rem;
    --breakpoint-xl: 80rem;
    --breakpoint-2xl: 96rem;
}

注意:如果你硬要使用tailwind.config.js怎么办?

从 Tailwind CSS v4.0 开始,官方将 PostCSS 插件逻辑移出主包,如果你尝试像旧版本一样使用:

ts
复制编辑
require("tailwindcss")()

就会报你看到的错误:

"It looks like you're trying to use tailwindcss directly as a PostCSS plugin..."

Error An error has occurred It looks like you're trying to use `tailwindcss` directly as a PostCSS plugin. The PostCSS plugin has moved to a separate package, so to continue using Tailwind CSS with PostCSS you'll need to install `@tailwindcss/postcss` and update your PostCSS configuration. Stack Trace Pretty Raw

我也不知道,有人知道的话,评论区快快告诉我!!!

面试官:讲一下HTPP缓存...

前言

不知道各位有没有在面试中被拷打HTTP缓存策略的经历,那到底什么是HTTP缓存? HTTP缓存具体有什么作用? HTTP缓存的策略是什么? 今天我们就来简单了解一下HTTP缓存

HTTP简介

HTTP缓存是一种在客户端或服务端临时存储资源(HTML,CSS,JS文件,图片等)的机制。每当浏览器请求相同资源的时候,不是直接从服务端获取,而是看本地有没有相同请求,如果有就直接从本地获取。如果资源更新,那下次请求时则从服务端获取并保存到本地。

这样做的好处有:

  • 减少请求次数,减轻服务端压力;
  • 减少网页响应时间,增加用户体验;
  • 减少网络带宽,减少用户流量消耗;

HTTP缓存流程

  1. 浏览器向服务端发送请求
  2. 服务端收到浏览器发送过来的请求,返回资源,并在响应头加上对应的缓存标识
  3. 浏览器接收到响应,根据响应报文中缓存标识(如Cache-Control,Etag,Last-Modified 与 If-Modified-Since等)做出对应的缓存策略
  4. 浏览器下次发送同样的请求,先在本地查找有没有对应的缓存,如果有直接返回结果

为了更加直白的演示,我们可以看一下简单的流程图:

image.png

HTTP缓存策略

根据缓存策略的不同,HTTP缓存策略可以分为强缓存和协商缓存。

强缓存

强缓存是指在浏览器请求资源时,优先判断本地缓存是否可用。若缓存未过期,则无需向服务端发送请求,直接返回本地的缓存。其核心依赖于响应头中的Cache Control和Expires字段

Cache Contorl: 通过指定有效时间实现浏览器精准缓存的关键字段,有以下几个常用指令

常用指令 作用
max-age 指定缓存过期的时间,以秒为单位。在该时间内,浏览器直接从本地缓存
pubcic 表示资源可以被中间任何代理服务器缓存,适用于不需要用户验证的公共资源
private 表示该资源只有客户端可以缓存,常用于用户私密资源
no-cache 强制浏览器在使用缓存前,必须先向服务器发送请求进行验证。
no-store 表示禁止任何缓存,每次请求资源都向服务端发送新的请求

Expires: 指定资源过期的绝对时间,但由于依赖服务器与客户端的时间同步,存在误差风险,逐渐被 Cache-Control 替代。

Expires: Wed, 15 Apr 2026 03:19:25 GMT

强缓存的优势在于极大地减少了网络请求的次数。若是过期时间到了,浏览器又会怎么处理呢?

协商缓存

当强缓存失效时,协商缓存发挥作用。浏览器就会携带缓存标签向服务端发送请求,确认资源是否发生变化。如果没有发生变化,则返回304状态,让浏览器使用本地缓存。如果发生变化,则返回200状态,重新返回资源,浏览器会将收到的资源重新缓存到本地。

协商缓存主要依靠 ETagLast-Modified 两组字段

ETag/If-None-Match: 服务端为资源生成的唯一指纹ETag,浏览器再次发送请求时携带If-None-Match,服务端通过比对过后,若资源未修改,返回304状态,浏览器直接使用本地缓存,若资源已修改,则返回新的资源。

Last-Modified/If-Modidied-Sence: 作用机制与ETag类似,不同的是Last—Modified通过是通过资源最后的修改时间进行比对。

两者对比

精确度上:ETag是通过服务端计算出来的hash值作为标识,而Last-Modified是通过时间作为标识且单位为秒,如果资源在一秒内连续变化,那么Last-Modified就不能准确得到最新的数据。

性能上: ETag是通过计算得出的hash值,无疑增加了服务端负载,而Last-Modified只是返回一个时间。在性能上Modified要优于ETag。

服务器需要同时考虑精准度和性能问题,服务器可依据资源特性灵活选择或组合使用 ETag 与 Last-Modified。

总结

通过协商缓存,即使强缓存失效,也能通过少量数据交换(仅验证标识)避免重复传输完整资源,在动态内容更新场景中尤为重要。

协商缓存和强缓存并非孤立存在,而是相互配合、分阶段发挥作用,共同构建高效的 HTTP 缓存体系。

缓存位置

那么从HTTP缓存的资源放在哪里呢? HTTP缓存资源的位置主要分为客户端缓存和服务端缓存。

客户端缓存

客户端缓存可以分为内存缓存和磁盘缓存。

  1. 内存缓存是位于浏览器运行时占用的内容空间中,是浏览器优先使用的缓存区域。其特点是读写速度特别快,适用于需要快速响应的资源。
  2. 磁盘缓存是将数据存储在用户的磁盘当中,相比内存缓存,磁盘缓存容量更大,可存储更多资源,且数据不会因浏览器关闭而丢失,能长期保存,不过读取速度比较慢。常用于资源比较大,不常更新的资源。

浏览器缓存

除了客户端缓存以外,服务端自身也会进行缓存用于减轻服务端的压力。服务端缓存可以分为服务器本地缓存和内容分发网络缓存(CDN)。

服务器本地缓存:服务器可在本地设置缓存机制,将频繁访问的数据或资源存储在内存(如使用 Redis 等内存数据库)或磁盘中。当接收到相同请求时,直接从本地缓存返回结果,减少数据库查询或文件读取的开销。

内容分发网络(CDN):CDN 是一种分布式的缓存系统,由分布在全球各地的边缘节点服务器组成。当用户请求资源时,CDN 会将请求分配到离用户最近的节点,该节点若缓存了对应资源,就直接返回给用户,大大缩短了网络传输距离,提升加载速度。

结语

HTTP 缓存是 Web 性能优化的 “基石”,看似简单却暗藏玄机。掌握这些面试常考点:

  • 强缓存和协商缓存的区别与配合
  • 常见缓存标识的作用
  • 缓存位置与适用场景

理解这些逻辑后,面试问到的时候就不会再手足无措了。

image.png

创作不易 礼貌集赞

使用 openapi-typescript-codegen 自动生成 openapi 接口文档的 TypeScript 类型

安装依赖

npm i openapi-typescript-codegen -D

配置生成脚本

这里配置了只生成接口数据类型的定义,需要生成每个 API 的请求封装需要修改 参数

"generate-api": "npx openapi-typescript-codegen --input http://xxx/v2/api-docs --output ./src/api/generated --exportCore false --exportServices false",

脚本中的 http://xxx/v2/api-docs 换成你接口文档的 JSON 数据源地址,可以在浏览器控制台找到

image.png

生成结果

执行 npm run generate-api 后就能看到一堆类型定义

image.png

使用

然后就可以愉快的使用自动生成的类型定义了

image.png

手摸手带你封装Vue组件库(17)Loading加载组件

loading 我们一般用于页面等待,或者某个模块需要加载的时候,我们一般会使用 loading 组件。

loading 组件的展示一般分为两种,一种是全局 loading,一种是局部 loading。这两种使用也不一样,全局 loading 我们使用方法调用的方式来使用,局部 loading 我们使用指令调用的方式来使用。

创建如下的结构。

project-20250516-1.png

自定义 v-loading 指令

先思考一下,指令不是组件,所以只要用户引入并 use 了组件库,则直接生效,所以我们需要在组件的入口文件 index.js 中注册指令。

/packages/components/index.js

import * as components from "./components";
import "@test-ui/theme-chalk/index.less";

const FUNCTION_COMP = ["TMessage"]; // 方法调用类组件
const DIRECTIVE_COMP = ["TLoading"]; // 指令类组件

export default {
  install(app) {
    Object.entries(components).forEach(([key, value]) => {
      if (!FUNCTION_COMP.includes(key)) app.component(key, value);
      if (DIRECTIVE_COMP.includes(key)) app.use(value);
    });
  },
};

export const TMessage = components.TMessage;
export const TLoading = components.TLoading;

为什么是 use,而不是直接 app.directive,然后组件里面直接写自定义指令不就完了,因为我们还有全局的 loading,全聚德 loading 需要调用方法,所以我们在 loading 的组件内部再写 app.directive

我们这下来写一下 loading 组件入口文件,因为能被 use,所以是抛出的是一个对象,且携带有 install 方法。

import vLoading from "./src/directive.js";

export const TLoading = {
  install(app) {
    app.directive("loading", vLoading);
  },·
  // 后续要写全局的方法在这
};

export default TLoading;

loading/src/directive.js

const vLoading = {
  mounted(el, binding) {
    const value = binding.value;
  },
};

export default vLoading;

我们画一个简单的 loading,使用 svg 的动画来实现。

loading/src/loading.vue

<template>
  <div class="'t-loading'">
    <div class="t-loading__spinner">
      <div class="t-loading__spinner-icon">
        <svg width="60" height="30" viewBox="0 0 100 50">
          <circle cx="25" cy="25" r="10" fill="#5e72e4">
            <animate
              attributeName="opacity"
              values="1;0.3;1"
              dur="1.5s"
              repeatCount="indefinite"
            />
          </circle>
          <circle cx="50" cy="25" r="10" fill="#5e72e4">
            <animate
              attributeName="opacity"
              values="0.3;1;0.3"
              dur="1.5s"
              repeatCount="indefinite"
            />
          </circle>
          <circle cx="75" cy="25" r="10" fill="#5e72e4">
            <animate
              attributeName="opacity"
              values="0.3;1;0.3"
              begin="0.5s"
              dur="1.5s"
              repeatCount="indefinite"
            />
          </circle>
        </svg>
      </div>
      <div class="t-loading__text">加载中</div>
    </div>
  </div>
</template>

<script setup></script>

loading.less

.t-loading {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background-color: rgba(255, 255, 255, 0.8);
  z-index: 1001;
  .t-loading__spinner {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    font-size: 14px;
    color: var(--t-primary);
    text-align: center;
  }
}

我们需要在自定义指令中 mounted 的时候,将 loading 组件挂载到当前元素上,所以我们需要在 directive.js 中引入 loading.vue 组件,然后通过 h 函数生成 vnode,然后通过 render 方法将组件挂载到当前元素上。

loading/src/directive.js

import TLoadingComponent from "./loading.vue";
import { h, render } from "vue";

const createLoading = (el) => {
  el.style.position = "relative";
  const vnode = h(TLoadingComponent);
  render(vnode, el);
};

const vLoading = {
  mounted(el, binding) {
    const value = binding.value;
    if (value) createLoading(el);
  },
};

export default vLoading;

我们来写一个示例看一下

<t-table
  :column-data="columnData"
  :table-data="tableData"
  border
  v-loading="true"
/>

project-20250516-2.png

看起来没问题,但是我们一般情况下给传入一个布尔值,当这个值为 true 的时候才显示,false 的时候消失,这时候我们需要根据传入值的变化来控制显示和隐藏,这时候我们在 v-loading 指令中写一个 update 方法,并且当当前组件销毁的时候我们也需要将 loading 组件销毁。

import TLoadingComponent from "./loading.vue";
import { h, render } from "vue";

const createLoading = (el) => {
  el.style.position = "relative";
  const vnode = h(TLoadingComponent);
  render(vnode, el);
};

const vLoading = {
  mounted(el, binding) {
    const value = binding.value;
    if (value) createLoading(el);
  },
  updated(el, binding) {
    if (!binding.value && binding.value !== binding.oldValue) {
      el.removeChild(el.querySelector(".t-loading"));
    } else if (binding.value && binding.value !== binding.oldValue) {
      createLoading(el);
    }
  },
  unmounted(el) {
    el.removeChild(el.querySelector(".t-loading"));
  },
};

export default vLoading;

我们只要发现绑定的值第一次生成的时候是正常的,然后变为 falseloading 消失,然后重新改变为 true 的时候组件不会重新生成了或者说新生成的组件没有在界面渲染,是因为什么呢?因为仅使用 removeChild 移除 DOM 节点不会触发 Vue 的生命周期钩子,导致组件实例仍然存在并保持对 DOM 的引用。这时候怎么处理呢?

import TLoadingComponent from "./loading.vue";
import { h, render } from "vue";

const createLoading = (el) => {
  el.style.position = "relative";
  const vnode = h(TLoadingComponent);
  render(vnode, el);
};

const vLoading = {
  mounted(el, binding) {
    const value = binding.value;
    if (value) createLoading(el);
  },
  updated(el, binding) {
    if (!binding.value && binding.value !== binding.oldValue) {
      el.removeChild(el.querySelector(".t-loading"));
      render(null, el);
    } else if (binding.value && binding.value !== binding.oldValue) {
      createLoading(el);
    }
  },
  unmounted(el) {
    el.removeChild(el.querySelector(".t-loading"));
    render(null, el);
  },
};

export default vLoading;

这时候试一下呢?是不是正常了,你会发现我们添加了一个 render(null, el),这个作用就是将上次的 loading 组件销毁。

补充属性

我们一般情况下需要自定义加载内容,以及加载的背景色,这时候怎么怎么传递呢?我们看一下 element-plus,打开 F12,你会发现他的属性实际是在 DOM 节点上插入的自定义属性,但是是非标准的自定义属性,因为自定义属性是必须 data- 开头,获取可以直接通过 DOM.dateset.[属性名]来获取,那这种非标准的怎么获取属性值呢?我们可以通过 getAttribute(属性名) 来获取,这下知道怎么做也简单了。

<t-table
  :column-data="columnData"
  :table-data="tableData"
  border
  v-loading="loading"
  loading-text="等待中"
  loading-background="rgba(122, 122, 122, 0.6)"
/>

我们在 createLoading 的方法中获取一下属性值,然后传递给组件

packages/loading/src/directive.js

const createLoading = (el) => {
  el.style.position = "relative";
  const vnode = h(TLoadingComponent, {
    text: el.getAttribute("loading-text"),
    background: el.getAttribute("loading-background"),
  });
  render(vnode, el);
};

然后我们在 loading 组件内部获取一下组件

<template>
  <div
    class="'t-loading'"
    :style="{
      'background-color': background,
    }"
  >
    <div class="t-loading__spinner">
      <div class="t-loading__spinner-icon">
        <svg width="60" height="30" viewBox="0 0 100 50">
          <!-- ... -->
        </svg>
      </div>
      <div class="t-loading__text" v-if="text">{{ text }}</div>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  text: {
    type: String,
  },
  background: {
    type: String,
  },
});
</script>

project-20250516-3.png

全屏加载

全屏加载我们可以使用调用方法来生成组件,我们可以抛出去一个方法,使用者可以使用这个方法来生成一个 loading 组件,然后这个方法返回一个操作 loading 的一个对象,这个对象包含关闭当前 loading 的方法,element-plus 是引入 ElMessage,然后通过 ElLoading.service() 传入一个 loading 的配置对象来显示组件,那我们可以在 loading 的入口文件中 export 一个 service 方法就行就行,然后 service 方法返回一个对象,包含关闭的方法。

import TLoadingComponent from "./loading.vue";
import { h, render } from "vue";

const createLoading = (el) => {
  el.style.position = "relative";
  const vnode = h(TLoadingComponent, {
    text: el.getAttribute("loading-text"),
    background: el.getAttribute("loading-background"),
  });
  render(vnode, el);
};

const vLoading = {
  mounted(el, binding) {
    const value = binding.value;
    if (value) createLoading(el);
  },
  updated(el, binding) {
    if (!binding.value && binding.value !== binding.oldValue) {
      el.removeChild(el.querySelector(".t-loading"));
      render(null, el);
    } else if (binding.value && binding.value !== binding.oldValue) {
      createLoading(el);
    }
  },
  unmounted(el) {
    el.removeChild(el.querySelector(".t-loading"));
    render(null, el);
  },
};

export const createGlobalLoading = ({ text, background }) => {
  let vnode = h(TLoadingComponent, {
    text,
    loadingBackground: background,
    screen: true, // 是否全屏
  });
  window.document.body.classList.add("t-loading-screen-parent");
  render(vnode, window.document.body);
  return {
    close() {
      window.document.body.removeChild(vnode.el);
      render(null, window.document.body); // 该代码作用是清除vnode
      vnode = null;
    },
  };
};

export default vLoading;

我们在给 loading 组件添加一个 screen 属性,如果 screentrue,则全屏显示,否则在绑定的元素上显示,我们也需要设置一个全屏加载的一个 class,来单独设置全屏加载的样式。

<template>
  <div
    :class="['t-loading', { 't-loading-mask--screen': screen }]"
    :style="{
      'background-color': background,
    }"
  >
    <div class="t-loading__spinner">
      <div class="t-loading__spinner-icon">
        <svg width="60" height="30" viewBox="0 0 100 50">
          <!-- ... -->
        </svg>
      </div>
      <div class="t-loading__text" v-if="text">{{ text }}</div>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  text: {
    type: String,
  },
  background: {
    type: String,
  },
  screen: {
    type: Boolean,
  },
});
</script>

loading.less

.t-loading-mask--screen {
  position: fixed;
  width: 100vw;
  height: 100vh;
  background-color: rgba(255, 255, 255, 0.8);
  z-index: 1001;
  pointer-events: none;
}

我们写一个试试

<template>
  <t-button type="primary" @click="openFullScreenLoading"> 全屏加载 </t-button>
</template>
<script setup>
  import { TLoading } from "@test-ui/components";

  const openFullScreenLoading = () => {
    const loading = TLoading.service({
      text: "全屏加载",
      background: "rgba(0, 0, 0, 0.7)",
    });

    setTimeout(() => {
      loading.close();
    }, 3000);
  };
</script>

project-20250516-4.png

目前是正常的,但是细心的小伙伴会发现这个遮罩层是可以触发底部滚动的,那怎么解决呢?我们可以在触发全屏加载的时候给 body 设置一个 overflow: hidden,在关闭的时候再移除这个样式,我们可以给 body 添加一个类名来设置样式,然后关闭的时候移除掉这个类名。

export const createGlobalLoading = ({ text, background }) => {
  let vnode = h(TLoadingComponent, {
    text,
    loadingBackground: background,
    screen: true,
  });
  window.document.body.classList.add("t-loading-screen-parent");
  render(vnode, window.document.body);
  return {
    close() {
      window.document.body.classList.remove("t-loading-screen-parent");
      window.document.body.removeChild(vnode.el);
      render(null, window.document.body); // 该代码作用是清除vnode
      vnode = null;
    },
  };
};
.t-loading-screen-parent {
  overflow: hidden !important;
}

这样就完成了。

自定义图标

我们有时候想要吧加载的动画换一下,这个实现也比较容易,element 是把动画做在了组件内部,你只需要改变组件的图表即可,我们这边是在 svg 里面实现的动画图标,如果你想和 element-plus 一样,你就只需要 spinner 添加 css 动画即可,这边我们就不写了。给我们可以同样使用自定义属性传递 svg 图表,然后在生成 loading 组件的时候获取,然后在组件内部渲染即可。

<template>
  <t-table
    :column-data="columnData"
    :table-data="tableData"
    border
    v-loading="loading"
    loading-text="loading..."
    :loading-spinner="loadingSVG1"
    style="margin-top: 20px"
  />
  <t-button
    type="primary"
    style="margin-top: 20px"
    @click="openFullScreenLoading2"
  >
    全屏加载自定义图标
  </t-button>
</template>
<script setup>
  const loadingSVG1 = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42 42" width="42" height="42">
  <circle cx="21" cy="21" r="5" fill="none" stroke="#5e72e4" stroke-width="2">
    <animate attributeName="r" from="5" to="18" dur="1.5s" repeatCount="indefinite"/>
    <animate attributeName="opacity" from="1" to="0" dur="1.5s" repeatCount="indefinite"/>
  </circle>
  <circle cx="21" cy="21" r="5" fill="none" stroke="#5e72e4" stroke-width="2">
    <animate attributeName="r" from="5" to="18" dur="1.5s" begin="0.5s" repeatCount="indefinite"/>
    <animate attributeName="opacity" from="1" to="0" dur="1.5s" begin="0.5s" repeatCount="indefinite"/>
  </circle>
</svg>
`;
  const loadingSVG2 = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42 42" width="42" height="42">
  <circle cx="21" cy="21" r="18" fill="none" stroke="#5e72e4" stroke-width="3" stroke-dasharray="5,5">
    <animate attributeName="stroke-dashoffset" from="0" to="20" dur="1s" repeatCount="indefinite"/>
    <animate attributeName="opacity" values="1;0.5;1" dur="2s" repeatCount="indefinite"/>
  </circle>
</svg>
`;

  const openFullScreenLoading2 = () => {
    const loading = TLoading.service({
      text: "全屏加载",
      background: "rgba(0, 0, 0, 0.7)",
      loadingSpinner: loadingSVG2,
    });

    setTimeout(() => {
      loading.close();
    }, 3000);
  };
</script>

然后我们改一下

packages/loading/src/directive.js

import TLoadingComponent from "./loading.vue";
import { h, render } from "vue";

const createLoading = (el) => {
  el.style.position = "relative";
  const vnode = h(TLoadingComponent, {
    text: el.getAttribute("loading-text"),
    background: el.getAttribute("loading-background"),
    icon: el.getAttribute("loading-spinner"),
  });
  render(vnode, el);
};

const vLoading = {
  mounted(el, binding) {
    const value = binding.value;
    if (value) createLoading(el);
  },
  updated(el, binding) {
    if (!binding.value && binding.value !== binding.oldValue) {
      el.removeChild(el.querySelector(".t-loading"));
      render(null, el);
    } else if (binding.value && binding.value !== binding.oldValue) {
      createLoading(el);
    }
  },
  unmounted(el) {
    el.removeChild(el.querySelector(".t-loading"));
    render(null, el);
  },
};

export const createGlobalLoading = ({ text, background, loadingSpinner }) => {
  let vnode = h(TLoadingComponent, {
    text,
    loadingBackground: background,
    screen: true,
    icon: loadingSpinner,
  });
  window.document.body.classList.add("t-loading-screen-parent");
  render(vnode, window.document.body);
  return {
    close() {
      window.document.body.classList.remove("t-loading-screen-parent");
      window.document.body.removeChild(vnode.el);
      render(null, window.document.body); // 该代码作用是清除vnode
      vnode = null;
    },
  };
};

export default vLoading;

packages/loading/src/loading.vue

<template>
  <div
    :class="['t-loading', { 't-loading-mask--screen': screen }]"
    :style="{
      'background-color': background,
    }"
  >
    <div class="t-loading__spinner">
      <div class="t-loading__spinner-icon" v-if="!icon">
        <svg width="60" height="30" viewBox="0 0 100 50">
          <circle cx="25" cy="25" r="10" fill="#5e72e4">
            <animate
              attributeName="opacity"
              values="1;0.3;1"
              dur="1.5s"
              repeatCount="indefinite"
            />
          </circle>
          <circle cx="50" cy="25" r="10" fill="#5e72e4">
            <animate
              attributeName="opacity"
              values="0.3;1;0.3"
              dur="1.5s"
              repeatCount="indefinite"
            />
          </circle>
          <circle cx="75" cy="25" r="10" fill="#5e72e4">
            <animate
              attributeName="opacity"
              values="0.3;1;0.3"
              begin="0.5s"
              dur="1.5s"
              repeatCount="indefinite"
            />
          </circle>
        </svg>
      </div>
      <div v-else v-html="icon"></div>
      <div class="t-loading__text" v-if="text">{{ text }}</div>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  text: {
    type: String,
  },
  background: {
    type: String,
  },
  screen: {
    type: Boolean,
  },
  icon: {
    type: String,
  },
});
</script>

project-20250516-5.png

丸美!

本节的loading加载组件就算开发完了,我们的组件教程依旧会持续更新,大家持续关注。

本专栏源码地址

什么?LocalStorage 也能被监听?为什么我试了却不行?

我们都知道,localStorage 是前端开发中常用的浏览器本地存储方案之一。然而你是否遇到过这样的情况:

网上说 localStorage 可以被监听,但我尝试了之后却没有任何效果?

本文将从源码出发,带你彻底理解 localStorage 的监听机制,并揭秘背后的行为逻辑。


## 事件监听的基本方式

我们先来看一段简单的代码:

```js
window.addEventListener('storage', (event) => {
  console.log('Storage changed:', event);
});

运行这段代码后,监听器貌似什么也没有触发?为什么?


localStorage 的监听机制:跨页面才会触发

关键点在于:

storage 事件 只有在其他页面(同源)对 localStorage 进行修改时,当前页面才会触发事件!

这意味着你在当前页面执行以下代码:

localStorage.setItem('key', 'value');

不会触发当前页面上的 storage 事件的。

但如果你打开了两个同源页面 A.htmlB.html

  • B.html 中执行 localStorage.setItem(...)
  • 此时 A.html 中的监听函数会被触发!

官方文档如何说?

MDN StorageEvent 可以得知:

  • 事件只会在 其他页面改变 localStorage 时触发
  • 同一个页面内的操作不会触发

监听 localStorage 的正确方式

如果你的目标是监听 本页面的 localStorage 改变,你需要自己封装监听机制。

自定义封装方案

比如重写 setItem

(function () {
  const originalSetItem = localStorage.setItem;
  localStorage.setItem = function (key, value) {
    const event = new Event('localstorage-change');
    event.key = key;
    event.newValue = value;
    window.dispatchEvent(event);
    originalSetItem.apply(this, arguments);
  };
})();

然后监听:

window.addEventListener('localstorage-change', (e) => {
  console.log('Key changed:', e.key);
});

原理揭秘:源码中的事件派发

浏览器的 localStorage 事件是如何派发的?

Chromium 源码分析

我们来看 Chromium 的 DOMStorage 实现中一个关键函数:

void StorageArea::DispatchStorageEvent(...) {
  if (SameOrigin && NotCurrentPage) {
    // 触发 storage 事件
  }
}

可见浏览器明确判断了:

  • 当前页面是否是修改者
  • 若是当前页面,不触发事件

为什么要设计成这样?

这是为了避免循环事件触发。设想以下场景:

  • 页面 A 设置 localStorage
  • 触发事件回调,又设置 localStorage
  • 又触发事件,进入死循环...

为防止这种问题,浏览器选择只在「非当前页面」中触发事件。


如何验证?

你可以打开两个同源页面,分别执行如下操作来验证:

  1. 页面 A 中:
window.addEventListener('storage', (e) => {
  console.log('页面 A 收到事件:', e);
});
  1. 页面 B 中:
localStorage.setItem('demo', '123');

你会在页面 A 的控制台中看到触发的 storage 事件。


结语

总结一下:

  • localStoragestorage 事件只能在 其他窗口或标签页触发
  • 如果你想监听 本页面localStorage 变化,需要手动封装逻辑
  • 浏览器这样设计,是为了避免事件循环带来的性能与逻辑问题

ES6中Reflect对象与Proxy结合实现代理和响应式编程

前言

在 JavaScript 中,Reflect 是一个内置对象,提供了拦截和操作 JavaScript 对象的元方法。它是 ES6 (ES2015) 引入的特性,主要用于简化元编程(meta-programming)并与 Proxy 结合使用实现对对象属性更细粒度的操作控制。

代理对象

Proxy 的第一个参数为要进行代理的目标对象,类型为Object,如果我们代理的目标是一个基础数据类型那应该怎么实现呢?

基础数据类型的代理

在 JavaScript 中,基础数据类型(如 numberstringboolean 等)无法直接被 Proxy 代理,因为 Proxy 只能拦截对象(包括数组、函数、类等)的操作。但可以通过对象封装的方式间接代理基础类型:

const target  = {
    value: '123' // 这里可以是number 、string、boolean...
}
const proxy = new Proxy(target, {
    get (target, key, receiver){
        // receiver 指向 proxy实例
        // 思考 ? 此处可否直接返回 target[key]
         return Reflect.get(target, key, receiver)
    },
    set (target, key, value, receiver){
        return Reflect.set(target, key, value, receiver)
    }
})

上述代码中有一个疑问,能否通过return target[key] 取代 return Reflect.get(target, key, receiver)? 事实上这里target[key] 等价于 Reflect.get(target, key), receiver是指向代理的实例,相当于call/apply的第一个参数,用于指定函数执行的上下文,上面例子中如果不涉及this指向,仅仅是对于简单类型的代理可使用Reflect.get(target, key) 或 target[key](但不推荐),接下来我们看下面这个例子说明不推荐的原因:

复杂数据类型的代理

const people  = {
    get name(){
        console.log('thisArg:', this)
        return this._name;
    },
    _name:'xixi'
 }
 
 const proxy1 = new Proxy(people, {
     get (target, key, receiver){
      // 思考 ? 此处可否直接返回 target[key]
      console.log('get name:', target, receiver)
      return Reflect.get(target, key, receiver);
     },
     
 })
 console.log(proxy1.name)
 // 输出:
 // get name: { name: [Getter], _name: 'xixi' } name { name: [Getter], _name: 'xixi' }
 // thisArg: { name: [Getter], _name: 'xixi' }
 // get name: { name: [Getter], _name: 'xixi' } _name { name: [Getter], _name: 'xixi' }
 // xixi
 
 const proxy2 = new Proxy(people, {
     get (target, key, receiver){
      // 思考 ? 此处可否直接返回 target[key]
      console.log('get name:', target, receiver)
      return target[key];
     },
 })
 console.log(proxy2.name)
 // 输出:
 // get name: { name: [Getter], _name: 'xixi' } name { name: [Getter], _name: 'xixi' }
 // thisArg: { name: [Getter], _name: 'xixi' }
 // xixi

结论:通过对比输出结果,可以看出直接return target[key]会导致this无法绑定在代理对象上,当修改属性时代理事件就无法触发导致错误,所以建议直接按照Reflect.get(target, key, receiver)处理。

响应式编程

此处我们以vue3的响应式编程为例,自定义实现ref和reactive的简化版:


// 存储依赖关系的 WeakMap
const targetMap = new WeakMap();

// 当前正在收集依赖的副作用函数
let activeEffect = null;

// 副作用函数
function effect(fn) {
  activeEffect = fn;
  fn(); // 执行副作用,触发依赖收集
  activeEffect = null; // 清空
}

// 依赖收集函数
function track(target, key) {
  if (activeEffect) {
    let depsMap = targetMap.get(target);
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()));
    }
    
    let dep = depsMap.get(key);
    if (!dep) {
      depsMap.set(key, (dep = new Set()));
    }
    
    dep.add(activeEffect); // 将副作用添加到依赖集合
  }
}

// 触发依赖更新
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  
  const dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect()); // 执行所有依赖副作用
  }
}

// 简化版 reactive
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver);
      track(target, key); // 收集依赖
      return result;
    },
    set(target, key, value, receiver) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      if (oldValue !== value) {
        trigger(target, key); // 触发更新
      }
      return result;
    }
  });
}

// 简化版 ref
function ref(initialValue) {
  const _value = { value: initialValue }; // 创建一个包裹对象
  
  // 对于对象类型,使用 reactive 转换为响应式
  if (typeof initialValue === 'object' && initialValue !== null) {
    _value.value = reactive(initialValue);
  }
  
  return new Proxy(_value, {
    get(target, key) {
      track(target, key); // 收集依赖
      return target[key];
    },
    set(target, key, value) {
      // 对于对象类型,使用 reactive 转换
      if (key === 'value' && typeof value === 'object' && value !== null) {
        target[key] = reactive(value);
      } else {
        target[key] = value;
      }
      trigger(target, key); // 触发更新
      return true;
    }
  });
}

Three.js 完全学习指南(一)Three.js 简介与核心概念

Three.js 简介与核心概念

什么是 Three.js?

Three.js 是一个轻量级的 3D 图形库,它封装了 WebGL 的底层 API,让开发者能够更容易地创建和展示 3D 图形。它提供了丰富的功能,包括:

  • 场景管理
  • 相机控制
  • 光照系统
  • 材质系统
  • 几何体
  • 动画系统
  • 后期处理
  • 等等

Three.js 示例场景

图 1.1: Three.js 创建的复杂 3D 场景示例

WebGL 基础概念

在开始使用 Three.js 之前,我们需要了解一些 WebGL 的基础概念:

1. 渲染管线

WebGL 的渲染管线主要包含以下步骤:

  1. 顶点着色器(Vertex Shader):处理顶点数据
  2. 图元装配(Primitive Assembly):将顶点组装成图元
  3. 光栅化(Rasterization):将图元转换为像素
  4. 片元着色器(Fragment Shader):处理像素颜色
  5. 帧缓冲(Frame Buffer):存储最终的渲染结果
graph LR
    A[顶点数据] --> B[顶点着色器]
    B --> C[图元装配]
    C --> D[光栅化]
    D --> E[片元着色器]
    E --> F[帧缓冲]

图 1.2: WebGL 渲染管线流程图

2. 坐标系

WebGL 使用右手坐标系:

  • X 轴:向右为正
  • Y 轴:向上为正
  • Z 轴:向外为正
graph TD
    subgraph 右手坐标系
        A((原点)) --> B[X轴]
        A --> C[Y轴]
        A --> D[Z轴]
    end

图 1.3: 右手坐标系示意图

Three.js 坐标系示例

图 1.4: Three.js 中的坐标系应用示例

开发环境搭建

1. 创建项目

首先,创建一个新的项目目录并初始化:

mkdir threejs-demo
cd threejs-demo
npm init -y

2. 安装依赖

安装必要的依赖:

npm install three
npm install vite --save-dev

3. 创建基础项目结构

threejs-demo/
├── index.html
├── src/
│   ├── main.js
│   └── style.css
├── package.json
└── vite.config.js

图 1.5: 项目目录结构

4. 配置 Vite

创建 vite.config.js

export default {
  root: './',
  publicDir: 'public',
  server: {
    host: true
  }
}

5. 创建 HTML 文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js 入门</title>
    <link rel="stylesheet" href="src/style.css">
</head>
<body>
    <div id="app"></div>
    <script type="module" src="src/main.js"></script>
</body>
</html>

6. 添加基础样式

body {
    margin: 0;
    overflow: hidden;
}

#app {
    width: 100vw;
    height: 100vh;
}

第一个 3D 场景

让我们创建一个简单的 3D 场景,包含一个旋转的立方体:

import * as THREE from 'three';

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

// 创建相机
const camera = new THREE.PerspectiveCamera(
    75, // 视角
    window.innerWidth / window.innerHeight, // 宽高比
    0.1, // 近平面
    1000 // 远平面
);
camera.position.z = 5;

// 创建渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.getElementById('app').appendChild(renderer.domElement);

// 创建一个立方体
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({
    color: 0x00ff00,
    wireframe: true
});
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

// 动画循环
function animate() {
    requestAnimationFrame(animate);

    // 旋转立方体
    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;

    // 渲染场景
    renderer.render(scene, camera);
}

// 处理窗口大小变化
window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
});

// 开始动画
animate();

第一个 3D 场景

图 1.6: 运行结果:一个旋转的绿色线框立方体

核心概念解析

1. 场景(Scene)

场景是所有 3D 对象的容器,它定义了:

  • 3D 空间
  • 背景色
  • 雾效果
  • 等等

场景示例

图 1.7: 包含多个几何体的场景示例

2. 相机(Camera)

相机决定了我们如何观察场景:

  • 透视相机(PerspectiveCamera):模拟人眼视角
  • 正交相机(OrthographicCamera):无透视效果
graph TD
    subgraph 相机类型
        A[相机] --> B[透视相机]
        A --> C[正交相机]
        B --> D[模拟人眼]
        C --> E[无透视]
    end

图 1.8: 相机类型对比图

3. 渲染器(Renderer)

渲染器负责将场景和相机的内容绘制到屏幕上:

  • WebGLRenderer:使用 WebGL 进行渲染
  • 可配置抗锯齿、阴影等效果

渲染效果对比转存失败,建议直接上传图片文件

图 1.9: 不同渲染效果对比

4. 网格(Mesh)

网格是 3D 对象的基本单位,由两部分组成:

  • 几何体(Geometry):定义形状
  • 材质(Material):定义外观

网格示例转存失败,建议直接上传图片文件

图 1.10: 网格的几何体和法线可视化

常见问题与解决方案

  1. 性能问题

    • 使用 requestAnimationFrame 进行动画
    • 及时释放不需要的资源
    • 使用适当的几何体复杂度
  2. 内存管理

    • 使用 dispose() 方法释放资源
    • 避免频繁创建新对象
    • 重用几何体和材质
  3. 兼容性问题

    • 检查 WebGL 支持
    • 提供降级方案
    • 使用 polyfill 解决兼容性问题

下一步学习

在下一章中,我们将深入学习:

  • 场景的详细配置
  • 不同类型的相机
  • 渲染器的进阶设置
  • 动画系统的基础知识

练习

  1. 修改立方体的颜色和大小
  2. 添加多个立方体
  3. 实现鼠标控制相机
  4. 添加简单的光照效果

资源链接

猜成语小游戏

这是一个猜成语的网页游戏,用户根据显示的成语 首字母 来猜测对应的四字成语。

例如 ABBS = 哀兵必胜 ABJB = 按部就班

屏幕截图 2025-05-19 155410.png

功能特点

  • 随机生成四字成语的首字母缩写
  • 用户输入猜测的成语并提交验证
  • 正确 / 错误答案的反馈
  • 重置功能,确保游戏流程顺畅
  • 界面元素合理布局,视觉层次分明

技术实现

  • 使用idioms对象数组存储成语信息,每个对象包含:
    • code:成语中文写法
    • letter:成语首字母缩写
    • explain:成语解释
  • DOM 操作:通过document.getElementById获取和操作页面元素
  • 事件监听:使用addEventListener绑定按钮点击事件
  • 随机数生成:Math.random()用于随机选择成语
  • 字符串处理:验证用户输入与正确答案

项目结构

QQ20250519-102954.png

项目代码

一、游戏界面和基本结构

整体结构:页面使用了简单的垂直布局,从上到下依次是标题、游戏规则说明和游戏区域。

  1. 使用<link>标签,并行加载CSS文件
  2. 同时在页面中适当添加了[emoji表情],增加页面的趣味性(emoji6.com/emojiall/)
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>猜成语游戏</title>
    <link rel="stylesheet" href="guess.css">
</head>
<body>
    <h1>🎉欢迎来到猜成语游戏!🎉</h1>
    <div class="rule">
        ✨️游戏规则:请根据这个四字成语首字母,来猜出所对应的四字成语
        <br>点击"提示"按钮可以获得成语的解释
    </div>
    <div class="game">
        <div id="clue">abdc</div>
        <input type="text" id="answer" placeholder="请输入成语">
        <button id="submit-btn">提交答案</button>
        <div id="result"></div>
        <button id="hint-btn">提示</button>
        <div id="hint"></div>
        <button id="next-btn">下一个成语</button>
    </div>
    <!-- 引入js文件 -->
    <script src="guess.js"></script>
</body>
</html>

背景设置:使用彩虹图片作为背景,并设置为覆盖整个页面,省去了使用标签,然后设置样式的步骤,避免图片元素对文档流的影响,保持了 HTML 结构的简洁

body{
      background-image:url('img/彩虹.jpg');
      background-size:cover;
    }

游戏区域

  • 成语首字母显示区:以大号字体展示成语首字母
  • 输入框:用户输入猜测的成语
  • 提交按钮:提交答案
  • 结果显示区:显示用户猜测的结果
  • 提示按钮:获取成语解释
  • 下一个成语按钮:开始新的一轮游戏

样式设置: 1.游戏区域使用margin-top: 50px;属性实现整体下移,不遮挡图片

        h1 {
            font-size: 60px;
            text-align: center;
            color: #ee2746;
        }
        .rule {
            text-align: center;
            font-size: 25px;
            color: whitesmoke;
        }
        .game {
            margin: auto;//居中
            width: 900px;
            height: 450px;
            text-align: center;
            margin-top: 50px;
        }
        #clue {
            font-size: 130px;
            font-weight: bold;
            /* 字母间距 */
            letter-spacing: 20px;
            margin: 20px 0;
            color: black;
        }
        .result {
            margin: 20px 0;
            font-size: 18px;
        }
        .hint {
            margin: 10px 0;
            font-size: 30px;
            color: #1ba784;
        }
        input{
            padding: 18px 30px;
            font-size: 35px;
            border: 2px solid #1ba784;
            border-radius: 5px;
        }

2.对按钮进行了样式设置,添加了鼠标悬停效果,使按钮变得更加好看,这里的颜色我是通过[中国色]选择的,里面有特别多颜色,可供选择,其他两个按钮同理(www.zhongguose.com/)

QQ20250519-153142.png

二、代码实现

把信息存储到数组中: 这里成语数据页可以进行自行添加

  1. 使用 Math.random() 生成一个 0 到 1 之间的随机小数,乘以 idioms 数组的长度后取整,得到一个随机的成语索引。
  2. 根据随机索引从 idioms 数组中获取对应成语的首字母缩写。
// 成语数据
        const idioms = [
            { code: "安步当车", letter: "ABDC", explain: "以从容的步行代替乘车。形容轻松缓慢地行走。也指人闲适自得,从容而行。" },
            { code: "爱不释手", letter: "ABSS", explain: "喜爱得舍不得放手。" },
            { code: "按部就班", letter: "ABJB", explain: "按照一定的步骤、顺序进行。也指按老规矩办事,缺乏创新精神。" },
            { code: "八拜之交", letter: "BBZJ", explain: "旧时朋友结为兄弟的关系。" }
            ];
        let currentIdiomIndex = 0;
        // 随机生成首字母
        function generateClue() {
            currentIdiomIndex = Math.floor(Math.random() * idioms.length);//生成随机数,得到一个随机的成语索引
            const letter = idioms[currentIdiomIndex].letter;//从 idioms 数组中获取对应成语的首字母缩写
            document.getElementById('clue').innerText = letter;//将首字母缩写显示在页面上的 clue 元素中
            document.getElementById('result').innerText = '';
            document.getElementById('hint').innerText = '';//清空元素内容
            document.getElementById('next-btn').style.display = 'none';//隐藏按钮
        }

对提交答案按钮进行监听

检查答案是否正确,并给出提示,同时显示 下一个成语 按钮

        // 监听提交答案按钮的点击事件, 
        document.getElementById('submit-btn').addEventListener('click', function () {
            const userAnswer = document.getElementById('answer').value.trim();
            const correctAnswer = idioms[currentIdiomIndex].code;
            if (userAnswer === correctAnswer) {
                document.getElementById('result').innerText = '🎊恭喜你,猜对了,棒棒哒,请继续加油哦!';
                document.getElementById('result').style.color = '#1ba784';
                document.getElementById('result').style.fontSize = '30px';
            } else {
                document.getElementById('result').innerText = '🧨很遗憾,猜错了,请点击提示按钮,再试一次吧!';
                document.getElementById('result').style.color = '#e16c96';
                document.getElementById('result').style.fontSize = '30px';
            }
            document.getElementById('next-btn').style.display = 'inline-block';
        });

提示功能

将当前随机选中成语的解释,显示在页面上的 hint 元素中

        // 提示功能,将当前随机选中成语的解释显示在页面上的 hint 元素中
        document.getElementById('hint-btn').addEventListener('click', function () {
            const explanation = idioms[currentIdiomIndex].explain;
            document.getElementById('hint').innerText = explanation;
        });

生成下一个成语并初始化游戏

调用 generateClue 函数重新随机选取一个成语并显示首字母缩写

        // 下一个成语
        document.getElementById('next-btn').addEventListener('click', function () {
            generateClue();//重新随机选取一个成语并显示首字母缩写
            document.getElementById('answer').value = '';//同时清空用户输入框
        });
        // 初始化游戏
        generateClue();

总结

这是一个由HTML+CSS+JS组成的猜成语小游戏,同时为用户提供了一个有趣且易于使用的成语学习工具

当然我的项目还有不足之处,欢迎大家留言点评,我后续也会进行完善!

❌