普通视图

发现新文章,点击刷新页面。
今天 — 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的原生功能
  • 下篇再写动作,脑子不够用了

东杰智能:拟向遨博山东采购机器人产品,金额2432.5万元

2025年11月19日 20:55
36氪获悉,东杰智能公告,公司拟与关联方遨博(山东)智能机器人有限公司签订采购合同,货物总价格(含税)2432.5万元。公司全资子公司太原东杰装备有限公司生产车间于2009年投入使用,建设较早,自动化水平偏低。为了提升公司生产车间的自动化水平,降低成本,本次采购遨博焊接机器人50台用于提升焊接效率及质量。因公司海辰菏泽项目及马来西亚PTT项目等需要,采购遨博焊接机器人250台。为了提升员工的办公体验,采购5台双臂咖啡机器人,分别设置在公司及子公司办公区域,供全体员工及客户使用。

速达股份:股东琪韵投资拟减持不超3%公司股份

2025年11月19日 20:46
36氪获悉,速达股份公告,持股5%以上股东上海琪韵投资管理事务所(普通合伙)因企业经营需要,计划通过集中竞价方式减持不超过76万股(占总股本的1%),通过大宗交易方式减持不超过152万股(占总股本的2%)。减持股份来源为公司首次公开发行前发行的股份,减持期间为自公告披露之日起15个交易日后的90个自然日内(即2025年12月11日至2026年3月10日),减持方式包括集中竞价和大宗交易,减持价格按市场价格确定。

国盛证券:实控人将由江西省交通运输厅变更为江西省国资委

2025年11月19日 20:44
36氪获悉,国盛证券公告,收到控股股东江西交投的《告知函》,经江西省人民政府同意,江西省交通运输厅拟将其持有的江西交投90.00%股权由无偿划转至江西国控调整为直接无偿划转至江西省国资委。本次划转完成后,公司实际控制人将由江西省交通运输厅变更为江西省国资委,控股股东仍为江西交投。此次股权无偿划转符合相关法律法规规定,不会对公司治理及生产经营产生重大影响。

湖南白银:长城资管拟减持不超1.95%公司股份

2025年11月19日 20:38
36氪获悉,湖南白银公告,持股5%以上股东中国长城资产管理股份有限公司(长城资管)计划在本公告披露之日起15个交易日后的3个月内(即2025年12月11日至2026年3月10日),通过集中竞价和大宗交易方式合计减持公司股份不超过5,500万股,占公司总股本比例1.95%。减持原因为自身经营和发展需要。

赛伍技术:小批量交付钙钛矿叠层组件光转膜,对业绩影响较小

2025年11月19日 20:32
36氪获悉,赛伍技术公告,公司近期于公众号发布的《全球首家!赛伍技术实现钙钛矿叠层组件光转膜批量交付》文章中提到的“全球首家”仅为基于公司对行业认知及对行业信息检索的判断,目前暂无市场机构具体排名。此次批量交付为小批量交付,目前该业务处于商业化初期阶段,出货量以及销售金额极小,占公司2024年度营业收入比重不足0.05%,短期内不会对公司业绩产生重大影响。未来公司产品需求受钙钛矿产业化影响,存在不确定性。
❌
❌