普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月19日技术

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

2025年11月19日 22:25

看烂代码的场景

接手老旧 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 才会告诉你:那些年踩过的模块加载失败的坑(二)

2025年11月19日 22:18

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

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 保持一致

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

作者 168169
2025年11月19日 22:06

前情

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

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 版本兼容性问题

作者 eason_fan
2025年11月19日 20:38

解决 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第八章(路由处理程序)

作者 小满zs
2025年11月19日 20:24

路由处理程序(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 崩溃梗图

作者 冴羽
2025年11月19日 20:23

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的基本应用场景

2025年11月19日 18:32

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 独家实测 (附:无法登录解法)

作者 coder_pig
2025年11月19日 18:30

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图床

作者 _小九
2025年11月19日 18:15

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 后端生产环境部署,实现注册功能(三)

作者 小胖霞
2025年11月19日 17:47

一、部署前准备

  • 本地环境: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源码阅读】低代码如何实现交互?(上)

作者 云鹤_
2025年11月19日 17:40

基于 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

2025年11月19日 17:19

需求

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

游戏界面

标签结构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

2025年11月19日 17:14

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

作者 旧时光_
2025年11月19日 17:03

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-小游戏-单词消消乐

2025年11月19日 17:02

需求

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

游戏界面

标签结构(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>

干了10年前端,才学会使用IntersectionObserver

作者 tyro曹仓舒
2025年11月19日 16:45

IntersectionObserver 是 JavaScript 原生 API,用于异步监听目标元素与视口(或指定容器元素)的交叉状态(即元素是否进入 / 离开视口、交叉比例多少)。核心优势是性能优异(浏览器原生优化,避免 scroll 事件的高频触发),常见场景:懒加载图片 / 视频、滚动加载列表、曝光统计、元素进入视口时触发动画等。

一、核心概念

  1. 目标元素(target) :需要监听的 DOM 元素(如图片、列表项)。
  2. 根元素(root) :作为 “视口” 的参考容器,默认是浏览器视口(null),必须是目标元素的祖先元素。
  3. 根边界(rootMargin) :根元素的 “扩展 / 收缩边距”,用于提前 / 延迟触发监听(如提前 100px 检测元素即将进入视口)。
  4. 阈值(threshold) :触发回调的 “交叉比例阈值”(0~1),可传单个值或数组(如 [0, 0.5, 1] 表示元素刚进入、一半进入、完全进入时都触发)。
  5. 交叉状态(intersectionRatio) :目标元素与根元素的交叉比例(0 = 完全不交叉,1 = 完全交叉)。

二、基本使用步骤

1. 语法结构

// 1. 创建观察器实例,传入回调函数和配置项
const observer = new IntersectionObserver((entries, observer) => {
  // entries:所有被监听元素的交叉状态数组(每个元素是 IntersectionObserverEntry 对象)
  entries.forEach(entry => {
    // entry:单个元素的交叉状态信息
    if (entry.isIntersecting) {
      // 元素进入视口(交叉比例 > 0)
      console.log('元素进入视口', entry.target);
      // 执行业务逻辑(如懒加载、触发动画)
      // 可选:只监听一次,触发后取消观察
      observer.unobserve(entry.target);
    } else {
      // 元素离开视口(交叉比例 = 0)
      console.log('元素离开视口', entry.target);
    }
  });
}, {
  root: null, // 根元素,默认视口(null)
  rootMargin: '0px', // 根元素边距(格式:上 右 下 左,支持 px/%)
  threshold: 0 // 阈值(默认 0,元素刚进入视口时触发)
});

// 2. 监听目标元素(可监听多个)
const target1 = document.querySelector('.target1');
const target2 = document.querySelector('.target2');
observer.observe(target1);
observer.observe(target2);

// 3. 可选:停止监听单个元素
observer.unobserve(target1);

// 4. 可选:销毁观察器(所有监听都停止)
observer.disconnect();

2. 关键参数详解

参数 说明
entries 数组,每个元素是 IntersectionObserverEntry 对象,包含单个目标的交叉信息:- isIntersecting:布尔值,是否正在交叉(进入视口)- intersectionRatio:交叉比例(0~1)- target:被监听的 DOM 元素- boundingClientRect:目标元素的位置信息- rootBounds:根元素的位置信息
root 参考容器(DOM 元素),默认 null(浏览器视口),必须是目标元素的祖先。
rootMargin 根元素的边距,用于扩展 / 收缩根元素的 “有效视口”,格式同 CSS 边距。例:rootMargin: '50px 0px' → 根元素上下扩展 50px,提前 50px 触发监听。
threshold 触发回调的交叉比例阈值,可传数组:- threshold: 0(默认)→ 元素刚进入视口(交叉比例 >0)时触发- threshold: 1 → 元素完全进入视口(交叉比例 =1)时触发- threshold: [0, 0.5, 1] → 元素刚进入、一半进入、完全进入时各触发一次

三、常见场景示例

示例 1:图片懒加载(核心场景)

需求:页面滚动时,图片进入视口后再加载真实图片(优化首屏加载速度)。

<!-- HTML:占位图 + 真实图片地址存放在 data-src 属性 -->
<img class="lazy-img" src="placeholder.jpg" data-src="real-img1.jpg" alt="懒加载图片">
<img class="lazy-img" src="placeholder.jpg" data-src="real-img2.jpg" alt="懒加载图片">
// 1. 创建观察器
const lazyLoadObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      // 元素进入视口,加载真实图片
      const img = entry.target;
      img.src = img.dataset.src; // 替换 src 为真实地址
      img.classList.add('loaded'); // 可选:添加加载完成样式
      lazyLoadObserver.unobserve(img); // 只加载一次,取消监听
    }
  });
}, {
  rootMargin: '100px 0px', // 提前 100px 开始加载(优化体验,避免白屏)
  threshold: 0.1 // 元素 10% 进入视口时触发
});

// 2. 监听所有懒加载图片
document.querySelectorAll('.lazy-img').forEach(img => {
  lazyLoadObserver.observe(img);
});

示例 2:滚动加载更多(无限滚动)

需求:滚动到页面底部的 “加载更多” 按钮时,请求下一页数据。

<ul class="list"></ul>
<div class="load-more">加载更多</div>
const loadMoreBtn = document.querySelector('.load-more');
const list = document.querySelector('.list');
let page = 1;

// 创建观察器(监听“加载更多”按钮)
const loadMoreObserver = new IntersectionObserver((entries) => {
  const [entry] = entries;
  if (entry.isIntersecting && !isLoading) { // isLoading 防止重复请求
    isLoading = true;
    loadMoreBtn.textContent = '加载中...';
    // 模拟请求下一页数据
    fetch(`/api/data?page=${page}`)
      .then(res => res.json())
      .then(data => {
        data.forEach(item => {
          const li = document.createElement('li');
          li.textContent = item.content;
          list.appendChild(li);
        });
        page++;
        isLoading = false;
        loadMoreBtn.textContent = '加载更多';
      });
  }
}, { rootMargin: '50px 0px' }); // 提前 50px 触发,优化体验

// 监听“加载更多”按钮
loadMoreObserver.observe(loadMoreBtn);

示例 3:元素进入视口触发动画

需求:元素滚动进入视口时,添加 “淡入” 动画。

/* CSS:初始状态(透明、偏移)+ 动画状态 */
.fade-in {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.5s, transform 0.5s;
}
.fade-in.active {
  opacity: 1;
  transform: translateY(0);
}
<div class="fade-in">元素 1:进入视口淡入</div>
<div class="fade-in">元素 2:进入视口淡入</div>
// 创建观察器
const animationObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('active'); // 触发动画
      animationObserver.unobserve(entry.target); // 只触发一次
    }
  });
}, { threshold: 0.3 }); // 元素 30% 进入视口时触发

// 监听所有需要动画的元素
document.querySelectorAll('.fade-in').forEach(el => {
  animationObserver.observe(el);
});

四、注意事项

  1. 兼容性:支持 Chrome 51+、Firefox 55+、Safari 12.1+、Edge 16+,不支持 IE(需兼容可使用 polyfill)。
  2. 异步特性IntersectionObserver 是异步的,回调函数不会阻塞主线程(性能优势),但无法同步获取元素交叉状态。
  3. root 必须是祖先元素:如果指定 root,必须确保它是目标元素的父级 / 祖先元素,否则监听无效。
  4. 动态元素监听:如果目标元素是动态创建的(如通过 JS 新增的列表项),创建后需调用 observer.observe(新元素) 手动添加监听。
  5. rootMargin 单位:支持 px 或 %,但不能混合单位(如 50px 10% 是允许的,50 10px 不允许)。

五、总结

IntersectionObserver 的核心价值是高效监听元素交叉状态,替代传统的 scroll + getBoundingClientRect() 方案(后者高频触发,性能较差)。使用时只需三步:

  1. 创建观察器(配置回调和参数);
  2. 监听目标元素;
  3. 触发后执行业务逻辑(并可选取消监听)。

常见应用:懒加载、无限滚动、曝光统计、滚动动画,是前端性能优化和交互体验提升的重要 API。

React源码学习准备工作①——什么是Fiber

作者 Jseeza
2025年11月19日 16:35

引言

在 React 16 以前,React的更新方式是这样的:一次性、不可中断的深度递归更新。这种更新方式:

  • 采用调用递归树来遍历组件树
  • 根节点开始递归render
  • 中间过程不能暂停、不能打断
  • 一旦开始,就必须一次性执行到结束

这种更新方式被称作:Stack Reconciler(调用栈调和器)。 其优点是显而易见的:实现简单,逻辑清晰,理论上来说性能不会太差,小更新速度非常快,不可打断的一次性能保证渲染的一致性,包括UI的一致性和生命周期顺序的固定可靠。

但是,也正是因为这种不可打断的一次性,带来了非常致命的问题:大组件树会卡死主线程。

React 的更新逻辑是运行在 JS 主线程中的,但是 JS 主线程除了负责 React 的更新,还需要去负责:

  • UI渲染
  • 用户输入事件
  • 动画
  • 网络回调
  • 等等

如果我们需要渲染一个巨型的组件树, React 就会一次性递归计算组件树的所有节点,占用了 JS 主线程,并且中途无法暂停,从而导致 JS 堵塞,页面卡死,用户的交互事件也没有反应——因为主线程都被哪去给 React 渲染组件树了。

不仅于此,Raact 阻塞进程还会导致浏览器无法响应更高优先级的任务,在 React 16 以前,React 不会让用户输入等更高优先级的任务去插队响应,JS 线程必须等待当前组件树 render 完成,才能去响应其他任务——给用户的体验就是:页面卡顿、掉帧、输出延迟

此外,因为递归栈形式的遍历不能中断,因此也很难去保存当前执行的上下文,记录执行到哪一步从而恢复执行,这种方式完全无法实现渐进式渲染,包括分片等功能自然也无法实现。

总结:React 16 以前使用的Stack Reconciler,优点是实现简单、一次性渲染保证一致性;缺点是不能中断、不能分片、不能恢复,导致长任务会阻塞主线程,引发页面卡顿,因此被Fiber替代。

React 开发团队也正是为了实现 “可中断、可恢复”的渲染,从而引入了Fiber的概念。

Fiber 的定义

什么是 Fiber

一句话概括:Fiber 是 React 用来执行“可中断更新”的一种数据结构(FiberNode)+调度机制(Workloop)。

Fiber 既是一个轻量级数据结构(描述组件的工作单元),又是一个任务调度系统(让更新分片执行,不卡主线程)。

可以这么说,Fiber=数据结构+调度系统

Fiber 的引入使得渲染过程可以中断,并且根据需要重新调度任务,这种可中断可分片的优化使得 React 可以更好利用 JS 主线程的空闲时间,优化性能,提升用户体验。

Fiber 是一种数据结构

为什么这么说呢?我们可以看看FiberNode的源码。

export class FiberNode {
  constructor(tag, pendinfProps, key, mode){
    this.tag = tag;
    this.key = key;
    this.elementType = null;
    this.type = null;
    this.stateNode = null;

    // 树形结构
    this.return = null;
    this.child = null;
    this.sibling = null;

    // props
    this.pendingProps = pendingProps;
    this,memoizedProps = null;

    // 状态
    this.memoizedState = null;
    this.updateQueue = null;

    // Effect 相关
    this.flags = NoFlags;
    this.subtreeFlags = NoFlags;
    this.deletions = null;

    // 调度 lane
    this.lanes = NoLanes;
    this.childLanes = NoLanes;

    // 双缓存树
    this.alternate = null;
  }
}

我们可以重点关注以下几个内容。

1. child / sibling / return

Fiber 是一个非常典型的链表结构+树结构的混合,而非一棵标准的多叉树。

child → 第一个子节点  
sibling → 右兄弟节点  
return → 父节点  

2. alternate

Fiber 使用的是“双缓存”机制,alternate 是双缓存树的核心。

Fiber 会同时维护两棵 Fiber 树:

  • 一棵是当前页面正在用的树(current)
  • 另一棵是正在计算下一帧UI的树(workInProgress)

计算完成后,就用一次性“切换指针”的方式把新的树换成 current,渲染过程可以实现无闪烁、可中断、不卡顿。双缓存依赖 alternate 形成两棵树,alternate 相当于两棵树切换的桥梁,能让 React 同时维护 current 树和 workInProgress 树,并在它们之间随时切换,无需重新创建整棵树。

每个 Fiber 节点都有两个版本,alternate 字段去记录它们的映射关系:

currentFiber.alternate = workInProgressFiber
workInProgressFiber.alternate = currentFiber

这种桥梁关系可以使 React 快速从 current 得到对应的 WIP,计算下一帧UI树时也不需要 new 新的树,而是直接复用已有的节点,在更新的过程中也会保存上一次的状态。

这里延伸一下:alternate 为什么可以让 Fiber 重用已有的节点?这里可以直接参考源码:

if (current.alternate !== null) {
  // reuse
  workInProgress = current.alternate;
} else {
  // create a new fiber
}

这意味着,在 current.alternate 存在时,Fiber 会直接复用已有的节点,而不是创建一个新的Tree Node,这将大大减少性能。

这也意味着,WIP 的构建是可以随时产生随时停止的,只需要根据 current 即可随时构造,那么,在 JS 线程中,一旦有更高优先级的任务发生,WIP 可以立即被丢弃,等待更高优先级的任务完成,然后再重新算,期间不会对 current 造成任何影响,用户只能看到 current 的内容,页面体验也不会受到影响。

在 WIP 构建完成后,UI 切换并不是重新创建,而是“调换指针”。

root.current = finishedWork;

Alternate 保证两棵树的节点是一一对应的,那么只需要切换指针,就能实现将 WIP 推到页面上。

用一句话总结:Alternate 是双缓存的核心,因为它让 React 能维持 current 和 workInProgress 两棵并行的 Fiber 树。所有的更新都在WIP上进行,最终通过 alternate 快速切换指针使页面更新。 这种方式带来了可中断、可恢复、可丢弃、低卡顿的并发渲染能力。

3. lans

React 17 内部引入 lanes 取代 expirationTime,但 lanes 的真实能力在 React 18 并发模式中才真正向开发者开放。

Lans 模型决定:

  • 哪些任务可以中断
  • 哪些任务可以合并
  • 哪些任务需要优先执行

这是并发模式的基础。

Fiber 是一种调度系统

说 Fiber 是一种调度系统,本质上是抓住了 Fiber 的设计核心:Fiber 不是一个单纯的树结构,而是一个可以拆分任务、按优先级执行、暂停和恢复的工作调度引擎。上文提到的 lans 很好地揭示了这样的一种行为。

为什么说 Fiber 是一种调度系统

1. 可中断/可恢复

React 16 以前,渲染是“递归+同步”的渲染:一旦开始渲染,就无法停止。Fiber 支持将渲染工作拆分成小单元,渲染间隙可以将 JS 线程让给优先级更高的任务,等待任务完成后再回来继续渲染。

这种机制非常像操作系统到的 CPU 调度:Fiber 是任务线程/进程,React 根据需求去调度。

2. 优先级机制

操作系统的 CPU 调度会提到优先级这个概念,根据优先级安排任务调度顺序,保证高优先级的任务优先被执行。

Fiber 也可以实现类似的优先级调度机制,支持不同优先级的任务,实现原理就是上文提到的 Lans,通过优先级调度系统,React 可以“插队”执行高优先级任务,例如在渲染过程中来了一个用户交互任务,React 可以暂停渲染先去处理交互任务,完成后再返回继续执行渲染任务。

上下文的储存也会保证不会丢失以前的渲染结果。这个优先级机制是并发渲染的基础。

3. 时间分片

Fiber 可以通过“时间分片”的思想,将大的渲染任务分解为小的任务,每一帧都只完成部分任务。这样 JS 主线程不会长时间被渲染任务占据,浏览器调度 API 在此基础上可以决定什么时候继续渲染任务,这样可以保证用户交互、动画等高优先级的任务不会被低优先级的渲染任务卡住。

4. 调度器

React 有一个单独的调度层(Scheduler),与 Fiber 结合,从而实现决定何时执行任务。这个调度层支持优先级任务、超时、挂起、恢复等,是一个真正的任务调度系统。

5. 可撤销/可放弃任务

如果正在执行的渲染任务优先级低于高优先级的用户交互任务,则当前的渲染任务可以被暂停,断点执行时机可以被推后或者放弃。渲染进度会被保存,下次可以从中断处恢复,这样,React 的渲染就不是一次性的必须全部成功或者全部失败,而是有“弹性”的。

从调度系统角度看 Fiber 的优势

一句话总结:更好地实现可中断、可恢复、低卡顿的渲染与页面交互。

  • 更好响应页面交互:高优先级的用户交互任务被优先执行,低优先级任务可以被打断,使用户的页面使用体验更流畅。
  • 提升页面性能:渲染任务被分片分时渲染,减少了 React 长任务占用 JS 主线程的时间,浏览器可以及时进行重绘/回流。
  • 支持并发特性:Fiber 是 React 并发模式的基础,调度系统为并发渲染提供了核心与基石。
  • 错误恢复能力更强:Fiber 会记住渲染的上下文,渲染任务中断或放弃不会影响现有页面与整个渲染任务,可以根据上下文恢复渲染任务。

Fiber 如何实现渲染

这里我们要研究实现渲染的关键函数。

1. 初次渲染:创建双缓存树

初次渲染时没有 alternate,于是 React 会创建一棵新的 workInProgress Fiber 树。

这里可以去看一看 Fiber 的源码 ReactFiber.js

function createWorkInProgress(current, pendingProps) {
  let workInProgress = current.alternate;
  if (workInProgress === null) {
    // 初次渲染:current 没有 alternate,会去创建一个新的 Fiber
    workInProgress = new FiberNode(current.tag, pendingProps, current.key);
    workInProgress.stateNode = current.stateNode;
    workInProgress.alternate = current;
    current.alternate = workInProgress;
  }
  return workInProgress;
}

初次渲染之后,current 和 WIP 两棵树就建好了,彼此通过 alternate 连接,相互复用,减少了后续节点构建的压力(因为后续就复用了)。

2. 更新阶段:利用双缓存机制构建新的UI

更新流程发生在 ReactFiberWorkLoop.js 中。

核心步骤如下:

  1. 从 current 树中获取对应 Fiber;
  2. 通过 createWorkInProgress 创建或复用 WIP 树;
  3. 所有的计算、diff、effect 都在 WIP 树种进行;
  4. 完成后执行 commit 阶段;
  5. 用改变指针的方式交换两棵树的角色:WIP 变成新的 current。

① Render 阶段:构建 WIP 树

循环由 performUnitOfWork(WIP) 驱动实现。

function performUnitOfWork(unitOfWork) {
  const current = unitOfWork.alternate;
  let next = beginWork(current, unitOfWork);
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    completeUnitOfWork(unitOfWork);
  }
  return next;
}

渲染都在WIP上计算,如果节点有子节点,那就进入子节点继续处理,直到节点没有子节点了(叶子节点),则向上归并进入 complete 阶段。

② Commit 阶段:切换缓存树

commit 阶段有两个特点:

  1. 同步执行,不可中断
  2. 会真正地更新 DOM/UI

在 commitLayoutEffects 阶段,React 会在浏览器执行绘制(paint)之前执行所有 Layout 副作用,统一在同一帧内完成,以避免 layout thrashing(布局抖动)。

由 commitRoot 执行角色交换,交换方式是指针指向修改,commit 阶段又被拆成三个阶段。来自ReactFiberWorkLoop.js

function commitRoot(root) {
  const finishedWork = root.finishedWork;
  root.finishedWork = null;

  // 阶段1:mutation 前
  commitBeforeMutationEffects(root, finishedWork);

  // 阶段2:mutation
  commitMutationEffects(root, finishedWork);
  root.current = finishedWork ;
  
  // 阶段3:layout 阶段
  commitLayoutEffects(root, finishedWork);
}

阶段一:mutation 前

该阶段发生在更新 DOM 前,用于为 mutation 做准备。

function commitBeforeMutationEffects(root, firstChild) {
  let nextEffect = firstChild;
  while (nextEffect !== null) {
    const flags = nextEffect.flags;
    if (flags & Snapshot) {
      commitBeforeMutationEffectOnFiber(nextEffect);
    }
    nextEffect = nextEffect.nextEffect;
  }
}

在这个阶段,React 会遍历所有带 Snapshot 标记的 Fiber,Snapshot 标记对应 getSnapshotBeforeUpdate。

这么做的目的是让我们在 DOM 更新前拿到更新前的 DOM 的真实状态的“快照”,如果在更新后再获取,就不完全准确了。

React 是一个增量更新的 Fiber 树,不是一个单纯的 DOM 树,可能会出现如下情况:

  • 多个组件有 Snapshot 副作用
  • 有些嵌套组件会产生 Snapshot
  • 某些组件的子组件没有更新,但子组件仍然需要 Snapshot

因此 React 不能只处理当前节点,它需要确保:每一个 Fiber 节点,只要在本次更新中被标记为 Snapshot,都必须在 DOM 更新前执行快照。

在这个阶段,只处理 Snapshot,不需要考虑其它flags,因为其它 flags 不是必须在 DOM 更新前执行。比如:

  • Placement(插入 DOM):mutation 阶段
  • Update(更新属性):mutation 阶段
  • Deletion(删除DOM):mutation 阶段
  • useEffect:Layout 后异步执行

总结:如果被问到 commitBeforeMutationEffects 要遍历所有 Snapshot Fiber 的原因,可以这么回答:

因为 Snapshot 对应 getSnapshotBeforeUpdate,它的语义要求“读取旧 DOM 状态”。
所以必须在 mutation 阶段(DOM 更新)之前执行。
React 通过遍历 effectList 上所有带 Snapshot 标记的 Fiber,确保所有组件在 DOM 被修改之前完成快照读取。
这保证了组件在 update 阶段能获得精准的 DOM 变化前的状态,使滚动恢复、光标位置保存、布局测量等成为可能。

阶段二:mutation

这个阶段是真正更新 DOM 的阶段,所有的 DOM 变动都在这一步完成。

function commitMutationEffects(root, finishedWork) {
  let nextEffect = finishedWork

  while (nextEffect !== null) {
    const flags = nextEffect.flags

    // 删除节点
    if (flags & Deletion) {
      commitDeletion(root, nextEffect)
    }

    // 插入或移动节点
    if (flags & Placement) {
      commitPlacement(nextEffect)
    }

    // 更新属性或文本
    if (flags & Update) {
      commitWork(nextEffect)
    }

    nextEffect = nextEffect.nextEffect
  }
}

React 用 flags 标记 effect,常见的与 mutation 相关的 flags 有:

  • Placement:需要插入(mount)或移动节点
  • Deletion:需要删除(unmount)节点
  • Update:更新属性/文本
  • Snapshot:在 before-mutation 阶段处理(已在前面完成)
  • Ref:需要更新/清理 ref(通常在 layout 阶段也会处理)
  • Passive:useEffect(但 cleanup 在 mutation 阶段需要先执行其 cleanup,再在 layout/after 写入)

处理顺序也有先后,遵循Deletion → Placement → Update 的顺序,这样可以保证 DOM 父节点的稳定性。

阶段三:layout 阶段

在 DOM 更新后执行:

  • 类组件的 componentDidMount
  • 类组件的 componentDidUpdate
  • hook 的 useLayoutEffect create 部分
  • 调用 ref 回调
function commitLayoutEffects(root, finishedWork) {
  let nextEffect = finishedWork;
  
  while (nextEffect !== null) {
    if (nextEffect.flags & Update) {
      commitLayoutEffectOnFiber(root, nextEffect);
    }
    nextEffect = nextEffect.nextEffect;
  }
}

主要作用就是:执行所有需要“看到最新 DOM”的副作用,从而实现同步、强制、阻塞,保证顺序一致,且能访问最新 DOM。

主流程如下:

function commitLayoutEffects(finishedWork, root, committedLanes) {
  // ① 清理上次的 passive destroy(useEffect cleanup)
  flushSyncCallbacksOnlyInLegacyMode();

  // ② 遍历 layoutEffects
  commitLayoutEffectsOnFiber(root, finishedWork, committedLanes);
}

function commitLayoutEffectsOnFiber(root, finishedWork, lanes) {
  let nextEffect = finishedWork.firstEffect;
  while (nextEffect !== null) {
    // 执行 layout 的 work
    commitLayoutEffectOnFiber(
      root,
      nextEffect.alternate,
      nextEffect,
      lanes
    );
    nextEffect = nextEffect.nextEffect;
  }
}

其中,我们重点关注一下commitLayoutEffectsOnFiber,这个阶段是根据 flags 分类处理副作用:

function commitLayoutEffectOnFiber(root, current, finishedWork, lanes) {
  const flags = finishedWork.flags;

  if (flags & Update) {
    // 处理 class 组件的 didUpdate 生命周期
  }

  if (flags & Callback) {
    // setState 的回调
  }

  if (flags & Ref) {
    // 安装 ref
  }

  // 重点:处理 Layout effects
  if (flags & LayoutMask) {
    commitHookEffectListMount(HookLayout, finishedWork)
  }
}

其中 LayoutMask 包括 useLayoutEffect 的销毁和创建。对于 useLayoutEffect,React会先执行 destory,再执行 create,这里的先后顺序永远不会被打破。

强调!useLayoutEffect 必须同步执行?

因为这一阶段的 DOM 已经更新,依然可以同步读取,但是还没有渲染在浏览器上,这时 useLayoutEffect 可以进行 DOM 的尺寸测量和位置计算、修改 DOM ,以及注入同步逻辑。 React 故意将 useLayoutEffect 安排在渲染浏览器之前,从而避免渲染在页面的 DOM 会出现闪烁,这是一个同步行为。

如果时会被问到这样的问题——为什么 useEffect 不在这个阶段执行?我们可以这么回答:

因为 useLayoutEffect 是同步 + 阻塞的,而 useEffect 是异步 + 不阻塞渲染的(调度到微任务/宏任务),所以 useEffect 不会在这个阶段的layout 执行,而是在下一轮事件循环执行。

以上阶段内容总结:

commitLayoutEffects 是 React commit 阶段的“布局阶段”,在 DOM 已更新但浏览器尚未绘制时执行。
它同步执行 useLayoutEffect 的 destroy 与 create、类组件的 didMount/didUpdate、ref 赋值等所有需要访问最新 DOM 的副作用。
React 通过 effect list 遍历仅更新的 Fiber,保证副作用按严格顺序执行,并确保 DOM 状态一致性

补充:为什么 Fiber 是“最小可工作单元”

因为 Fiber 以前是不可中断、不可暂停的“递归调用栈”,Fiber 的出现把递归变成了“可迭代的单链表结构”,每个 FiberNode 本质上是一个工作单元,这就是 Fiber “可中断”的根本原因。

React Fiber 的本质就是:把组件树从递归改写成可遍历的链表,让渲染变成可拆分的工作单元,从而可以暂停、恢复、丢弃、重做。

总结

React 16 引入 Fiber,本质上是用一套“可中断、可恢复”的调度系统来替代原先不可中断的 Stack Reconciler(递归调用栈)。Fiber 既是一种轻量级的数据结构(FiberNode),又是一套完整的渲染与调度机制(WorkLoop + Scheduler)。它通过:

  • 双缓存机制
  • 单链表化的树节点FiberNode
  • 可分片与可中断的工作单元
  • 基于优先级的调度模型

让 React 能够在渲染期间暂停、恢复、放弃任务,并在合适的时机继续执行,不去长时间的占据 JS 主线程,从而避免卡顿。

Fiber 的引入彻底改变了 React 的渲染模型,使得:

  • 渲染变得可控、不再阻塞主线程
  • 更高优先级的用户交互可以“插队”
  • 更新可以分片完成(时间切片)
  • DOM 更新被拆解成 before-mutation → mutation → layout 三阶段,更可预测、更加精细

可以说:Fiber 是 React 并发特性的基础,也是 React 性能优化的核心。

React 从同步递归 → 可调度的 Fiber 架构,是一次从“函数调用”到“任务调度”的根本性转变,为后续的 Concurrent Mode、Suspense 等特性奠定了全部基础。

以上为个人在学习过程中的一些理解和感悟,部分有参考,如有不足,欢迎指正。

学习React-DnD:实现多任务项拖拽-useDrop处理

作者 Wect
2025年11月19日 15:45

在上一篇技术分享中,我们聚焦于useDrag钩子实现Todo任务项的拖动触发逻辑,完成了任务的选中与拖拽启动功能。而拖拽交互的闭环,必然离不开放置接收环节——当用户将任务拖拽到目标位置时,如何精准判断拖动类型(单个/多个)并执行对应的排序逻辑,才是确保交互流畅性的核心。本文将详细拆解这一环节的实现思路,重点解析批量排序操作的设计与hover事件的逻辑处理。

SelectTodosDrop.gif

核心问题:区分拖动类型,匹配差异化逻辑

任务拖拽的放置接收环节,首要解决的问题是“识别当前拖动场景”。当用户拖拽任务时,存在两种典型场景:单个任务独立拖动、多个已选中任务批量拖动。这两种场景的排序逻辑存在本质差异:单个任务只需处理“源位置”与“目标位置”的双向交换;而批量任务则需要先提取所有选中项,再整体插入目标位置,同时保持选中项内部的相对顺序。

针对这一差异,我们的技术方案分为两步:一是新增批量排序的Context操作类型,专门处理多任务排序逻辑;二是在useDrop的hover事件中添加类型判断,根据是否为批量拖动执行对应逻辑。

第一步:实现批量排序操作BATCH_REORDER_TODOS

单个任务排序可通过简单的数组元素交换实现,但批量排序需要处理“选中项提取-目标位置计算-选中项插入”三个核心步骤。为此,我们新增BATCH_REORDER_TODOS操作类型,封装完整的批量排序逻辑。

1.1 核心设计思路

批量排序的核心需求是:将所有选中的任务作为一个整体,移动到目标位置,并保持选中项在原始数组中的相对顺序。具体思路如下:

  • 边界校验:排除“无选中任务”“目标位置越界”等无效场景;
  • 选中项排序:确保选中任务的顺序与原始数组一致,避免排序混乱;
  • 分离数组:将原始任务数组拆分为“选中任务”和“非选中任务”两个集合;
  • 目标位置校准:计算选中任务在非选中数组中的实际插入位置;
  • 数组重组:将选中任务整体插入目标位置,形成新的任务数组。

1.2 完整代码实现与解析

以下是BATCH_REORDER_TODOS在Context reducer中的实现代码,关键步骤已添加详细注释:

case ActionTypes.BATCH_REORDER_TODOS:
  {
    // 从action中获取目标位置索引和移动方向
    const { destinationIndex, direction } = action.payload;

    // 边界条件检查:无选中任务/目标位置越界则返回原状态
    if (!state.selectedTodos.length || destinationIndex < 0 || destinationIndex >= state.todos.length) {
      return state;
    }

    // 复制原始任务数组,避免直接修改state
    let newTodos = [...state.todos];

    // 关键:按选中任务在原始数组中的顺序重新排序
    // 通过findIndex匹配id,确保排序与原始位置一致
    let tempSelectedTodos = [...state.selectedTodos].sort((a, b) => {
      return newTodos.findIndex(todo => todo.id === a.id) - newTodos.findIndex(todo => todo.id === b.id);
    });

    // 创建选中任务ID集合,用于快速过滤非选中任务
    const selectedTodoIds = new Set(tempSelectedTodos.map(todo => todo.id));

    // 从原始数组中移除所有选中任务,得到非选中任务数组
    const nonSelectedTodos = newTodos.filter(todo => !selectedTodoIds.has(todo.id));

    // 计算选中任务在非选中数组中的实际插入位置
    // 原理:通过目标位置的任务ID,找到其在非选中数组中的索引
    let actualDestinationIndex = nonSelectedTodos.findIndex(todo => todo.id === state.todos[destinationIndex].id);

    // 确保目标位置不超出非选中数组范围
    actualDestinationIndex = Math.min(actualDestinationIndex, nonSelectedTodos.length);

    // 根据移动方向微调目标位置:UP表示向上移动,需将插入位置后移一位
    if(direction === 'UP'){
      actualDestinationIndex++;
    }

    // 重组数组:将选中任务整体插入目标位置
    const resultTodos = [
      ...nonSelectedTodos.slice(0, actualDestinationIndex), // 目标位置前的非选中任务
      ...tempSelectedTodos, // 选中任务整体插入
      ...nonSelectedTodos.slice(actualDestinationIndex) // 目标位置后的非选中任务
    ];

    // 返回新状态,保持选中状态方便用户继续操作
    return {
      ...state,
      todos: resultTodos,
      selectedTodos: [...state.selectedTodos],
    }
  }

1.3 配套Action创建函数

为了在组件中调用批量排序逻辑,我们需要创建对应的action创建函数,将目标位置和移动方向作为参数传递:

// 批量重新排序任务的action创建函数
batchReorderTodos: (destinationIndex, direction) => {
  dispatch({
    type: ActionTypes.BATCH_REORDER_TODOS,
    payload: { destinationIndex, direction },
  });
},

第二步:优化useDrop的hover事件,区分拖动类型

useDrop钩子的hover事件是处理“放置接收”的关键——当拖拽的任务悬停在目标任务上时,需要实时计算位置并触发排序。我们在此处添加“是否为批量拖动”的判断,分别执行单个和批量排序逻辑。

2.1 核心交互逻辑

hover事件的核心需求是“精准判断插入位置”:

  • 批量拖动时:根据鼠标在目标任务上的垂直位置(上半部分/下半部分),确定整体插入方向(上方/下方);
  • 单个拖动时:直接匹配源任务与目标任务的位置,执行交换排序。

同时需要避免“自我交换”问题——当拖拽的任务与目标任务为同一任务(单个拖动),或目标任务属于选中任务集合(批量拖动)时,不执行任何操作。

2.2 完整hover事件代码实现

hover: (item, monitor) => {
  if (item.selected) {
    // 场景一:批量拖拽逻辑
    // 避免将选中任务拖到自身集合内
    if (item.selectedTodos.some(selectedTodo => selectedTodo.id === todo.id)) return;

    // 获取目标任务在原始数组中的索引
    const destinationIndex = todos.findIndex(oneTodo => oneTodo.id === todo.id);

    // 计算鼠标在目标任务元素上的垂直偏移量
    // monitor.getClientOffset().y:鼠标在视口中的Y坐标
    // divRef.current.getBoundingClientRect().top:目标元素顶部在视口中的Y坐标
    const hoverOffset = monitor.getClientOffset().y - divRef.current.getBoundingClientRect().top;
    // 目标元素的半高,作为判断插入方向的阈值
    const halfHeight = divRef.current.offsetHeight / 2;

    if (hoverOffset > halfHeight) {
      // 鼠标在目标元素下半部分:将选中任务插入到目标元素下方
      batchReorderTodos(destinationIndex, 'UP');
    } else {
      // 鼠标在目标元素上半部分:将选中任务插入到目标元素上方
      batchReorderTodos(destinationIndex, 'DOWN');
    }
  } else {
    // 场景二:单个拖拽逻辑
    // 避免任务自我交换
    if (item.id === todo.id) return;

    // 关键:通过ID获取实时索引,而非依赖初始索引(避免快速拖拽导致的索引混乱)
    const sourceIndex = todos.findIndex(oneTodo => oneTodo.id === item.id);
    const destinationIndex = todos.findIndex(oneTodo => oneTodo.id === todo.id);

    // 执行单个任务排序
    reorderTodos(sourceIndex, destinationIndex);
  }
},

2.3 关键技术点解析

上述代码中,有两个极易踩坑的技术点需要重点关注:

  1. 索引获取方式:放弃“依赖初始索引”的方式,改用findIndex通过任务ID获取实时索引。这是因为快速拖拽过程中,任务数组顺序会动态变化,初始索引会失效,而ID作为唯一标识能确保索引精准。
  2. 批量拖动的位置判断:通过“鼠标垂直偏移量+元素半高”的组合,实现“hover上半部分插上方,hover下半部分插下方”的自然交互。这种设计符合用户直觉,避免了“拖拽到任务边缘时位置判断模糊”的问题。

功能闭环:从拖动到放置的交互优化

通过新增批量排序操作和优化hover事件逻辑,我们完成了Todo任务拖拽的完整功能闭环。实际使用中,用户可通过以下流程完成拖拽操作:

  1. 单个任务拖拽:直接拖动目标任务,悬停到目标位置即可完成排序;
  2. 批量任务拖拽:先选中多个任务(可通过Ctrl/Shift键辅助),拖动任意选中任务,整体悬停到目标位置,根据鼠标位置完成批量插入。

这种实现方式既保证了操作的灵活性,又通过边界校验(如越界判断、自我交换排除)确保了功能的稳定性。下图为批量拖拽的实际效果演示:

总结

本文通过“批量排序操作封装+hover事件类型区分”的技术方案,解决了Todo任务拖拽中“放置接收”的核心问题。核心亮点在于:

  • 用ID作为索引匹配的唯一标识,避免了动态排序中的索引混乱问题;
  • 批量排序时保持选中项的原始顺序,符合用户操作预期;
  • 基于鼠标位置的精细判断,提升了拖拽交互的流畅性。

打包票!前端和小白一定明白的人工智能基础概念!

作者 孟祥_成都
2025年11月18日 13:38

AI时代,不知道你是否和我有同样的经历:搜索了大量号称“小白也能看懂”的AI科普文章,结果点进去,仍有90%的内容让人一头雾水。

这篇文章,是我在阅读众多资料后,整理出的一份更易懂的总结。它不强求全面,但力求逻辑清晰、层层递进——从基础概念逐步引出更复杂的内容,而不是一上来就抛出“神经网络”“深度学习”或“ChatGPT预测模型”这样的术语。

我相信,只要你具备初中知识水平,就能轻松理解。让我们开始吧!

一、人工智能的起源

1956年,一群科学家在达特茅斯会议上首次提出“人工智能”这一概念。他们讨论的核心问题是:如何制造出能够学习并模拟人类智能的机器。

从此,人工智能作为一个独立的研究领域正式诞生。

但问题在于,机器处理信息的方式与人类截然不同。机器接收的所有数据最终都会转化为数字(包括文字)。

简单解释为什么文字在计算机内部也是数字表示的,就涉及到编码的知识,例如 ASCII 编码,字母 a 在计算机内部表示 97。而 97 最终会被解释而 2 进制,因为计算机本身就是 2 进制的。它只能认识 0 和 1。然后我们将数字和文字做个映射,例如 01100001 表示字母 a ,而 01100001 的 10 进制就是 97.

我们抽象一下计算机的思考方式,简单来说就是:

f(x)=y

  • 我们向计算机输入参数 x

  • 计算机将参数转为数字(这就是为什么很多文章说什么向量这个概念,向量可以简单理解为数字组成的多维数组,也就是说例如 “苹果” 这个词,最终要转化为数字,计算机才能理解),然后通过函数 f 处理并计算

  • 最终输出结果 y

但这显然不是人类思考问题的方式。那么,如何让机器具备类似人类的判断与学习能力,成为真正的“智能机器”呢?

科学家们提出了不同的思路。

二、符号主义:用规则模拟智能

在人工智能的早期阶段,符号主义(Symbolism)是一种主流思路。它认为,可以通过数学逻辑来模拟人类的推理过程。

举个例子,我们设计一个判断是否下雨的机器:

  • 参数 aa:是否为阴天

  • 参数 bb:湿度是否大于70%

只有当 a 和 b 同时为“真”时,机器才输出“要下雨”,否则输出“不下雨”。

这种思路本质上就是编程中的 if...else... 逻辑。

别小看符号主义,它的成功应用之一就是“专家系统”。比如在医疗诊断中:

  • 从 头疼 + 发热 + 咳嗽 的症状 → 能推测出得了流感

  • 从 腹痛 + 尿血 → 能推测出得了 肾结石

通过一系列规则组合,专家系统能够模拟人类专家的决策过程,并在特定领域取得了显著成果。

但它也有明显的局限:

  1. 规则难以统一:比如面对同一张股票走势图,不同专家可能做出完全相反的判断。

  2. 无法自主学习:系统本身不具备学习能力,依赖人工更新规则。

随着研究的深入,另一种思路逐渐兴起:与其预设所有规则,不如让机器自己从数据中学习。这就是“联结主义”(Connectionism)。

三、联结主义:让机器自己学习

这种模式有点像训狗,你说坐下,它坐下你就奖励零食,如果错了,就跟它一飞腿,这样你就能训练出一个会听坐下指令的狗了。

我们把狗换成机器,也可以用同样的方式训练,让它在某个任务下完成任务。例如说现在要训练一个能识别苹果图片的智能。

举例:识别苹果

那么机器肯定要识别苹果的特征,才能区别别的图片,假设我们设置了如下维度

  • 直径: 苹果直径大约10cm

  • 颜色: 苹果是红色

  • 形状: 苹果是球形

例如在某些条件下,直径,颜色,形状都符合苹果特性的条件下,才是苹果,但是我们之前说了,计算机只认识数字,只能通过计算来判断,所以我们需要结合一些数学公式来把 直径,颜色,形状,映射为数字,通过数字的计算映射它们在现实生活的是否对应。

既然文字也可以通过数字映射,例如 97 代表数字 a,那么其它属性也可以,例如(我乱说的,就是表达一种意思),我们把形状,颜色,和直径,都理解为权重。什么意思呢?我们举个例子:

假设我们给每个特征分配一个权重(weight),代表这个特征对“是否是苹果”的重要程度:

  • 直径:苹果直径约 10cm 权重 = +0.6(越接近 10cm 越可能是苹果)

  • 颜色:红色程度(0 不是红,1 是红) 权重 = +0.3(红色对判断有贡献)

  • 形状:球形程度(0 不是球形,1 是球形) 权重 = +0.4(球形对判断有贡献)

然后,我们设计一个简单的“苹果得分”公式:

苹果得分=(直径得分)×0.6+(颜色得分)×0.3+(形状得分)×0.4

然后得出来的值,如果大于 1 就是苹果,如果小于 1 就不是苹果。

计算例子

例1:一个红苹果(直径 10cm,红色,球形)

  • 直径得分 = 1
  • 颜色得分 = 1
  • 形状得分 = 1

苹果得分 = 1×0.6+1×0.3+1×0.4=1.3

例2:一个橙子(直径 8cm,橙色,球形)

  • 直径得分 = 0.8(假设 8cm 离 10cm 差 2cm,得分 0.8)
  • 颜色得分 = 0(不是红色)
  • 形状得分 = 1

苹果得分 = 0.8×0.6+0×0.3+1×0.4=0.48+0.4=0.88

大家应该明白上面的意思了吧。我们再次抽象为数学公式,也就是变为 1 次函数。将得分用 x 表示,将权重用 w 表示,如下:

z = (w1 × x1) + (w2 × x2) + (w3 × x3) + b

其中:

  • w1,w2,w3 是各特征的权重(重要性)

  • b 是偏置项(可理解为判断门槛)

所以

  • 如果 z≥0,判定为苹果

  • 如果 z<0,判定为非苹果

因为有 w1,w2,w3 3个参数,不利于我们后面的讲解,我们再次简化公式,来帮助我们理解后面的概念。

z = (w1 × x1) + (w2 × x2) + b

变为只有两个参数来决定是否是苹果,其实这是这是初中数学中的 线性方程,它的图像是一条直线。如下:

这条直线下方的就是就是非苹果,上方的就是苹果。

接下来有人会问,你说形状,直径这些特征的值,是怎么来的呢?

它们当然不是天然存在的,而是需要我们人为设计和提取的。这个过程在传统机器学习中被称为 “特征工程”。我们又要举一个粗糙的例子了,我们拿颜色得分来举例:

  • 思路: 苹果通常是红色、绿色或黄色。我们需要量化“红色程度”。

  • 设计方法:

    • 如果是从图片中提取: 计算机可以分析图片的所有像素点。

      • 将图片从RGB颜色空间转换到 HSV 颜色空间(H代表色调,能更好地表示颜色本身)。
      • 统计所有像素中,色调(H)在红色范围内(比如0-10度和350-360度)的像素比例。比例越高,x2越接近1。
    • 如果是从文字描述提取: 如果我们的数据是文字“深红色”,我们可以建立一个颜色词典:

      • “深红色” -> x2 = 0.9

      • “浅红色” -> x2 = 0.7

      • “绿色” -> x2 = 0.3(因为青苹果也存在)

      • “蓝色” -> x2 = 0

好了,特征得分我们解决了,然后就是训练,调整 w1 和 w2 参数,从而找出一个分界线,在分界线范围内的就是苹果,范围外的就是其它。当然这个分界线不一定是直线,也可以是很复杂的曲线范围,我们只是为了引出核心概念,就是:

“机器学习”!

虽然没有直接说出“机器学习”四个字,但已经完整地描绘了它的核心思想:通过数据(苹果的特征)和反馈(得分是否大于阈值),让机器自动调整内部参数(权重 w 和偏置 b),从而学会一项任务(识别苹果)。

我们接着聊,刚才我们举得例子非常粗糙,但可以很容易的理解大概的意思。

上面这种机器学习的思路,称为联结主义,可这种思路在最初曾一度被整个世界称为骗子思路!

为什么是骗子?

刚才我们举了一个非常简单的例子,让机器根据 直径、颜色 来判断是不是苹果。

我们最终把它抽象成一个数学公式:

z = (w1 × x1) + (w2 × x2) + b

本质上,它就是一个一次函数(二维下是一条直线,三维下是一张平面)。

在很多任务中,这种模型真的能工作。

比如“苹果 vs 不是苹果”,

如果苹果的数据大多集中在同一块区域,那么一条直线(或平面)确实能把它们区分开来。

如果有一个任务,根本无法用一条直线分开呢?

科学家们很快就发现:

并不是所有问题都像识别苹果一样简单。

其中最典型的例子就是 异或 XOR )问题

先看什么是异或:

输入 A 输入 B 输出
0 0 0
0 1 1
1 0 1
1 1 0

也就是输入是一样的情况,例如都是 0 或者都是 1 ,得到的结果是 0,否则得到的结果是 1。

如下图,我们是无法找到一条直线,分割红色点和蓝色点的

这就意味着,简单的线性模型,有些情况是无法模拟的!

也就是说 ❌ 再怎么调整 w1、w2、b,这个模型也永远无法学会异或。

这导致了人工智能历史上第一次寒冬(AI Winter)。

四、突破:引入非线性!—— 神经网络的诞生

后来科学家们发现:

如果在两个线性模型中间,再加上一层“非线性函数”,就能解开异或。

你可以把思想理解成:

  • 一条直线不能分的

  • 两条直线可以

  • 让模型像搭积木一样把“多条直线”组合起来 → 就能拼出复杂的边界

什么意思呢,我们可以简单用下入理解:

如上图右边,是不是边界变成了不是一条直线,而是两个曲线去分割呢,借助这这种思路就解决了异或问题。

这就是**神经网络(Neural Network)**最核心的思想:

多个简单模型叠在一起,中间加上激活函数(非线性), 就能表达更复杂的决策边界。

我们从开始的线性模型,也就是单层结构是:

输入 → 权重加权 → 激活函数 → 输出

简单来说就是输入 x1, x2 的得分 —> 跟 w1,w2 权重计算 -> 跟阈值对比,例如大于 1 就是苹果 -> 输出是否是苹果

然后再看看新的多层结构:

输入 → [权重加权 + 激活函数] → [权重加权 + 激活函数] → 输出

好了至此,我们就明白了神经网络的概念,神经网络也是联结主义的一部分。此时再次解释以下联结主义的主张:

“智能来自大量简单单元(神经元)的连接和学习,而不是手写规则。”

我们再来一个小小结,就是从符号主义,这种依靠人自身写规则到联结主义,到神经网络,我们逐渐步入了新的人工智能时代。

五、从神经网络到深度学习:当网络变得“深不可测”

好了,现在我们明白了神经网络——它通过多层连接,巧妙地解决了简单模型无法处理的复杂问题(如异或问题)。那么,深度学习又是什么呢?

其实答案出乎意料的简单:

深度学习 = 特别“深”的神经网络。

这里的“深”,指的不是哲学的深邃,而是字面意思上的层数非常多。

我们可以做一个直观的对比:

  • 传统的神经网络:可能只有几层(比如一个输入层、一个隐藏层、一个输出层)。就像一个简单的三明治。

  • 深度学习模型:则拥有十几层、上百层甚至上千层。这就像一个巨无霸汉堡,拥有无数层馅料和面包。

为什么层数多了就厉害?

还记得我们之前手动设计“特征”的麻烦吗?(比如要自己写规则计算“颜色得分”、“形状得分”)深度学习的强大之处在于,它能够自动完成这件事,而且做得比我们好得多。

我们拿一个识别狗的例子举例:

我们可以把一个深度网络理解成一个分工极其精细的流水线工厂,用来识别一张“狗”的图片:

  1. 第一层(最基础的工人):只负责检测图像中最简单的边缘和色块。比如这里是横线,那里是竖线,那片是黑色。
  2. 中间几层(初级组装工):接收下一层的“边缘和色块”,把它们组合成更复杂的局部特征。比如,“两个圆圈”可能是眼睛,“一个三角形”可能是耳朵。
  3. 最后层(最终决策者):基于前面所有层传递过来的、已经高度抽象化的信息(比如“这是一个有胡须、尖耳朵、竖瞳的动物面部”),最终做出判断:“这是狗。”

这个过程,就是一个“逐层抽象,不断精炼”的过程。 每一层都在前一层的基础上,学习并提取更复杂、更核心的特征。网络越深,能学到的特征就越抽象,解决问题的能力也就越强。

到此我相信大部分应该明白了几个很基础的概念,就是机器学习,神经网络,深度学习的概念。我是一名前端开发技术专家,目前除了开始接触 ai 部分的知识,前端部分正在写关于 headless 组件库教程 这是网站首页,欢迎大家给个赞哦,感谢!同时也在写酷炫的动画教程,有兴趣的同学可以看这篇文章

下一章我将简单介绍一下 chatgpt 的基本原理,也是面相纯小白,也绝对包票你能看懂。

❌
❌