阅读视图

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

拒绝 API 轰炸:一行代码让你的 React 搜索框不再“抽搐”

你是否遇到过这样的场景:你在搜索框里想搜一个 "Apple",结果刚敲了几个字母,控制台的 Network 面板就像过年放鞭炮一样噼里啪啦闪个不停?

  1. 输入 A -> 发起请求
  2. 输入 Ap -> 发起请求
  3. 输入 App -> 发起请求
  4. ...

这不仅浪费了服务器资源,还可能导致竞态问题(先发出的请求后返回,覆盖了最新的结果),让用户看到的界面疯狂闪烁。

今天,我们不依赖臃肿的第三方库(如 lodash),只用原生的 React Hooks,手写一个轻量级、高性能的 useDebounce(防抖钩子),并深入剖析它背后的“电梯门哲学”。


核心代码:仅需 10 行

这就是我们要实现的 useDebounce。它的作用很简单:延迟值的更新

import * as React from "react";

export default function useDebounce(value, delay) {
  // 1. 保存一个内部状态,用于“滞后”更新
  const [state, setState] = React.useState(value);

  React.useEffect(() => {
    // 2. 设定一个定时器:delay 毫秒后,才去更新 state
    const id = window.setTimeout(() => {
      setState(value);
    }, delay);

    // 3. 【关键的一步】清理函数
    // 如果在 delay 时间还没到,value 就变了(用户又打字了),
    // 这一行代码会立刻执行,杀掉上一个定时器。
    return () => {
      window.clearTimeout(id);
    };
  }, [value, delay]); // 依赖项:只要 value 或 delay 变了,就重置计时

  return state;
}

它是如何工作的?(电梯门理论)

要理解这段代码,不要把它看作程序,把它想象成一部电梯的自动门

  • delay (延迟时间) :电梯设定为“没人经过 5 秒后自动关门”。
  • value 改变 (用户输入) :有人走进电梯。
  • clearTimeout (清理函数) :电梯的红外感应器。

剧本如下:

  1. 用户输入 "A"

    • 一个人走进电梯。
    • 电梯开始倒计时:5... 4... 3...
  2. 用户迅速输入 "B" (此时倒计时刚走到 3):

    • 感应器触发(Cleanup) :电梯检测到又有人来了,之前的倒计时作废!
    • 重新计时:5... 4... 3...
  3. 用户输入 "C"

    • 感应器再次触发:之前的倒计时又作废。
    • 重新计时:5... 4... 3... 2... 1... 0!
  4. 时间到

    • 没人再进来了,电梯关门(setState 执行)。
    • 电梯上行(发起 API 请求)。

结论: 无论中间进了多少人,电梯只会在最后一个人进入并站稳后,才关门运行一次。这就是防抖


最佳实践:UI 与 逻辑的分离

在实际组件中,我们需要维护两个“世界”:

  1. 快世界 (searchTerm) :响应用户打字,用于 <input> 的显示,必须实时,否则用户会觉得卡顿。
  2. 慢世界 (debouncedTerm) :响应 API 请求,通过 useDebounce 过滤,只有静止时才更新。
import React, { useState, useEffect } from "react";
import useDebounce from "./useDebounce";

export default function SearchBar() {
  // 1. 快状态:控制 UI,打字丝般顺滑
  const [searchTerm, setSearchTerm] = useState("");
  
  // 2. 慢状态:控制逻辑,延迟 500ms 更新
  const debouncedTerm = useDebounce(searchTerm, 500);

  // 3. 只有当“慢状态”改变时,才发请求
  useEffect(() => {
    if (debouncedTerm) {
      console.log(`正在向服务器搜索: ${debouncedTerm}`);
      // API.search(debouncedTerm)...
    }
  }, [debouncedTerm]); // <--- 这里的依赖是关键!

  return (
    <div className="p-4">
      <input
        type="text"
        placeholder="输入关键词..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        className="border p-2 rounded w-full"
      />
      <p className="text-gray-500 mt-2">
        实时输入: {searchTerm} <br/>
        最终搜索: {debouncedTerm}
      </p>
    </div>
  );
}

总结

useEffect 不仅仅是生命周期的替代品,它的清理函数 (Cleanup Function) 才是处理异步逻辑的灵魂。

通过 setTimeout 延迟执行,配合 clearTimeout 在新变化到来时“毁约”,我们仅用了几行原生代码就实现了高性能的防抖逻辑。

记住: 想要用户体验好,让 UI 跑得快一点;想要服务器压力小,让逻辑跑得慢一点。useDebounce 就是连接快慢世界的桥梁。

Prisma 7 重磅发布:告别 Rust,拥抱 TypeScript,性能提升 3 倍

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777

2025 年 11 月 19 日,Prisma 团队正式发布了 Prisma ORM 和 Prisma Postgres 的最新版本——Prisma 7。在过去的一年中,Prisma 团队始终将开发者体验放在首位,致力于让使用 Prisma 构建应用程序变得更加简单、高效。无论开发者使用何种开发工具,或选择在哪个平台部署,Prisma 7 都将带来卓越的开发体验。

为未来而设计

愿景与承诺

2024 年 12 月,Prisma 团队发布了 Prisma ORM 的未来发展路线图,详细阐述了他们的愿景以及实现这些目标的计划。这不仅仅是一份产品规划文档,更是 Prisma 团队对 Prisma ORM 未来发展的明确承诺,以及他们如何持续支持开发者社区的坚定决心。

与此同时,Prisma 团队还推出了自己的托管 Postgres 服务——Prisma Postgres。这款产品以简洁性和高性能为核心设计理念,旨在为开发者提供一个开箱即用的数据库解决方案,让开发者能够享受到与 Prisma ORM 同样出色的开发者体验。

随着 ORM 路线图的发布和 Prisma Postgres 的推出,Prisma 团队为构建下一代工具奠定了坚实的基础。市场反馈超出了他们的预期:ORM 的市场份额和使用量实现了显著增长,而 Prisma Postgres 在个人项目和商业应用中的采用率也令人瞩目。

重大技术突破

从 Rust 到 TypeScript:一次大胆的架构转型

在 Prisma 6.0.0 发布时,Prisma 团队承诺将带来更好的性能、更强的灵活性以及更完善的类型安全性。为了实现这些目标,他们需要做出一些根本性的改变。

为什么选择离开 Rust?

Prisma 团队做出了一个看似反直觉的决定:将 Prisma Client 从 Rust 迁移到 TypeScript。对于许多人来说,这可能是一个令人困惑的选择,毕竟 Rust 以其卓越的性能而闻名。但在 Prisma 的场景下,性能只是故事的一部分。

使用 Rust 构建客户端的一个显著副作用是,它限制了社区贡献者的参与门槛。如果开发者没有深厚的 Rust 开发经验,想要为 ORM 做出有意义的贡献将变得非常困难。从技术角度来看,Rust 与 JavaScript 运行时之间的通信层比纯 JavaScript 实现要慢得多,同时还会在运行时层面引入额外的依赖关系。

Deno 团队的 Luca Casonato 对此深有感触:

"当我们听说 Prisma 要从 Rust 迁移时,我们意识到不再需要处理原生插件 API,这将让在 Deno 中支持 Prisma 变得简单得多。我们所有人都对此感到非常兴奋!"

迁移带来的显著收益

迁移到纯 TypeScript 客户端为更快的运行时、更小的体积和更简单的部署流程奠定了基础。开发者不再需要担心特定运行时的兼容性问题,也不必担心像 Cloudflare Workers 这样的基础设施提供商对应用大小的限制。

经过持续迭代和优化,Prisma 团队取得了令人瞩目的成果:

  • 体积减少 90%:打包输出大幅缩小
  • 性能提升 3 倍:查询执行速度显著加快
  • 资源占用降低:CPU 和内存利用率大幅下降
  • 部署更简单:Vercel Edge 和 Cloudflare Workers 的部署流程更加顺畅

这些改进都有详尽的基准测试数据支持。更重要的是,开发者无需重构整个应用程序就能享受到这些好处,迁移过程非常简单直接。

知名开发者 Kent C. Dodds 在体验后给出了积极反馈:

"我在几周前完成了升级,整个过程非常顺利。切换到新的无 Rust 客户端是如此简单,这真是太棒了。"

生成代码的新归宿:从 node_modules 到项目源码

工作流程的优化

Prisma 团队认真听取了社区关于生成代码处理方式的反馈,并进行了重大改进。此前,Prisma 习惯在项目的 node_modules 目录中生成客户端代码。虽然这样做让生成的客户端看起来像普通库一样,但随着时间推移,他们发现这种方式严重影响了开发者的工作流程。

当客户端需要更新时,所有应用相关的进程都必须先停止,然后才能重新生成类型。这不仅打断了开发流程,还降低了开发效率。

现在,Prisma 默认在项目源代码目录中生成类型和 Prisma Client。这样一来,开发者现有的开发和构建工具会将其视为应用程序的一部分。当开发者修改模型并运行 prisma generate 时,工具和文件监视器可以立即响应这些变化,保持开发工作流程的连续性。整个 Prisma 配置现在真正成为了项目的一部分,而不再是隐藏在 node_modules 中的"黑盒"。

全新的配置文件系统

Prisma 团队还引入了全新的 Prisma 配置文件,支持动态项目配置。这个配置文件的核心目的是将数据定义与 Prisma 的数据交互方式分离开来。

在此之前,项目配置分散在 Prisma schema 文件或 package.json 中。新的 Prisma 配置文件统一了配置管理,开发者可以在一个地方定义 schema 位置、种子脚本或数据库 URL。由于配置文件支持 JavaScript 或 TypeScript 格式,开发者可以使用 dotenv 等工具进行动态配置,这为开发者提供了更大的灵活性,也符合现代开发工具的标准。

类型系统的性能革命

与 ArkType 的合作

类型安全是 Prisma ORM 的核心优势之一。Prisma 团队不仅要确保提供正确的类型,还要以最高效的方式实现。为了改进类型生成系统,他们与 ArkType 的创建者 David Blass 展开了深度合作,全面评估了类型生成表现。

评估结果令人振奋:

  • Schema 评估类型减少 98%:大幅降低了类型系统的复杂度
  • 查询评估类型减少 45%:查询相关的类型开销显著降低
  • 类型检查速度提升 70%:完整的类型检查过程更加快速

为什么这很重要?

与生态系统中的其他 ORM 相比,Prisma 生成的类型不仅评估速度更快,而且需要更少的类型定义就能为开发者提供有用的 TypeScript 信息。这意味着开发者可以更自信地构建应用程序,因为 Prisma 提供的类型安全不仅快速,而且开销更小。

Prisma Postgres:为所有人打造的数据库服务

不仅仅是 ORM

Prisma 不仅仅是一个 ORM 工具。Prisma 团队推出的托管 Postgres 数据库服务旨在解决开发者在开始使用 Prisma ORM 时面临的首要问题——如何快速获得一个可用的数据库。

技术架构优势

Prisma Postgres 基于由 unikernel microVM 驱动的裸机基础设施构建。这意味着数据库服务不仅启动快速,而且能够持续保持高性能。Prisma 团队始终将开发者体验放在首位,自动处理所有复杂的运维工作,让开发者无需关心服务器配置、资源分配等底层细节。

所有配置和管理工作都由 Prisma 团队负责,Prisma Postgres 与 Prisma ORM 实现了原生集成,为开发者提供与 ORM 同样出色的开发者体验。

极简的启动流程

开始使用 Prisma Postgres 非常简单,只需在终端运行一个命令即可。系统会自动为开发者配置数据库,并提供认领链接。对于在开发工作流程中使用 AI 代理的开发者,Prisma 还提供了专用的 API 和 MCP 服务器,可以在需要时自动创建和管理数据库。

Jason Lengstorf 分享了他的使用体验:

"每当我使用 Prisma 时,我都会按照入门指南安装各种工具,然后才意识到我还需要去获取一个数据库。我总是在这个过程中感到困惑,所以能够如此轻松地创建数据库真是太棒了!"

标准协议支持

Prisma Postgres 并没有止步于简单的数据库托管。Prisma 团队现在采用了标准的 Postgres 连接协议,这意味着更广泛的生态系统工具都可以与数据库通信。无论是 Cloudflare Hyperdrive、TablePlus、Retool,还是其他 ORM 工具,都可以无缝使用 Prisma Postgres。

这一切都得益于 Prisma Postgres 基于标准 Postgres 构建,同时针对最佳使用体验进行了优化。

其他重要更新

这个版本包含了大量改进和新功能,如果一一详述,这篇文章会变得非常冗长。

Prisma 团队花费了大量时间处理积压的问题和功能请求,解决了一些最受社区关注的需求,包括:

  • 映射枚举支持
  • 更新了项目所需的最低 Node.js 和 TypeScript 版本
  • 全新版本的 Prisma Studio(通过 npx prisma studio 访问)

开发者可以在 Prisma 的更新日志中找到此版本的所有详细内容,Prisma 团队还提供了完整的迁移指南帮助开发者顺利升级。

结语

对 Prisma 团队而言,Prisma 7 不仅仅是一个版本发布,更是 Prisma ORM 和 Prisma Postgres 未来发展的基石。他们希望构建的工具能够为开发者提供最佳体验,让开发者能够专注于构建和发布出色的应用程序。

Prisma 团队特别感谢他们令人难以置信的社区,以及所有在预发布阶段提供宝贵反馈的开发者。如果开发者正在尝试 Prisma 7,可以向 Prisma 团队反馈想法和体验。

你的游戏没有这个怎么能够顺利出海?

点击上方亿元程序员+关注和★星标

引言

哈喽大家好,不知道小伙伴们最近有没有发现,如今的游戏出海,已经不是从前的粗放买量靠堆砌素材喂算法了,现在都在拼长线运营或者AI了。

其中Supercell的《荒野乱斗》就是最好的例子,上线五年了,如今成功逆袭。

笔者想起,八年前就已经参与过游戏的多语言版本了,那时候的主流是港台(繁体)、东南亚(英文)、韩国(韩文)。

除去接入对应的SDK外,最为深刻的就是翻译和本地化,那时候不需要很高端的操作,就是把游戏内所有的中文整理出来,给到专门负责翻译的人,完成后导回到游戏即可。

其中最头疼的就是英文,两个字能变10多个字母,很容易导致超框,以上的处理有个“国际化”的简称,叫i18n

言归正传,今天我们就来聊聊i18n

什么是i18n?

先简单科普一下

i18n是国际化的简称,来源是英文单词internationalization的首末字符in18为单词中间的字符数。

在资讯领域,国际化(i18n)可以让产品无需做大的改变,就能够满足不同语言和地区的需要。

i18n的优势是什么?

对程序来说,在不修改内部代码的情况下,就能根据不同语言及地区切换相应的语言界面。

正如笔者引言所说,把文本交给翻译人员,回来后原路返回即可。

最开始就制定严格的规范,所有的文本都需要通过i18n,这样能够减少后期提取文本和导回文本的麻烦操作。

一起来看个例子

既然i18n如此重要,那么我们一起来看看它在Cocos游戏开发中如何使用。

1.安装插件

首先Store找到i18n多语言化插件,选择添加到项目即可。

添加完成之后,就可以在资源管理器看到插件对应的脚本,分别对应数据、Label管理和Sprite管理。

2.添加语言

首先通过菜单扩展->I18n Setting打开本地化控制面板。

简单添加8个语言,从下到上分别为中文、俄语、韩语、日语、法语、西班牙语、英语和德语(首字母排序)。

为什么8个?因为可以和别人说你“精通”八国语言(你好,世界)。

编辑完成后,插件会自动在resources\i18n目录生成对应的Ts配置文件。

3.编辑中文

zh.ts中添加你好,世界,作为示例。

4.翻译其他7种语言

完成的所有中文内容,交给专业的翻译人员进行翻译,获得其余7种语言,我们这里简单示例就找搭子就行。

还是非常靠谱的。

5.LocalizedLabel组件

插件生成的LocalizedLabel组件,就是对Label的简单封装,根据key和语言配置获取对应的文本。

直接挂到Label组件上,配置对应的文本的key即可。

6.效果演示

我们通过点击本地化编辑面板中不同语言的“小眼睛”,即可完成语言的切换,并且看到切换语言后的效果。

上面只是编辑器演示,实际需要修改默认语言,可以在脚本中修改。

7.进阶

如果一个包体内有多种语言,想要支持动态切换语言,可以通过import * as i18n from 'db://i18n/LanguageData';导入i18n,并通过i18n.init('en');进行语言切换,最后通过i18n.updateSceneRenderers();刷新即可。

此外LocalizedSprite组件也是同理,对Sprite的简单封装。

结语

当然了,并非所有的项目都需要使用这套插件,国际化的逻辑还是简单的,一般公司项目都有自己的技术积累,有自己的实现。

其实最主要是一个规范的问题,通过语言包的限制,避免文本到处都是。但是也使得配置表比较难以配置,这个需要小伙伴们自己权衡了。

你们使用的是什么方案?

我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐专栏:

知识付费专栏

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

100个Cocos实例

8年主程手把手打造Cocos独立游戏开发框架

和8年游戏主程一起学习设计模式

从零开始开发贪吃蛇小游戏到上线系列

点击下方灰色按钮+关注。

Vue高阶组件已过时?这3种新方案让你的代码更优雅

还记得那些年被高阶组件支配的恐惧吗?props命名冲突、组件嵌套过深、调试困难...每次修改组件都像在拆炸弹。如果你还在Vue 3中苦苦挣扎如何复用组件逻辑,今天这篇文章就是为你准备的。

我将带你彻底告别HOC的痛点,掌握3种更现代、更优雅的代码复用方案。这些方案都是基于Vue 3的Composition API,不仅解决了HOC的老问题,还能让你的代码更加清晰和可维护。

为什么说HOC在Vue 3中已经过时?

先来看一个典型的高阶组件例子。假设我们需要给多个组件添加用户登录状态检查:

// 传统的HOC实现
function withAuth(WrappedComponent) {
  return {
    name: `WithAuth${WrappedComponent.name}`,
    data() {
      return {
        isLoggedIn: false,
        userInfo: null
      }
    },
    async mounted() {
      // 检查登录状态
      const user = await checkLoginStatus()
      this.isLoggedIn = !!user
      this.userInfo = user
    },
    render() {
      // 传递所有props和事件
      return h(WrappedComponent, {
        ...this.$attrs,
        ...this.$props,
        isLoggedIn: this.isLoggedIn,
        userInfo: this.userInfo
      })
    }
  }
}

// 使用HOC
const UserProfileWithAuth = withAuth(UserProfile)

这个HOC看似解决了问题,但实际上带来了不少麻烦。首先是props冲突风险,如果被包裹的组件已经有isLoggedIn这个prop,就会产生命名冲突。其次是调试困难,在Vue Devtools中你会看到一堆WithAuth前缀的组件,很难追踪原始组件。

最重要的是,在Vue 3的Composition API时代,我们有更好的选择。

方案一:Composition函数 - 最推荐的替代方案

Composition API的核心思想就是逻辑复用,让我们看看如何用composable函数重构上面的认证逻辑:

// 使用Composition函数
import { ref, onMounted } from 'vue'
import { checkLoginStatus } from '@/api/auth'

// 将认证逻辑提取为独立的composable
export function useAuth() {
  const isLoggedIn = ref(false)
  const userInfo = ref(null)
  const loading = ref(true)

  const checkAuth = async () => {
    try {
      loading.value = true
      const user = await checkLoginStatus()
      isLoggedIn.value = !!user
      userInfo.value = user
    } catch (error) {
      console.error('认证检查失败:', error)
      isLoggedIn.value = false
      userInfo.value = null
    } finally {
      loading.value = false
    }
  }

  onMounted(() => {
    checkAuth()
  })

  return {
    isLoggedIn,
    userInfo,
    loading,
    checkAuth
  }
}

// 在组件中使用
import { useAuth } from '@/composables/useAuth'

export default {
  name: 'UserProfile',
  setup() {
    const { isLoggedIn, userInfo, loading } = useAuth()

    return {
      isLoggedIn,
      userInfo,
      loading
    }
  }
}

这种方式的优势很明显。逻辑完全独立,不会产生props冲突。在Devtools中调试时,你能清晰地看到原始组件和响应式数据。而且这个useAuth函数可以在任何组件中复用,不需要额外的组件嵌套。

方案二:渲染函数与插槽的完美结合

对于需要控制UI渲染的场景,我们可以结合渲染函数和插槽来实现更灵活的逻辑复用:

// 使用渲染函数和插槽
import { h } from 'vue'

export default {
  name: 'AuthWrapper',
  setup(props, { slots }) {
    const { isLoggedIn, userInfo, loading } = useAuth()

    return () => {
      if (loading.value) {
        // 加载状态显示加载UI
        return slots.loading ? slots.loading() : h('div', '加载中...')
      }

      if (!isLoggedIn.value) {
        // 未登录显示登录提示
        return slots.unauthorized ? slots.unauthorized() : h('div', '请先登录')
      }

      // 已登录状态渲染默认插槽,并传递用户数据
      return slots.default ? slots.default({
        user: userInfo.value
      }) : null
    }
  }
}

// 使用方式
<template>
  <AuthWrapper>
    <template #loading>
      <div class="skeleton-loader">正在检查登录状态...</div>
    </template>
    
    <template #unauthorized>
      <div class="login-prompt">
        <h3>需要登录</h3>
        <button @click="redirectToLogin">立即登录</button>
      </div>
    </template>
    
    <template #default="{ user }">
      <UserProfile :user="user" />
    </template>
  </AuthWrapper>
</template>

这种方式保留了组件的声明式特性,同时提供了完整的UI控制能力。你可以为不同状态提供不同的UI,而且组件结构在Devtools中保持清晰。

方案三:自定义指令处理DOM相关逻辑

对于需要直接操作DOM的逻辑复用,自定义指令是不错的选择:

// 权限控制指令
import { useAuth } from '@/composables/useAuth'

const authDirective = {
  mounted(el, binding) {
    const { isLoggedIn, userInfo } = useAuth()
    
    const { value: requiredRole } = binding
    
    // 如果没有登录,隐藏元素
    if (!isLoggedIn.value) {
      el.style.display = 'none'
      return
    }
    
    // 如果需要特定角色但用户没有权限,隐藏元素
    if (requiredRole && userInfo.value?.role !== requiredRole) {
      el.style.display = 'none'
    }
  },
  updated(el, binding) {
    // 权限变化时重新检查
    authDirective.mounted(el, binding)
  }
}

// 注册指令
app.directive('auth', authDirective)

// 在模板中使用
<template>
  <button v-auth>只有登录用户能看到这个按钮</button>
  <button v-auth="'admin'">只有管理员能看到这个按钮</button>
</template>

自定义指令特别适合处理这种与DOM操作相关的逻辑,代码简洁且易于理解。

实战对比:用户权限管理场景

让我们通过一个完整的用户权限管理例子,对比一下HOC和新方案的差异:

// 传统HOC方式 - 不推荐
function withUserRole(WrappedComponent, requiredRole) {
  return {
    data() {
      return {
        currentUser: null
      }
    },
    computed: {
      hasPermission() {
        return this.currentUser?.role === requiredRole
      }
    },
    render() {
      if (!this.hasPermission) {
        return h('div', '无权限访问')
      }
      return h(WrappedComponent, {
        ...this.$attrs,
        ...this.$props,
        user: this.currentUser
      })
    }
  }
}

// Composition函数方式 - 推荐
export function useUserPermission(requiredRole) {
  const { userInfo } = useAuth()
  const hasPermission = computed(() => {
    return userInfo.value?.role === requiredRole
  })
  
  return {
    hasPermission,
    user: userInfo
  }
}

// 在组件中使用
export default {
  setup() {
    const { hasPermission, user } = useUserPermission('admin')
    
    if (!hasPermission.value) {
      return () => h('div', '无权限访问')
    }
    
    return () => h(AdminPanel, { user })
  }
}

Composition方式不仅代码更简洁,而且类型推断更友好,测试也更容易。

迁移指南:从HOC平稳过渡

如果你有现有的HOC代码需要迁移,可以按照以下步骤进行:

首先,识别HOC的核心逻辑。比如上面的withAuth核心就是认证状态管理。

然后,将核心逻辑提取为Composition函数:

// 将HOC逻辑转换为composable
function withAuthHOC(WrappedComponent) {
  return {
    data() {
      return {
        isLoggedIn: false,
        userInfo: null
      }
    },
    async mounted() {
      const user = await checkLoginStatus()
      this.isLoggedIn = !!user
      this.userInfo = user
    },
    render() {
      return h(WrappedComponent, {
        ...this.$props,
        isLoggedIn: this.isLoggedIn,
        userInfo: this.userInfo
      })
    }
  }
}

// 转换为
export function useAuth() {
  const isLoggedIn = ref(false)
  const userInfo = ref(null)
  
  onMounted(async () => {
    const user = await checkLoginStatus()
    isLoggedIn.value = !!user
    userInfo.value = user
  })
  
  return { isLoggedIn, userInfo }
}

最后,逐步替换项目中的HOC使用,可以先从新组件开始采用新方案,再逐步重构旧组件。

选择合适方案的决策指南

面对不同的场景,该如何选择最合适的方案呢?

当你需要复用纯逻辑时,比如数据获取、状态管理,选择Composition函数。这是最灵活和可复用的方案。

当你需要复用包含UI的逻辑时,比如加载状态、空状态,选择渲染函数与插槽组合。这提供了最好的UI控制能力。

当你需要操作DOM时,比如权限控制隐藏、点击外部关闭,选择自定义指令。这是最符合Vue设计理念的方式。

记住一个原则:能用Composition函数解决的问题,就不要用组件包装。保持组件的纯粹性,让逻辑和UI分离。

拥抱Vue 3的新范式

通过今天的分享,相信你已经看到了Vue 3为逻辑复用带来的全新可能性。从HOC到Composition API,不仅仅是API的变化,更是开发思维的升级。

HOC代表的组件包装模式已经成为过去,而基于函数的组合模式正是未来。这种转变让我们的代码更加清晰、可测试、可维护。

下次当你想要复用逻辑时,不妨先想一想:这个需求真的需要包装组件吗?还是可以用一个简单的Composition函数来解决?

希望这些方案能够帮助你写出更优雅的Vue代码。如果你在迁移过程中遇到任何问题,欢迎在评论区分享你的经历和困惑。

TypeScript 简史:它是怎么拯救我的烂代码的

看烂代码的场景

接手老旧 JavaScript 项目的时候,盯着屏幕上的一行代码发呆,这种绝望你一定体会过:

JavaScript

function process(data) {
    return data.value + 10; // 此时 data 是 undefined,程序崩了
}

看着这个 data,我满脑子都是问号:

  • 它是对象还是数字?
  • 到底是谁传进来的?
  • 我要是改了它,会不会导致隔壁模块的页面挂掉?
  • 为什么明明是字符串 '10',结果拼成了 '1010'

这时候我就在想,要是代码能自己告诉我“我是谁,我从哪里来,我要去哪里”,该多好。

这就是 TypeScript 诞生的意义。它不是微软为了炫技造的轮子,而是为了解决 JavaScript 在大规模应用中“失控”的必然选择。


为什么我们需要 TypeScript?

JavaScript 的“娘胎顽疾”

JavaScript 诞生的时候,Brendan Eich 只用了 10 天。这事儿说穿了挺传奇,但也留下了隐患。

当时的场景很简单:验证一下表单,改改页面背景色。所以它的设计哲学是 “怎么方便怎么来”

JavaScript

var data = "hello";
data = 123;           // 随便改类型,没事
data.abcd = "what?";  // 随便加属性,也不报错

这种“自由”在写几十行代码时是天堂,但在写几十万行代码时就是地狱。

事情是怎么失控的?

随着 AjaxNode.js 的出现,前端不再是画页面的,而是写应用程序的。

想想也是,当代码量从 500 行变成 50,000 行,团队从 1 个人变成 20 个人:

  • 你写的 getUser(id),同事调用时传了个对象。
  • 后端 API 偷偷改了一个字段名,前端只有等到用户点击报错了才知道。
  • 重构?别逗了,改一行代码,心里都要祈祷半天。

问题的根源在于 JavaScript 是动态弱类型。它在运行前完全不知道自己错了,非要等到撞了南墙(报错)才回头。


它是怎么解决问题的?

核心思路:给 JS 穿上铠甲

TypeScript 的原理说穿了挺简单:它就是给 JavaScript 加上了类型约束,但在运行前又把这些约束脱得干干净净。

看个流程图就明白了:

代码段

graph LR
    A[TypeScript源码] -->|编译/检查| B(类型检查器)
    B -- 报错 --> C[修复代码]
    B -- 通过 --> D[抹除类型信息]
    D --> E[纯净的JavaScript]
    E --> F[浏览器/Node运行]

巧妙的“超集”策略

微软的大神 Anders Hejlsberg(这也是 C# 之父)非常聪明。他知道如果搞一门新语言,开发者肯定不买账(看看 Google 的 Dart 就知道了)。

所以他搞了个 “超集(Superset)” 策略:

  1. 向后兼容:任何合法的 JavaScript 代码,直接粘贴进 TypeScript 文件,它都能跑。你不需要重写代码。
  2. 类型擦除:TS 编译后就是普通的 JS。浏览器根本不知道 TS 的存在,不用装任何插件。

TypeScript

// TypeScript 写法
function add(a: number, b: number): number {
    return a + b;
}

// 编译出来的 JavaScript(类型全没了)
function add(a, b) {
    return a + b;
}

这招“瞒天过海”非常高明,既让你爽了(有类型检查),又让浏览器爽了(只认 JS)。


为什么 TS 能赢?(深度分析)

在 TS 出来之前,Google 的 Closure 和 Dart,甚至 Facebook 的 Flow 都尝试过解决这个问题。为什么最后是 TypeScript 统一了江湖?

对比分析

我们来看看这张技术演进图:

代码段

timeline
    title 前端类型探索之路
    2009 : Closure Compiler : 注释太繁琐
    2009 : CoffeeScript : 语法糖,无类型
    2011 : Dart : 甚至想换掉JS虚拟机
    2012 : TypeScript 诞生 : 拥抱JS,做超集
    2014 : Angular 宣布采用 TS
    2016 : VS Code 崛起
    2023 : 统治地位确立

TypeScript 胜出的关键点

  1. 工具链的降维打击

    这点必须得吹一下 VS Code。VS Code 是用 TS 写的,它对 TS 的支持简直是原生级的。

    • 智能补全:你打个点 .,属性全出来了,不用去翻文档。
    • 重构神器:按 F2 重命名一个变量,整个项目几百个文件里的引用全改好了。
  2. 渐进式的温柔

    CoffeeScript 和 Dart 要求你“学会新语法,忘掉旧习惯”。

    TypeScript 说:“没事,你先用 any 凑合着,等有空了再补类型。”这种低门槛让很多老项目敢于尝试迁移。

  3. 生态圈的马太效应

    现在你装个第三方库,如果没有自带 TypeScript 类型定义(d.ts),大家都会觉得这个库“不正规”。Angular、Vue3、React 全部深度拥抱 TS。

潜在的坑

当然,TS 也不是银弹,这里要注意几个软肋:

  • AnyScript 现象:很多新手遇到类型报错就写 any,结果写成了“带编译过程的 JavaScript”,完全失去了类型的意义。
  • 体操级类型:有时候为了描述一个复杂的动态结构,类型定义写得比业务逻辑还长,人称“类型体操”。
  • 编译时间:项目大了以后,tsc 跑一遍确实挺慢的(虽然现在有了 SWC/Esbuild 等加速方案)。

写在最后

TypeScript 的成功告诉我们要顺势而为。它没有试图颠覆 JavaScript,而是承认了 JS 的混乱,然后提供了一套工具来管理这种混乱。

下次当你接手一个全是 any 的 TS 项目时,你会知道:

  • 这哥们儿可能是在迁移初期。
  • 或者他只是单纯的懒。
  • 最重要的:至少你还能重构,因为编译器会教你做人。

如果你的团队还在用纯 JS 裸奔,赶紧试试 TS 吧。哪怕只是为了那个“点一下能出属性提示”的爽快感,也值了。


相关文档

只有前端 Leader 才会告诉你:那些年踩过的模块加载失败的坑(二)

最近项目准备要发布了,项目有比较多的一些代码提交和修复,发现在生产环境中,偶尔会遇到下面的错误:

TypeError: Failed to fetch dynamically imported module:
https://***-dev.***.internal.***.tech/assets/index-DUebgR3_.js

Failed to load module script: Expected a JavaScript-or-Wasm module script 
but the server responded with a MIME type of "text/html".

去做了一些调研,整理了这篇文章

🔍 问题原因分析

1. 根本原因

Nginx 配置问题:当浏览器请求不存在的静态资源文件时,nginx 返回了 index.html 而不是 404 错误。

原始的nginx配置:

location / {
    try_files $uri $uri/ /index.html;
}

这个配置作用是让前端路由(比如 /about/user/profile 这类路径)在刷新或直接访问时不会返回 404,导致所有找不到的文件(包括 /assets/ 下的 JS 文件)都返回 index.html(对应MIME type: text/html),但浏览器期望的是 JavaScript 文件。

2. 触发场景

我们的触发场景主要是场景A,其他两种也是可能会触发的场景

场景 A:版本更新后的缓存问题

1. 用户访问网站,浏览器缓存了 index.html(引用 index-ABC123.js)
2. 服务器部署新版本,生成新的哈希文件 index-DEF456.js
3. 用户刷新页面,浏览器使用缓存的 HTML,尝试加载已删除的 index-ABC123.js
4. 文件不存在,nginx 返回 index.html
5. 浏览器把 HTML 当作 JS 解析,抛出 TypeError

场景 B:部署不完整

1. CI/CD 部署过程中,HTML 文件已更新
2. 某些 chunk 文件因网络问题未完全上传
3. 用户访问时,HTML 引用了不存在的 chunk 文件
4. 触发模块加载错误

场景 C:CDN/浏览器缓存不一致

1. CDN 缓存了新版本的 HTML
2. 某些静态资源仍指向旧版本或缓存未更新
3. 导致文件引用不匹配

3. 错误链条

graph TD
    A[浏览器请求 /assets/index-DUebgR3_.js] --> B{文件是否存在?}
    B -->|否| C[nginx 执行 try_files]
    C --> D[返回 /index.html]
    D --> E[MIME type: text/html]
    E --> F[浏览器期望: application/javascript]
    F --> G[TypeError: MIME type 不匹配]

✅ 解决方案

方案 1:修复 Nginx 配置(核心)

修改内容

server {
    listen       80;
    server_name  _;

    root   /usr/share/nginx/html;
    index  index.html;

    # 静态资源:找不到返回 404,不返回 index.html
    location /assets/ {
        try_files $uri =404;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # SPA 路由:只对非静态资源路径生效
    location / {
        try_files $uri $uri/ /index.html;
        # 禁用 index.html 缓存,确保用户总是获取最新版本
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    # Gzip 压缩
    gzip on;
    gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
    gzip_min_length 1024;
}

改进点

  • /assets/ 路径返回真实的 404,避免误返回 HTML
  • ✅ 静态资源设置长期缓存(1年),提升性能
  • index.html 禁用缓存,防止引用过期的静态资源
  • ✅ 区分 SPA 路由和静态资源路由

📘 SPA 加载流程详解

单页应用的完整加载过程

很多人误以为 SPA 只是"返回 index.html 就完事了",实际上 index.html 只是入口,JS 文件才是应用的核心

🔄 完整加载流程(6个步骤)
用户访问: https://yourapp.com/users/123
    ↓
① 服务器返回 index.html(HTML 入口文件)
    ↓
② 浏览器解析 HTML,发现 <script src="/assets/index-DUebgR3_.js">
    ↓
③ 浏览器自动发起第二个请求: GET /assets/index-DUebgR3_.js
    ↓                           ⚠️ 我们的错误发生在这一步,由于项目更新,打包部署了新的版本,文件的hash值也发生了变化,但是本地之前启动项目的缓存请求的还是旧文件,期望拿到js文件,但是由于服务端找不到,返回了html,就出现了开头的错误
④ 服务器返回 JS 文件(包含 React 应用代码)
    ↓
⑤ 浏览器执行 JS:React 启动,读取 URL (/users/123)
    ↓
⑥ React Router 匹配路由,渲染 <UserProfile id="123" /> 组件
📄 index.html 和 JS 文件的关系

index.html 的实际内容(简化版):

<!DOCTYPE html>
<html>
<head>
    <title>Innies</title>
    <link rel="stylesheet" href="/assets/style-ABC123.css">
</head>
<body>
    <div id="root"></div>  <!-- ⚠️ 注意:这里是空的! -->

    <!-- ⚠️ 关键:这行代码触发浏览器请求 JS 文件 -->
    <script type="module" src="/assets/index-DUebgR3_.js"></script>
</body>
</html>

JS 文件包含真正的应用代码

// /assets/index-DUebgR3_.js 的内容
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { BrowserRouter } from 'react-router-dom';

// 这里才开始渲染页面内容
ReactDOM.render(
  <BrowserRouter>
    <App />  {/* 包含所有路由、组件、业务逻辑 */}
  </BrowserRouter>,
  document.getElementById('root')  // 找到 HTML 里的 <div id="root">,开始渲染
);

关键理解

  • index.html 只是一个空壳(只有一个空的 <div id="root">
  • 所有页面内容、路由、组件都在 JS 文件里
  • 没有 JS 文件,页面就是空白,什么都显示不出来
⚠️ 错误发生的位置

本文档解决的错误发生在第③步

正常流程

③ 浏览器请求: GET /assets/index-DUebgR3_.js
    ↓
✅ 服务器返回: JavaScript 文件(Content-Type: application/javascript)
    ↓
✅ 浏览器执行 JS,React 启动
    ↓
✅ 页面渲染成功

错误流程(修复前)

③ 浏览器请求: GET /assets/index-DUebgR3_.js
    ↓
❌ 服务器找不到文件(可能是旧版本文件已被删除)
    ↓
❌ Nginx 配置错误:try_files $uri $uri/ /index.html
    ↓
❌ 返回: index.html 内容(Content-Type: text/html)
    ↓
❌ 浏览器期望 JavaScript,但收到 HTML
    ↓
❌ TypeError: Expected JavaScript but got MIME type "text/html"
    ↓
❌ React 无法启动,页面白屏或崩溃
📊 请求和响应对比
步骤 正常情况 错误情况(修复前)
浏览器请求 GET /assets/index-DUebgR3_.js GET /assets/index-DUebgR3_.js
文件状态 ✅ 文件存在 ❌ 文件不存在(旧版本)
服务器返回 JavaScript 代码 ❌ index.html 内容
Content-Type application/javascript text/html
浏览器行为 ✅ 执行 JS,渲染页面 ❌ MIME type 不匹配报错
用户体验 ✅ 页面正常显示 ❌ 白屏或错误提示
💡 为什么 JS 文件找不到会导致整个应用崩溃?

因为 JS 文件包含:

  • ✅ React 核心代码
  • ✅ 所有组件定义
  • ✅ 路由配置
  • ✅ 状态管理
  • ✅ 业务逻辑

没有这个 JS 文件,index.html 只是一个空壳,无法渲染任何内容。

这就像:

  • index.html = 汽车的外壳
  • index-DUebgR3_.js = 发动机
  • 没有发动机,汽车就无法启动

🎯 为什么要区分 SPA 路由和静态资源路由?

问题背景: 单页应用(SPA)和传统的静态资源服务有本质区别,需要不同的处理策略。

📘 SPA 路由工作原理

单页应用(SPA)的核心机制

  1. 服务器层面:所有 URL 路径都返回同一个 index.html

    /users/123    → 服务器返回 index.html
    /dashboard    → 服务器返回 index.html
    /settings     → 服务器返回 index.html
    
  2. 浏览器层面:前端路由(如 React Router)解析 URL 并渲染对应组件

    // index.html 加载后,前端路由接管 URL 解析
    /users/123React Router 匹配 → <UserProfile id="123" />
    /dashboard    → React Router 匹配 → <Dashboard />
    /settings     → React Router 匹配 → <Settings />
    

为什么 /users/123 需要返回 index.html

典型场景:

  • 用户直接在浏览器输入 https://yourapp.com/users/123
  • 或在 /users/123 页面刷新浏览器
  • 服务器收到 HTTP 请求:GET /users/123

如果不返回 index.html 会发生什么?

❌ 服务器在文件系统中找不到 /users/123 文件
❌ 返回 404 错误
❌ 用户看到错误页面,应用无法加载

返回 index.html 后的完整流程

1. 服务器返回 index.html(包含 React 应用的启动代码)
2. 浏览器加载并执行 index.html 中的 JavaScript
3. React 应用启动
4. React Router 读取当前 URL: /users/123
5. 匹配路由规则,渲染 <UserProfile id="123" /> 组件
6. 用户看到正确的页面 ✅
📊 路由类型对比
类型 路径示例 期望行为 原因
SPA 路由 /users/123
/settings
/dashboard
返回 index.html 这些是前端路由,由 React Router 处理,服务器没有对应文件
静态资源 /assets/index-DUebgR3_.js
/assets/style.css
/favicon.ico
返回文件或 404 这些是真实的物理文件,不存在就应该报错

修复前的问题

location / {
    try_files $uri $uri/ /index.html;  # ❌ 所有路径都用这个规则
}

这会导致:

  • /users/123 → 返回 index.html ✓(正确)
  • /assets/missing.js → 返回 index.html ✗(错误!应该返回 404)

修复后的方案

# 规则 1:静态资源 - 严格匹配
location /assets/ {
    try_files $uri =404;  # 找不到就返回 404,绝不返回 HTML
}

# 规则 2:SPA 路由 - 兜底方案
location / {
    try_files $uri $uri/ /index.html;  # 找不到才返回 HTML
}

工作原理

请求: /users/123
  ↓ 不匹配 /assets/
  ↓ 进入 location /
  ↓ $uri 不存在 → 返回 index.html ✅
  
请求: /assets/index-ABC.js (存在)
  ↓ 匹配 /assets/
  ↓ $uri 存在 → 返回文件 ✅
  
请求: /assets/index-OLD.js (不存在)
  ↓ 匹配 /assets/
  ↓ $uri 不存在 → 返回 404 ✅(不是 HTML!)

核心收益

  1. 类型安全:浏览器期望 JS 文件,就不会收到 HTML 文件
  2. 快速失败:资源缺失立即返回 404,触发前端错误处理(重试/提示)
  3. 正确缓存:静态资源和 HTML 可以设置不同的缓存策略
  4. 问题可见:404 错误可以被监控系统捕获,便于及时发现部署问题

⚠️ 重要说明:404 不是终极解决方案

返回 404 的作用

❌ 修复前:返回 HTML → MIME type 错误 → 用户看到技术错误 → 无法恢复
✅ 修复后:返回 404 → 触发 error 事件 → 前端捕获错误 → 自动重试/提示用户

404 只是让问题"正确地暴露",真正的解决方案是 方案 3 的前端错误处理

  • 🔄 自动重试加载(处理临时网络问题)
  • 🔄 自动刷新页面(清除过期缓存)
  • 💬 友好的用户提示(引导用户清除缓存)

完整的解决链条

文件不存在 
  ↓
nginx 返回 404(不是 HTML)
  ↓
浏览器触发 error 事件
  ↓
前端错误处理器捕获
  ↓
自动重试(2次)或提示用户清除缓存
  ↓
问题解决 ✅

三个方案的角色

方案 角色 作用
方案 1 (Nginx) 🚦 正确的错误信号 让错误以正确的方式暴露(404 而非 HTML)
方案 2 (Vite) 🛡️ 减少问题发生 优化构建,减少 chunk 文件数量和依赖复杂度
方案 3 (前端) 🔧 自动修复 捕获错误并自动恢复,用户无感知或有友好提示

根本性的预防措施(见后文"预防措施"章节):

  • ✅ 禁用 index.html 缓存
  • ✅ 原子性部署(避免文件不完整)
  • ✅ 保留旧版本静态资源(避免缓存引用失效)

方案 2:优化 Vite 构建配置

修改内容

export default defineConfig(({ mode }) => {
  return {
    build: {
      rollupOptions: {
        output: {
          manualChunks: {
            // 拆分大型依赖,减少单个文件失败的影响
            'react-vendor': ['react', 'react-dom', 'react-router-dom'],
            'ui-vendor': ['@zhiman/design', 'framer-motion', 'lucide-react'],
          },
        },
      },
      assetsDir: 'assets',
      chunkSizeWarningLimit: 1000,
    },
    // ... 其他配置
  };
});

改进点

  • ✅ 合理拆分 chunk,避免单个巨大文件
  • ✅ 减少动态导入失败的影响范围
  • ✅ 提升首屏加载速度

方案 3:添加前端错误处理

新增文件src/utils/moduleLoadErrorHandler.ts

核心功能

export function setupModuleLoadErrorHandler(): void {
  // 监听全局错误
  window.addEventListener('error', (event) => {
    // 检测模块加载错误
    if (isModuleLoadError(event.message)) {
      // 自动重试(最多 2 次)
      if (reloadCount < MAX_RELOAD_ATTEMPTS) {
        sessionStorage.setItem(RELOAD_KEY, String(reloadCount + 1));
        setTimeout(() => window.location.reload(), 500);
      } else {
        // 显示友好的错误提示
        showErrorUI();
      }
    }
  });
}

特性

  • ✅ 自动检测模块加载失败
  • ✅ 智能重试机制(最多 2 次)
  • ✅ 友好的用户提示界面
  • ✅ 一键清除缓存并重新加载
  • ✅ 防止无限重载循环

使用方式

// src/main.tsx
import { setupModuleLoadErrorHandler } from './utils/moduleLoadErrorHandler';

setupModuleLoadErrorHandler();

📊 效果对比

修复前

用户体验:❌ 白屏 / 加载失败
错误信息:❌ 技术性错误提示
恢复方式:❌ 用户需要手动清除缓存
影响范围:❌ 版本更新时频繁出现

修复后

用户体验:✅ 自动重试 / 友好提示
错误信息:✅ 用户友好的提示界面
恢复方式:✅ 自动重试 + 一键清除缓存
影响范围:✅ 大幅减少错误发生率
  • 💡 总结与建议

    问题本质

    说白了就是:旧文件找不到了,Nginx 配置有问题,返回了错的东西。

    用户浏览器缓存的旧 HTML 里写着"去加载 index-ABC123.js",但服务器上这个文件早删了。正常情况应该返回 404,但 Nginx 配置写错了,返回了一个 HTML 页面。浏览器期望拿到 JS 文件,结果拿到 HTML,直接懵了,抛错。

    解决思路很简单

    核心就一句话:让 Nginx 该返回 404 就返回 404,别瞎返回 HTML。然后前端监听到 404 错误,自动刷新页面就行了。

    三个方案其实就是围绕这个思路:

    1. 改 Nginx:找不到文件就老老实实返回 404,别整那些花里胡哨的
    2. 前端兜底:监听加载失败,自动重试或者刷新页面,用户基本无感
    3. 优化打包:把文件拆小点,减少出问题的概率

    为什么这么简单的问题会困扰这么久?

    因为大多数人(包括我们一开始)都把 Nginx 配置写成了:

    nginx

    location / {
        try_files $uri $uri/ /index.html;  # 啥都找不到就返回 HTML
    }
    

    这个配置的本意是支持 SPA 前端路由,但副作用是连静态资源文件找不到也返回 HTML,这就埋了个大坑。

    三个方案的优先级

    如果时间紧迫,只能先做一个,建议顺序是:

    1. 先改 Nginx(5分钟搞定,治本)
    2. 再加前端兜底(半小时搞定,救急)
    3. 最后优化构建(锦上添花,可选)

    一些踩坑经验

    关于 Nginx 配置:

    • 静态资源目录(/assets/)要单独配置,找不到就返回 404
    • 测试方法:直接浏览器访问一个不存在的 /assets/xxx.js,看返回的是不是 404
    • 别偷懒,该分开配置就分开配置

    关于缓存策略:

    • index.html 千万别缓存,或者设置较短时间的缓存,
    • 这是问题的根源
    • 静态资源随便缓存,文件名带 hash,不会冲突
    • CDN 的缓存规则要和 Nginx 保持一致

前端跨标签页通信方案(下)

前情

平时开发很少有接触到有什么需求需要实现跨标签页通信,但最近因为一些变故,不得不重新开始找工作了,其中就有面试官问到一道题,跨标签页怎么实现数据通信,我当时只答出二种,面试完后特意重新查资料,因此有些文章

SharedWorker

共享工作线程可以在多个标签页之间共享数据和逻辑,通过postMessage通信

关键代码如下:

标签页1

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SharedWorker0</title>
</head>
<body>
  <h1>SharedWorker0</h1>
  <button id="communication">SharedWorker0.html 发送消息</button>
  <script>
    // 主线程
    const worker = new SharedWorker('sw.js');

    // 发送消息
    document.getElementById('communication').addEventListener('click', () => {
      worker.port.postMessage('Hello from Tab:SharedWorker0.html');
    });
  </script>
</body>
</html>

标签页2

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SharedWorker1</title>
</head>
<body>
  <h1>SharedWorker1</h1>
  <script>
    // 主线程
    const worker = new SharedWorker('sw.js');

    // 接收消息
    worker.port.onmessage = (e) => {
      console.log('Received:SharedWorker1.html', e.data);
    };
  </script>
</body>
</html>

sw.js关键代码:

const connections = [];

self.onconnect = (e) => {
  const port = e.ports[0];
  connections.push(port);
  
  port.onmessage = (e) => {
    // 广播给所有连接的页面
    connections.forEach(p => p.postMessage(e.data));
  };
};

动图演示:

20250923_203404.gif

提醒:

  • 同源标签才有效
  • 不同页面创建 SharedWorker 时,若指定的脚本路径不同(即使内容相同),会创建不同的 worker 实例
  • 页面与 SharedWorker 之间通过 MessagePort 通信,需通过 port.postMessage() 发送消息,通过 port.onmessage 接收消息
  • SharedWorker 无法访问 DOM、window 对象或页面的全局变量,仅能使用 JavaScript 核心 API 和部分 Web API(如 fetchWebSocket
  • 兼容性一般,安卓webview全系不兼容

Service Worker

专门用于同源标签页通信的 API,创建一个频道后,所有加入该频道的页面都能收到消息

关键代码如下:

标签页1

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ServiceWorker0</title>
</head>
<body>
  <h1>ServiceWorker0</h1>
  <button id="sendBtn">发送消息</button>
  <script>
    // 注册ServiceWorker
    let swReg;
    navigator.serviceWorker.register('ServiceWorker.js')
      .then(reg => {
        swReg = reg;
        console.log('SW注册成功');
      });
    
    // 发送消息
    document.getElementById('sendBtn').addEventListener('click', () => {
      if (swReg && swReg.active) {
        swReg.active.postMessage('来自页面0的消息');
      }
    });
  </script>
</body>
</html>

标签页2

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ServiceWorker1</title>
</head>
<body>
  <h1>ServiceWorker1</h1>
  <script>
    // 注册ServiceWorker
    navigator.serviceWorker.register('ServiceWorker.js')
      .then(() => console.log('SW注册成功'));
    
    // 接收消息
    navigator.serviceWorker.addEventListener('message', (e) => {
      console.log('---- Received:ServiceWorker1.html ----:',  e.data);
    });
  </script>
</body>
</html>

ServiceWorker.js关键代码

// 快速激活
self.addEventListener('install', e => e.waitUntil(self.skipWaiting()));
self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));

// 消息转发
self.addEventListener('message', e => {
  self.clients.matchAll().then(clients => {
    clients.forEach(client => {
      if (client.id !== e.source.id) {
        client.postMessage(e.data);
      }
    });
  });
});

演示动图如下:

20250923_212126.gif

提醒:

  • Service Worker 要求页面必须在 HTTPS 环境 下运行(localhost 除外,方便本地开发),这是出于安全考虑,防止中间人攻击篡改 Service Worker 脚本
  • Service Worker 有严格的生命周期(安装、激活、空闲、销毁),一旦注册成功会长期运行在后台,更新 Service Worker 需满足两个条件:
  1. 脚本 URL 不变但内容有差异
  2. 需在 install 事件中调用 self.skipWaiting(),并在 activate 事件中调用 self.clients.claim() 让新 Worker 立即生效
  • Service Worker 的作用域由注册路径决定,默认只能控制其所在路径及子路径下的页面,例如:/sw.js 可控制全站,/js/sw.js 默认只能控制 /js/ 路径下的页面,可通过 scope 参数指定作用域,但不能超出注册文件所在路径的范围
  • 可在浏览器开发者工具的 Application > Service Workers 面板进行调试, • 查看当前运行的 Service Worker 状态 • 强制更新、停止或注销 Worker • 模拟离线环境
  • 主流浏览器都支持,使用的时候可以通过Is service worker ready?,测试兼容性

window.open + window.opener

如果标签页是通过window.open打开的,可以直接通过opener属性通信 父窗口,打开子窗口的页面关键代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>parent</title>
</head>
<body>
  <h1>window.open parent</h1>
  <button id="openBtn">打开子窗口</button>
  <button id="sendBtn">发送消息</button>
  <div id="messageDisplay"></div>
  <script>
    let childWindow = null;
    let messageHandler = null;
    
    // 打开子窗口
    document.getElementById('openBtn').addEventListener('click', () => {
      // 如果已有窗口,先关闭
      if (childWindow && !childWindow.closed) {
        childWindow.close();
      }
      childWindow = window.open('./children.html', 'childWindow');
    });

    // 发送消息
    document.getElementById('sendBtn').addEventListener('click', () => {
      if (childWindow && !childWindow.closed) {
      // window.location.origin限制接收域名
        childWindow.postMessage('Hello child', window.location.origin);
      } else {
        alert('请先打开子窗口');
      }
    });
    
    // 接收子窗口的消息
    messageHandler = (e) => {
      if (e.origin === window.location.origin && e.source !== window) {
        document.getElementById('messageDisplay').textContent = '收到消息: ' + e.data;
        console.log('父页面收到消息:', e.data);
      }
    };
    
    window.addEventListener('message', messageHandler);
  </script>
</body>
</html>

通过window.open打开的子页面关键代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>children</title>
</head>
<body>
  <h1>子窗口</h1>
  <button id="replyBtn">回复父窗口</button>
  <div id="messageDisplay"></div>
  
  <script>
    let messageHandler = null;
    
    // 只在页面加载完成后设置消息监听
    window.onload = function() {
      // 接收父页面消息
      messageHandler = (e) => {
        if (e.origin === window.location.origin && e.source !== window) {
          console.log('子页面收到消息:', e.data);
          
          // 显示收到的消息
          document.getElementById('messageDisplay').textContent = '收到消息: ' + e.data;
          
          window.opener.postMessage('子窗口已收到消息', e.origin);
        }
      };
      
      window.addEventListener('message', messageHandler);
    };
    
    // 手动回复按钮
    document.getElementById('replyBtn').addEventListener('click', () => {
      if (window.opener) {
        window.opener.postMessage('来自子窗口的回复', window.location.origin);
      }
    });
  </script>
</body>
</html>

提醒:

  • 允许跨域通信,但必须由开发者显式指定信任的源,避免恶意网站滥用
  • 在事件监听的时候记得判断e.source,避免自己发送的事件自己接收了
  • 若子窗口被关闭,父窗口中对它的引用(如 childWindow)会变成无效对象,调用其方法会报错
  • window.open使用会有一些限制,最好是在事件中使用,有的浏览器还会有权限提示,需要用户同意才行,若 window.open 被浏览器拦截(非用户主动触发),会返回 null,导致后续通信失败

总结

面试官有提到Service Worker也可以,我面试完后的查询资料尝试了这些方法,都挺顺利的,就是Service Worker折腾了一会才跑通,使用起来相比前面的一些方式,它稍微复杂一些,我觉得用于消息通信只是它的冰山一角,它有一个主要功能就是用来解决一些耗性能的计算密集任务

个人技术有限,如果你有更好的跨标签页通信方式,期待你的分享,你工作中有遇到这种跨标签页通信的需求么,如果有你用的是哪一种了,期待你的留言

解决 Monorepo 项目中 node-sass 安装失败的 Python 版本兼容性问题

解决 Monorepo 项目中 node-sass 安装失败的 Python 版本兼容性问题

问题背景

在最近的一个 Monorepo 项目(具体是 a-mono 中的 table-list 子项目)中,我遇到了一个令人头疼的依赖安装问题。项目在使用 eden-mono 工具安装依赖时卡在 node-sass 的构建阶段,导致整个开发流程受阻。

项目环境

  • 项目类型:Monorepo(使用 eden-mono 管理)
  • 构建工具:eden-mono
  • 依赖管理:pnpm

错误现象

在项目根目录执行 eden-mono install --filter=table-list 时,安装过程在 node-sass@6.0.1 的 postinstall 脚本处失败,报错信息如下:

ValueError: invalid mode: 'rU' while trying to load binding.gyp
gyp ERR! configure error
gyp ERR! stack Error: `gyp` failed with exit code: 1

问题分析

根本原因

经过深入分析,发现问题出在 Python 版本兼容性 上:

  1. node-gyp 版本过旧:项目使用的是 node-gyp@7.1.2,这个版本发布于 2020 年
  2. Python 版本过高:系统安装了 Python 3.11.13,而旧的 node-gyp 不支持
  3. 语法不兼容'rU' 模式是 Python 2 的语法,在 Python 3 中已被移除

技术细节

node-gyp 在构建过程中会调用 Python 脚本来生成构建配置,其中使用了 open(build_file_path, "rU") 这样的语法。Python 3.11 不再支持 'rU' 模式,导致构建失败。

尝试过的解决方案

方案一:升级 node-gyp

npm install -g node-gyp@latest

结果:失败,因为 monorepo 项目中的 node-sass 版本锁定了 node-gyp 版本,升级全局包无法影响项目内部依赖

方案二:配置 Python 路径

npm config set python /path/to/python3.10

结果:失败,npm 配置语法在较新版本中发生了变化,而且 monorepo 项目的依赖管理更加复杂

方案三:降级 Node.js 版本

nvm use 16.20.2

结果:部分成功,但仍然遇到 Python 兼容性问题,因为 node-gyp 仍然调用不兼容的 Python 版本

方案四:替换 node-sass 为 sass

# 在 monorepo 中尝试替换
npm uninstall node-sass
npm install sass

结果:理论上可行,但 monorepo 项目中存在复杂的间接依赖关系,无法完全替换 node-sass

最终解决方案:暴力卸载重装

在尝试了所有常规方法后,我决定采用最粗暴但有效的方法:完全卸载所有 Python 版本,只保留通过 pyenv 管理的 Python 3.10.19

执行步骤

  1. 查看当前 Python 版本
pyenv versions
which -a python3 python3.10 python3.11
  1. 卸载 Python 3.11
pyenv uninstall 3.11.9
brew uninstall python@3.11
  1. 卸载通过 Homebrew 安装的 Python 3.10
brew uninstall python@3.10
  1. 清理 monorepo 缓存和依赖
cd /Users/bytedance/Documents/work-space/ttam_core_mono/packages/campaign-list
rm -rf node_modules
rm -rf .pnpm-store
pnpm store prune
  1. 重新安装依赖(使用 eden-mono)
nvm exec 16.20.2 eden-mono install --filter=campaign-list

结果

成功! 安装过程顺利完成,node-sass 构建成功,monorepo 项目可以正常运行。

经验教训

版本兼容性的重要性

这次问题深刻说明了开发环境中版本兼容性的重要性:

  1. Node.js 版本:不同版本的 Node.js 对依赖包的支持程度不同
  2. Python 版本:构建工具的 Python 支持存在版本限制
  3. 依赖版本:间接依赖可能导致意想不到的兼容性问题

最佳实践建议

  1. 使用版本管理工具

    • Node.js:使用 nvm 管理版本,确保 .nvmrc 文件存在
    • Python:使用 pyenv 管理版本,确保 .python-version 文件存在
  2. 锁定环境版本

    • 在 monorepo 根目录和子项目目录都放置版本锁定文件
    • 明确指定项目所需的运行时版本
    • 定期检查和更新这些版本锁定文件
  3. 定期更新依赖

    • 避免使用过时的依赖包(如 node-sass)
    • 及时升级到官方推荐的替代方案(如 sass)
    • 在 monorepo 中使用批量更新工具
  4. 环境隔离

    • 为不同项目使用不同的虚拟环境
    • 避免全局安装可能产生冲突的工具
    • 考虑使用容器化技术隔离复杂的构建环境

总结

虽然通过暴力卸载重装解决了这个 monorepo 项目的问题,但这并不是最理想的方式。在理想情况下,我们应该:

  1. 提前规划好 monorepo 项目的运行时环境,考虑各子项目的兼容性
  2. 使用容器化技术(如 Docker)来标准化复杂的 monorepo 环境
  3. 建立完善的 CI/CD 流程来检测环境兼容性问题
  4. 为 monorepo 项目建立专门的构建环境管理策略

这次经历让我更加重视开发环境的标准化管理,特别是对于复杂的 monorepo 项目。希望这篇文章能帮助遇到类似问题的开发者少走弯路,特别是在处理 monorepo 项目中的环境兼容性问题时。


参考链接

Next.js第八章(路由处理程序)

路由处理程序(Route Handlers)

路由处理程序,可以让我们在Next.js中编写API接口,并且支持与客户端组件的交互,真正做到了什么叫前后端分离人不分离

文件结构

定义前端路由页面我们使用的page.tsx文件,而定义API接口我们使用的route.ts文件,并且他两都不受文件夹的限制,可以放在任何地方,只需要文件的名称以route.ts结尾即可。

注意:page.tsx文件和route.ts文件不能放在同一个文件夹下,否则会报错,因为Next.js就搞不清到底用哪一个了,所以我们最好把前后端代码分开。

为此我们可以定义一个api文件夹,然后在这个文件夹下创建一对应的模块例如user login register等。

目录结构如下

app/
├── api
│   ├── user
│   │   └── route.ts
│   ├── login
│   │   └── route.ts
│   └── register
│       └── route.ts

定义请求

Next.js是遵循RESTful API的规范,所以我们可以使用HTTP方法来定义请求。

export async function GET(request) {}
 
export async function HEAD(request) {}
 
export async function POST(request) {}
 
export async function PUT(request) {}
 
export async function DELETE(request) {}
 
export async function PATCH(request) {}
 
//如果没有定义OPTIONS方法,则Next.js会自动实现OPTIONS方法
export async function OPTIONS(request) {}

注意: 我们在定义这些请求方法的时候不能修改方法名称而且必须是大写,否则无效。

工具准备: 打开vsCode / Cursor 找到插件市场搜索REST Client,安装完成后,我们可以使用REST Client来测试API接口。

image.png

定义GET请求

src/app/api/user/route.ts

import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
    const query = request.nextUrl.searchParams; //接受url中的参数
    console.log(query.get('id'));
    return NextResponse.json({ message: 'Get request successful' }); //返回json数据
}

REST client测试:

在src目录新建test.http文件,编写测试请求

src/test.http

GET http://localhost:3000/api/user?id=123 HTTP/1.1

image.png

定义Post请求

src/app/api/user/route.ts

import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest){
    //const body = await request.formData(); //接受formData数据
    //const body = await request.text(); //接受text数据
    //const body = await request.arrayBuffer(); //接受arrayBuffer数据
    //const body = await request.blob(); //接受blob数据
    const body = await request.json(); //接受json数据
    console.log(body); //打印请求体中的数据
    return NextResponse.json({ message: 'Post request successful', body },{status: 201});
     //返回json数据
}

REST client测试:

src/test.http

POST http://localhost:3000/api/user HTTP/1.1
Content-Type: application/json

{
    "name": "张三",
    "age": 18
}

image.png

动态参数

我们可以在路由中使用方括号[]来定义动态参数,例如/api/user/[id],其中[id]就是动态参数,这个参数可以在请求中传递,这个跟前端路由的动态路由类似。

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

接受动态参参数,需要在第二个参数解构{ params },需注意这个参数是异步的,所以需要使用await来等待参数解析完成。

import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest, 
{ params }: { params: Promise<{ id: string }> }) {
    const { id } = await params;
    console.log(id);
    return NextResponse.json({ message: `Hello, ${id}!` });
}

REST client测试:

src/test.http

GET http://localhost:3000/api/user/886 HTTP/1.1

image.png

cookie

Next.js也内置了cookie的操作可以方便让我们读写,接下来我们用一个登录的例子来演示如何使用cookie。

安装手动挡组件库shadcn/ui官网地址

npx shadcn@latest init 

为什么使用这个组件库?因为这个组件库是把组件放入你项目的目录下面,这样做的好处是可以让你随时修改组件库样式,并且还能通过AI分析修改组件库

安装button,input组件

npx shadcn@latest add button
npx shadcn@latest add input

新建login接口 src/app/api/login/route.ts

import { cookies } from "next/headers"; //引入cookies
import { NextRequest, NextResponse } from "next/server"; //引入NextRequest, NextResponse
//模拟登录成功后设置cookie
export async function POST(request: NextRequest) {
    const body = await request.json();
    if(body.username === 'admin' && body.password === '123456'){
        const cookieStore = await cookies(); //获取cookie
        cookieStore.set('token', '123456',{
            httpOnly: true, //只允许在服务器端访问
            maxAge: 60 * 60 * 24 * 30, //30天
        });
        return NextResponse.json({ code: 1 }, { status: 200 });
    }else{
        return NextResponse.json({ code: 0 }, { status: 401 });
    }
}
//检查登录状态
export async function GET(request: NextRequest) {
    const cookieStore = await cookies();
    const token = cookieStore.get('token');
    if(token && token.value === '123456'){
        return NextResponse.json({ code:1 }, { status: 200 });
    }else{
        return NextResponse.json({ code:0 }, { status: 401 });
    }
}

src/app/page.tsx

'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useRouter } from 'next/navigation';

export default  function HomePage() {
    const router = useRouter();
    const [username, setUsername] = useState('');
    const [password, setPassword] = useState('');
    const handleLogin = () => {
        fetch('/api/login', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ username, password }),
        }).then(res => {
            return res.json();
        }).then(data => {
            if(data.code === 1){
                router.push('/home');
            }
        });
    }
    return (
        <div className='mt-10 flex flex-col items-center justify-center gap-4'>
            <Input value={username} onChange={(e) => setUsername(e.target.value)} className='w-[250px]' placeholder="请输入用户名" />
            <Input value={password} onChange={(e) => setPassword(e.target.value)} className='w-[250px]' placeholder="请输入密码" />
            <Button onClick={handleLogin}>登录</Button>
        </div>
    )
}

src/app/home/page.tsx

'use client';
import { useEffect } from 'react';
import { redirect } from 'next/navigation';
const checkLogin = async () => {
    const res = await fetch('/api/login');
    const data = await res.json();
    if (data.code === 1) {
        return true;
    } else {
        redirect('/');
    }
}
export default function HomePage() {
    useEffect(() => {
        checkLogin()    
    }, []);
    return <div>你已经登录进入home页面</div>;
}

123.gif

Cloudflare 崩溃梗图

1. 新闻

昨天,Cloudflare 崩了。

随后,OpenAI、X、Spotify、AWS、Shopify 等大型网站也崩了。

据说全球 20% 的网站都受到波及,不知道你是否也被影响了?

2. 事故原因

整个事故持续了 5 个小时,根据 Cloudflare 的报告,最初公司怀疑是遭到了超大规模 DDoS 攻击,不过很快就发现了核心问题。

事故的根本原因是因为 Cloudflare 内部的一套用于识别和阻断恶意机器人流量的自动生成配置文件。

该配置文件在例行升级后规模意外变大,远超系统预期,撑爆了路由网络流量的软件限制,继而导致大量流量被标记为爬虫而被 Ban。

CEO 发布了道歉声明:

不过这也不是第一次发生这种大规模事故了。

一个月前,亚马逊 AWS 刚出现持续故障,超过一千个网站和在线应用数小时瘫痪。

今年 7 月,美国网络安全服务提供商 CrowdStrike 的一次软件升级错误则造成全球范围蓝屏事故,机场停航、银行受阻、医院手术延期,影响持续多日。

3. 梗图

每次这种大事故都会有不少梗图出现,这次也不少。

3.1. 第一天上班

苦了这位缩写为 SB 的老哥 😂

3.2. 真正的底座

原本你以为的 Cloudflare:

经过这次事故,实际的 Cloudflare:

3.3. 死循环

3.4. 按秒赔偿

3.5. 影响到我了

3.6. 影响惨了

3.7. 这是发动战争了?

3.8. 加速失败

3.9. mc 亦有记载

基本数据类型Symbol的基本应用场景

Symbol 作为 ES6 新增的基本数据类型,核心特性是唯一性不可枚举性,在实际项目中主要用于解决命名冲突、保护对象私有属性等场景。以下是具体的应用举例及代码实现:

一、作为对象的唯一属性名,避免属性冲突

当多人协作开发或引入第三方库时,普通字符串属性名容易被覆盖,Symbol 可确保属性唯一。

示例:组件库的私有属性

// 定义唯一的 Symbol 属性
const internalState = Symbol('internalState');

class Button {
  constructor() {
    // 用 Symbol 作为私有属性名,外部无法直接访问
    this[internalState] = {
      clicked: false,
      disabled: false
    };
  }

  click() {
    if (!this[internalState].disabled) {
      this[internalState].clicked = true;
      console.log('按钮被点击');
    }
  }

  disable() {
    this[internalState].disabled = true;
  }
}

const btn = new Button();
btn.click(); // 正常执行

// 外部无法通过常规方式访问或修改 internalState
console.log(btn.internalState); // undefined
console.log(btn[Symbol('internalState')]); // undefined(Symbol 是唯一的)

二、定义常量,避免魔术字符串

魔术字符串(直接写在代码中的字符串)易出错且难维护,用 Symbol 定义唯一常量更可靠。

示例:状态管理中的事件类型

// event-types.js
export const EVENT_TYPES = {
  LOGIN: Symbol('login'),
  LOGOUT: Symbol('logout'),
  UPDATE_USER: Symbol('updateUser')
};

// 使用常量
function handleEvent(eventType) {
  switch (eventType) {
    case EVENT_TYPES.LOGIN:
      console.log('用户登录');
      break;
    case EVENT_TYPES.LOGOUT:
      console.log('用户登出');
      break;
    default:
      console.log('未知事件');
  }
}

handleEvent(EVENT_TYPES.LOGIN); // 输出“用户登录”

三、实现对象的 “私有属性”

虽然 JavaScript 没有真正的私有属性,但 Symbol 属性默认不可被 for...inObject.keys() 枚举,可模拟私有属性。

示例:类的私有方法 / 属性

const privateMethod = Symbol('privateMethod');

class User {
  constructor(name) {
    this.name = name; // 公共属性
    this[Symbol('id')] = Math.random().toString(36).slice(2); // 私有属性
  }

  [privateMethod]() {
    // 私有方法,外部无法调用
    return `用户ID:${this[Symbol('id')]}`;
  }

  getInfo() {
    // 公共方法间接调用私有方法
    return `${this.name} - ${this[privateMethod]()}`;
  }
}

const user = new User('Alice');
console.log(user.getInfo()); // 正常输出

// 无法枚举 Symbol 属性
console.log(Object.keys(user)); // ['name']
for (const key in user) {
  console.log(key); // 仅输出 'name'
}

四、自定义迭代器(Iterator)

Symbol.iterator 是内置 Symbol,用于定义对象的迭代器,让对象可被 for...of 遍历。

示例:自定义可迭代对象

const iterableObj = {
  data: ['a', 'b', 'c'],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.data.length) {
          return { value: this.data[index++], done: false };
        } else {
          return { done: true };
        }
      }
    };
  }
};

// 可通过 for...of 遍历
for (const item of iterableObj) {
  console.log(item); // 输出 a、b、c
}

五、Vue 中的应用:自定义组件的 v-model 修饰符

在 Vue 3 中,可通过 Symbol 定义自定义的 v-model 修饰符,避免与内置修饰符冲突。

示例:Vue 组件的自定义修饰符

// 定义唯一的修饰符 Symbol
const trimSymbol = Symbol('trim');

// 组件内
export default {
  props: {
    modelValue: String,
    modelModifiers: {
      default: () => ({})
    }
  },
  emits: ['update:modelValue'],
  methods: {
    handleInput(e) {
      let value = e.target.value;
      // 判断是否使用自定义修饰符
      if (this.modelModifiers[trimSymbol]) {
        value = value.trim();
      }
      this.$emit('update:modelValue', value);
    }
  }
};

总结

Symbol 在项目中的核心应用场景包括:

  1. 避免属性名冲突(多人协作 / 第三方库集成);
  2. 模拟私有属性 / 方法(不可枚举特性);
  3. 定义唯一常量(替代魔术字符串);
  4. 扩展内置对象行为(如自定义迭代器)。

【AI省流快讯】Cloudflare 炸了 / Gemini 3 来了 / Antigravity 独家实测 (附:无法登录解法)

1. Cloudflare 挂了

🤡 昨晚陆续刷到 "CF挂了" 的消息,没太在意,直到无法打开" 盗版漫画" 站点,我才意识到问题的严重性:

🤣 原因众说纷纭,刷到这哥们的 "梗图",差点把我笑岔气:

😃 还有人猜测可能是 Google 发布的 "哈基米 3" (Gemini) 发起的攻击:

时间线

  • 19:30】用户开始报告网站无法访问,出现10xx、52x、50x系列错误;Cloudflare Dashboard无法访问;部分Cloudflare域名解析中断。
  • 19:48】Cloudflare正式确认服务异常,启动紧急调查。
  • 20:03】持续调查中,未发现明显进展。
  • 20:13】部分服务开始恢复,但错误率仍高于正常水平。
  • 20:21】监测到部分服务恢复迹象;多次反复出现故障与恢复的波动;20:23、20:55等时间点再次中断。
  • 21:04】技术团队紧急关闭伦敦节点的WARP服务接入以控制影响范围。
  • 21:09】官方确认定位到根本原因,开始实施修复方案。
  • 21:13】Cloudflare Access与WARP服务全面恢复,错误率回落至日常水平。
  • 22:12】X应用恢复。
  • 22:22】Cloudflare状态页更新:"我们正在继续努力修复此问题"。
  • 22:34】状态页再次更新:"我们已经部署了一项变更,已恢复仪表板服务。我们仍在努力解决对整体应用服务的影响"。
  • 22:42】全局恢复完成,Cloudflare宣布事件解决,后续监控与处理继续进行中。

Cloudflare 发言人 Jackie Dutton在官方声明中表示,故障源于一个 用于管理威胁流量的自动生成配置文件。该配置文件原本用于防护潜在安全威胁,但由于文件规模异常庞大,导致多项内部系统在处理流量时发生故障:

截止目前,Cloudflare 全球网络服务已全面恢复,受影响的X、ChatGPT、Facebook 等主流平台均已恢复正常使用。😀 在网上看到大佬的原因分析,也贴下:

😆 难兄难弟啊,前阵子 亚马逊AWS 的大规模宕机 (10.20,美东区域数据库权限和DNS管理系统配置故障),故障持续约15小时,直接造成全球互联网大面积混乱。

2. Gemini 3 来了

😄 千呼万唤的 "哈基米 3" (Gemini) 终于来了,不过竟然没搞个发布会,只是在 官方博客 发下文章:

《A new era of intelligence with Gemini 3》

先简要回顾了一下 Gemini 系列的发展历程:

  • Gemini 1:着重在 "原生多模态" (文本+图像) 和 "长上下文窗口"。
  • Gemini 2:开始推动 "智能代理式 (agentic) " 与 "推理与思考" 能力。

Gemini 3 在上述基础上进一步提升,方称其为迄今 "最智能、最安全" 的模型:

  • 推理能力 & 多模态理解:在各种 AI 基准测试 (benchmarks) 上表现优异:LMArena (1501 Elo)、GPQA Diamond (91.9%)、MMMU-Pro (81%)、Video-MMMU (87.6%)、SimpleQA Verified (72.1%)。模型能更好理解背景、意图,给予更有深度、少空话的回答
  • Gemini 3 Deep Think:"深思" 增强模式,可进行更深的链式推理、更强代码执行与工具调用,提升复杂问题的求解能力,Humanity's Last Exam (41.0%)、GPQA Diamond (93.8%)、ARC-AGI-2 (带代码执行,45.1%)。该模式将在数周内向 Google AI Ultra 订阅用户开放。

三大应用场景

学习

  • 模型支持文本、图像、视频、音频、代码等多模态输入,100w token 的上下文窗口。
  • 如:可将手写不同语言的食谱翻译并制作家庭食谱;分析视频运动比赛 (如Picklebal) 帮助你提高训练。
  • 可在 Google 搜索中的 "AI Mode" 借助 Gemini 3 提供生成式 UI、互动工具、仿真体验。

构建

  • 强 "零样本生成" 能力:不用给示例、不用教,只说想法,直接生成你想要的东西。能处理复杂 提示/指令 (提示/指令),生成更丰富、互动性更强的 Web UI。基准测试:WebDev Arena (比谁能更好地完成Web开发任务,1487 Elo,战绩亮眼)、Terminal-Bench 2.0 (54.2%,命令行处理真实开发任务的能力)、SWE-bench Verified (软件工程能力-修bug、补功能,76.2%-非常高,大部分模型在30%-40%)。
  • 可在 Google 的 AI Studio、Vertex AI、Gemini CLI、以及新出的 AI IDE-Google Antigravity 中使用。第三方平台也支持,如:Cursor、GitHub、JetBrains、Manus、Replit 等。

计划

  • 在长期多步骤任务中表现提升,如:Vending-Bench 2 中可 "模拟一年" 运营决策。
  • 新增 Gemini Agent 工具,能够代表用户自动完成多步骤复杂任务,如管理邮箱、自动化工作流程和旅行计划,且仍受用户控制。已向 Google AI Ultra 用户开放早期体验。

😄 打开 ai.studio 直接就能看到最新的模型了:


谷歌官方 在演示中展示了三个 Vibe Coding 例子:

  • AI 课程平台登录页:通过简单 Prompt ("新布鲁特主义风格,创意有趣设计,平滑滚动动画,谷歌色彩,深浅主题"),直接生成了一个完整的、具有动画效果和深浅主题切换的登陆页。
  • SaaS 数据看板:用户上传 CSV 数据文件和参考设计截图,自动生成了一个具有图表、筛选器、深色主题的专业数据仪表盘。
  • 互动游戏:通过复杂 Prompt (涉及React、Three.js、3D 效果等技术细节),生成一个完全可玩的3D游戏。

😄 国内 自媒体 基本都是在吹它的 "前端能力" (看效果图确实挺6的):

  • 能生成精确的 SVG 矢量图:包括复杂的动画 SVG (如:旋转风扇动画),而非简单栅格图。
  • 3D 和动画:支持生成 Three.js 3D 模型、WebGL 着色器、CSS 动画等高级视觉效果。
  • 完成应用框架:能理解复杂的技术栈要求 (React、Three.js Fiber、TypeScript 等),生成模块化、结构清晰的代码。
  • 注解修改:用户可以在生成的界面上用 "标注" 的方式指出要修改的地方 (画圈、画箭头、添加文字),Gemini 3 会理解这些视觉标注并精确修改代码。这得益于它 多模态理解能力的显著提升 (对屏幕截图的理解准确率达到 72.7%,达到现有水平的两倍)。
  • 去 "AI味":排版、色彩搭配、组件结构看起来是 "精心设计" 的,而非生硬地套模版。

🤔 目前杰哥还没 深度体验 这个新模型,不好评价,只实测下这个新出的 AI IDE —— Antigravity 吧~

3. Google Antigravity (反重力)

3.1. 下载安装

下载地址:

下载 Google Antigravity

下载完双击安装:

接着是不断按 Next 的 傻瓜式安装 (是否从VS Code 或 Cursor 导入设置):

选主题:

选使用 Agent 的方式 & 编辑器配置 (默认就好):

😐 最后,大部分人会卡在登录这里,杰哥也是折腾了一早上,买了两个号才登上的。

3.2. 登录问题的解决

3.2.1. 代理问题

如图,先检查 TUN (全局/系统代理) 模式有没有开:

接着看 代理 的 "地区",香港是不行滴,群里有人说新加坡/日本可以,杰哥用的 美国,其次是 代理 的 "质量"。

3.2.2. 账号问题

代理没问题了,基本是能自动打开浏览器,跳转到授权页,然后授权成功的:

接着返回 Antigravity 可能会出现这两种情况:

这极大概率就是 "Google账号" 的问题,先访问下述网址,查看:账号当前的国家或地区版本

《Google 服务条款》

比如我的号:

🤷‍♀️ 香港肯定是不行的,可以访问下述地址申请修改 (一年只能改一次 ❗️)

《账号关联地区更改请求》

具体操作:

😐 改完,如果还不行的话,那应该是 "账号本身有问题" 或者触发了 Google 莫名其妙的拦截规则。我一开始在海鲜市场买了一个 "美区" 的老号,一直卡 Setting Up 那里转。后面又收了个 "日区" 的号,秒进 ❗️❗️❗️

😄 还有个群友提供了一个野路子:

登Google play,在美区买本0刀的免费电子书,就成美区了。

💡 反正进不去,就是 "代理" 和 "账号" 的问题!我现在的组合是:日区号 + 美国代理。都没问题,会进入这个是否允许采集信息的页面,取消勾选,然后 Next

接着就能来到 IDE 的主页面了:

3.3. 初体验

🤣 熟悉的 VS Code 套壳界面,还是很有亲切感的,右侧有常规的 AI Chat

除了选模型外,还支持选模式:

Ctrl + E 可以打开类似于 Cursor Agents 模式的 "Agent Manager":

上面我写了一个,让 Antigravity 基于 Claudeflare 故障信息生成一个用于发布到 自媒体平台 的长图的 简单的Prompt,发送后可以看到 Agent 开始干活:

涉及到命令执行,让你 Accept

觉得烦可以点下右侧切成 Turbo 模式:

活干完,要预览,跳转 Chrome,提示安装一个 浏览器插件

以便 Agent 能直接操作浏览器 (如获取页面节点、自动化、截图等)。最后看下生成效果:

🤔 同样的 Prompt,分别看下 Claude 4.5GPT 5 的生成效果:

🤣 哈哈,你更 Pick (喜欢) 哪个模型生成的页面呢?

3.4. 限额

群里有小伙伴没蹬几次就出现了这个:

看了下官网:

《Google Antigravity Plans》

💡 额度 由Google动态决定 (基于系统容量、防止滥用),每五小时刷新一次,额度与任务复杂度相关。🐶 官方表示:只有 极少数高强度用户 会撞到每5小时上限。

😄 所以这个额度是 "不透明" 的,L站 有人说不一定得等五个小时,等了十几分钟又可以用了~

【开源】耗时数月、我开发了一款功能全面【30W行代码】的AI图床

AI编程发展迅猛,现在如果你是一个全栈开发,借助AI编辑器,比如Trae你可以开发很多你以往无法实现的功能,并且效率会大大提示,TareCoding能力已经非常智强了,借助他,我完成了这个30W行代码的开源项目,现在,想把这个项目推荐给你。

当前文章主要是介绍我们开发出的这个项目,在后续,将会新开一个文章专门介绍AI Coding的各种技巧,如何使用他,才能让他在大型项目中依然如虎添翼。

如果你想快速了解此项目、你可以访问PixelPunk官方文档

如果你想快速体验此项目、你可以访问V1版本,前往体验功能。

如果你想完整体验前后台全面的功能、你可以访问测试环境。【root 123456】

如果您认可我的项目,愿意为我献上一个宝贵的Star,你可以前往PixelPunk项目。

image.png

开发这样一个项目在现在这个时间来看似乎没什么必要,实际上开发这样一个项目的原因其实就是在年初的时候失业了一段时间,闲在家里无聊,于是想着做一个自己的开源项目,最开始其实做的是另一个类型的项目,开发了一段时间感觉有些无聊就转战做了现在的这个项目PixelPunk图床, 其实并不只是想要做一个图床,只是当前来说的一期功能比较贴合图床,后期的跌代准备支持更多格式包括视频文档等等,并且由于AI多模态模型的能力发展迅速,后期结合AI可以实现更多可玩性比较高的内容。

市面上已经有了非常多的开源图床了,开发这样一个项目要追求一些差异点才会有价值,本质上来说这类服务其实核心就是个存图片而已,其他功能并不是很重要,但是作为个人使用用户来说,除了存图也愿意去尝试一些便捷的功能,于是思考到这个点之后,我开始了这个项目的开发,项目的命名[PixelPunk] 是我让AI起的,因为要做就要做一个不一样的有特点,我要做一个ui上就不一样的图床出来,于是有了此命名,中文翻译过来是像素朋克,由于像素风格感觉ui不是很适合工具类网站使用,于是我选择了赛博朋克风格,围绕这个ui来开发了此项目。

项目概览

首先呢项目从开发就一个全栈项目,前后端放一起了,采用的技术栈是 go+vue, 前端会将打包的文件放入到go中一起打包为二进制安装包,这样部署起来将非常简单。 项目分为了用户端和管理端,并且同属于一个项目,不分离开发,为了符合我们定制化的ui,所有组件都是自定义的组件,项目使用了70+自定义组件。 项目接入了多模态AI大模型,在以前我们的图床要实现各种功能需要对接各种各样的平台,现在有了AI,我们只需要一个模型就能完成非常多的功能,比如【语义化描述图片】,【**图片OCR识别】,【**图片自动分类】,【**图片自动打标】,【**图标自动审核NSFW、违规图、敏感图、血腥图等等】,【图片颜色提取】等等功能都只需要配置一个AI模型即可,对于成本而言,比之前的三方API可能更加便宜

关于部署

作为一个开源项目,我想的是需要使用者使用起来足够简单,部署起来也足够快,本身来讲,我们项目需要用来这些内容,mysql|Sqlite + Redis|系统内存 + qdrant向量数据库, 这三件套,为了安装简单,我将向量数据库直接集成到安装包,用户可以无需关系任何内容,我们可以做到0配置启动项目,不需要你做任何配置即可超快部署项目。

我们提供了两种部署方式,一种是安装包部署,你可以下载对应平台的安装包**.zip安装包**,下载到你的服务器,解压之后里面有一个install.sh,直接sh ./install.sh即可安装,当然手动部署 可能还需要两个步骤,你可以使用我们的脚本直接进行部署,也可以看看我们的部署文档,PixelPunk部署文档

安装包一键部署 curl -fsSL https://download.pixelpunk.cc/shell/install.sh | bash

docker-compose一键部署脚本 curl -fsSL https://download.pixelpunk.cc/shell/docker-install.sh | bash

我们的部署非常简单,你只需要执行完脚本即可直接启动项目,我们的docker-compose部署方式已经配置了所有数据库缓存等信息,启动项目进入 http://ip:9520 会自动跳转到安装页面,添加管理员账号密码即可完成安装, 如果使用安装包模式呢,那么就支持你可以自定义选择数据库,可以填写自己的mysql,也可以使用系统内置的Sqlite,可以选择自己的向量数据库和缓存,也可以使用系统内置的,自由选择,总之,一键脚本预估20S就可以帮你安装并且启动项目,无需你的任何配置,希望会自己内置生成一些必要信息,比如jwt,系统安装后你可以进入后台管理进行修改。

image.png

项目部分功能

我们的项目功能可以说已经非常全面了,并且还在持续迭代,目前代码总行数已经达到了30W行,很多功能需要你自己体验,我们覆盖了主流图床的全部功能,并且还在进一步持续加入更多有趣的元素,我们可以列举一些功能做简要说明。

10+精美主题

作为个人使用的工具,我一直在持续优化UI和交互,始终认为,UI还是很重要的一个步骤,目前的UI还不够精美,也是后续会持续调整的一个点,目前提供了10多套主题,并且您可以自定义主题,我在项目中放置了主题开发文档,你可以根据模板文件去替换一套变量即可完成一套主题的开发。

image.png

image.png

多语言双风格

我们目前内置了三种语言中英日,并且为了迎合我们PixelPunk风格的特色,我们新增了一种风格选项,你可以选择赛博朋克风格的文案,让系统的所有内容提示充满赛博味道~

image.png

image.png

多布局系统

我们网站为了更好的工作体验,提供了两种的布局方式,传统布局,工作布局,既可以使用传统的轻量化的布局让人轻松,也可以使用工作台布局让工作更高效。并且您还可以在后台限制这些功能,使用固定布局而不开放这些功能,在后台管理部分都可以实现。

image.png

image.png

多种登录方式

我们内置默认使用传统邮箱的登录方式,并且支持关闭注册,邮箱配置也是后台配置即可,同时系统对接了GithubGoogleLinuxDo三种接入成本非常低的快捷登录方式,并且可能由于你是国内服务器,无法直接访问这些服务,所以系统贴心的准备了(代理服务)配置,让你即使是国内服务器也依然可以顺利完成这些快捷登录。

image.png

image.png

强大的特色文件上传

文件上传是一个基础的功能,所有图床都支持,我们当然也支持,我们在此功能上进行了耐心的打磨,支持很多特色功能,

  • 支持后台动态配置允许格式,动态配置上传图片限制尺寸
  • 支持大文件分片上传断点续传等功能。
  • 支持秒传,后台动态开启是否启用
  • 支持游客模式,限制游客上传数量,并限制上传资源有效时间
  • 支持自定义资源保存时间,后台可配置用户允许选择有效期,精确到分钟
  • 支持重复图检测,重复图自动秒传,不占用空间
  • 支持水印,并且特色化水印,开发了一个水印专用面板用于你配置特色化水印,支持文字、图片水印。
  • 支持中断上传,取消上传
  • 支持上传实时进度提示
  • 支持自动优化图片自定义上传文件夹
  • 支持文件夹上传,拖拽上传,项目内全局上传(任意页面都支持上传)
  • 支持原图复制,MD格式复制,HTML格式复制,缩略图格式复制,全部图片链接批量复制
  • 支持公开、私密、受保护,三种权限设置,公开图片可以在任何地方显示并且授权给管理员可以用于推荐,并且在作者首页可以展示对外,私密则仅自己可见,系统其他人不可见,受保护权限图片只能在系统观看打开,无法产生链接,除自己登录系统外,其他人无法观看。
  • 支持持久化,你可以选择图片并且上传中,跳转到任何页面而不会中断你的上传,你可以在上传过程中干任何事情
  • 支持特色悬浮球,当你不在上传页面的其他页面,如果有上传内容,会有一个特色上传球实时告知您上传的进度,并且你可以随意拖动控制悬浮球的位置。

image.png

image.png

image.png

超过上传渠道支持

作为一个图床的基本素养,都会支持对接三方,目前我们已经支持了10+的三方云储存渠道,并且添加时候可以测试渠道保障你的配置,首先我们支持服务器自身存储,这也是系统默认渠道,你可以在后台渠道配置更多,比如阿里云COS,腾讯云OSS,七牛云,雨云,又拍云,S3,Cloudfare R2,WebDav,AZure,Sftp,Ftp,等等渠道,S3协议本身就可以支持非常多的基于S3协议的渠道了,并且,如果你想要更多渠道,可以去往我们官网网站提起诉求,我们可以很快支持新的渠道。

image.png

image.png

特色AI功能

AI也是我们图床的一大特色,我们利用AI做了这些事情

  • 自动分类
  • 自动打标
  • 语义化图片,提取信息,提取色调
  • 实现ai自然语言搜索
  • 实现相似图搜索
  • 实现NSFW违规图审核

而这些,仅仅只需要配置一个openai的gpt4-mini即可完成,后续会支持更多渠道(目前仅支持配置OPENAI格式的渠道),目前测试感觉gpt-4.1-mini足以胜任工作,并且价格低廉,性价比很高。

  • 违规图片检测 可以自定义等级 宽松或严格

image.png

  • 自然语言搜索图片

image.png

  • 相似图搜索

image.png

  • 图片ai描述

image.png

  • 自动分类打标签

image.png

文件夹功能

文件分类是一个和合理的诉求,所以我们可以自定义创建文件夹,同样可以控制不同的权限,并且文件夹可以无限嵌套 你可以自定义多层级的文件夹 合理管理你的文件,并且我们支持文件夹移动图片移动批量操作右键菜单拖拽排序等等特色功能,你可以灵活的管理你的文件。

image.png

  • 右键菜单

image.png

分享系统

我们拥有大量素材,或者一些收藏资源需要分享给好友,我们可以任意创建分享,可以分享自己的文件夹,图片,也可以组合分享,也可以分享用户公开的推荐图,选择任意探索广场的图进行分享,并且可以统计访问次数,限制访问次数,达到访问次数关闭分享,密码分享,限制时间有效期分享,邮箱通知等等功能。

您可以观看我们的演示分享内容

  • 创建分享

image.png

image.png

防盗链管理

图床安全始终是一个问题,经常会遇到被盗刷的风险,由于流量费用贵,鄙人有幸被刷破产过一次(TMD),我们加入了防盗链配置,可以配置白黑名单域名|IP,可以配置网站refer等内容,并且对于违规的用户,我们可以配置让其302到他地址,可以自定义返回固定图,也可以直接拒绝

当我们对接三方渠道的时候,正常情况我们会拿到远程url地址,我们依然可以在后台配置渠道将其隐藏远程url地址,如果配置,那么域名请求将会从我们服务器代理获取,可以隐藏掉三方的地址,或者配置私有存储桶通过秘钥去动态获取图片,防止你的图片被盗刷大量流量

image.png

开放API

图床的基本功能之一,我们可以生成秘钥在三方进行上传文件,不同的是,我们系统支持了设置秘钥时可以限制其使用的空间限制上传次数,可以指定上传的文件夹,指定文件格式等等,并位置提供了完整的上传文档,支持单文件上传,多文件上传

image.png

随机图片

经常会有人需要一个随机图片的API,但是受限于使用别人的不够稳定,也不够灵活,于是我们开放了一个随机API功能,你可以动态的配置,选择其绑定你需要随机的图片,比如指定随机任意文件夹,指定返回方式302重定向,或直接返回图片,你可以点击pixelpunk随机图片API演示 ,每次刷新你可以获取新的图片。

image.png

空间带宽控制

我们允许为用户配置限制的使用空间和流量,后台动态灵活配置这些内容,保证多用户使用的时候限制用户使用量。

更多功能

image.png

image.png

image.png

image.png

image.png

总之我们的功能远不止如此,我们还有很多有意思的功能,一些更多的细节需要你去探索,比如,公告系统、消息通知、活动日志、限制登录、IP登录记录,超全的管理系统、埋点统计、访客系统等等模块,这是我个人第一个花费较多时间开发的一套系统,目前对比市面上所有的开源图床,自我认为是一款相对功能最全面的图床,耗费了我大量时间。

如果佬友花费时间看到了这里,那么希望能收获你的一个宝贵的Star,后续的功能我依然会持续跌代,如果你有任何需求,可以私信我,如果合理,我可以无偿免费优先加入到后续跌代中去。

待更新预期功能

  • 后端多语言适配
  • UI 美化
  • Desktop 端开发
  • 更多格式支持 (视频|文档)
  • 交互体验优化
  • 更多渠道支持
  • 更多AI接入
  • 图片处理工具箱

Node+Express+MySQL 后端生产环境部署,实现注册功能(三)

一、部署前准备

  • 本地环境:MacOS(开发端)
  • 服务器环境:阿里云 Ubuntu 22.04 轻量应用服务器
  • 技术栈:Node.js + Express + MySQL
  • 核心目标:将本地开发完成的 Express 后端项目部署到阿里云,实现公网访问接口

二、服务器环境配置(最终生效配置)

1. 登录服务器

通过 Mac 终端 SSH 连接服务器:

ssh root@你的服务器公网IP # 例如:ssh root@47.101.129.155

输入服务器登录密码即可进入。

2. 安装核心依赖软件
# 更新系统包
sudo apt update && sudo apt upgrade -y

# 安装Node.js(v16+)
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt install -y nodejs

# 安装MySQL服务器
sudo apt install -y mysql-server

# 安装PM2(Node服务进程管理)
sudo npm install pm2 -g

# 安装Nginx(反向代理)
sudo apt install -y nginx

创建本地数据库

  1. 确保数据库已创建:用 MySQL Bench 连接本地 MySQL(root/admin123/3306),创建数据库 mydb(字符集 utf8mb4,排序规则 utf8mb4_unicode_ci)。

三、创建用户表(存储注册信息)

在 MySQL Bench 中,对 mydb 数据库执行以下 SQL,创建 users 表(用于存储注册用户):

USE mydb; -- 切换到 mydb 数据库

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY, -- 自增主键
  email VARCHAR(100) NOT NULL UNIQUE, -- 邮箱(唯一,避免重复注册)
  password VARCHAR(255) NOT NULL, -- 加密后的密码(bcrypt 加密后长度固定60)
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP, -- 注册时间
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -- 更新时间
);

四 新建项目

创建文件夹node-api-test

  1. 安装依赖:需要 mysql2(数据库连接)、bcrypt(密码加密,不能明文存储)、express-validator(参数校验),dotenv 判断环境变量

npm init -y

npm install mysql2 bcrypt express-validator deotnev

2. 多环境配置文件(区分测试 / 正式)

在项目根目录创建 多个 .env 文件,分别对应不同环境:

project/
├── .env.development  # 开发环境(本地调试)
├── .env.production   # 生产环境(正式服务器)
├── .env.test         # 测试环境(可选,测试服务器)
└── .gitignore        # 忽略 .env* 文件,避免提交到 Git

文件内容示例

NODE_ENV=development  # 标识环境
DB_HOST=localhost     # 本地数据库地址
DB_USER=root          # 本地数据库账号
DB_PASSWORD=admin123  # 本地数据库密码
DB_NAME=mydb          # 本地数据库名
API_PORT=3000         # 开发环境端口

.env.production(生产环境):

NODE_ENV=production
DB_HOST=10.0.0.1      # 服务器数据库地址(内网 IP)
DB_USER=prod_user     # 服务器数据库账号(非 root,更安全)
DB_PASSWORD=Prod@123  # 服务器数据库密码(复杂密码)
DB_NAME=mydb_prod     # 生产环境数据库名(可与开发环境不同)
API_PORT=3000         # 生产环境端口
3. 在代码中加载对应环境的配置

db/mysql.js,根据 NODE_ENV 自动加载对应的 .env 文件:

// db/mysql.js(ESM 版)
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';

// 解决 ESM 中 __dirname 问题
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// 1. 确定当前环境(默认 development)
const env = process.env.NODE_ENV || 'development';

// 2. 加载对应环境的 .env 文件(如 .env.development 或 .env.production)
const envPath = path.resolve(__dirname, `../.env.${env}`);
dotenv.config({ path: envPath });  // 加载指定路径的 .env 文件

// 3. 从 process.env 中读取配置(环境变量全部是字符串类型)
const dbConfig = {
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  port: Number(process.env.DB_PORT) || 3306,  // 转换为数字
  connectionLimit: 10,
};

// 创建连接池
const pool = mysql.createPool(dbConfig);

// 测试连接时打印当前环境
export async function testDbConnection() {
  try {
    await pool.getConnection();
    console.log(`✅ 数据库连接成功(环境:${env},数据库:${dbConfig.database}`);
  } catch (err) {
    console.error(`❌ 数据库连接失败(环境:${env}):`, err.message);
    throw err;
  }
}

export { pool };

app.js如下:


import express from 'express'
import bodyParser from 'body-parser'
import userRouter from './routes/user.js'
import { testDbConnection } from './db/mysql.js'
import HttpError from './utils/HttpError.js' // 导入自定义错误类

const app = express()
// 从环境变量读取端口(对应.env中的API_PORT)
const port = process.env.API_PORT || 3000

// 解析JSON请求(必须,否则无法获取req.body)
app.use(bodyParser.json())

// 挂载用户模块路由
app.use('/api/user', userRouter)

// 全局错误处理中间件(必须放在所有路由和中间件之后)
app.use((err, req, res, next) => {
  // 1. 处理自定义HttpError
  if (err instanceof HttpError) {
    return res.status(err.statusCode).json({
      status: err.statusCode, // 业务错误状态码(如400)
      message: err.message, // 错误提示信息
      errors: err.errors, // 详细错误列表(如参数校验错误)
    })
  }

  // 2. 处理系统错误(如数据库连接失败、代码bug等)
  console.error('系统错误堆栈:', err.stack) // 打印堆栈,方便后端调试
  res.status(500).json({
    status: 500,
    message:
      process.env.NODE_ENV === 'production'
        ? '服务器内部错误,请稍后重试' // 生产环境隐藏具体错误
        : `系统错误:${err.message}`, // 开发环境显示具体错误(便于调试)
    errors: [],
  })
})

// 启动服务(端口来自环境变量)
app.listen(port, () => {
  console.log(
    `服务启动成功(环境:${process.env.NODE_ENV}):http://localhost:${port}`
  )
  testDbConnection() // 启动时验证数据库连接
})

注册接口编写

在根目录下新建routes/user.js

import express from 'express'
import { body, validationResult } from 'express-validator'
import bcrypt from 'bcrypt'
import { pool } from '../db/mysql.js'
import HttpError from '../utils/HttpError.js'

const router = express.Router()

// 注册接口:POST /api/user/register
router.post(
  '/register',
  // 参数校验(字段名与前端传入、数据库字段一致)
  [
    body('email').isEmail().withMessage('邮箱格式错误'), // 对应数据库email字段
    body('password').isLength({ min: 6 }).withMessage('密码至少6位'), // 对应password字段
    body('nickname')
      .optional()
      .isLength({ max: 50 })
      .withMessage('昵称最多50字'), // 对应nickname字段
  ],
  async (req, res, next) => {
    try {
      // 校验参数
      const errors = validationResult(req)
      if (!errors.isEmpty()) {
        throw new HttpError(400, '参数校验失败', errors.array())
      }

      // 解构前端传入的参数(字段名与数据库字段一致)
      const { email, password, nickname } = req.body

      // 1. 检查邮箱是否已注册(SQL中使用email字段,与数据库一致)
      const [existingUsers] = await pool.query(
        'SELECT id FROM users WHERE email = ?', // WHERE条件用email字段
        [email]
      )
      if (existingUsers.length > 0) {
        throw new HttpError(400, '该邮箱已被注册')
      }

      // 2. 密码加密
      //   const hashedPassword = await bcrypt.hash(password, 10)

      // 3. 插入数据库(字段名与数据库表完全一致)
      const [result] = await pool.query(
        'INSERT INTO users (email, password, nickname) VALUES (?, ?, ?)', // 字段顺序:email, password, nickname
        [email, password, nickname || null] // 对应字段的值
      )

      // 4. 返回结果(包含数据库自动生成的id和字段)
      res.status(200).json({
        code: 200,
        message: '注册成功',
        data: {
          userId: result.insertId, // 数据库自增id
          email: email, // 与数据库email字段一致
          nickname: nickname || null, // 与数据库nickname字段一致
          createdAt: new Date().toISOString(),
        },
      })
    } catch (err) {
      next(err)
    }
  }
)

// 获取所有用户
router.get('/allUsers', async (req, res, next) => {
  try {
    const [users] = await pool.query('SELECT * FROM users')
    res.status(200).json({
      code: 200,
      message: '获取成功',
      data: users,
    })
  } catch (err) {
    next(err)
  }
})

export default router

本地postman测试

image.png

数据库查看这条数据

image.png

下一篇学习如何把代码发布到服务器,通过域名来访问接口,实现注册,顺便把前端页面也发布上去

【Amis源码阅读】低代码如何实现交互?(上)

基于 6.13.0 版本

前期回顾

  1. 【Amis源码阅读】组件注册方法远比预想的多!
  2. 【Amis源码阅读】如何将json配置渲染成页面?

前言

  • 组件渲染搞定了,那组件如何进行交互呢?amis提出了事件动作的概念,在监听到事件后通过动作做出反应
    • 事件:
      • 渲染器事件:组件内部执行的事件,会暴露给外部监听。比如初始化、点击、值变化等事件
      • 广播事件:全局事件,其他组件可在自身监听相关广播事件
    • 动作:监听到事件时,希望执行的逻辑。比如打开弹窗、toast提示、刷新接口等
  • 本篇先聊事件的工作逻辑,从常用的渲染器事件入手(渲染器等同组件)

渲染器事件

onEvent事件监听

  • amis支持onEvent的形式监听组件事件的触发时机,比如组件被点击时触发一个toast。那写入onEvent中的动作是何时何地被执行的呢?
{
  "onEvent": {
    "click": {
      "actions": [ 
        {
          "actionType": "toast",
          "args": {
            "msgType": "success",
            "msg": "点击成功"
          }
        }
      ]
    }
  }
}

组件中的事件触发

  • 以常见的Page组件中init(初始化)事件为例,它实际就是在类组件的componentDidMount生命周期(挂载阶段)中触发了一次,dispatchEvent就是事件的入口了
// packages/amis/src/renderers/Page.tsx

export default class Page extends React.Component<PageProps> {
...

async componentDidMount() {
    const {
      initApi,
      initFetch,
      initFetchOn,
      store,
      messages,
      data,
      dispatchEvent,
      env
    } = this.props;

    this.mounted = true;

    const rendererEvent = await dispatchEvent('init', data, this);
...
  }
}
  • 再以Tpl组件中的click(点击)、mouseenter(鼠标移入)、mouseleave(鼠标移出)事件为例,可以直观的看出他们就是在组件绑定的onClickonMouseEnteronMouseLeave事件中执行了一遍dispatchEvent
  • 此时可以推测出,onEvent应该是在dispatchEvent中被执行了
// packages/amis/src/renderers/Tpl.tsx

export interface TplSchema extends BaseSchema {
...

@autobind
  handleClick(e: React.MouseEvent<HTMLDivElement>) {
    const {dispatchEvent, data} = this.props;
    dispatchEvent(e, data);
  }
  
  @autobind
  handleMouseEnter(e: React.MouseEvent<any>) {
    const {dispatchEvent, data} = this.props;
    dispatchEvent(e, data);
  }

  @autobind
  handleMouseLeave(e: React.MouseEvent<any>) {
    const {dispatchEvent, data} = this.props;
    dispatchEvent(e, data);
  }
  
  render() {
  return (
      <Component
      ...
        onClick={this.handleClick}
        onMouseEnter={this.handleMouseEnter}
        onMouseLeave={this.handleMouseLeave}
        {...testIdBuilder?.getChild('tpl')?.getTestId()}
      >
        ...
      </Component>
    );
  }
}

工作流

触发事件(dispatchEvent)

  • broadcast参数可忽略,没用(factory.tsx中的类型定义也说明了这点)
  • rendererEventListeners是个全局变量(事件队列),所有的事件监听器都存储在这
  • dispatchEvent流程
    • bindEvent绑定事件,返回unbindEvent销毁函数
    • createRendererEvent创建事件对象
    • 事件队列里的事件按权重排序确定执行优先级
    • 遍历事件队列,若有事件有debounce属性,就设置防抖,同时设置executing为真;若没有就直接执行事件(runAction
    • 遍历事件队列的时候会通过checkExecuted函数计数,当遍历完毕后(也就意味着事件都执行完毕了,会等待防抖的事件执行完),执行unbindEvent销毁事件
// packages/amis-core/src/utils/renderer-event.ts

let rendererEventListeners: RendererEventListener[] = [];
...

// 触发事件
export async function dispatchEvent(
  e: string | React.MouseEvent<any>,
  renderer: React.Component<RendererProps>,
  scoped: IScopedContext,
  data: any,
  broadcast?: RendererEvent<any>
): Promise<RendererEvent<any> | void> {
  let unbindEvent: ((eventName?: string) => void) | null | undefined = null;
  const eventName = typeof e === 'string' ? e : e.type;

  const from = renderer?.props.id || renderer?.props.name || '';
...

  broadcast && renderer.props.onBroadcast?.(e as string, broadcast, data);

  if (!broadcast) {
    const eventConfig = renderer?.props?.onEvent?.[eventName];

    if (!eventConfig) {
      // 没命中也没关系
      return Promise.resolve();
    }

    unbindEvent = bindEvent(renderer);
  }
  // 没有可处理的监听
  if (!rendererEventListeners.length) {
    return Promise.resolve();
  }
  // 如果是广播动作,就直接复用
  const rendererEvent =
    broadcast ||
    createRendererEvent(eventName, {
      env: renderer?.props?.env,
      nativeEvent: e,
      data,
      scoped
    });

  // 过滤&排序
  const listeners = rendererEventListeners
    .filter(
      (item: RendererEventListener) =>
        item.type === eventName &&
        (broadcast
          ? true
          : item.renderer === renderer &&
            item.actions === renderer.props?.onEvent?.[eventName].actions)
    )
    .sort(
      (prev: RendererEventListener, next: RendererEventListener) =>
        next.weight - prev.weight
    );
  let executedCount = 0;
  const checkExecuted = () => {
    executedCount++;
    if (executedCount === listeners.length) {
      unbindEvent?.(eventName);
    }
  };
  for (let listener of listeners) {
    const {
      wait = 100,
      trailing = true,
      leading = false,
      maxWait = 10000
    } = listener?.debounce || {};
    if (listener?.debounce) {
      const debounced = debounce(
        async () => {
          await runActions(listener.actions, listener.renderer, rendererEvent);
          checkExecuted();
        },
        wait,
        {
          trailing,
          leading,
          maxWait
        }
      );
      rendererEventListeners.forEach(item => {
        // 找到事件队列中正在执行的事件加上标识,下次待执行队列就会把这个事件过滤掉
        if (
          item.renderer === listener.renderer &&
          listener.type === item.type
        ) {
          item.executing = true;
          item.debounceInstance = debounced;
        }
      });
      debounced();
    } else {
      await runActions(listener.actions, listener.renderer, rendererEvent);
      checkExecuted();
    }

    if (listener?.track) {
      const {id: trackId, name: trackName} = listener.track;
      renderer?.props?.env?.tracker({
        eventType: listener.type,
        eventData: {
          trackId,
          trackName
        }
      });
    }

    // 停止后续监听器执行
    if (rendererEvent.stoped) {
      break;
    }
  }
  return Promise.resolve(rendererEvent);
}

绑定事件(bindEvent)

  • 所谓绑定事件就是把事件推入事件队列
  • 首先会遍历onEvent中的内容
  • 然后处理防抖场景:如果存在相同的事件且在防抖时间内(executing为真),则取消旧事件防抖并移除旧事件,把新事件加入事件队列。比如说,事件队列中存有用户触发了3次的事件a(假设都在防抖时间内),则前2次事件在bindEvent阶段会被删除,只保留第3次事件
  • 如果不存在上述情况,直接加入事件队列
  • 最终都是返回解绑事件的函数(从事件队列中移除)
// packages/amis-core/src/utils/renderer-event.ts

// 绑定事件
export const bindEvent = (renderer: any) => {
  if (!renderer) {
    return undefined;
  }
  const listeners: EventListeners = renderer.props.$schema.onEvent;
  if (listeners) {
    // 暂存
    for (let key of Object.keys(listeners)) {
      const listener = rendererEventListeners.find(
        (item: RendererEventListener) =>
          item.renderer === renderer &&
          item.type === key &&
          item.actions === listeners[key].actions
      );
    // 存在相同的事件且在防抖时间内
      if (listener?.executing) {
        listener?.debounceInstance?.cancel?.();
        rendererEventListeners = rendererEventListeners.filter(
          (item: RendererEventListener) =>
            !(
              item.renderer === listener.renderer && item.type === listener.type
            )
        );
        listener.actions.length &&
          rendererEventListeners.push({
            renderer,
            type: key,
            debounce: listener.debounce || null,
            track: listeners[key].track || null,
            weight: listener.weight || 0,
            actions: listener.actions
          });
      }
      if (!listener && listeners[key].actions?.length) {
        rendererEventListeners.push({
          renderer,
          type: key,
          debounce: listeners[key].debounce || null,
          track: listeners[key].track || null,
          weight: listeners[key].weight || 0,
          actions: listeners[key].actions
        });
      }
    }
    return (eventName?: string) => {
      // eventName用来避免过滤广播事件
      rendererEventListeners = rendererEventListeners.filter(
        (item: RendererEventListener) =>
          // 如果 eventName 为 undefined,表示全部解绑,否则解绑指定事件
          eventName === undefined
            ? item.renderer !== renderer
            : item.renderer !== renderer || item.type !== eventName
      );
    };
  }

  return undefined;
};

执行动作(runActions)

  • 这里只是一个执行动作的前置处理
  • 遍历动作,通过getActionByType查找动作实例,若没有则判断是否是组件专有动作(组件都有可调用),若再没有则判断是否是打开页面相关的动作,若还是没有则直接调用组件自定义的动作
  • 实际的动作执行还是在runAction中,等下一篇再完整的分析动作相关流程
// packages/amis-core/src/actions/Action.ts

export const runActions = async (
  actions: ListenerAction | ListenerAction[],
  renderer: ListenerContext,
  event: any
) => {
  if (!Array.isArray(actions)) {
    actions = [actions];
  }

  for (const actionConfig of actions) {
    let actionInstrance = getActionByType(actionConfig.actionType);

    // 如果存在指定组件ID,说明是组件专有动作
    if (
      !actionInstrance &&
      (actionConfig.componentId || actionConfig.componentName)
    ) {
      actionInstrance = [
        'static',
        'nonstatic',
        'show',
        'visibility',
        'hidden',
        'enabled',
        'disabled',
        'usability'
      ].includes(actionConfig.actionType)
        ? getActionByType('status')
        : getActionByType('component');
    } else if (['url', 'link', 'jump'].includes(actionConfig.actionType)) {
      // 打开页面动作
      actionInstrance = getActionByType('openlink');
    }

    // 找不到就通过组件专有动作完成
    if (!actionInstrance) {
      actionInstrance = getActionByType('component');
    }

    try {
      // 这些节点的子节点运行逻辑由节点内部实现
      await runAction(actionInstrance, actionConfig, renderer, event);
    } catch (e) {
      ...
    }

    if (event.stoped) {
      break;
    }
  }
};

设计特性

全局事件管理

  • 通过rendererEventListeners队列统一管理,和react的事件委托有点类似
  • 支持跨组件通信
  • 支持全局权重排序、防抖

延迟绑定,执行完销毁

  • 事件都是触发后,在bindEvent中绑定的(加入事件队列),减少内存占用
  • 然后执行完毕会立即销毁,避免内存泄漏

广播事件

  • 独立于渲染器事件的全局事件,基于BroadcastChannel类实现

工作流

  • 由于是全局事件,肯定得优先绑定了

绑定事件入口

  • 组件渲染(渲染流程可参考之前的组件渲染篇)时绑定
  • 组件生成时都会传入childRef,直接在组件的ref上通过bindGlobalEvent绑定了广播事件
// packages/amis-core/src/SchemaRenderer.tsx

import {
  bindEvent,
  bindGlobalEventForRenderer as bindGlobalEvent
} from './utils/renderer-event';

export class SchemaRenderer extends React.Component<SchemaRendererProps, any> {
...
@autobind
  childRef(ref: any) {
    ...
    while (ref?.getWrappedInstance?.()) {
      ref = ref.getWrappedInstance();
    }

    ...

    if (ref) {
      // 这里无法区分监听的是不是广播,所以又bind一下,主要是为了绑广播
      this.unbindEvent?.();
      this.unbindGlobalEvent?.();

      this.unbindEvent = bindEvent(ref);
      this.unbindGlobalEvent = bindGlobalEvent(ref);
    }
    ...
  }
  
  render(): JSX.Element | null {
...

    let component = supportRef ? (
      <Component {...props} ref={this.childRef} storeRef={this.storeRef} />
    ) : (
      <Component
        {...props}
        forwardedRef={this.childRef}
        storeRef={this.storeRef}
      />
    );

    ...

    return this.props.env.enableAMISDebug ? (
      <DebugWrapper renderer={renderer}>{component}</DebugWrapper>
    ) : (
      component
    );
  }
}

绑定事件(bindGlobalEventForRenderer)

  • 这里并未区分广播事件
  • 遍历事件,创建BroadcastChannel对象,推入bcs广播事件队列
  • 挂载onmessage消息监听,接收到广播时通过runActions触发动作
  • 最终返回一个注销广播实例的函数
  • 小疑问:这里直接把renderer.props.$schema.onEvent中所有的动作都绑定了广播事件,虽然统一管理了广播事件的绑定,但是绑定了很多多余的动作,这里实际可以判断actionTypebroadcast才绑定?
// packages/amis-core/src/utils/renderer-event.ts

export const bindGlobalEventForRenderer = (renderer: any) => {
  ...
  const listeners: EventListeners = renderer.props.$schema.onEvent;
  let bcs: Array<{
    renderer: any;
    bc: BroadcastChannel;
  }> = [];
  if (listeners) {
    for (let key of Object.keys(listeners)) {
      const listener = listeners[key];
      ...
      const bc = new BroadcastChannel(key);
      bcs.push({
        renderer: renderer,
        bc
      });
      bc.onmessage = e => {
        const { eventName, data } = e.data;
        const rendererEvent = createRendererEvent(eventName, {
          env: renderer?.props?.env,
          nativeEvent: eventName,
          scoped: renderer?.context,
          data
        });
        // 过滤掉当前的广播事件,避免循环广播
        const actions = listener.actions.filter(
          a => !(a.actionType === 'broadcast' && a.eventName === eventName)
        );

        runActions(actions, renderer, rendererEvent);
      };
    }
    return () => {
      bcs
        .filter(item => item.renderer === renderer)
        .forEach(item => item.bc.close());
    };
  }
  return void 0;
};

触发事件(dispatchGlobalEventForRenderer)

  • 广播动作packages/amis-core/src/actions/BroadcastAction.ts中调用了dispatchGlobalEventForRenderer
  • 代码较短,内部直接调用dispatchGlobalEvent方法,然后创建BroadcastChannel实例发送消息,然后关闭,齐活!
  • 接收消息的地方就是上文bindGlobalEventForRenderer中挂载了onmessage事件的地方,不赘述
// packages/amis-core/src/utils/renderer-event.ts

export async function dispatchGlobalEventForRenderer(
  eventName: string,
  renderer: React.Component<RendererProps>,
  scoped: IScopedContext,
  data: any,
  broadcast: RendererEvent<any>
) {
  ...
  dispatchGlobalEvent(eventName, data);
}

export async function dispatchGlobalEvent(eventName: string, data: any) {
  ...

  const bc = new BroadcastChannel(eventName);
  bc.postMessage({
    eventName,
    data
  });
  bc.close();
}

解绑事件

  • 广播事件是长期绑定的,只有在组件卸载时才解绑
// packages/amis-core/src/SchemaRenderer.tsx

import {
  bindEvent,
  bindGlobalEventForRenderer as bindGlobalEvent
} from './utils/renderer-event';

export class SchemaRenderer extends React.Component<SchemaRendererProps, any> {
...
componentWillUnmount() {
    this.unbindEvent?.();
    this.unbindGlobalEvent?.();
  }

}

总结

  • amis的事件管理还是挺值得学习的
  • 渲染器事件就是在组件的执行过程中开了个口子,支持插入想执行的逻辑
  • 广播事件就是依赖BroadcastChannel的原生功能
  • 下篇再写动作,脑子不够用了

JavaScript-小游戏-2048

需求

开局生成随机靠边方块 点击方向键移动方块 相邻方块且数值相同发生合并 方块占满界面触发游戏结束提示

游戏界面

标签结构HTML

area区域和death区域分别表示游戏区域和死亡提示区域

 <!-- 页面 -->
  <div class="area"></div>
  <!-- 死亡提示区域 -->
  <div class="death"></div>

层叠样式 css

设置游戏区域为flex布局 主轴默认是水平 flex-wrap: wrap;设置主轴自动换行 注意这里游戏区域的大小是200px*200px的 后面可以算出每个方块的大小

 .area {
      display: flex;
      flex-wrap: wrap;
      width: 200px;
      height: 200px;
      font-size: 30px;
    }

获取元素 js

获取两个游戏区域和游戏结束提示区域的div

   const area = document.querySelector('.area')
    const death = document.querySelector('.death')

数据分析

二维数组arr[y][x]第一个索引表示y坐标 第二个索引表示x坐标 数组的值表示方块的数值(2,4,8...)没有值就表示这个坐标位置没有方块 这里还要初始化一个rArr数组是用来将数组旋转结果存入新数组防止重复处理的

后面的移动和合并都是需要旋转数组进行操作

let arr = [] //方块坐标和数值
    let rArr = [] //旋转数组
    //初始化
    for (let i = 0; i < 5; i++) {
      arr[i] = []
      rArr[i] = []
    }

功能实现

渲染方法

  • 像素点的思路渲染游戏区域为5*5个方块 所以算出每个方块 的大小为40px*40px 渲染
  • 如果有方块 就渲染为粉色 方块的数值${arr[y][x]}也渲染在div
  • 其余部分就是画布
  • block字符串累加完毕之后渲染到页面上
 //渲染页面
    function render() {
      block = ''
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (arr[y][x]) {
            block += `<div class="square"style="width: 40px;height: 40px;text-align: center; line-height: 40px; background-color: pink;">${arr[y][x]}</div>`
          }
          else {
            block += ' <div class="block" style="width: 40px;height: 40px;background-color: antiquewhite;"></div>'
          }
        }
      }
      area.innerHTML = block
    }
    render()

游戏区域页面如下 游戏区域

开局随机生成两个方块

这里的方块坐标不能重复

  • 声明两个数字类型的全局变量 用来表示两个随机生成的坐标
  • 这里的randomStart函数用来随机生成不重复的两个坐标 ac变量表示随机生成[0,4]之间的整数坐标 如果生成的随机整数坐标a c重复 就重新调用randomStart函数生成
  • 这里运用了三元运算符 来根据 Math.random() 随机生成[0,1)范围内的两个小数的大小 决定这两个方块的坐标位置 这里坐标位置可以重复
  • 第一个方块Math.random()> 0.5 执行前面的表达式arr[0][a] = 2 方块随机在上面的边 否则执行后面的表达式 arr[a][0] = 2方块随机在左边的边 第二个方块同理

三元运算符

条件?表达式A:表达式B 条件为真执行前面的表达式A 条件为假执行后面的表达式B三元运算符例常见用法是处理null值 这里表示 如果传入的参数是假的话name的值为 'stranger' 如果是真的话就是参数name属性的值

 //三元运算符
    //以箭头函数形式 定义greeting函数 这里传入的参数是对象
    const greeting = (person) => {
      const name = person ? person.name : 'stranger'
      return `hello ${name}`
    }
    console.log(greeting({ age: 18, name: 'a' })); //hello a

    //这里三元运算符是真 
    // 但是字符串本身没有name属性
    console.log(greeting('b')); //undefined

    //传入的都是字面量 是最简单的表达式 可以通过代码直接看出值
    //这些是假值 
    //数字字面量
    console.log(greeting(0)); //hello stranger
    //null字面量
    console.log(greeting(null)); //hello stranger
    //字符串字面量
    console.log(greeting('')); //hello stranger

最终开局随机生成两个方块功能代码如下

//随机生成两个不相同随机坐标
    let a = 0, c = 0
    function randomStart() {
      //生成的随机数不能重复 连续两次的话
      a = Math.floor(Math.random() * 5)  //0-5
      c = Math.floor(Math.random() * 5)
      if (c === a) {
        randomStart()
      }
    }
    //开局生成两个不重复的方块
    randomStart()
    Math.random() > 0.5 ? arr[0][a] = 2 : arr[a][0] = 2
    Math.random() > 0.5 ? arr[0][c] = 2 : arr[c][0] = 2

效果如下 随机

点击方向键移动方块

分析点击方向键之后方块变化

这里有上下左右四个方向 每个方向都要写移动逻辑的话代码冗余了 所以可以根据要旋转的方向不同对数组进行旋转处理 然后再左移 这时候再进行左移这个方向的合并判断 最后把数组旋转回去 移动处理完毕之后还需要生成随机新方块

这里的设计不太符合单一职责(一个函数 模块只负责一件事)事件监听承受了太多功能 目前还不知道怎么优化比较好

根据分析将功能拆分

  • 除了向左移动的情况 其他方向都是 点击键盘方向键之后先更改type的值再根据type调用rotate()函数旋转数组 move()往左移动 add()判断合并最后再rotate()旋转回去

这里的rotate()要传入具体的type参数 因为在上下移动过程中出现了顺时针和逆时针的旋转 这里将数组旋转回去的时候要传入的type就不同了

  • 向左移动的情况很简单 只需要直接move()移动再执行合并 移动完之后
  • 生成新随机方块 random()
  • 此时arr已经处理完毕 rander()渲染页面
  • 判断是否死亡 deathJudge() 键盘按键事件如下
let type = ''//移动方向
    //每次移动完生成一个边缘新方块
    document.addEventListener('keydown', function (e) {
      if (e.key === 'ArrowUp') {
        type = 'up'
        rotate('up')//旋转
        move()//移动
        add()
        rotate('down')//旋转回去
      }

      //这里处理错了down
      else if (e.key === 'ArrowDown') {
        type = 'down'
        rotate('down')//旋转
        move()//移动
        add()
        rotate('up')//旋转回去
      }
      else if (e.key === 'ArrowLeft') {
        type = 'left'
        move()
        add()
      }
      else if (e.key === 'ArrowRight') {
        type = 'right'
        rotate('right')//旋转
        move()//移动
        add()
        //因为这里是翻转 所以和之前一样处理翻转回去就行
        rotate('right')
      }
      //移动处理完毕生成新随机方块
      random()
      render()
      deathJudge()
    })

旋转处理

  • 如果有值就根据type不同旋转 right 这里相当于翻转 所以旋转回去只需要再翻转一次rotate('right')就行rightdown这里相当于顺时针旋转90度 所以旋转回去需要逆时针旋转90度也就是rotate(up)down

up这里相当于逆时针旋转90度 所以旋转回去需要顺时针旋转90度也就是rotate('down')up

  • 旋转完毕后 删除已经处理完旋转的元素 便于后面将旋转之后的数组给arr

这里删除处理也可以用delete 不会破坏索引!delete

  • 然后遍历rArr把数值给arr 再把rArr相应的元素删除方便下次rotate()旋转

放进新数组是为了防止旋转后的元素被重复旋转遍历 最终代码如下

  //翻转数组 变成左移
    function rotate(type) {
      console.log('rotate', type);
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (arr[y][x]) {
            //翻转
            if (type === 'right') {
              //这里旋转结果必须放进新数组防止重复处理
              rArr[y][4 - x] = arr[y][x]
            }
            //顺时针90
            else if (type === 'down') {
              rArr[x][4 - y] = arr[y][x]
            }
            //逆时针90
            else if (type === 'up') {
              rArr[4 - x][y] = arr[y][x]
            }
            //删除原位置元素
            // arr[y][x] = 0
            delete arr[y][x]
          }
        }
      }
      //把旋转之后的数组给arr 方便转回去
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 5; i++) {
          if (rArr[j][i]) {
            arr[j][i] = rArr[j][i]
            delete rArr[j][i]
          }
        }
      }
      // console.log('rotate旋转处理之后的arr', arr);
    }

移动处理

旋转完毕后 数组只需要左移动就可以

  • 首先遍历arr数组 如果这行出现空位 !arr[y][x]true 表示 arr[y][x]为false也就是为空和0的时候 执行后面的语句
  • 遍历这一行空位后面的部分 这里循环的起始点是i = x 如果后面有值就给前面空位 这里记得删掉后面的值 然后直接break跳出空位后元素遍历循环继续对这一行进行空位搜索

补充说明breakcontinue的区别 break只跳出一层循环 这里相当于 x>=1的情况下都不执行内层循环了 breakbreakcontinue跳过当前 然后进行下一个迭代 这里相当于只有在x===1的情况下才不执行内层循环 其他情况是正常执行的 continuecontinue

    // 移动逻辑
    //所有数组都旋转处理成左移判断
    function move() {
      console.log('move');
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (!arr[y][x]) {
            //后面如果后面有值就给前面空位
            for (let i = x; i < 5; i++) {
              //只执行一次 找出最靠近空位的值
              if (arr[y][i]) {
                arr[y][x] = arr[y][i]
                arr[y][i] = 0
                //这里用哪个符号比较好
                break //跳出for i 循环么
              }
            }
          }
        }
      }
    }

点击方向键移动方块效果如下 移动

合并判断

这里合并判断也会根据方向不同 判断的方向不同 所以这里放在数组旋转回去之前 所以无论方向如何都是对左移进行合并判断

  • 合并这里是向左合并的 判断的是arr[j][i] 和右边相邻位置 arr[j][i + 1]的元素数值 这里的判断范围缩小i < 4 如果相等就左边方块数值累加然后右边方块数值清空

注意 这里只完成了数值的合并 合并完之后还要向左移动 如图例合并之后移动

  • 全部合并完之后要都向左移动move()
 //合并
    function add() {
      console.log('合并add', type, arr);
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 4; i++) {
          //相邻检测
          if (arr[j][i]) {
            if (arr[j][i] === arr[j][i + 1]) {
              console.log('找到左移方块', j, i);
              arr[j][i] += arr[j][i]
              arr[j][i + 1] = 0
            }
          }
        }
      }
      //全部合并完之后再向左移动
      move()
    }

合并效果如下 合并

移动完毕生成新随机方块

这些方块是从没有方块的坐标中随机生成的

  • scope数组表示没有方块的坐标 第一个索引表示方块序号可以用来随机选取方块 第二个索引表示方块的x或者y值 scope
  • 循环遍历arr把没有值的坐标放进scope数组
  • index表示随机的scope索引 范围为[0,scope.length-1]

Math.floor Math.random 表示从[a,b]随机整数 Math.floor(Math.random() * (b - a + 1)) + a; Math.random()随机生成[0,1)之间的随机小数 Math.random() * (b - a + 1) 生成[0,b-a+1) Math.floor 对小数向下取整得到[0,b-a]之间的整数 最后再加上a 范围偏移成[a,b]

  • 这里randomY表示随机到的y坐标 也就是scope[index][0]index个方块的第0个坐标
  • 最后随机生成的方块数值可能为2和4 继续用Math.random()随机生成的小数和0.5的大小比较决定随机生成方块的数值
//移动之后再随机生成一个方块 数值可能是4或者2
    function random() {
      let scope = []
      //因为可能后面没有值 长度实际上小于10
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 5; i++) {
          //方块不重复
          //把没有值的坐标放进数组 然后再随机索引进行选择
          if (!arr[j][i]) {
            scope.push([j, i])
          }
        }
      }
      //随机索引
      let index = Math.floor(Math.random() * scope.length)
      let randomY = scope[index][0]
      let randomX = scope[index][1]

      //随机数值
      Math.random() > 0.5 ? arr[randomY][randomX] = 2 : arr[randomY][randomX] = 4
    }

移动完毕生成新随机方块效果如下 新方块

死亡功能

死亡判断

开局只生成两个方块不需要判断 之后每次移动完毕都要判断一次

  • death表示是否出现死亡 Boolean类型初始值是true
  • 遍历arr 如果位置上元素没有值 就还没有死亡 death = false 这个是要找的非常态 出现这种情况就知道最终结果了 所以初始值是常态true 元素上有值还要继续往下找

这里不能用!arr[j].every((value) => value > 1) 因为every对稀疏数组的空槽是不执行的 在random()randomStart()给随机坐标赋值的时候造成了arr[j]是稀疏数组 会导致判断失误 补充说明 稀疏数组创建方式稀疏数组

  • 最终的death值就能表示死亡情况
  • 如果death === true就执行死亡效果
   //死亡判断
    function deathJudge() {
      let death = true //表示是否进入死亡
      for (let y = 0; y < 5; y++) {
        //如果这一行有空位 就跳出循环
        //这里不是表示每个都大于
        //不能用every因为稀疏数组不执行
        for (let x = 0; x < 5; x++) {
          //有一个位置上的元素没有值 就不是死亡
          if (!arr[y][x]) {
            death = false
          }
        }
      }
      console.log(death, '死亡判断');
      if (death === true) {
        deachCss()  //死亡效果
      }
    }

死亡效果

要找出方块数值最大值打印在游戏结束区域

  • maxArr数组用来放每行的最大值
  • maxX表示每行的最大值 找出来之后放进maxArr数组
  • arr[y]采用线性遍历 像直线一样逐个排查 这里默认每行的第一个元素为最大值 然后遍历后面的元素 如果比maxX大 就更新 把值给maxX

后面还会更新数组最大值的一些比较方法

  • 把每行的最大值pushmaxArr数组
  • 然后再对maxArr进行线性遍历 从而找到整个arr数组的最大值
  • 最后把最大数值渲染到页面
 function deachCss() {
      //找到数组中的最大数值
      let maxArr = []
      let maxX = 0
      for (let y = 0; y < 5; y++) {
        maxX = arr[y][0]
        for (let x = 1; x < 5; x++) {
          if (arr[y][x] > maxX) {
            maxX = arr[y][x]
          }
        }
        maxArr.push(maxX) //每行的最大
      }
      console.log(maxArr);
      //再从每行的最大里面找
      let max = maxArr[0]
      for (let a = 1; a < maxArr.length; a++) {
        if (max < maxArr[a]) {
          max = maxArr[a]
        }
      }
      //死亡效果
      death.innerText = `游戏结束 最大方块为${max}`
    }

死亡效果如下 死亡效果

最终代码

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>2048</title>
</head>

<body>
  <!-- 页面 -->
  <div class="area"></div>
  <!-- 死亡提示区域 -->
  <div class="death"></div>
  <style>
    .area {
      display: flex;
      flex-wrap: wrap;
      width: 200px;
      height: 200px;
      font-size: 25px;
    }
  </style>
  <script>
    const area = document.querySelector('.area')
    const death = document.querySelector('.death')
    let arr = [] //方块坐标和数值
    let rArr = [] //旋转数组
    //初始化
    for (let i = 0; i < 5; i++) {
      arr[i] = []
      rArr[i] = []
    }
    deathJudge() //开局只随机生成两个方块不需要判断死亡

    //随机生成两个不相同随机坐标
    let a = 0, c = 0
    function randomStart() {
      //生成的随机数不能重复 连续两次的话
      a = Math.floor(Math.random() * 5)  //0-5
      c = Math.floor(Math.random() * 5)
      if (c === a) {
        randomStart()
      }
    }
    //开局生成两个不重复的方块
    randomStart()
    Math.random() > 0.5 ? arr[0][a] = 2 : arr[a][0] = 2
    Math.random() > 0.5 ? arr[0][c] = 2 : arr[c][0] = 2

    //移动之后再随机生成一个方块 数值可能是4或者2
    function random() {
      let scope = []
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 5; i++) {
          //方块不重复
          //把没有值的坐标放进数组 然后再随机索引进行选择
          if (!arr[j][i]) {
            scope.push([j, i])
          }
        }
      }
      //随机索引
      let index = Math.floor(Math.random() * scope.length)
      let randomY = scope[index][0]
      let randomX = scope[index][1]

      //随机数值
      Math.random() > 0.5 ? arr[randomY][randomX] = 2 : arr[randomY][randomX] = 4
    }


    let type = ''//移动方向
    //每次移动完生成一个边缘新方块

    document.addEventListener('keydown', function (e) {
      if (e.key === 'ArrowUp') {
        type = 'up'
        rotate('up')//旋转
        move()//移动
        add()
        rotate('down')//旋转回去

      }

      //这里处理错了down
      else if (e.key === 'ArrowDown') {
        type = 'down'
        rotate('down')//旋转
        move()//移动
        add()
        rotate('up')//旋转回去

      }
      else if (e.key === 'ArrowLeft') {
        type = 'left'
        move()
        add()
      }
      else if (e.key === 'ArrowRight') {
        type = 'right'
        rotate('right')//旋转
        move()//移动
        add()
        //因为这里是翻转 所以和之前一样处理翻转回去就行
        rotate('right')
      }
      //移动处理完毕生成新随机方块
      random()
      render()
      deathJudge()
    })

    //翻转数组 变成左移
    function rotate(type) {
      console.log('rotate', type);
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (arr[y][x]) {
            //翻转
            if (type === 'right') {
              //这里旋转结果必须放进新数组防止重复处理
              rArr[y][4 - x] = arr[y][x]
            }
            //顺时针90
            else if (type === 'down') {
              rArr[x][4 - y] = arr[y][x]
            }
            //逆时针90
            else if (type === 'up') {
              rArr[4 - x][y] = arr[y][x]
            }
            //删除原位置元素
            // arr[y][x] = 0
            delete arr[y][x]
          }
        }
      }
      //把旋转之后的数组给arr 方便转回去
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 5; i++) {
          if (rArr[j][i]) {
            arr[j][i] = rArr[j][i]
            delete rArr[j][i]
          }
        }
      }
      // console.log('rotate旋转处理之后的arr', arr);
    }


    // 移动逻辑
    //所有数组都旋转处理成左移判断
    function move() {
      console.log('move');
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (!arr[y][x]) {
            //后面如果后面有值就给前面空位
            for (let i = x; i < 5; i++) {
              //只执行一次 找出最靠近空位的值
              if (arr[y][i]) {
                arr[y][x] = arr[y][i]
                arr[y][i] = 0
                //这里用哪个符号比较好
                break //跳出for i 循环么
              }
            }
          }
        }
      }
    }

    //合并
    function add() {
      console.log('合并add', type, arr);
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 4; i++) {
          //相邻检测
          if (arr[j][i]) {
            if (arr[j][i] === arr[j][i + 1]) {
              console.log('找到左移方块', j, i);
              arr[j][i] += arr[j][i]
              arr[j][i + 1] = 0
            }
          }
        }
      }
      //全部合并完之后再向左移动
      move()
    }

    //死亡判断
    function deathJudge() {
      let death = true //表示是否进入死亡
      for (let y = 0; y < 5; y++) {
        //如果这一行有空位 就跳出循环
        //这里不是表示每个都大于
        //不能用every因为稀疏数组不执行
        for (let x = 0; x < 5; x++) {
          //有一个位置上的元素没有值 就不是死亡
          if (!arr[y][x]) {
            death = false
          }
        }
      }
      console.log(death, '死亡判断');
      if (death === true) {
        deachCss()  //死亡效果
      }
    }


    function deachCss() {
      //找到数组中的最大数值
      let maxArr = []
      let maxX = 0
      for (let y = 0; y < 5; y++) {
        maxX = arr[y][0]
        for (let x = 1; x < 5; x++) {
          if (arr[y][x] > maxX) {
            maxX = arr[y][x]
          }
        }
        maxArr.push(maxX) //每行的最大
      }
      console.log(maxArr);
      //再从每行的最大里面找
      let max = maxArr[0]
      for (let a = 1; a < maxArr.length; a++) {
        if (max < maxArr[a]) {
          max = maxArr[a]
        }
      }
      //死亡效果
      death.innerText = `游戏结束 最大方块为${max}`
    }


    //渲染页面
    function render() {
      block = ''
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (arr[y][x]) {
            block += `<div class="square"style="width: 40px;height: 40px;text-align: center; line-height: 40px; background-color: pink;">${arr[y][x]}</div>`
          }
          else {
            block += ' <div class="block" style="width: 40px;height: 40px;background-color: antiquewhite;"></div>'

          }
        }
      }
      area.innerHTML = block
    }
    render()


  </script>
</body>

</html>

JavaScript-实现函数方法-改变this指向call apply bind

this

  • 函数执行时决定的不是定义时决定的
  • this只和函数的调用有关*
  • obj.fn() fnthis就是obj arr[0] () this就是arr

重定义

call

  • 会直接执行函数
  • 函数的参数逐个传进去 call
实现
  • 首先先判断thisArg参数 如果为空就默认window 这里运用了||运算符 有一个t结果就为t 所以短路效果为前面的表达式结果为t 后面的表达式就不执行

补充说明&&运算符 前面一个f结果就为f 所以短路效果是前面为f后面不执行

  • symbol(基本数据类型)有唯一性 可以保证属性与原对象不冲突 let key = Symbol('temp')创建一个symbol类型的变量key其中temp是这个变量的描述符
  • 利用对象的方法中的this指向对象 :通过把这个函数给thisArg对象的方法再通过这个对象调用就改变了函数this的指向 其中利用数组的展开运算符把value(剩余参数数组)挨个传进这个方法完成了mycall改变this指向和直接执行函数的功能
    //实现call
    Function.prototype.myCall = function (thisArg, ...value) {
      //如果参数为空 就默认是window
      //利用||特性
      thisArg = thisArg || window

      let key = Symbol('temp')
      thisArg[key] = this
      //利用...数组的展开运算符把value的所有元素 逐个全部传入
      thisArg[key](...value)
      delete thisArg[key]

    }
  • 测试用例
//测试用例
    //this指向对象
    function fnCall(a, b) {
      console.log(this, a, b);
    }
    fnCall.myCall(obj, 1, 2)
    //this指向函数
    function fn1() {
      console.log('这是测试函数');
    }
    fnCall.myCall(fn1, 1, 2)

apply

  • 也会直接执行函数
  • 参数以对象(一般是数组的形式传进去) apply
实现

mycall的基本思想是一样的区别如下

  • 因为参数是数组形式 所以myApply中的value参数不需要用剩余参数数组...value直接获取传入的数组value即可 然后还是使用...展开运算符把参数数组展开传入
 //实现apply
     Function.prototype.myApply = function (thisArg, value) {
      thisArg = thisArg || window
      let key = Symbol('temp')
      thisArg[key] = this
      //利用...数组的展开运算符把value的所有元素 逐个全部传入
      thisArg[key](...value)
      delete thisArg[key]
    }
    fnCall.myApply(obj, [1, 2])
  • 测试用例同mycall

bind

  • 参数逐个传入
  • 返回新数组 调用之后才执行(参数自动传进去 this改变) bind
实现

不会立即执行函数 而是返回新的函数

  • 因为返回的新函数中的this就不是调用函数了 所以要把this放进fn函数里
  • 返回一个新函数 先给thisArg对象添加fn方法(就是要改变this的函数) 然后把原函数的返回结果也就是对象方法的结果原函数的返回结果放进result作为新函数的返回结果 删除属性避免对原thisArg造成干扰 最后把result变量return出去

举例子(没有this干扰)理解result变量的必要性 没有result 这里把内部函数的返回值return出去 就能实现调用outside函数返回一个inside函数并且把他的她的返回值也复制了有result最终代码

 //实现bind
    //创建新函数 this绑定到指定对象上
    Function.prototype.myBind = function (thisArg, ...value) {
      //不会立即执行函数 而是返回新的函数
      thisArg = thisArg || window
      let fn = this //把调用的函数存起来
      return function () {
        let key = Symbol('temp') //每次调用都创建新的临时键
        thisArg[key] = fn
        const result = thisArg[key](...value)
        delete thisArg.fn
        return result  //返回原函数的执行结果
      }
    }
  • 测试用例
//生成新的函数
    function bindFn(a, b) {
      console.log('myBind结果', this, a, b);
    }
    let Person = {
      name: 'a'
    }
    const newBindfn = bindFn.myBind(Person, 2, 1)
//传入thisArg为空的情况
    const newBindfn1 = bindFn.myBind('', 2, 3)  //this指向window
//新生成的函数返回值
    function fn() {
      return '函数返回结果'
    }
    console.log(fn.myBind(Person)());//函数返回结果

第4章:布局类组件 —— 4.8 LayoutBuilder、AfterLayout

4.8 LayoutBuilder、AfterLayout

📚 章节概览

本章节是第4章的最后一节,将学习如何在布局过程中动态构建UI,以及如何获取组件的实际尺寸和位置:

  • LayoutBuilder - 布局过程中获取约束信息
  • BoxConstraints - 约束信息详解
  • 响应式布局 - 根据约束动态构建
  • AfterLayout - 布局完成后获取尺寸
  • RenderAfterLayout - 自定义RenderObject
  • localToGlobal - 坐标转换
  • Build和Layout - 交错执行机制

🎯 核心知识点

LayoutBuilder vs AfterLayout

特性 LayoutBuilder AfterLayout
执行时机 布局阶段(Layout) 布局完成后(Post-Layout)
获取信息 约束信息(BoxConstraints) 实际尺寸和位置
主要用途 响应式布局 尺寸获取
性能 较好 稍差(额外回调)

1️⃣ LayoutBuilder(布局构建器)

1.1 什么是LayoutBuilder

LayoutBuilder 可以在布局过程中拿到父组件传递的约束信息(BoxConstraints),然后根据约束信息动态地构建不同的布局。

1.2 构造函数

LayoutBuilder({
  Key? key,
  required Widget Function(BuildContext, BoxConstraints) builder,
})

1.3 基础用法

LayoutBuilder(
  builder: (BuildContext context, BoxConstraints constraints) {
    // 打印约束信息(调试用)
    print('LayoutBuilder约束: $constraints');
    print('  maxWidth: ${constraints.maxWidth}');
    print('  maxHeight: ${constraints.maxHeight}');
    
    // constraints包含父组件传递的约束信息
    if (constraints.maxWidth > 600) {
      return DesktopLayout();
    } else {
      return MobileLayout();
    }
  },
)

控制台输出示例:

LayoutBuilder约束: BoxConstraints(0.0<=w<=392.7, 0.0<=h<=Infinity)
  maxWidth: 392.7272644042969
  maxHeight: Infinity

1.4 BoxConstraints(约束信息)

class BoxConstraints {
  final double minWidth;   // 最小宽度
  final double maxWidth;   // 最大宽度
  final double minHeight;  // 最小高度
  final double maxHeight;  // 最大高度
  
  bool get isTight;        // 是否为固定约束
  bool get isNormalized;   // 是否标准化
  // ... 更多方法
}

常用属性:

  • minWidth / maxWidth:宽度范围
  • minHeight / maxHeight:高度范围
  • isTight:是否固定尺寸(min == max)
  • biggest:最大可用尺寸
  • smallest:最小可用尺寸

2️⃣ 响应式布局实战

2.1 响应式Column

根据可用宽度动态切换单列/双列布局:

class ResponsiveColumn extends StatelessWidget {
  const ResponsiveColumn({super.key, required this.children});

  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        if (constraints.maxWidth < 200) {
          // 最大宽度小于200,显示单列
          return Column(
            children: children,
            mainAxisSize: MainAxisSize.min,
          );
        } else {
          // 大于200,显示双列
          var widgets = <Widget>[];
          for (var i = 0; i < children.length; i += 2) {
            if (i + 1 < children.length) {
              widgets.add(Row(
                children: [children[i], children[i + 1]],
                mainAxisSize: MainAxisSize.min,
              ));
            } else {
              widgets.add(children[i]);
            }
          }
          return Column(
            children: widgets,
            mainAxisSize: MainAxisSize.min,
          );
        }
      },
    );
  }
}

使用示例:

ResponsiveColumn(
  children: [
    Text('Item 1'),
    Text('Item 2'),
    Text('Item 3'),
    Text('Item 4'),
  ],
)

2.2 响应式断点

常见的响应式断点:

enum DeviceType { mobile, tablet, desktop }

DeviceType getDeviceType(double width) {
  if (width < 600) {
    return DeviceType.mobile;    // 手机
  } else if (width < 1200) {
    return DeviceType.tablet;    // 平板
  } else {
    return DeviceType.desktop;   // 桌面
  }
}

// 使用
LayoutBuilder(
  builder: (context, constraints) {
    final deviceType = getDeviceType(constraints.maxWidth);
    
    switch (deviceType) {
      case DeviceType.mobile:
        return MobileLayout();
      case DeviceType.tablet:
        return TabletLayout();
      case DeviceType.desktop:
        return DesktopLayout();
    }
  },
)

2.3 自适应网格

根据宽度自动调整列数:

LayoutBuilder(
  builder: (context, constraints) {
    // 计算列数
    final cardWidth = 120.0;
    final spacing = 8.0;
    final columns = (constraints.maxWidth / (cardWidth + spacing))
        .floor()
        .clamp(1, 6);  // 最少1列,最多6列
    
    return GridView.builder(
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: columns,
        crossAxisSpacing: spacing,
        mainAxisSpacing: spacing,
      ),
      itemBuilder: (context, index) => Card(...),
    );
  },
)

3️⃣ AfterLayout(布局后回调)

3.1 什么是AfterLayout

AfterLayout 是一个自定义组件,用于在布局完成后获取组件的实际尺寸和位置信息。

3.2 实现原理

通过自定义 RenderObject,在 performLayout 方法中添加回调:

class AfterLayout extends SingleChildRenderObjectWidget {
  const AfterLayout({
    super.key,
    required this.callback,
    super.child,
  });

  final ValueChanged<RenderAfterLayout> callback;

  @override
  RenderAfterLayout createRenderObject(BuildContext context) {
    return RenderAfterLayout(callback: callback);
  }

  @override
  void updateRenderObject(
    BuildContext context,
    RenderAfterLayout renderObject,
  ) {
    renderObject.callback = callback;
  }
}

class RenderAfterLayout extends RenderProxyBox {
  RenderAfterLayout({required this.callback});

  ValueChanged<RenderAfterLayout> callback;

  @override
  void performLayout() {
    super.performLayout();
    // 布局完成后触发回调
    WidgetsBinding.instance.addPostFrameCallback((_) {
      callback(this);
    });
  }

  /// 获取组件在屏幕中的偏移坐标
  Offset get offset => localToGlobal(Offset.zero);
}

3.3 基础用法

AfterLayout(
  callback: (RenderAfterLayout ral) {
    print('AfterLayout回调:');
    print('  尺寸: ${ral.size}');        // Size(105.0, 17.0)
    print('  位置: ${ral.offset}');      // Offset(42.5, 290.0)
  },
  child: Text('flutter@wendux'),
)

控制台输出:

AfterLayout回调:
  尺寸: Size(105.0, 17.0)
  位置: Offset(42.5, 290.0)

3.4 获取相对坐标

使用 localToGlobal 方法获取相对于某个父组件的坐标:

Builder(builder: (context) {
  return Container(
    color: Colors.grey.shade200,
    width: 100,
    height: 100,
    child: AfterLayout(
      callback: (RenderAfterLayout ral) {
        // 获取相对于Container的坐标
        Offset offset = ral.localToGlobal(
          Offset.zero,
          ancestor: context.findRenderObject(),
        );
        print('占用空间范围: ${offset & ral.size}');
      },
      child: Text('A'),
    ),
  );
})

4️⃣ RenderAfterLayout详解

4.1 继承关系

RenderObject
    ↓
RenderBox
    ↓
RenderProxyBox
    ↓
RenderAfterLayout

4.2 主要方法

方法/属性 说明 返回值
size 组件尺寸 Size
offset 屏幕坐标 Offset
localToGlobal(Offset) 转换为全局坐标 Offset
localToGlobal(..., ancestor) 转换为相对坐标 Offset
paintBounds 绘制边界 Rect

4.3 坐标转换

// 转换为屏幕坐标
Offset screenOffset = ral.localToGlobal(Offset.zero);

// 转换为相对于ancestor的坐标
Offset relativeOffset = ral.localToGlobal(
  Offset.zero,
  ancestor: ancestorRenderObject,
);

// 计算占用空间
Rect bounds = offset & size;  // Rect.fromLTWH(x, y, width, height)

5️⃣ Build和Layout的交错执行

5.1 执行流程

graph TB
    A[开始Build] --> B[遇到LayoutBuilder]
    B --> C[进入Layout阶段]
    C --> D[执行LayoutBuilder.builder]
    D --> E[返回新Widget]
    E --> F[继续Build新Widget]
    F --> G[完成]
    
    style A fill:#e1f5ff
    style C fill:#ffe1e1
    style F fill:#e1f5ff

关键点:

  • Build 和 Layout 不是严格按顺序执行的
  • LayoutBuilder 的 builder 在 Layout 阶段执行
  • builder 中可以返回新 Widget,触发新的 Build

5.2 执行顺序示例

print('1. 开始Build');

LayoutBuilder(
  builder: (context, constraints) {
    print('3. 执行LayoutBuilder.builder(Layout阶段)');
    return Column(
      children: [
        Text('Hello'),  // 4. 触发新的Build
      ],
    );
  },
)

print('2. LayoutBuilder创建完成');

输出顺序:

1. 开始Build
2. LayoutBuilder创建完成
3. 执行LayoutBuilder.builder(Layout阶段)
4. Build Text Widget

🤔 常见问题(FAQ)

Q1: LayoutBuilder和MediaQuery的区别?

A:

特性 LayoutBuilder MediaQuery
获取信息 父组件约束 屏幕尺寸
作用范围 当前组件 全局
响应变化 父约束变化 屏幕尺寸变化
使用场景 组件级响应式 全局响应式
// LayoutBuilder - 父组件约束
LayoutBuilder(
  builder: (context, constraints) {
    // constraints来自父组件
    return Text('宽度: ${constraints.maxWidth}');
  },
)

// MediaQuery - 屏幕尺寸
final screenWidth = MediaQuery.of(context).size.width;

Q2: 如何在StatefulWidget中使用AfterLayout?

A: 使用 addPostFrameCallback 避免在 build 中调用 setState

class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  Size _size = Size.zero;

  @override
  Widget build(BuildContext context) {
    return AfterLayout(
      callback: (RenderAfterLayout ral) {
        // ✅ 正确:使用 addPostFrameCallback
        WidgetsBinding.instance.addPostFrameCallback((_) {
          if (mounted) {
            setState(() {
              _size = ral.size;
            });
          }
        });
      },
      child: Text('Hello'),
    );
  }
}

Q3: LayoutBuilder的builder何时执行?

A: 在以下情况会执行:

  1. 首次布局:组件首次被添加到树中
  2. 约束变化:父组件传递的约束发生变化
  3. 重新布局:调用 markNeedsLayout()
LayoutBuilder(
  builder: (context, constraints) {
    print('Builder执行,约束: $constraints');
    return Container();
  },
)

Q4: 如何优化LayoutBuilder性能?

A:

  1. 避免过度嵌套
  2. 缓存计算结果
  3. 使用const构造函数
LayoutBuilder(
  builder: (context, constraints) {
    // ❌ 每次都创建新Widget
    return Column(
      children: [
        Text('Item 1'),
        Text('Item 2'),
      ],
    );
    
    // ✅ 使用const
    return const Column(
      children: [
        Text('Item 1'),
        Text('Item 2'),
      ],
    );
  },
)

Q5: AfterLayout会影响性能吗?

A: 会有轻微影响,因为:

  1. 额外的回调开销
  2. 可能触发额外的 setState
  3. 每次布局都会执行回调

优化建议:

  • 只在必要时使用
  • 避免在回调中进行重量级操作
  • 使用防抖/节流

🎯 跟着做练习

练习1:实现一个响应式导航栏

目标: 宽度>600显示完整标签,否则显示图标

步骤:

  1. 使用 LayoutBuilder
  2. 判断 constraints.maxWidth
  3. 返回不同的UI
💡 查看答案
class ResponsiveNavigationBar extends StatelessWidget {
  const ResponsiveNavigationBar({super.key});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final showLabels = constraints.maxWidth > 600;
        
        return Container(
          height: 60,
          color: Colors.blue,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _buildNavItem(
                icon: Icons.home,
                label: '首页',
                showLabel: showLabels,
              ),
              _buildNavItem(
                icon: Icons.search,
                label: '搜索',
                showLabel: showLabels,
              ),
              _buildNavItem(
                icon: Icons.person,
                label: '我的',
                showLabel: showLabels,
              ),
            ],
          ),
        );
      },
    );
  }

  Widget _buildNavItem({
    required IconData icon,
    required String label,
    required bool showLabel,
  }) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(icon, color: Colors.white),
        if (showLabel) ...[
          const SizedBox(height: 4),
          Text(
            label,
            style: const TextStyle(color: Colors.white, fontSize: 12),
          ),
        ],
      ],
    );
  }
}

练习2:实现文本溢出检测

目标: 检测Text是否溢出,显示"展开"按钮

步骤:

  1. 使用 AfterLayout 获取Text尺寸
  2. 计算是否溢出
  3. 显示/隐藏展开按钮
💡 查看答案
class ExpandableText extends StatefulWidget {
  const ExpandableText({super.key, required this.text});

  final String text;

  @override
  State<ExpandableText> createState() => _ExpandableTextState();
}

class _ExpandableTextState extends State<ExpandableText> {
  bool _expanded = false;
  bool _isOverflow = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        AfterLayout(
          callback: (RenderAfterLayout ral) {
            // 检查是否溢出
            final textPainter = TextPainter(
              text: TextSpan(text: widget.text),
              maxLines: _expanded ? null : 3,
              textDirection: TextDirection.ltr,
            )..layout(maxWidth: ral.size.width);

            WidgetsBinding.instance.addPostFrameCallback((_) {
              if (mounted) {
                setState(() {
                  _isOverflow = textPainter.didExceedMaxLines;
                });
              }
            });
          },
          child: Text(
            widget.text,
            maxLines: _expanded ? null : 3,
            overflow: TextOverflow.ellipsis,
          ),
        ),
        if (_isOverflow)
          TextButton(
            onPressed: () {
              setState(() {
                _expanded = !_expanded;
              });
            },
            child: Text(_expanded ? '收起' : '展开'),
          ),
      ],
    );
  }
}

📋 小结

核心概念

组件 用途 执行时机
LayoutBuilder 获取约束,响应式布局 Layout阶段
AfterLayout 获取尺寸和位置 Layout完成后
BoxConstraints 约束信息 Layout阶段传递

LayoutBuilder使用场景

场景 示例
响应式布局 根据宽度显示不同UI
自适应网格 动态调整列数
断点设计 手机/平板/桌面切换
动态组件 根据空间大小选择组件

AfterLayout使用场景

场景 示例
尺寸获取 获取组件实际大小
位置计算 计算组件坐标
溢出检测 判断Text是否溢出
动画准备 获取起始位置

记忆技巧

  1. LayoutBuilder:Layout阶段构建UI
  2. AfterLayout:Layout之后获取信息
  3. Build和Layout:可以交错执行
  4. BoxConstraints:约束向传递
  5. RenderObject:渲染树的节点

🔗 相关资源


JavaScript-小游戏-单词消消乐

需求

生成六个按钮 按钮上的内容随机生成 点到匹配的按钮 那两个按钮就隐藏 (之后会做从单词库随机选取单词的进阶版消消乐)

游戏界面

标签结构(html)

创建一个div类名为game 里面嵌套了六个按钮

 <div class="game">
    <button></button>
    <button></button>
    <button></button>
    <button></button>
    <button></button>
    <button></button>
  </div>

层叠样式(css)

  • 先用通配符选择器 * 匹配页面中所有的元素清除默认边距和边框
  • 设置button的宽高 写border-radius设计成圆角的按钮 背景色粉色 字体上网找了一个萌一点的字体 字的颜色为白色
 /* 清除默认样式 */
    * {
      padding: 0;
      margin: 0;
      border: 0;
    }

    button {
      width: 100px;
      height: 100px;
      border-radius: 15%;
      background-color: pink;
      font-size: 15px;
      color: white;
      font-family: Verdana, sans-serif;
    }

获取元素

界面结构写好之后获取div区域 获取按钮用的是querySelectorAll将页面中所有的按钮都获取 这里获得的是NodeList 类数组

document.querySelectorAll('div')得到的数组是div元素

 const btns = document.querySelectorAll('div')
    console.log(btns);  //NodeList [div.game]

获取元素代码如下

//获取
    let game = document.querySelector('.game')
    let buttons = document.querySelectorAll('button')

游戏界面如下 在这里插入图片描述

数据分析

判断数组

用两个数组a和b分别存放英文和中文 确保这两个数组对应的单词和翻译的索引是一样的 定义的

这两个数组可以简化代码 在判断消除的时候不需要把每个可能都罗列上去 而且可以直接拼接数组 为后面打乱顺序使用

每次刷新按钮上都会打乱顺序生成这些英文和中文 所以另一个数组arr由英文数组a和中文数组b拼接而成

  //用来对应判断的数组
    let a = ['understand', 'peace', 'forget']
    let b = ['理解', '和平', '忘记']
    //用来打乱的数组
    let arr = a.concat(b)

功能实现

按钮内容随机

随机打乱数组

采用洗牌法 从后往前遍历 当前元素和自己以及前面的元素随机交换 循环的示意图如下 在这里插入图片描述

循环过程中 交换范围[0-i]是闭区间的原因
  • 如果是[0-i) 不包含自身来交换 就会打乱平衡在这里插入图片描述

  • [0-i]是闭区间保证了元素在每个位置的概率均等 而且每个元素在某个位置的概率也均等![[1958b7eac80038003f846ec6bc8514e2.png]]

交换

传统的元素交换方式就是创建一个temp变量 交换本质上不会改变值 只会改变空间的指向 值可以被多个空间指向 ![[Pasted image 20251021105630.png]]

以下是数组元素交换的代码举例

 arr = [0, 1]
    //元素交换
    let t = arr[0]
    arr[0] = arr[1]
    arr[1] = t
    console.log(arr, t);  //[1, 0] 0
随机打乱数组最终代码如下

这里采用的是传统的交换方式

  //从后往前遍历 拿到的元素和前面的随机交换
    for (let i = arr.length - 1; i > 0; i--) {
      let r = Math.floor(Math.random() * i)
      //元素交换
      let t = arr[i]
      arr[i] = arr[r]
      arr[r] = t
    }

渲染到按钮上

设置buttons数组元素的innerText属性为随机打乱后的数组 渲染到按钮上

 //随机交换之后渲染到页面
    for (let i = 0; i < arr.length; i++) {
      buttons[i].innerText = arr[i]
    }

最终效果如下 点击刷新按钮之后随机打乱的数组会渲染到按钮上 完成了按钮随机功能 ![[刷新.gif]]

消除功能

数据分析

一开始存在了两个数组里 后面发现存在对象里更合适

对象是一种无序的数据集合 有属性和方法 这里采用的是属性来存储 属性是以键值对(Key-Value)的形式存在的每个属性由一个键(key)和一个与之关联的值(value)组成。 数组是特殊的对象 属性名是索引是有序的所以是有序的数据集合

按钮内容存为对象属性的key用于判断中英文是否匹配,对应的e.target作为value用于匹配成功之后隐藏按钮 都存在对象里 当判断完毕之后清空对象也更方便

点击事件

btns是NodeList类数组 可以用forEach方法遍历数组然后添加点击事件

    //map给按钮添加点击事件
    btns.forEach((value) => {
      value.addEventListener('click', () => {
        console.log('click');
      })
    }
    )

以下是遍历数组然后添加点击事件效果图示 在这里插入图片描述

类数组中没有map方法 如果要使用map方法 需要先 Array.from把btns转换为数组

 //map给按钮添加点击事件
    Array.from(btns).map((value, index) => {
      value.addEventListener('click', () => {
        console.log('click');
      })
    }
    )
  • 最终利用for循环把所有的按钮都添加点击事件
  • 对象的e.target.innerText属性对应值为e.target 属性名用于判断 属性值用于判断成功之后隐藏对应的按钮
  • 如果judge方法判断匹配 返回true 就把相应的e.target的可见值改为hidden
  • 然后清空对象 便于下次判断
let obj = {}  //把内容和按钮存在对象里
    for (let i = 0; i < buttons.length; i++) {
      buttons[i].addEventListener('click', (e) => {
        obj[e.target.innerText] = e.target //重复点击也不会有重复的元素
        //点击两个不同的元素才判断
        if (judge()) {
          Object.values(obj)[0].style.visibility = 'hidden'
          Object.values(obj)[1].style.visibility = 'hidden'
          obj = {}
        }
      })
    }

判断方法

  • 遍历a英文数组或者b中文数组 (这里遍历其中任意一个数组就可以 因为这两个数组索引是相对应的 长度也相等) 不需要每种可能都罗列上去
  • 调用Object.keys(obj)获取这个对象中所有的属性名 返回一个数组
  • 如果这个数组中有对应的英文且也有对应的中文 就返回true

这里判断条件改为arr.some((value) => value === a[i] && value===b[i])是不行因为这里的value表示的是当前的元素这个表达式的返回值固定为false当前的元素不可能又等于a[i]又等于b[i] 所以只能分别判断 都为真就返回真

 //判断方法
    function judge() {
      for (let i = 0; i < a.length; i++) {
        if (Object.keys(obj).some((values) => values === a[i])
          && Object.keys(obj).some((values) => values === b[i])) {
          return true
        }
      }
    }

消除功能最终效果如下

![[最终 1.gif]]

最终代码


<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>单词消消乐</title>
</head>

<body>
  <div class="game">
    <button></button>
    <button></button>
    <button></button>
    <button></button>
    <button></button>
    <button></button>
  </div>
  <style>
    /* 清除默认样式 */
    * {
      padding: 0;
      margin: 0;
      border: 0;
    }

    button {
      width: 100px;
      height: 100px;
      border-radius: 15%;
      background-color: pink;
      font-size: 15px;
      color: white;
      font-family: Verdana, sans-serif;
    }
  </style>

  <script>
    //获取
    let game = document.querySelector('.game')
    let buttons = document.querySelectorAll('button')
    const btns = document.querySelectorAll('div')
    console.log(btns);  //NodeList [div.game]

    //用来对应判断的数组
    let a = ['understand', 'peace', 'forget']
    let b = ['理解', '和平', '忘记']
    //用来打乱的数组
    let arr = a.concat(b)


    //1每次打开页面 按钮位置随机
    //从后往前遍历 拿到的元素和前面的随机交换
    for (let i = arr.length - 1; i > 0; i--) {
      let r = Math.floor(Math.random() * i)
      //元素交换
      let t = arr[i]
      arr[i] = arr[r]
      arr[r] = t
    }

    //随机交换之后渲染到页面
    for (let i = 0; i < arr.length; i++) {
      buttons[i].innerText = arr[i]
    }


    //2 点击对应的两个按钮 就消除
    let obj = {}  //把内容和按钮存在对象里
    for (let i = 0; i < buttons.length; i++) {
      buttons[i].addEventListener('click', (e) => {
        obj[e.target.innerText] = e.target //重复点击也不会有重复的元素
        //点击两个不同的元素才判断
        if (judge()) {
          Object.values(obj)[0].style.visibility = 'hidden'
          Object.values(obj)[1].style.visibility = 'hidden'
          obj = {}
        }
      })
    }

    //判断方法
    function judge() {
      for (let i = 0; i < a.length; i++) {
        if (Object.keys(obj).some((values) => values === a[i])
          && Object.keys(obj).some((values) => values === b[i])) {
          return true
        }
      }
    }

  </script>

</body>

</html>
❌