普通视图

发现新文章,点击刷新页面。
今天 — 2025年9月16日掘金 前端

2025 年还有前端不会 Nodejs ?

2025年9月16日 09:11

大家好,我是双越。前百度 滴滴 资深前端工程师,慕课网金牌讲师,PMP。我的代表作有:

  • wangEditor 开源 web 富文本编辑器,GitHub 18k star,npm 周下载量 20k
  • 划水AI Node 全栈 AIGC 知识库,包括 AI 写作、多人协同编辑。复杂业务,真实上线。
  • 前端面试派 系统专业的面试导航,刷题,写简历,看面试技巧,内推工作。开源免费。

近期我准备开发一个 AI Agent 智能体项目 智语zhitalk AI 智能面试官,可以优化简历、模拟面试。很多同学想加入项目学习,但是自己是前端不会 Nodejs ,于是我就先写一篇 Nodejs 的入门资料,帮他们初步了解 Nodejs 以便能快速跟上项目进度。如有问题可留言或私信。

开始

Nodejs 于 2009 年发布,现在 2025 年 8 月已经更新到 v24 版本,现在几乎所有的开放平台、云服务、Serverless 等都支持 Nodejs 。它早已经成为最热门的开发语言之一,而且随着国外对于 SSR 全栈的推广,还在继续扩展它的影响范围。

还有,如果你作为前端人员想接触 AI 开发,最便捷的方式就是使用 Nodejs 作为开发语言,学习成本最低。

接下来我试图用一篇文章,用最简单的方式,帮助各位前端开发人员入门 Nodejs ,其他它很简单。你先不要看 Nodejs 文档,你先按照我的学习方式来。

学习要求:

  • 熟悉 JS 语法,熟悉 npm 使用
  • 熟悉 HTTP 协议,做过 ajax 请求

JS runtime 运行时

一段 JS 代码或其他编程代码,本质上就是一段字符串,就跟我们随便写一句话、一段话一样,都是字符串。

console.log('hello world')

一段字符串能干嘛?能看,能读,能写,能复制,能粘贴。如果是富文本能设置一下颜色字体大小等...

它能被执行吗?

如果我们拷贝一段 JS 代码,穿越到 JS 语言发明之前的 90 年代(那会儿 PC 技术已经很发达了,盖茨早就世界首富了),你如何执行它?不能。

最开始 JS 代码(字符串)是在浏览器中执行的。浏览器中内置了 JS 解释器,然后你在 html 代码中通过特定的语法 <script> 标签内嵌 JS 代码,浏览器识别以后执行这段 JS 代码。

一个语言的执行环境,我们就称为 runtime “运行时”,没有 runtime 代码就无法被执行,就是一堆字符串,任何语言都需要 runtime 。

Nodejs 就是继浏览器以后第二个 JS 代码的 runtime 运行时。从此以后,JS 代码不只能运行在浏览器了,它还可以运行在 Nodejs 环境。而 Nodejs 可以被安装在任何操作系统中(windows mac linux 等),也就是说 JS 代码可以在任何电脑上运行,即便没有浏览器也可以运行。

现在的 JS 运行时除了浏览器、Nodejs 之外还有 Deno Bun ,这俩用途都和 Nodejs 类似,而且目前尚未全面流行,你可以先保持观察,不用深入。

安装 Nodejs 并写 demo

现代前端开发都是用 npm 管理插件,用 nodejs 支持打包工具,所以你应该是安装了 nodejs 的。

如果没有安装,就下载安装一下,安装完以后打开控制台输入一下命令,可以看到 Nodejs 的版本。

node --version
npm --version

然后在控制台输入 node 即可进入 nodejs 环境,可输入任何 JS 代码,按 ctrl+c 退出

image.png

你可以创建一个 .js 文件,然后使用 node 命令来执行它。例如我在当前的 src 目录下创建了 test.js 文件

// src/test.js
function fn() {
  console.log('this is a test file')
}

fn()

然后使用 node 命令执行它,即可打印预期的结果

image.png

不能使用浏览器 API

Nodejs 运行时可以执行 JS 代码,但你不能使用浏览器 API ,因为这里根本没有浏览器

例如我在 Nodejs 环境执行 alert('hello') 就报错了 alert is not definedalert 没有定义,Nodejs 中没有 alert ,只有浏览器中才有 alert

image.png

所以,JS 语法和浏览器 API 要分开来看,他俩也压根不是一个东西,只是初学者一块学习就容易混在一块。

常见的浏览器 API 还有:

  • DOM 操作,如 document getElementById createElement appendChild
  • DOM 时间,如 addEventListener
  • BOM 操作,如 window navigation sreen
  • XMLHTTPRequest ,但 fetch 是可以在 Nodejs 中使用的

Nodejs 内置的 API

Nodejs 不能使用浏览器 API ,这个好理解,因为不是浏览器环境。

但如果 Nodejs 只能执行 JS 语法,没有其他 API 这也不行啊。因为光有 JS 语法,写个函数、做个计算、打印个字符串等,解决不了具体的问题呀。

所以,Nodejs 也需要提供(内置)其他 API 让我们能开发具体的功能。

Nodejs 是作为一个软件安装在操作系统上的,所以 Nodejs 提供了关于操作系统的 API

新建一个文件 os.js 写入如下代码

// src/os.js

const os = require('os') // 和 import 语法类似

function getSystemInfo() {
  return {
    platform: os.platform(),
    type: os.type(),
    architecture: os.arch(),
    cpuCount: os.cpus().length,
    cpuModel: os.cpus()[0].model,
    totalMemoryGB: Math.round(os.totalmem() / 1024 / 1024 / 1024),
    hostname: os.hostname(),
  }
}

const systemInfo = getSystemInfo()
console.log(systemInfo)

然后在控制台使用 node 命令执行这个文件,即可打印我当前的系统信息

image.png

至此就能开始体现 Nodejs 和浏览器 JS 的区别了。你一定要注意去观察和思考这种区别,以及两者不同的用途,这个很重要。

像以上这些 OS 的信息,浏览器能获取吗?当然不能。浏览器是客户端,如果能轻松获取 OS 信息,那就是安全漏洞。而 Nodejs 是服务端或者本机,我自己获取我自己的 OS 信息,这肯定是没问题的。

获取 OS 信息这个功能不常用,这也只是一个简单的例子,接下来我们才正式开始写一些实际的功能。

文件 API

Nodejs 是作为一个软件安装在操作系统上的,文件是操作系统的数据组织方式,所以 Nodejs 作为服务端语言要能操作文件,如创建文件、读取文件、修改文件、删除文件、复制文件等操作。

新建一个 js 文件 src/file.js 写入如下代码

首先,引入 fs path 两个模块。其中 fs 就是 file system 文件系统,用于操作文件,path 用于操作文件路径。

const fs = require('fs')
const path = require('path')

然后定义文件路径,我们计划在 file 目录下创建 data.txt 文件

// 定义文件路径
const fileDir = path.join(__dirname, '../file')
const filePath = path.join(fileDir, 'data.txt')

然后创建文件

// 功能1: 在 /file 目录下创建 data.txt 文件并写入内容
function createAndWriteFile() {
  // 确保文件目录存在,否则先目录和文件
  if (!fs.existsSync(fileDir)) {
    fs.mkdirSync(fileDir, { recursive: true }) // 创建目录和文件
    console.log('创建目录:', fileDir)
  }

  // 写入文件内容
  fs.writeFileSync(filePath, content, 'utf8') // 写入内容
  console.log('文件创建成功:', filePath)
  console.log('写入内容:', content)
}

createAndWriteFile()

在控制台执行 node src/file.js 可以看到 data.txt 文件被创建出来了

image.png

然后继续写代码,读取文件内容

// 功能2: 读取文件内容
function readFile() {
  try {
    const data = fs.readFileSync(filePath, 'utf8') // 读取文件内容
    console.log('读取的文件内容:', data)
    return data
  } catch (error) {
    console.error('读取文件失败:', error.message)
    return null
  }
}
readFile()

在控制台执行 node src/file.js 可以看到读取了正确的文件内容

image.png

Nodejs 还有更多文件操作的 API ,你可以咨询 AI 或让 AI 给你写出 demo

HTTP 服务

Nodejs 对前端人员最大的赋能就是:可以开发服务端,可以前后端都做,可以做全栈开发。

前端主要开发 HTML CSS JS ,通过 ajax 访问服务端 API 接口获取 JSON 数据,然后渲染到页面上。

服务端开发,最主要的就是 API 接口,接收前端 ajax 请求,获取数据,最后返回数据给前端。

新建一个 js 文件 src/http.js 写入如下内容

  • 引入 Nodejs 内置的 http 模块,用于启动 HTTP 服务
  • 通过 createServer 创建一个 HTTP 服务,函数内的两个参数分别代表 RequestResponse ,这两个是 HTTP 协议的基础知识 —— 你写前端 ajax 也用得着,默认你已经熟知
  • 函数内,通过 res.setHeader 设置 HTTP header(前端 ajax 也需要设置 header),返回格式为 JSON
  • 函数内,通过 res.end 返回本次请求的内容,要返回字符串格式
  • 通过 server.listen 监听 3000 端口
const http = require('http')

// 创建 HTTP 服务器
const server = http.createServer((req, res) => {
  // 设置响应头为 JSON 格式
  res.setHeader('Content-Type', 'application/json')

  // 创建要返回的 JSON 数据
  const response = {
    message: 'Hello World!',
    time: new Date().toLocaleString(),
    status: 'success',
  }

  // 返回 JSON 响应
  res.end(JSON.stringify(response, null, 2))
})

// 监听 300 端口
server.listen(3000, () => {
  console.log('HTTP 服务器已启动,监听端口: 3000')
  console.log('访问地址: http://localhost:3000')
})

在控制台运行 node src/http.js 可启动本地的 HTTP 服务,并监听 3000 端口

image.png

使用浏览器访问 localhost:3000 可以看到服务端返回的 JSON 内容,说明 API 接口成功了

image.png

以上最简单的 get 请求,而且没有判断路由,下面再来一个稍微复杂一点的例子。

新建一个 JS 文件 src/http-post.js 代码如下,模拟一个创建用户的 API 接口

  • 使用 req.method 可以获得前端 ajax 请求的 method ,这里规定必须是 POST
  • 使用 req.url 可以获得前端 ajax 请求的路由,这里规定必须是 /api/user
  • 使用 req.on('data', (chunk) => { }) 可以接收到客户端传递过来的 request body 数据
const http = require('http')

// 创建 HTTP 服务器
const server = http.createServer((req, res) => {
  // 设置响应头为 JSON 格式
  res.setHeader('Content-Type', 'application/json')

  // 检查是否为 POST 请求且路由为 /api/user
  if (req.method === 'POST' && req.url === '/api/user') {
    let body = ''

    // 接收数据
    req.on('data', (chunk) => {
      body += chunk.toString()
    })

    // 数据接收完成
    req.on('end', () => {
      try {
        // 解析 JSON 数据
        const userData = JSON.parse(body)

        // 打印接收到的数据
        console.log('接收到的用户数据:', userData)
        console.log('userId:', userData.userId)
        console.log('name:', userData.name)
        console.log('email:', userData.email)
        
        // TODO...

        // 返回成功响应
        const response = {
          status: 'success',
        }

        res.statusCode = 200
        res.end(JSON.stringify(response))
      } catch (error) {
        // 如果 JSON 解析失败,返回错误
        const response = {
          status: 'error',
          message: '无效的 JSON 数据',
        }

        res.statusCode = 400
        res.end(JSON.stringify(response))
      }
    })
  } else {
    // 其他请求返回简单提示
    const response = {
      message: '请发送 POST 请求到 /api/user',
    }

    res.end(JSON.stringify(response))
  }
})

// 监听 3000 端口
server.listen(3000, () => {
  console.log('HTTP 服务器已启动,监听端口: 3000')
  console.log('POST 请求地址: http://localhost:3000/api/user')
})

控制台运行 node src/http-post.js 启动服务并监听 3000 端口。

但 POST 请求我们不能直接使用浏览器访问测试,因为浏览器访问是 GET 请求。

测试 POST 请求一般有两种方式,如果你控制台支持 curl 命令,可以使用它来测试,在控制台运行

curl -X POST http://localhost:3000/api/user \
  -H "Content-Type: application/json" \
  -d '{"userId": 123, "name": "张三", "email": "zhang@example.com"}'

或者,你安装 Postman 然后选择 POST 输入 url 和 body 信息,点击 Send 按钮,即可得到返回结果。

image.png

至此 HTTP 服务端的基础知识其实都已经包含了

  • 启动 HTTP 服务,监听端口
  • Request Reponse
  • method
  • url
  • 获取 Request body
  • 返回数据

通过这些知识搞一个基础的 HTTP 服务端是完全没有问题的,都在这个范围之内。例如你想要获取 JWT token 或者设置 cookie 都可以通过操作 HTTP header 来搞定,具体可以咨询 AI ,不看文档方便。

另,以上代码中的 // TODO... 位置,可以把 user 数据插入到数据库,这一点后面再说。

Koa 框架

在 Nodejs 刚开始流行的时候,早就有人总结了 Nodejs 作为服务端的通用能力,并且开发了框架,可以让我们更加便捷的开发 Nodejs 服务端,其中最出名的就是 expresskoa

两者设计思路和使用方式都一样,而且很多年都没变,早已稳定了,我们以后者 koa 为例。

新建一个目录,执行 npm init -y 创建一个 package.json 文件,和做前端开发一样。

然后安装 koa 和 nodemon ,后者用于启动服务,和代码逻辑没关系,可暂时不用管

npm i koa
npm i nodemon -D

然后创建一个目录 src 再在里面创建一个文件 index.js 写入如下代码

// src/index.js

const Koa = require('koa')
const app = new Koa()

app.use(async (ctx) => {
  ctx.body = 'Hello World'
})

app.listen(3000)

然后在 package.json 文件加入一个命令 "dev": "nodemon src/index.js"

image.png

再执行 npm run dev 就可以启动 nodejs 服务,用浏览器访问 localhost:3000 就可以看到访问结果

image.png

如果你想返回一段 JSON 怎么办?直接给 ctx.body 赋值一段 JSON 即可,不用考虑转换为字符串格式

image.png

如果想要支持 POST PUT 的 HTTP 请求方法,可如下修改代码

  • 先安装 npm i koa-body 它可用于获取 request body 数据
  • 使用 ctx.method 判断 Method ,其他就很简单了
const Koa = require('koa')
const { koaBody } = require('koa-body')
const app = new Koa()

app.use(koaBody())
app.use(async (ctx) => {
  if (ctx.method === 'POST') {
    const { user, email } = ctx.request.body || {}
    console.log('user and email ', user, email)

    // 其他处理,如 await insertUserToDatabase(user, email)

    ctx.body = {
      status: 'success',
      user,
      email,
    }
  }
})

app.listen(3000)

启动 nodejs 服务端以后,我们使用 Postman 测试如下,而且服务端也打印了正确的结果

image.png

如果想使用路由,那就再安装 koa router

npm install @koa/router

代码如下

  • 通过 router.post 即可定义method 和路由 path
  • 还可以继续扩展其他路由
const Koa = require('koa')
const koaBody = require('koa-body')
const Router = require('@koa/router')

const app = new Koa()
const router = new Router()

app.use(koaBody())

router.post('/api/user', (ctx) => {
  const { user, email } = ctx.request.body || {}
  console.log('user and email ', user, email)

  // 其他处理,如 await insertUserToDatabase(user, email)

  ctx.body = {
    status: 'success',
    user,
    email,
  }
})

// 继续扩展其他路由...

app.use(router.routes())
app.use(router.allowedMethods())

app.listen(3000)

至此,你已经看到 koa 是如何启动 nodejs 服务并且处理基础的请求,其他更多的 API 你可以参考文档,也可以直接咨询 AI 更方便。

只要你熟悉前端 ajax 请求和 HTTP 协议,那这些知识点基本都难不倒你。

操作数据库

数据库最常见的操作就是增删改查 CRUD ,你可能之前听说过操作数据库需要专门的 SQL 语言,挺麻烦的。

其实现在做常见的 web 应用开发,基本不会使用 SQL 语句了,最常见的就是 ORM 工具,例如 Prisma

你一开始看它的文档肯定是看不懂的,而且也不需要你立刻看懂所有的,你只需要知道它是如何增删改查的就可以入门了。

下面这段代码能看懂吗?不要管它如何执行,你能看懂语意就可以,仅语意。

  • 数据库里有一个 user 表(就像一个 excel 表),这一点代码中没写
  • user 表中创建一行数据,两列 nameemail
  • 查询这个表中的所有数据,并打印
async function main() {
  await prisma.user.create({
    data: {
      name: 'Alice',
      email: 'alice@prisma.io',
    },
  })

  const allUsers = await prisma.user.findMany()
  console.dir(allUsers, { depth: null })
}

我想大部分人应该都能看懂这几行代码的语意,其实现代 ORM 工具操作数据库就是使用 create fined update delete 等这些函数进行增删改查的操作,并不是 SQL 语句。

再来个复杂一点的例子,这个不要求看懂,当然能看懂最好

  • 数据库中有三个表 user profile posts
  • user 表中创建一行,两列 name email ,同时:
    • posts 表中插入一行,一列 title
    • profile 表中插入一行,一列 bio
  • 查询所有 user 表中的数据,同时查询出 postsprofile 两个表的相关数据
async function main() {
  await prisma.user.create({
    data: {
      name: 'Alice',
      email: 'alice@prisma.io',
      posts: {
        create: { title: 'Hello World' },
      },
      profile: {
        create: { bio: 'I like turtles' },
      },
    },
  })

  const allUsers = await prisma.user.findMany({
    include: {
      posts: true,
      profile: true,
    },
  })
  console.dir(allUsers, { depth: null })
}

把数据库的增删改查结合到上面 nodejs 服务端代码中,就可以开发一个基础的数据服务 API 。

调用第三方服务

第三方服务一般都是 API 的形式,一般有两种调用方式,以 deepseek API 为例

第一种方式,直接发送 http 请求调用,deepseek API 文档是使用 curl 工具举例的

curl https://api.deepseek.com/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <DeepSeek API Key>" \
  -d '{
        "model": "deepseek-chat",
        "messages": [
          {"role": "system", "content": "You are a helpful assistant."},
          {"role": "user", "content": "Hello!"}
        ],
        "stream": false
      }'

其实我们可以转换为 nodejs 内置的 fetch 方法去请求,这是我用 AI 生成的代码

    await fetch('https://api.deepseek.com/chat/completions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer <DeepSeek API Key>' // 请替换为你的实际 API Key
      },
      body: JSON.stringify({
        model: "deepseek-chat",
        messages: [
          { role: "system", content: "You are a helpful assistant." },
          { role: "user", content: "Hello!" }
        ],
        stream: false
      })
    });

第二种方式,有的第三方开放平台会开发一个 npm 插件,让你直接安装并调用它的方法。例如调用 deepseek API 可以安装 openai

npm i openai

然后直接引入并调用它的方法即可。其实它背后也是发送 HTTP 请求,不过它封装了。

// Please install OpenAI SDK first: `npm install openai`

import OpenAI from "openai";

const openai = new OpenAI({
        baseURL: 'https://api.deepseek.com',
        apiKey: '<DeepSeek API Key>'
});

async function main() {
  const completion = await openai.chat.completions.create({
    messages: [{ role: "system", content: "You are a helpful assistant." }],
    model: "deepseek-chat",
  });

  console.log(completion.choices[0].message.content);
}

main();

把调用 deepseek API 结合到上面 nodejs 服务端代码中,就可以开发一个自己的 AI 接口服务。

最后

如果你能看懂大部分内容,说明你可以快速入门 Nodejs 并进行 AI 开发。有兴趣的可以关注我的 智语zhitalk AI Agent 项目。

极简三分钟ES6 - ES8中字符串扩展

2025年9月16日 08:29

padStart() & padEnd():字符串填充术

作用:给字符串加「边距」

想象给手机号码排版

// 手机号补位 
"13800138000".padStart(13, "📱") // "📱📱13800138000"
"同意".padEnd(6, "✅")           // "同意✅✅✅✅"
 
// 金额对齐 
"¥99".padStart(8)     // "     ¥99"(默认空格填充)
"¥9999".padEnd(10, "*") // "¥9999*****"

trimStart() & trimEnd():智能空格铲

作用:精准清除首尾「空白垃圾」

对比旧版trim()

// 用户输入清理 
const userInput = "  订单号:A123  ";
console.log(userInput.trim());           // "订单号:A123"(两端全清)
console.log(userInput.trimStart());      // "订单号:A123  "(仅清开头)
console.log(userInput.trimEnd());        // "  订单号:A123"(仅清结尾)

应用场景

  • 表单输入:保留用户刻意输入的尾部空格
  • 日志处理:清除行首时间戳后的多余空格

一些常见的使用场景

生成表格对齐文本

const products = [
  { name: "手机", price: 5999 },
  { name: "耳机", price: 899 }
];
 
// 生成整齐价目表 
products.forEach(p  => {
  const nameLine = p.name.padEnd(10,  " "); // 中文全角空格 
  const priceLine = ${p.price}`.padStart(8); 
  console.log(` | ${nameLine} | ${priceLine} |`);
});
 
// 输出:
// | 手机     |     ¥5999 |
// | 耳机     |      ¥899 |

安全处理用户输入

// 防止SQL注入(清理首尾特殊字符)
const searchTerm = "  ;DROP TABLE users;  "
  .trimStart()
  .trimEnd();
 
console.log(`SELECT  * FROM products WHERE name='${searchTerm}'`);
// 输出:SELECT * FROM products WHERE name=';DROP TABLE users;'

生成固定格式ID

// 订单号补零至10位
const genOrderId = (id) => id.toString().padStart(10,  "0");
 
console.log(genOrderId(325));  // "0000000325"
console.log(genOrderId(98765));  // "0000098765"

一些特殊情况

填充字符超长时截断

"Hi".padEnd(10, "ABCDE") // "HiABCDEAB"(重复填充至10字符)

原字符串更长时直接返回

"HelloWorld".padStart(5, "*") // "HelloWorld"(忽略填充)

处理Emoji和双字节字符

"🐱".padEnd(3, "❤️") // "🐱❤️❤️"(1个Emoji=2字符长度)
"中文".padStart(5, "X") // "XXX中文"(每个汉字=2字符)

与传统方案对比

需求 ES5方案 ES8方案 优势
字符串开头补零 while(str.length<10) str="0"+str str.padStart(10,"0") 代码减少80%
清除开头空格 str.replace(/^\s+/, "") str.trimStart() 无需理解正则
格式化文本对齐 手动计算空格数量 padEnd+固定长度 自适应不同内容长度

牢记

pad补位如排版,trim清空首尾端,字符串操作用ES8,简洁高效不绕弯!”

黑客劫持:周下载量超20+亿的NPM包被攻击

2025年9月16日 07:11

上周,黑客攻击者通过钓鱼手段入侵了 NPM 包维护者的账号,并在多个下载量巨大的包中注入恶意代码。这些包合计每周下载量超过 26 亿次,影响范围极其广泛。

事件经过

被劫持账号的维护者 Josh Junon(社区昵称 qix)已在第一时间确认了事件。

他表示,自己收到了伪装成 npm 官方的钓鱼邮件,发件域名是 support@npmjs.help,该域名背后的网站几乎一比一模仿了官方 npmjs.com

在邮件中,攻击者声称用户的账号 2FA(双因素认证)凭证已过期,要求立刻更新,否则账号将在 2025 年 9 月 10 日被锁定:

“作为我们持续加强账号安全的一部分,我们要求所有用户更新双因素认证(2FA)凭证。我们的记录显示,您已超过 12 个月未更新 2FA。” “为了保证您账号的安全性与完整性,请尽快完成更新。请注意,从 2025 年 9 月 10 日起,未更新的账号将被暂时锁定,以防止未经授权的访问。”

这些钓鱼邮件同时发往了其他包维护者和开发者。BleepingComputer 的调查显示,npmjs[.]help 页面上还带有一个假冒的登录表单,所有输入的凭证会被窃取并传输到如下地址:

Plain Text https://websocket-api2[.]publicvm.com/images/jpg-to-png.php?name=[name]&pass=[password]

事件曝光后,NPM 官方已经移除了部分攻击者上传的恶意版本,其中包括被广泛使用的 debug 包(每周下载量 3.576 亿次)。

攻击机制

安全公司 Aikido Security 的分析指出,攻击者在获得维护权限后,对包进行了更新,在 index.js 文件中注入了恶意代码。

这段代码本质上是一个基于浏览器的拦截器,可以劫持网络流量和应用 API。它主要针对通过 Web 访问受感染应用的用户,监控加密货币地址与交易,并将交易定向重写到攻击者控制的钱包地址。

恶意逻辑的运行方式包括:

  • 注入浏览器环境,监控 Ethereum、Bitcoin、Solana、Tron、Litecoin、Bitcoin Cash 等加密货币钱包。
  • 一旦发现交易请求,就在签名前篡改目标地址,资金最终流入攻击者账户。
  • 通过劫持 fetch、XMLHttpRequest 和钱包 API(如 window.ethereum、Solana 等)来实现拦截与篡改。

研究员 Charlie Eriksen 表示:

“这些包被更新后,会在用户的浏览器端执行一段隐藏代码,静默拦截 Web3 交互,篡改钱包调用,将资金和授权重定向至攻击者的账户,而用户毫无察觉。” “危险之处在于,它的运作层面非常广:既能篡改网页显示的内容,也能干预 API 调用,甚至能欺骗应用认为自己正在签署合法交易。”

受影响的包

目前确认被劫持的热门包包括(括号内为周下载量):

  • backslash(26 万)
  • chalk-template(390 万)
  • supports-hyperlinks(1920 万)
  • has-ansi(1210 万)
  • simple-swizzle(2626 万)
  • color-string(2748 万)
  • error-ex(4717 万)
  • color-name(1.917 亿)
  • is-arrayish(7380 万)
  • slice-ansi(5980 万)
  • color-convert(1.935 亿)
  • wrap-ansi(1.9799 亿)
  • ansi-regex(2.4364 亿)
  • supports-color(2.871 亿)
  • strip-ansi(2.6117 亿)
  • chalk(2.9999 亿)
  • debug(3.576 亿)
  • ansi-styles(3.7141 亿)

影响范围

尽管此次攻击的影响极大,但并非所有用户都会中招。Privy 首席安全工程师 Andrew MacPherson 指出,受害需要满足以下条件:

  1. 在 9 月 8 日美东时间上午 9:00 至 11:30 之间进行全新安装。
  2. 在该时间段生成了新的 package-lock.json 文件。
  3. 项目依赖链中包含了受影响的包。

这意味着实际受害范围要比初看时小很多。

背景与趋势

这并非孤立事件。过去几个月,NPM 已多次成为攻击者的目标:

  • 2025 年 7 月:攻击者劫持了 eslint-config-prettier(周下载量超 3000 万)。
  • 2025 年 3 月:另有 10 个常用 npm 包被入侵,并植入信息窃取器。

这一系列事件显示:浏览器和前端供应链已经成为新的攻击面。通过篡改流量、窃取凭证和拦截交易,黑客能够直接突破开发与生产环境。

小结

npm 事件提醒我们,前端供应链已成为黑客眼中的“超级入口”, 开源的便利与风险是并存的。未来,安全工程与前端开发之间的界限会越来越模糊,前端开发绝对不再仅限于“切图仔”,需要在工具链之外,多一份“安全工程师思维”。

9 月 20 日,TRAE Meetup@Guangzhou 相聚羊城

作者 XCaptaino
2025年9月15日 23:36

点我立即报名

TRAE Meetup 是由官方发起的技术爱好者交流盛会,汇聚 TRAE 生态核心力量,通过深度技术解析、实战案例研讨与开放社群互动,助力开发者掌握工具应用精髓,攻克技术难题,实现创新想法的高效落地。

我们相信每位参与者都是技术社区的共建者,在这里,你将与同频伙伴共同探索标准化代码实践路径,携手打造兼具专业深度与社区温度的技术交流平台。

广州场亮点

核心团队深度解码:

TRAE 技术核心成员分享产品迭代历程与底层架构设计逻辑,披露 Cue 语言在复杂场景下的创新应用与思考。

实战方案即学即用:

特邀社区活跃技术专家分享实战经验。

日程安排

时间:9 月 20 日 14:00 - 18:00

13:00-14:00

签到

14:00 - 14:30

《AI 时代程序员的认知进化:从协作范式到工具演进》

天猪「TRAE - 架构师」

14:35-15:05

《TRAE cue 背后的挑战与思考》

Wayne「TRAE - 算法工程师」

15:10 - 15:30

茶歇

15:30 - 16:00

《产品思维×智能体协作:打造完整产品的 TRAE 实战》

关小楼「深圳市智源未来科技有限公司创始人」

16:05 - 16:35

《TRAE SOLO 氛围编程的正确姿势和案例分享》

安仔「 esix.co 系统架构师」

16:40 - 17:00

TRAE 广州 Fellow 分享

地点:

广东省广州市海珠区新港东路 277 号 赫基大厦 F57

立即报名

扫描下方的二维码

或点击阅读原文报名参与活动

9 月 20 日,我们线下见!

注:本次活动为审核制,请关注后续短信/邮件通知。

昨天 — 2025年9月15日掘金 前端

实现最大异步并发执行队列

作者 前端君
2025年9月15日 23:17

题目说明

日常开发中遇到异步并发执行时我们通常会使用 Promise.All([]) ,但是如果这个并发数量很大(如超过100)那么我们考虑到服务器的并发压力就需要设置一个最大并发数。

这也是一个初/中级的热门面试题,本文就详细介绍如何用各种姿势来实现 最大异步并发执行队列

/**
 * 最大异步并发执行队列
 * tasks 任务列表
 * maxConcurrency 最大并发数
 * @returns {Promise<void>}
 */
async function maxAsyncConcurrency(
  tasks: Array<() => Promise<void>>,
  maxConcurrency: number,
) {
  // 实现这个函数
  
}

测试代码


const wait = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const waitLog = async (ms, text) => {
  await wait(ms);
  console.log(text);
};
const main = async () => {
  await maxAsyncConcurrencyRecursion(
    [
      () => waitLog(1000, 1),
      () => waitLog(1000, 2),
      () => waitLog(1000, 3),
      () => waitLog(1000, 4),
      () => waitLog(1000, 5),
      () => waitLog(1000, 6),
      () => waitLog(1000, 7),
      () => waitLog(1000, 8),
      () => waitLog(1000, 9),
    ],
    3,
  );
}
main();

思路1:递归实现(最好理解)

通过递归方式实现,把每个并发当成一个运行管道,每个管道实现为一个运行任务的异步函数,函数中完成一个任务就从队列里取下一个任务继续执行,直到清空队列即可。

async function maxAsyncConcurrencyRecursion(tasks, maxConcurrency) {
  const queue = [...tasks];
  // 运行管道
  const pipeRunFn = async (fn) => {
    await fn();
    if (queue.length > 0) {
      const nextFn = queue.shift();
      await pipeRunFn(nextFn);
    }
  };
  // 最大运行管道
  const pipes = queue.splice(0, maxConcurrency);
  await Promise.all(pipes.map(pipeRunFn));
}

思路2:非递归实现

将思路1中的管道异步函数递归切换成 while 循环条件来实现。

async function maxAsyncConcurrency(fns, max) {
  const queue = [...fns];
  let active = 0;
  while(queue.length) {
    if (active >= max) {
      await wait(100); // 如果并发已经达到最大,就等会再进入while循环继续轮询
      continue;
    }
    const fn = queue.shift();
    active++;
    fn().finally(() => {
      active--;
    });
  }
}

更加贴合实践的用法,面向对象象实现流式新增任务

题目

class RequestQueue {
  private maxConcurrent: number; // 最大并发数量
  private queue: Array<() => Promise<void>> = []; // 存储任务队列
  
  constructor(maxConcurrent: number) {
    this.maxConcurrent = maxConcurrent;
  }

  /** 添加任务 */
  public addTask(task: () => Promise<void>) {}
}

测试代码


const main = async () => {
  const reqQueue = new RequestQueue(3);
  reqQueue.addTask(() => waitLog(1000, 1))
  await wait(100);
  reqQueue.addTask(() => waitLog(1000, 2))
  await wait(100);
  reqQueue.addTask(() => waitLog(1000, 3))
  await wait(100);
  reqQueue.addTask(() => waitLog(1000, 4))
  await wait(2000);
  reqQueue.addTask(() => waitLog(1000, 5))
  await wait(100);
  reqQueue.addTask(() => waitLog(1000, 6))
  await wait(100);
  reqQueue.addTask(() => waitLog(1000, 7))
  await wait(100);
  reqQueue.addTask(() => waitLog(1000, 8))
  await wait(100);
  reqQueue.addTask(() => waitLog(1000, 9))
}
main();

递归实现

流式增加任务,而不是一开始就拿到全量的任务列表。新增任务时自动触发并发执行

class RequestQueueRecursion {
  private maxConcurrent: number; // 最大并发数量
  private queue: Array<() => Promise<void>> = []; // 存储任务队列
  private active: number = 0; // 当前正在运行的任务计数

  constructor(maxConcurrent: number) {
    this.maxConcurrent = maxConcurrent;
  }

  /** 添加一个任务到队列中 */
  public addTask(task: () => Promise<void>) {
    this.queue.push(task);
    this.execute();
  }

  private async execute() {
    while(this.active < this.maxConcurrent && this.query.length > 0) {
      this.active ++;
      const fn = this.query.shift();
      fn().finally(() => {
        this.active--;
        this.execute();
      });
    }
  }
}

非递归实现

class RequestQueue {
  private maxConcurrent: number; // 最大并发数量
  private queue: Array<() => Promise<any>> = []; // 存储任务队列
  private active: number = 0; // 当前正在运行的任务计数

  constructor(maxConcurrent: number) {
    this.maxConcurrent = maxConcurrent;
  }

  /** 添加一个任务到队列中 */
  public addTask(task: () => Promise<any>) {
    this.queue.push(task);
    this.execute();
  }

  /** 运行管道 */
  private async execute() {
    const queue = this.queue;
    while(queue.length > 0) {
      if (this.active >= this.maxConcurrent) {
        await wait(100);
        continue;
      }
      this.active ++;
      const fn = queue.shift();
      fn().finally(() => {
        this.active--;
      });
    }
  }
}

上帝视角看 GPU 学习笔记

作者 Nicander
2025年9月15日 22:12

上帝视角看GPU(1):图形流水线基础_哔哩哔哩_bilibili

《上帝视角看 GPU》龚大教程学习笔记。

一、图形流水线基础

首先思考一张图片是如何显示在屏幕上呢?

首先需要了解一个概念帧缓存(Frame Buffer),这是内存中的一块区域,这块区域上的内容和显示器上显示的像素是一一对应的。

将帧缓存上的内容输出到屏幕上,需要通过显卡,显卡上有个输出端口连接到显示器上。

那这是最简单的需求,如果此时有一个需求:需要将图像的亮度提升 2 倍呢?也就是将每个像素的 RGB 分量都乘 2。

当然可以通过 CPU 来进行计算,但这样必然要占用大量 CPU 资源,更好的做法是加入一个 处理器(PU),这个处理器可以在每个像素上执行同样的操作;

为了适应更加灵活多变的需求,比如将图像上半部分亮度提升 2 倍,下半部分亮度提升 4 倍。我们可以在 PU 上挂上一个 **程序 **,这个程序是单入单出的,输入是像素坐标,输出的是像素 RGB 颜色,也就是 片元着色器 Pixel Shader。

好了,上边就是一个最基本的针对图像的流水线。

顺便一说,在任天堂的红白机上,就有这么一个处理器,叫做 PPU(Picture Processing Unit)

那么对于显示一个三维模型呢?我们来看看一个基础的完整的图形渲染管线:下图是一个最基础的图形渲染管线,其中绿色的部分是可编程的阶段,包括顶点着色器、片元着色器,红色的部分是固定流水线单元(Fixed-pipeline Unit),为了效率由硬件直接实现。

阶段 1:Input Assembler

Input Assembler 输入装配器,是图形流水线的第一个**固定阶段**(不可编程,但可配置)。它直接与应用程序(CPU端)提交的数据打交道。

💡PS:Input Assembler 是 Direct3D 中的明确标识的管线阶段,用于组装从 Vertex Buffers 和 Index Buffers 中的顶点数据,形成图元。

但在 OpenGL 中没有对应的阶段,这一“组装”的工作需要手动处理,通过glVertexAttribPointer 系列函数来指定顶点属性的格式和布局。

阶段 2: Vertex Shader

顶点着色器(Vertex Shader)是 图形渲染管线中第一个可编程的 部分,

图形渲染管线的第一个可编程部分是顶点着色器(Vertex Shader),它把一个单独的顶点 Vertex 作为输入,经过 shader 处理后输出,顶点着色器主要的目的是对 3D 坐标进行 MVP 变换,变换为屏幕坐标,同时顶点着色器允许我们对顶点属性进行一些基本处理。

阶段 3:Primitive Assembler

图元装配(Primitive Assembly)阶段也是一个固定阶段,用于将顶点着色器(或几何着色器)输出的所有顶点作为输入(如果是GL_TRIANGLE,那么就是一个三角形),并将所有的点装配成指定图元的形状。
![](https://cdn.nlark.com/yuque/0/2025/png/23057337/1757510502620-f817c78f-cdcd-44ec-958c-d9dfbbb9718b.png)

阶段 4:Rasterizer

图元装配阶段的输出会被传入光栅化阶段(Rasterization Stage),光栅化其实就是找出三角形所覆盖区域对应的屏幕上的像素,从而将这些像素提供给片元着色器。

光栅化阶段也是一个固定流水线单元,是一个算法固定的操作,由硬件直接处理,不可编程。

在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出屏幕范围以外的所有像素,用来提升执行效率。

💡 注意光栅化本质上是将顶点信息插值到图源覆盖的每个像素上。

这也可以解释从顶点着色器传值给片元着色器的值,会经过插值。这其实就是光栅化阶段后,片元着色器接收到的已经不是某个顶点的原始输出数据了,而是经过光栅化器插值后的、针对当前这个特定片元的值,这些变量在片元着色器中声明为 in 变量。

阶段 5: Pixel Shader

片段着色器 Pixel Shader 的主要目的是计算一个像素的最终颜色,这也是所有高级效果产生的地方。跟顶点着色器 Vertex Shader 一样,Pixel Shader 也是一个单入单出的结构,
#version 330 core
out vec4 FragColor;

in vec4 vertexColor; // 从顶点着色器传来的输入变量(名称相同、类型相同)

void main()
{
    FragColor = vertexColor;
}

通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。

阶段 6:Output Merger

在所有对应颜色值确定以后,最终的对象将会被传到最后一个阶段,我们叫做**Output Merger**(或者Alpha测试和混合)阶段,这个阶段检测片段的对应的深度(和模板(Stencil))值,用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查alpha值并对物体进行混合(Blend)。

所以,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同。

Output Merger 也是固定流水线单元;

总结

+ 整个图形流水线要经过几个阶段,其中 Vertex Shader 和 Pixel Shader 是我们可以进行编程的阶段。 + 我们也必须定义至少一个顶点着色器和一个片段着色器(因为GPU中没有默认的顶点/片段着色器)。 + Vertex Shader 和 Pixel Shader 编写就像是在编写一个回调函数,他们会对每一个顶点、每一个像素上执行。

二、逻辑上的模块划分

上一部分我们介绍了最基本的图形流水线,随着时代的发展,新的需求逐渐出现,现代的 GPU 不仅仅用于图形渲染,还可以进行通用计算、用于神经网络训练的 TensorCore 、用于视频编解码等多个领域,这些不同的领域在 GPU 上都是独立的模块,具有独立的流水线。我们来看看现在的 GPU 流水线是什么样的,以及他是如何发展来的。

1、 图形

Geometry Shader

Vertex Shader 和 Pixel Shader 都是单入单出的结构,只能处理单个顶点和像素。但如果我们要处理的是一个图元(三角形),就没法处理了。

这个需求催生了几何着色器 Geometry Shader 的出现, Geometry Shader 是渲染管线中一个可选的可编程阶段,它位于顶点着色器之后、图元装配和光栅化之前。几何着色器的输入是一个完整的图元及其所有顶点数据(例如,一个三角形需要输入3个顶点),而它的输出是零个、一个或多个全新的图元

换句话说,它是单入多出的结构。

注:Geometry Shader 看起来非常灵活,但实际使用时往往会发现性能很差,这是因为如由于灵活,硬件无法做各种假设来提升性能,只能实现的非常保守。

tessellation

image.png tessellation 的出现是因为对三角形细分需求的逐步增加,由于使用 Geometry shader 性能较差,GPU 流水线在 Vertex shader 后增加了专门的tessellation 功能,他是由三个部分组成 Hull Shader、tessellation、Domain Shader。

2、 计算

GPGPU 通用 GPU

由于 GPU 在图形渲染领域强大的计算能力,逐渐出现了使用 GPU 进行其他领域更加通用的并行计算的想法。

最早的想法是渲染一个覆盖屏幕的大三角形,在 Pixel Shader 里做通用的并行计算,相当于每一个像素 Pixel 是一个线程, 这种方式虽然很“hack”,但也产出了很多的成果,这个方向就是** GPU 通用计算(GPGPU)**。

这种方式虽然解决了一些问题,但也存在学习成本高(开发人员需要学习整个图形流水线)、存在性能浪费(还是需要通过整个图形流水线包括顶点着色器)。

这个需求进一步催生了有硬件支持的 GPGPU,不需要再通过图形流水线中那些固定的阶段,同时支持“多入多出”,独立与图形流水线单独存在,应用 GPU 上的计算单元进行通用计算的 shader 叫做 compute shader

3、 光线追踪

随着游戏对画面真实感的要求越来越高,基础图形流水线采用光栅化渲染方式,对于实现高质量画面往往要采用很多的 Hack,光线跟踪这一古老的技术逐渐引起了人们注意。

由于光线跟踪与光栅化渲染方式有着完全不一样的流程,长期以来研究人员一直在研究如何利用先有的 GPU 硬件实现光线跟踪,这样的需求随着 GPU 在硬件层面提供光线跟踪支持得到了实质的发展。

三、部署到硬件

前边介绍了 GPU 在逻辑模块上的划分,本节我们来看下具体对应到硬件上,GPU 是如何设计的。

unified shader

最开始的 GPU 设计上,Vertex Shader 和 Pixel Shader 有对应的处理单元进行处理,比如 2003 年的 GeForce FX 5600,有 2 个 Vertex Shader 单元和 4 个 Pixel Shader 单元,这意味着当顶点和像素的工作量是 1:2 的时候,他们才能发挥出最高效率。对于如果有一堆很小的三角形挤到一起(顶点多像素少)或很大的三角形覆盖(顶点少像素多)的情形都不能发挥出很高的效率。

最初这样设计是因为人们通常只用 Vertex Shader 处理坐标数据,需要有较高的精度处理能力;而 Pixel Shader 只处理纹理,只需低精度运算器,但需要采样器。

而随着需求的发展,Vertex Shader 和 Pixel Shader 的能力界限逐渐变的模糊。大规模地形渲染的需求,使得 Vertex Shader 得能读取纹理,而 Pixel Shader 进行通用计算的需求,也使得 Pixel Shader 得能处理高精度数据,最终就是两种处理单元统一了起来,叫做 <font style="color:rgb(15, 17, 21);">unified shader</font>,这样 GPU 也因为一致性而变得简单。

在 GPU 工作时,由调度器根据工作任务进行动态分配,决定哪些<font style="color:rgb(15, 17, 21);"> unified shader</font>用于处理 Pixel Shader,哪些处理 Vertex Shader。

最终的结果就是虽然图形流水线中有那么多的 shader,但在硬件层面他们的执行单元都是一样的。

四、完整的软件栈

理想的软件分层体系

理想的关于 GPU 软件分层体系:
  • API 应用程序接口层:为应用程序提供统一的编程接口(如 OpenGL、Vulkan、DirectX、CUDA 等),开发者使用 API 编写图形或计算任务,无需直接处理底层硬件细节;
  • OS 操作系统层;
  • DDI 设备驱动程序接口:是操作系统与 GPU 驱动程序之间的标准接口。由操作系统定义和实现;
  • Driver 驱动程序:将 API 调用翻译成 GPU 能理解的指令,管理 GPU 资源(如显存、命令队列),由 GPU 硬件厂商(如NVIDIA、AMD)实现,并且必须严格遵循DDI的规范;
  • GPU:GPU 接收由驱动程序提交的命令和数据,进行并行处理

但现实情况下会不一样,操作系统就包括了用户态和内核态。

Direct 3D

第一个例子是微软的 Direct 3D(D3D),这个 API 不跨平台(windows),但跨厂商(如NVIDIA、AMD)。

它将驱动拆分为了(用户态UMD + 内核态KMD),并引入引入核心调度器(DXGK),将厂商实现驱动程序由作文题变为填空题,减少了驱动程序开发工作量。

Direct 3D 采用自顶向下的模式,由微软定义 API,厂商来进行实现,不方便进行扩展。在 GPU 拥有新功能的硬件支持后,只有等待 Direct 3D 发布了新版本才能支持。

另外 D3D 的 每个版本的 API 是不兼容的,这意味着每出一版 D3D,程序都得大改才能用上。

OpenGL

OpenGL 是跨平台且跨厂商的,由 Khronos (开源组织)发布。

在不同的操作系统上,OpenGL

在 Windows 上,Windows 只提供了一个框架可安装用户驱动 ICD,让硬件厂商来实现 OpenGL runtime 的 UMD。

在 Linux 上,有两种方式,一种是完全由厂商来实现 UMD 和 KMD;另一种是基于 Mesa 框架。

OpenGL 的 API 设计是向下兼容的,之前的代码往往新版本也能用。

原教程视频中还有关于图形流水线中不可编程单元(重点光栅化)、光线跟踪流水线等部分内容,讲的也非常好。因为我本职工作涉及 WebGL 内容,所以对于暂时没有对这块内容不太有耐心写下来。大家由想了解的推荐看原视频。

React Native DApp 开发全栈实战·从 0 到 1 系列(兑换-合约部分)

作者 木西
2025年9月15日 21:25

前言

本文借助 Solidity 0.8、OpenZeppelin 与 Chainlink 喂价,构建一套 链上即时汇率结算、链下可信价格驱动 的微型兑换系统。本文将带你完成:

  1. 部署可铸造 ERC-20(BoykayuriToken,BTK)
  2. 部署 Chainlink 风格喂价合约(MockV3Aggregator),本地即可模拟 ETH/USD = 2000 的实时价格
  3. 部署 SwapToken —— 接收 ETH、按市价折算 USD、并立即向用户发放等值 BTK
  4. 使用 Hardhat 本地网络 + hardhat-deploy 插件一键启动,5 条指令完成编译、测试、部署全流程 无需前端,无需真实 LINK,即可体验 "价格输入 → 汇率计算 → 代币闪兑" 的完整闭环,为后续接入主网喂价、多币种池子、流动性挖矿奠定可复用的脚手架。

智能合约

代币合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract SwapToken is Ownable {
    AggregatorV3Interface internal priceFeed;
    IERC20 public token;
    uint public constant TOKENS_PER_USD = 1000; // 1 USD = 1000 MTK

    constructor(address _priceFeed, address _token) Ownable(msg.sender) {
        priceFeed = AggregatorV3Interface(_priceFeed);
        token = IERC20(_token);
    }

    function swap() public payable {
        uint usd = getEthInUsd(msg.value);
        uint amount = usd * TOKENS_PER_USD;
        require(token.balanceOf(address(this)) >= amount, "Not enough liquidity");
        token.transfer(msg.sender, amount);
    }

    function getEthInUsd(uint ethAmount) public view returns (uint) {
        (, int price, , , ) = priceFeed.latestRoundData(); // price in 8 decimals
        uint ethUsd = (ethAmount * uint(price)) / 1e18; // ETH amount in USD (8 decimals)
        return ethUsd / 1e8; // return USD amount
    }

    receive() external payable {}
}
喂价合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import { AggregatorV3Interface } from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";

contract MockV3Aggregator is AggregatorV3Interface {
    uint256 public constant versionvar = 4;

    uint8 public decimalsvar;
    int256 public latestAnswer;
    uint256 public latestTimestamp;
    uint256 public latestRound;
    mapping(uint256 => int256) public getAnswer;
    mapping(uint256 => uint256) public getTimestamp;
    mapping(uint256 => uint256) private getStartedAt;
    string private descriptionvar;

    constructor(
        uint8 _decimals,
        string memory _description,
        int256 _initialAnswer
    ) {
        decimalsvar = _decimals;
        descriptionvar = _description;
        updateAnswer(_initialAnswer);
    }

    function updateAnswer(int256 _answer) public {
        latestAnswer = _answer;
        latestTimestamp = block.timestamp;
        latestRound++;
        getAnswer[latestRound] = _answer;
        getTimestamp[latestRound] = block.timestamp;
        getStartedAt[latestRound] = block.timestamp;
    }

    function getRoundData(uint80 _roundId)
        external
        view
        override
        returns (
            uint80 roundId,
            int256 answer,
            uint256 startedAt,
            uint256 updatedAt,
            uint80 answeredInRound
        )
    {
        return (
            _roundId,
            getAnswer[_roundId],
            getStartedAt[_roundId],
            getTimestamp[_roundId],
            _roundId
        );
    }

    function latestRoundData()
        external
        view
        override
        returns (
            uint80 roundId,
            int256 answer,
            uint256 startedAt,
            uint256 updatedAt,
            uint80 answeredInRound
        )
    {
        return (
            uint80(latestRound),
            latestAnswer,
            getStartedAt[latestRound],
            latestTimestamp,
            uint80(latestRound)
        );
    }

    function decimals() external view override returns (uint8) {
        return decimalsvar;
    }

    function description() external view override returns (string memory) {
        return descriptionvar;
    }

    function version() external  pure override returns (uint256) {
        return versionvar;
    }
}
兑换合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract SwapToken is Ownable {
    AggregatorV3Interface internal priceFeed;
    IERC20 public token;
    uint public constant TOKENS_PER_USD = 1000; // 1 USD = 1000 MTK

    constructor(address _priceFeed, address _token) Ownable(msg.sender) {
        priceFeed = AggregatorV3Interface(_priceFeed);
        token = IERC20(_token);
    }

    function swap() public payable {
        uint usd = getEthInUsd(msg.value);
        uint amount = usd * TOKENS_PER_USD;
        require(token.balanceOf(address(this)) >= amount, "Not enough liquidity");
        token.transfer(msg.sender, amount);
    }

    function getEthInUsd(uint ethAmount) public view returns (uint) {
        (, int price, , , ) = priceFeed.latestRoundData(); // price in 8 decimals
        uint ethUsd = (ethAmount * uint(price)) / 1e18; // ETH amount in USD (8 decimals)
        return ethUsd / 1e8; // return USD amount
    }

    receive() external payable {}
}
编译指令:npx hardhat compile

测试合约

const { ethers } = require("hardhat");
const { expect } = require("chai");

describe("SwapToken", function () {
  let SwapToken, MockToken, MockV3Aggregator;
  let owner, user;

  beforeEach(async () => {
    [owner, user] = await ethers.getSigners();
    await deployments.fixture(["MockV3Aggregator","token","SwapToken"]);
    const MockTokenAddress = await deployments.get("MyToken");          // 存入资产        // 奖励代币(USDC)
        const MockV3AggregatorAddress = await deployments.get("MockV3Aggregator");
        const SwapTokenAddress = await deployments.get("SwapToken");
        
        MockToken = await ethers.getContractAt("MyToken", MockTokenAddress.address);
        MockV3Aggregator = await ethers.getContractAt("MockV3Aggregator", MockV3AggregatorAddress.address);
        SwapToken = await ethers.getContractAt("SwapToken", SwapTokenAddress.address);
        // 给SwapToken合约铸造资产
        await MockToken.mint(await SwapToken.getAddress(), ethers.parseEther("1000000"));
        console.log('name',await MockToken.name())
        console.log("symbol",await MockToken.symbol())
        console.log(await MockV3Aggregator.latestAnswer())
   
  });

  it("Should swap ETH for MTK", async function () {
    const ethAmount = ethers.parseEther("1"); // 1 ETH = 2000 USD = 2,000,000 MTK
    await SwapToken.connect(user).swap({ value: ethAmount });

    const balance = await MockToken.balanceOf(user.address);
    console.log(balance)
    expect(balance).to.equal(2000 * 1000); // 2,000,000 MTK
  });
});
测试指令:npx hardhat test ./test/xxxx.js

部署合约

module.exports = async  ({getNamedAccounts,deployments})=>{
    const getNamedAccount = (await getNamedAccounts()).firstAccount;
    const secondAccount= (await getNamedAccounts()).secondAccount;
    console.log('secondAccount',secondAccount)
    const {deploy,log} = deployments;
    const MyAsset  = await deployments.get("MyToken");
    //执行MockV3Aggregator部署合约
  const MockV3Aggregator=await deploy("MockV3Aggregator",{
        from:getNamedAccount,
        args: [8,"ETH/USDC", 200000000000],//参数
        log: true,
    })
  console.log("MockV3Aggregator合约地址:", MockV3Aggregator.address);
    const SwapToken=await deploy("SwapToken",{
        from:getNamedAccount,
        args: [MockV3Aggregator.address,MyAsset.address],//参数 喂价,资产地址
        log: true,
    })
    // await hre.run("verify:verify", {
    //     address: TokenC.address,
    //     constructorArguments: [TokenName, TokenSymbol],
    //     });
    console.log('SwapToken 兑换合约地址',SwapToken.address)
}
module.exports.tags = ["all", "SwapToken"];
部署指令:npx hardhat deploy --tags token,MockV3Aggregator,SwapToken

总结

通过本文,我们完成了:

  • 价格层:MockV3Aggregator 遵循 Chainlink 接口,可无缝替换为主网喂价
  • 资产层:ERC-20 代币自带 mintOwnable,方便快速补充流动性
  • 兑换层:SwapToken 利用 latestRoundData() 实时计算 ETH→USD→BTK 数量,全程链上可查
  • 脚本层:Hardhat 脚本化部署 + 测试固件,保证"一键重置、秒级回滚",让迭代安全又高效

后续优化:

  • 把 Mock 喂价替换为 ETH/USD 主网聚合器
  • 引入 Uniswap V2 风格的流动性池,实现双向兑换

🚀🚀🚀 RichLab - 花呗前端团队招贤纳士 - 【转岗/内推/社招】

2025年9月15日 20:55

欢迎加入我们!与AI共舞,定义下一代智能金融体验

直接联系 👉🏻👉🏻👉🏻 邮箱:alei.xl@antgroup.com

💫 我们是谁?

RichLab - 花呗前端团队,一个充满活力与创新的技术团队,我们正在做一件超酷的事情 —— 让AI与金融完美融合,重新定义用户体验!

我们不是传统的前端团队,我们是金融科技前沿的探索者,是AI时代的先行者。在这里,每一行代码都可能改变千万用户的金融生活,每一次创新都可能引领行业潮流。

🎯 我们在做什么?

🌟 核心使命

负责亿级用户规模的AI产品核心功能研发,直接参与定义下一代智能金融交互。

简单来说,我们就是那个让花呗变得更智能、更懂你的团队!

🔥 AI创新业务探索方向

花呗正从“消费金融工具”进化为“懂你的成长型生活伙伴”。

  • 智能助理是服务大脑,统一调度各类助手,以亲密度为指挥棒,实现场景化、个性化的服务分发;
  • 多模态内容生成是情感触点,借力AIGC技术将用户的日常消费账单转化为具有情感温度的视觉化内容,让每一笔消费都有温度和记忆;
  • 端智能服务是感知神经,通过端侧实时感知用户行为、环境与意图,驱动智能助理提前介入,实现“比你更懂你”的主动式服务体验。

三者深度融合,共同构建一个有温度、有记忆、会思考的AI金融智能服务体。

image.png

🎪 为什么选择我们?

1️⃣ 技术作品丰富 💪

  • 拥有金融级稳定性的前端工程体系
  • 孵化了Rococo、Bakery、Lever、Tina、Galacean等备受赞誉的技术产品

2️⃣ AI前沿探索 🤖

  • 在AI领域重点投入,积极探索储备
  • 拥抱AI研发范式的全面转型
  • 打造有创意、有特色、行业领先的金融产品体验

3️⃣ 技术氛围浓厚 🌟

  • 多次在D2、SEEConf、Qcon、CCF中国计算机学会、W3C大会、CGS中国图学学会、中关村论坛等顶级会议担任嘉宾
  • 开放、协同、分享的浓厚技术氛围
  • 鼓励参与开源社区建设

4️⃣ 团队文化超赞 ❤️

  • 倡导「专业、匠心、自由、有爱」的技术文化
  • 重视团队成员成长,把个人发展作为第一优先级
  • 成员之间开放、平等、简单,充满极强的信任感

5️⃣ 生活丰富多彩 🎸

  • RichBand和吉他班:部门有自己的乐队,且为 0 基础同学提供系统性器乐培训,全面支持个人爱好
  • 健身文化:找到你的健身搭子、骑行搭子、跑步搭子
  • 日常惊喜:说走就走的团建、奶茶、黑珍珠,老板们常常制造惊喜

🎯 岗位要求

✅ 基础要求

  • 有扎实的计算机基础知识,熟悉常用的数据结构、算法和设计模式,在日常研发中灵活使用。
  • 掌握 HTML/CSS/JavaScript,熟悉 React/Vue/Angular 等前端框架。
  • 有丰富的前端性能优化及移动端适配经验。
  • 了解金融/互联网信贷业务,有相关行业经验者优先。
  • 具备良好的服务意识、团队协作精神,学习能力强。

💡 加分项

  • 有相关AI产品业务经验
  • 有开源项目经验
  • 长期维护有影响力的技术博客

🚀 在这里你能获得什么?

✨ 技术成长

  • 接触最前沿的AI技术
  • 参与亿级用户规模的产品开发
  • 在金融科技领域深耕,成为行业专家

✨ 个人发展

  • 包容、开放的心态拥抱人才多样性
  • 不论你是业务型、技术型、领域型、架构型、综合型人才,都能找到最适合的位置
  • 团队成员成长和发展是管理第一优先级

✨ 生活体验

  • 专业的技术氛围 + 轻松的生活氛围
  • 丰富的团队活动和文化建设
  • 与志同道合的伙伴一起成长

🎪 团队风采

🎉 团建、奶茶、黑珍珠,老板们的惊喜,让工作充满乐趣~ image.png

🎸 艺术细胞。我们不仅会写代码,还会玩音乐!部门有自己的乐队,定期排练演出,让技术人的艺术细胞得到充分释放。 image.png

💪 健身文化。技术要强,身体也要棒!在这里,你可以轻松找到健身搭子、骑行搭子、跑步搭子,大家一起在健身房共同进步。 image.png

🔥 更多资讯见「视频号:RichLab后花园」

📮 如何加入我们?

联系方式

邮箱:alei.xl@antgroup.com

🌟 写在最后

如果你:

  • 对AI技术充满热情
  • 想要在金融科技前沿探索
  • 希望在一个专业、有爱、自由的团队中成长
  • 想要参与定义下一代智能金融体验

那么,RichLab - 花呗前端团队就是你的不二之选! 我们相信,技术改变世界,AI改变金融。加入我们,让我们一起用代码和AI,创造更美好的金融未来!


🎯 还在等什么?快来加入我们,一起定义未来! RichLab - 花呗前端团队,期待与你相遇!

Oxc 和 Rolldown Q4 更新计划速览!🚀🚀🚀

2025年9月15日 20:41

前言

今天 Oxc 和 Rolldown 先后发布了 Q4 季度的更新计划,一起来看看吧!

往期精彩推荐

正文

Oxc

OXC Q4 计划

Q4 的重点在于推出 Alpha 和 Beta 版本的功能,扩展 linter、格式化和压缩能力:

  1. Oxlint Custom JavaScript Plugin Alpha

Oxlint将推出自定义JavaScript插件的Alpha版,支持开发者创建专属规则,集成到570+现有规则中。Oxlint已比ESLint快50-100倍,此功能将增强灵活性,适配特定项目需求,如自定义代码规范或复杂逻辑检查。Alpha版提供API和示例,开发者可通过GitHub测试。

  1. Formatter Alpha

格式化工具Alpha版将支持JavaScript和TypeScript代码的自动格式化,类似Prettier但速度更快。功能包括统一的缩进、引号处理和分号规则,优化与VS Code等编辑器的集成。Alpha阶段将验证核心算法,适合大规模代码库。

  1. Minifier Beta

压缩工具进入Beta阶段,利用Rust并行处理能力,优于Terser,生成更小的输出文件。支持死代码消除、变量重命名等高级选项,适用于生产环境。Beta版将进一步稳定性能。

  1. Type-Aware Linting Alpha

类型感知linter的Alpha版结合TypeScript类型信息,提供更精准的代码检查,如类型不匹配或空指针检测。相比传统linter,它能减少运行时错误,适合混合JS/TS项目。

Rolldown

Rolldown Q4 计划

Rolldown作为Rollup API兼容的捆绑器,目标是取代Vite中的esbuild,提供10-30倍性能提升。Q4计划聚焦于全功能捆绑、优化和Vite生态集成:

  1. 实现完整的捆绑流程,从入口到依赖,简化Vite生产构建,取代多工具组合,提供高效优化。

  2. 懒编译功能按需加载模块,缩短初始加载时间,结合Vite热重载,显著提升开发体验。

  3. 将importmaps集成到Vite,支持浏览器原生模块映射,简化第三方库导入,增强兼容性。

  4. 提高捆绑包大小:

  • Lazy Barrel Optimization:优化barrel文件懒加载,减少不必要导入。
  • More Cross-Chunk Optimizations:跨代码块优化,如共享模块提取和树摇,缩小bundle体积10-20%。
  • TS Const Enum Optimization:内联TypeScript常量枚举,减少运行时开销。
  1. 稳定Vite插件支持,确保无缝兼容,修复边缘问题并提供迁移指南。

  2. 自动解析tsconfig.json,简化TypeScript项目配置。

  3. 升级文档站点,提供详细指南、API参考和示例,方便开发者集成。

  4. 支持模块联邦,实现微前端动态加载,性能优于Webpack的类似功能。

最后

如果你期待重要的功能不再上述更新列表中,可以去社区积极反馈哈~

今天的分享就这些了,感谢大家的阅读,如果文章中存在错误的地方欢迎指正!

往期精彩推荐

Web安全必备:关键 HTTP 标头解析

2025年9月15日 18:09

保护您的网站免受注入漏洞的侵害

内容安全政策 (CSP)

跨站脚本攻击 (XSS) 是一种攻击,攻击者会利用网站上的漏洞注入和执行恶意脚本。

Content-Security-Policy 通过限制网页可以执行哪些脚本,提供了额外的层级来缓解 XSS 攻击。

Content-Security-Policy:
  script-src 'nonce-{RANDOM1}' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';

可信类型

基于 DOM 的 XSS 是一种攻击,攻击者会将恶意数据传递到支持动态代码执行的接收器(例如 eval() 或 .innerHTML)。

Content-Security-Policy: require-trusted-types-for 'script'
// Feature detection
if (window.trustedTypes && trustedTypes.createPolicy) {
  // Name and create a policy
  const policy = trustedTypes.createPolicy('escapePolicy', {
    createHTML: str => {
      return str.replace(/</g, '&lt;').replace(/>/g, '&gt;');
    }
  });
}
// Assignment of raw strings is blocked by Trusted Types.
el.innerHTML = &#39;some string&#39;; // This throws an exception.

// Assignment of Trusted Types is accepted safely.
const escaped = policy.createHTML(&#39;&lt;img src=x onerror=alert(1)&gt;&#39;);
el.innerHTML = escaped;  // &#39;&amp;lt;img src=x onerror=alert(1)&amp;gt;&#39;

X-Content-Type-Options

如果恶意 HTML 文档是从您的网域中提交的(例如,上传到照片服务中的图片包含有效的 HTML 标记),某些浏览器会将其视为有效文档,并允许其在应用上下文中执行脚本,从而导致跨网站脚本漏洞

X-Content-Type-Options: nosniff 通过指示浏览器为给定响应在 Content-Type 标头中设置的 MIME 类型正确无误来防止此类问题。

X-Content-Type-Options: nosniff

将您的网站与其他网站隔离

X-Frame-Options

如果恶意网站可以将您的网站嵌入为 iframe,攻击者就可能会通过点击欺骗来诱导用户执行意外操作。此外,在某些情况下,Spectre 类型的攻击会让恶意网站有机会了解嵌入式文档的内容。

X-Frame-Options 用于指示是否应允许浏览器在 <frame><iframe><embed> 或 <object> 中渲染网页。

X-Frame-Options: DENY

跨源资源政策 (CORP)

攻击者可以嵌入来自其他来源(例如您的网站)的资源,以利用基于网络的跨网站数据泄露来了解这些资源。

Cross-Origin-Resource-Policy 通过指明可由哪些网站加载来缓解此风险。标头采用以下三个值之一:same-originsame-site 和 cross-origin。建议所有资源发送此标头,以指明它们是否允许由其他网站加载。

Cross-Origin-Resource-Policy: same-origin

跨源打开者政策 (COOP)

攻击者的网站可以利用基于网页的跨网站数据泄露,在弹出式窗口中打开另一个网站,以了解该网站的相关信息。在某些情况下,这可能还会允许利用基于 Spectre 的旁道攻击。

Cross-Origin-Opener-Policy 标头提供了一种方法,可让文档与通过 window.open() 打开的跨源窗口或不带 rel="noopener" 的 target="_blank" 链接隔离。因此,文档的任何跨源打开器都不会引用该文档,也无法与其互动。

Cross-Origin-Opener-Policy: same-origin-allow-popups

跨源资源共享 (CORS)

与本文中的其他内容不同,跨源资源共享 (CORS) 不是标头,而是一种用于请求和允许访问跨源资源的浏览器机制。

默认情况下,浏览器会强制执行同源政策,以防止网页访问跨源资源。例如,在加载跨源图片时,即使该图片在网页上以可视方式显示,网页上的 JavaScript 也无法访问该图片的数据。资源提供方可以通过选择启用 CORS 来放宽限制,并允许其他网站读取资源。

Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Credentials: true

安全地构建强大的网站

跨源嵌入器政策 (COEP)

为了降低基于 Spectre 的攻击窃取跨源资源的能力,SharedArrayBuffer 或 performance.measureUserAgentSpecificMemory() 等功能默认处于停用状态。

Cross-Origin-Embedder-Policy: require-corp 会阻止文档和工作器加载跨源资源(例如图片、脚本、样式表、iframe 等),除非这些资源明确选择通过 CORS 或 CORP 标头加载。COEP 可与 Cross-Origin-Opener-Policy 结合使用,以选择将文档纳入跨源隔离

如需为文档启用跨源隔离,请使用 Cross-Origin-Embedder-Policy: require-corp

Cross-Origin-Embedder-Policy: require-corp

加密指向您网站的流量

HTTP 严格传输安全协议 (HSTS)

通过普通 HTTP 连接进行的通信不会加密,因此网络级窃听者可以访问传输的数据。

Strict-Transport-Security 标头会告知浏览器绝不应使用 HTTP 加载网站,而应改用 HTTPS。设置完毕后,在标头中定义的时间段内,浏览器将使用 HTTPS(而非 HTTP)访问网域,且不会重定向。

Strict-Transport-Security: max-age=31536000

优雅表格设计:CSS 美化技巧详解

2025年9月15日 18:08

一个普通 Table 分步骤进行美化 代码

<table>
  <thead>
    <tr>
      <th>Country</th>
      <th>Mean temperature change (°C)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>United Kingdom</th>
      <td>1.912</td>
    </tr>
    <tr>
      <th>Afghanistan</th>
      <td>2.154</td>
    </tr>
    <tr>
      <th>Australia</th>
      <td>0.681</td>
    </tr>
    <tr>
      <th>Kenya</th>
      <td>1.162</td>
    </tr>
    <tr>
      <th>Honduras</th>
      <td>0.945</td>
    </tr>
    <tr>
      <th>Canada</th>
      <td>1.284</td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <th>Global average</th>
      <td>1.4</td>
    </tr>
  </tfoot>
</table>
  • 添加标题
<table>
<caption>Annual surface temperature change in 2022</caption>
</table>
  • 增加行距,对齐方式

第一列左对齐,其他列右对齐

body {
  font-family: "Open Sans", sans-serif;
  line-height: 1.5;
}
table {
  text-align: left;
}
th,
caption {
  text-align: start;
}
thead th:not(:first-child),
td {
  text-align: end;
}
th,
td {
  padding: 0.25rem 0.75rem;
}
  • 添加边框
table {
  border-collapse: collapse;
}
th,
td {
  border: 1px solid;
}
  • 修改表格头部和尾部
thead {
  border-block-end: 2px solid;
  background: whitesmoke;
}
tfoot {
  border-block: 2px solid;
  background: whitesmoke;
}
th,
td {
  border: 1px solid lightgrey;
}
  • 添加颜色
table {
  --color: #d0d0f5;
}
thead,
tfoot {
  background: var(--color);
}
tbody tr:nth-child(even) {
  background: color-mix(in srgb, var(--color), transparent 60%);
}
  • 固定第一列
th:first-child {
  position: sticky;
  inset-inline-start: 0;
}
td:first-of-type,
:where(thead, tfoot) th:nth-child(2) {
  border-inline-start: none;
}

th:first-child::after {
  content: "";
  position: absolute;
  inset-block-start: 0;
  inset-inline-end: 0;
  width: 1px;
  height: 100%;
  background: lightgrey;
}
  • 垂直对齐
th,
td {
  vertical-align: baseline;
}

thead th {
  vertical-align: bottom;
}
  • 列宽度自适应
thead th:not(:first-child) {
  width: 9rem;
}
table {
  width: max(65rem, 100%);
  /* 浏览器忽略单元格内容,而是使用在第一个表格行的列或单元格上定义的宽度来解析列宽 */
  table-layout: fixed;
}
th:first-of-type {
  width: 10rem;
}
  • 无障碍
<div
  class="wrapper"
  tabindex="0"
  role="region"
  aria-labelledby="tableCaption_01"
>
  <table>
    <caption id="tableCaption_01"></caption>
    <thead>
      <tr>
        <th scope="column">Country</th>
        <th scope="column">Mean temperature change (°C)</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">United Kingdom</th>
        <td>1.912</td>
      </tr>
      <tr>
        <th scope="row">Afghanistan</th>
        <td>2.154</td>
      </tr>
      <tr>
        <th scope="row">Australia</th>
        <td>0.681</td>
      </tr>
      <tr>
        <th scope="row">Kenya</th>
        <td>1.162</td>
      </tr>
      <tr>
        <th scope="row">Honduras</th>
        <td>0.945</td>
      </tr>
      <tr>
        <th scope="row">Canada</th>
        <td>1.284</td>
      </tr>
    </tbody>
    <tfoot>
      <tr>
        <th scope="row">Global average</th>
        <td>1.4</td>
      </tr>
    </tfoot>
  </table>
</div>
[role="region"][aria-labelledby][tabindex]:focus {
  outline: 0.1em solid rgba(0, 0, 0, 0.1);
}

div[tabindex="0"][aria-labelledby][role="region"] {
  background: linear-gradient(to right, transparent 30%, rgba(255, 255, 255, 0)),
    linear-gradient(to right, rgba(255, 255, 255, 0), white 70%) 0 100%,
    radial-gradient(
      farthest-side at 0% 50%,
      rgba(0, 0, 0, 0.2),
      rgba(0, 0, 0, 0)
    ),
    radial-gradient(
        farthest-side at 100% 50%,
        rgba(0, 0, 0, 0.2),
        rgba(0, 0, 0, 0)
      )
      0 100%;
  background-repeat: no-repeat;
  background-color: #fff;
  background-size: 4em 100%, 4em 100%, 1.4em 100%, 1.4em 100%;
  background-position: 0 0, 100%, 0 0, 100%;
  background-attachment: local, local, scroll, scroll;
}

网页深色模式完整实现:从响应式设计到系统主题联动

2025年9月15日 17:33

需求

  • 页面跳转时主题不要发生变化
  • 如果是第一个页面,自动使用主题
  • 默认 light 还是 dark
  • 在一个浏览器选项卡中更改主题时,网站的所有其他选项卡也应随之更改
  • 用户修改操作系统主题模式时,网站应该对此做出反应
  • 根据时间变化自动切换主题

实现

在 <head> 中添加 <meta name="color-scheme" content="light dark">

浏览器只会改变那些没有主动设置颜色的元素

CSS 中通过 light-dark() 设置不同的颜色

:root {
    color-scheme: light dark;
}

@media (prefers-color-scheme: light) {
    .element {
        color: black;
        background-color: white;
    }
}

@media (prefers-color-scheme: dark) {
    .element {
        color: white;
        background-color: black;
    }
}
:root {
    color-scheme: light dark;
}

.element {
    /* fallback 的颜色,当用户浏览器不支持 color: light-dark(black, white); 时,回退到这个颜色 */
    color: black;
    /* light mode 下 color 用 black, dark mode 下 color 用 white */
    color: light-dark(black, white);
    background-color: white;
    background-color: light-dark(white, black);
}
<html class="theme-light">
  <form class="theme-selector">
  <button
    aria-label="Enable light theme"
    aria-pressed="false"
    role="switch"
    type="button"
    id="theme-light-button"
    class="theme-button enabled"
    onclick="enableTheme('light', true)"
  >Light theme</button>
  <button
    aria-label="Enable dark theme"
    aria-pressed="false"
    role="switch"
    type="button"
    id="theme-dark-button"
    class="theme-button"
    onclick="enableTheme('dark', true)"
  >Dark theme</button>
  </form>
  <!--- Rest of the website --->
</html>
$theme-light-text-color: #111;
$theme-dark-text-color: #EEE;

@mixin color($property, $var, $fallback){
  #{$property}: $fallback; // This is a fallback for browsers that don't support the next line.
  #{$property}: var($var, $fallback);
}

p{
  @include color(color, --text-color, $theme-light-text-color);
}
.theme-dark{
  --text-color: #{$theme-dark-text-color};
}
// Find if user has set a preference and react to changes
(function initializeTheme(){
  syncBetweenTabs()
  listenToOSChanges()
  enableTheme(
    returnThemeBasedOnLocalStorage() ||
    returnThemeBasedOnOS() ||
    returnThemeBasedOnTime(),
    false)
}())

// Listen to preference changes. The event only fires in inactive tabs, so theme changes aren't applied twice.
function syncBetweenTabs(){
  window.addEventListener('storage', (e) => {
    const root = document.documentElement
    if (e.key === 'preference-theme'){
      if (e.newValue === 'light') enableTheme('light', true, false)
      else if (e.newValue === 'dark') enableTheme('dark', true, false) // The third argument makes sure the state isn't saved again.
    }
  })
}

// Add a listener in case OS-level preference changes.
function listenToOSChanges(){
  let mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)')

  mediaQueryList.addListener( (m)=> {
    const root = document.documentElement
    if (m.matches !== true){
      if (!root.classList.contains('theme-light')){
        enableTheme('light', true)
      }
    }
    else{
      if(!root.classList.contains('theme-dark')) enableTheme('dark', true)
    }
  })
}

// If no preference was set, check what the OS pref is.
function returnThemeBasedOnOS() {
  let mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)')
  if (mediaQueryList.matches) return 'dark'
else {
    mediaQueryList = window.matchMedia('(prefers-color-scheme: light)')
    if (mediaQueryList.matches) return 'light'
    else return undefined
}
}

// For subsequent page loads
function returnThemeBasedOnLocalStorage() {
  const pref = localStorage.getItem('preference-theme')
  const lastChanged = localStorage.getItem('preference-theme-last-change')
  let now = new Date()
  now = now.getTime()
  const minutesPassed = (now - lastChanged)/(1000*60)

  if (
    minutesPassed < 120 &&
    pref === "light"
  ) return 'light'
  else if (
    minutesPassed < 120 &&
    pref === "dark"
  ) return 'dark'
  else return undefined
}

// Fallback for when OS preference isn't available
function returnThemeBasedOnTime(){
  let date = new Date
  const hour = date.getHours()
  if (hour > 20 || hour < 5) return 'dark'
  else return 'light'
}

// Switch to another theme
function enableTheme(newTheme = 'light', withTransition = false, save = true){
  const root = document.documentElement
  let otherTheme
  newTheme === 'light' ? otherTheme = 'dark' : otherTheme = 'light'
  let currentTheme
  (root.classList.contains('theme-dark')) ? currentTheme = 'dark' : 'light'

  if (withTransition === true && newTheme !== currentTheme) animateThemeTransition()

  root.classList.add('theme-' + newTheme)
  root.classList.remove('theme-' + otherTheme)

  let button = document.getElementById('theme-' + otherTheme + '-button')
  button.classList.add('enabled')
  button.setAttribute('aria-pressed', false)

  button = document.getElementById('theme-' + newTheme + '-button')
  button.classList.remove('enabled')
  button.setAttribute('aria-pressed', true)

  if (save) saveToLocalStorage('preference-theme', newTheme)
}

// Save the state for subsequent page loads
function saveToLocalStorage(key, value){
  let now = new Date()
  now = now.getTime()
  localStorage.setItem(key, value)
  localStorage.setItem(key+"-last-change", now)
}

// Add class to smoothly transition between themes
function animateThemeTransition(){
  const root = document.documentElement
  root.classList.remove('theme-change-active')
  void root.offsetWidth // Trigger reflow to cancel the animation
  root.classList.add('theme-change-active')
}
(function removeAnimationClass(){
  const root = document.documentElement
  root.addEventListener(supportedAnimationEvent(), ()=>root.classList.remove('theme-change-active'), false)
}())

function supportedAnimationEvent(){
  const el = document.createElement("f")
  const animations = {
    "animation"      : "animationend",
    "OAnimation"     : "oAnimationEnd",
    "MozAnimation"   : "animationend",
    "WebkitAnimation": "webkitAnimationEnd"
  }

  for (t in animations){
    if (el.style[t] !== undefined) return animations[t]   // Return the name of the event fired by the browser to indicate a CSS animation has ended
  }
}

参考

A guide to implementing dark modes on websites | Koos Looijesteijn

内容安全策略(CSP)深度指南:从基础配置到高级防护

2025年9月15日 17:32

概念

HTTP 响应标头 Content-Security-Policy 允许站点管理者控制用户代理能够为指定的页面加载哪些资源。除了少数例外情况,设置的政策主要涉及指定源服务器和脚本端点。这将帮助防止跨站脚本攻击

解释:哪些文件可以在网站上运行

注意语法规则标点符号使用

Content-Security-Policy: script-src 'self' https://safe-external-site.com; style-src 'self'

Fetch 指令

Fetch 指令控制指定资源类型可以从哪里加载。

  • default-src: 默认策略,没有具体指定策略 default-src 'self' trusted-domain.com
  • img-src: 图片 img-src 'self' img.mydomain.com
  • font-src: 字体
  • object-src: <object><embed>
  • media-src: 视频、音频
  • script-src 脚本
  • style-src css

Fetch 指令语法

  • 'none' :不匹配任何内容
  • 'self':匹配当前主机域(同源,即主机和端口)。但是,不要匹配子域
  • 'unsafe-inline':允许内联 JavaScript 和 CSS,尽量不要使用,nonce 代替 unsafe-inline
  • 'unsafe-eval':允许动态文本用于 JavaScript eval
  • domain.example.com:允许从指定域加载资源。要匹配任何子域,请使用 * 通配符,例如 *.example.com
  • https: 或 ws:: 仅允许通过 HTTPS 或 WebSocket 加载资源
  • nonce-{token}:允许包含相同 nonce 属性值的内联脚本或 CSS
  • 'strict-dynamic' 关键字使得通过 nonce 或 hash 信任的脚本扩展到此脚本动态加载的脚本,例如通过使用 Document.createElement() 创建新的 <script> 标签,然后通过 Node.appendChild() 将其插入文档中

使用

  • 服务器 nodenginx
    • Nginx 中可以使用内置变量的 $request_id 作为唯一 id,而当 nginx 版本不支持时,则可以借助 lua 去生产一个 uuid
    • 接着通过 Nginx 的 sub_filter NONCE_TOKEN 'id' 将页面中的 NONCE_TOKEN 占位符替换为 id,或者使用 lua 进行替换
    • 最后使用 add_header Content-Security-Policy "script-src 'nonce-{id}'" 添加对应的 CSP 返回头
Content-Security-Policy: script-src 'nonce-5fAifFSghuhdf' 'strict-dynamic'
  • html meta 标签
<meta http-equiv="Content-Security-Policy" content="script-src 'self' https://safe-external-site.com">

HTTP Content-Security-Policy-Report-Only响应头允许 web 开发人员通过监测 (但不强制执行) 政策的影响来尝试政策。这些违反报告由 JSON 文档组成通过一个 HTTP POST 请求发送到指定的 URI。

Reporting-Endpoints: name-of-endpoint="后端请求地址"
Content-Security-Policy: default-src 'self'; report-to name-of-endpoint

在 create-react-app (CRA) 创建的应用中使用 react-router-dom v7以及懒加载的使用方法

2025年9月15日 17:17

在 create-react-app (CRA) 创建的应用中使用 react-router-dom v7 的方法

众所周知,react-router v7 的使用方式发生了一些变化,开发者在使用时可能会遇到一些问题。
官方文档提供了三种模式:框架模式、数据模式、声明模式。

  • 本文将重点介绍数据模式的基本使用方法。

  • 懒加载的使用方式也与之前的 router v6 有所不同,将在后续代码中进行说明。

以下是具体的使用步骤:

1. 准备 CRA 项目

确保你已有一个使用 create-react-app 创建的项目。

2. 安装 react-router-dom v7

npm install react-router-dom@7

3. 配置路由器及路由表 (/src/router/index.tsx)

以下配置中的 Demo 组件使用了懒加载方式。

// /src/router/index.tsx

import type { RouteObject } from "react-router-dom";
import React from "react";
import { createHashRouter, Navigate } from "react-router-dom";

import App from "@/App";

const routes: RouteObject[] = [
  {
    path: "/",
    // App 组件未使用异步加载
    Component: App,
    children: [
      {
        index: true, // 当路径为 / 时,作为默认子路由
        element: <Navigate to="/demo" replace />
      },
      {
        path: "/demo",
        // 异步导入组件(懒加载)
        lazy: async () => {
          const Component = await import("@/views/Demo");
          return {
            Component: Component.default,
          };
        },
        // 如果不使用懒加载,则直接使用 element 属性
        // element: <Demo />
      },
    ],
  }
];

export const router = createHashRouter(routes);

4. App.tsx 组件的写法

  • 使用 <Outlet /> 作为子路由的占位符。
  • 利用 useNavigation 处理加载状态。
import React from "react";
import { Link, Outlet, useNavigation } from "react-router-dom";
import Loading1 from "@/views/loading1"; // 修正了组件名拼写 (loding1 -> loading1)

function App() {
  // 判断异步加载状态
  const navigation = useNavigation();
  console.log(navigation.state);
  const isLoading = navigation.state === "loading";

  return (
    <div className="App">
      <div>
        <Link to="/demo">Demo</Link>
      </div>

      <div>
        {/* 如果是异步加载组件,可以这样显示加载过程中的提示 */}
        {isLoading ? <Loading1 /> : <Outlet />}
      </div>
    </div>
  );
}

export default App;

5. 入口文件写法 (/src/index.tsx)

由于我们使用的是布局路由和数据模式,写法会有所不同:

  • 使用 <RouterProvider /> 渲染路由。
// /src/index.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";

// 导入之前配置的路由器
import { router } from "./router";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);

// 使用 RouterProvider 渲染,App 组件已包含在路由配置中
root.render(<RouterProvider router={router} />);

完成以上步骤即可正常使用路由。

Electron38-Winchat聊天系统|vite7+electron38+vue3电脑端聊天Exe

作者 xiaoyan2015
2025年9月14日 10:35

2025最新款原创新作electron38+vite7+vue3 setup+pinia3仿微信客户端聊天程序。

未标题-3.png

009360截图20250911225743164.png

技术知识点

  • 跨平台框架:electron38.0.0
  • 前端框架:vite7.1.2+vue3.5.18+vue-router4.5.1
  • 组件库:element-plus^2.11.2
  • 状态插件:pinia^3.0.3
  • 存储插件:pinia-plugin-persistedstate^4.5.0
  • 打包构建:electron-builder^24.13.3
  • electron结合vite插件:vite-plugin-electron^0.29.0

p1.gif

p3.gif

基于最新版跨平台技术electron38+vite7搭建项目模板,整个项目使用vue3 setup语法糖编码开发。

360截图20250911234347782.png

001360截图20250911221236448.png

360截图20250911235310309.png

360截图20250911235524326.png

360截图20250911235637658.png

360截图20250911235730072.png

360截图20250911235839919.png

项目通用模板

86044af8129cbb94dab7410db3416ed3_1289798-20250913073744421-1992270846.png

7a38d1a01768cfa947aa85f425926a64_1289798-20250913073839199-108741287.png

<template>
  <template v-if="!route?.meta?.isNewWin">
    <div
      class="vu__container flexbox flex-alignc flex-justifyc"
      :style="{'--themeSkin': appstate.config.skin}"
    >
      <div class="vu__layout flexbox flex-col">
        <div class="vu__layout-body flex1 flexbox" @contextmenu.prevent>
          <!-- 菜单栏 -->
          <slot v-if="!route?.meta?.hideMenuBar" name="menubar">
            <MenuBar />
          </slot>

          <!-- 侧边栏 -->
          <div v-if="route?.meta?.showSideBar" class="vu__layout-sidebar flexbox">
            <aside class="vu__layout-sidebar__body flexbox flex-col">
              <slot name="sidebar">
                <SideBar />
              </slot>
            </aside>
          </div>

          <!-- 主内容区 -->
          <div class="vu__layout-main flex1 flexbox flex-col">
            <ToolBar v-if="!route?.meta?.hideToolBar" />
            <router-view v-slot="{ Component, route }">
              <keep-alive>
                <component :is="Component" :key="route.path" />
              </keep-alive>
            </router-view>
          </div>
        </div>
      </div>
    </div>
  </template>
  <template v-else>
    <WinLayout />
  </template>
</template>

006360截图20250911223546910.png

007360截图20250911224016250.png

008360截图20250911224537972.png

009360截图20250911225000821.png

009360截图20250911230909907.png

010360截图20250911230952495.png

019360截图20250911232843999.png

基于uniapp+vue3+uvue短视频+聊天+直播app系统

基于uniapp+vue3+deepseek+markdown搭建app版流式输出AI模板

vue3.5+deepseek+arco+markdown搭建web版流式输出AI模板

unios-admin手机版后台|uniapp+vue3全端admin管理系统

基于flutter3.32+window_manager仿macOS/Wins风格桌面os系统

flutter3.27+bitsdojo_window电脑端仿微信Exe应用

自研tauri2.0+vite6.x+vue3+rust+arco-design桌面版os管理系统Tauri2-ViteOS

基于uni-app+vue3+uvui跨三端仿微信app聊天模板

Flutter3.x深度融合短视频+直播+聊天app实例

原创electron31+vite5.x+elementPlus桌面端后台管理系统

自研tauri2.0+vite5+vue3+element-plus电脑版exe聊天系统Vue3-Tauri2Chat

JavaScript中的dispatchEvent方法详解

2025年9月15日 17:13

dispatchEvent是JavaScript中用于手动触发事件的核心方法,它允许开发者以编程方式派发自定义事件或内置事件,为前端开发提供了强大的事件控制能力。

一、基本概念

dispatchEvent()方法用于在当前节点上触发指定事件,从而调用该事件的所有监听函数。它的核心特点包括:

  1. 可以触发任何类型的事件:包括标准DOM事件和自定义事件12
  2. 同步执行:事件监听函数会立即执行,而不是异步执行2
  3. 返回值:返回一个布尔值,如果有监听函数调用preventDefault()则返回false,否则返回true2

二、基本语法

target.dispatchEvent(event)
  • target:要触发事件的目标元素
  • event:要触发的事件对象,可以是EventCustomEvent实例

三、使用步骤详解

1. 创建事件对象

有两种主要方式来创建事件对象:

(1) 使用Event构造函数(不需要传递额外数据)

// 创建普通事件
const event = new Event('build')

// 带选项的事件(指定冒泡、可取消等特性)
const event = new Event('custom', {
  bubbles: true,    // 事件是否冒泡
  cancelable: true  // 事件能否被取消
})

(2) 使用CustomEvent构造函数(可以传递额外数据)

// 创建自定义事件并携带数据
const customEvent = new CustomEvent('userAction', {
  detail: { 
    action: 'click',
    time: new Date()
  },
  bubbles: true,
  cancelable: true
})

2. 监听事件

在目标元素上添加事件监听:

// 监听标准事件
document.addEventListener('click', (e) => {
  console.log('点击事件被触发', e)
})

// 监听自定义事件
element.addEventListener('userAction', (e) => {
  console.log('自定义事件数据:', e.detail)
})

3. 触发事件

通过dispatchEvent方法触发事件:

// 触发点击事件
const clickEvent = new Event('click')
button.dispatchEvent(clickEvent)

// 触发自定义事件
const customEvent = new CustomEvent('userAction', {
  detail: { userId: 123 }
})
document.dispatchEvent(customEvent)

四、实际应用示例

1. 自定义全局通知系统

// 创建自定义事件
const notificationEvent = new CustomEvent('globalNotification', {
  detail: {
    message: '系统更新即将开始',
    type: 'warning'
  }
})

// 监听通知事件
window.addEventListener('globalNotification', (e) => {
  showToast(e.detail.message, e.detail.type)
})

// 触发通知
window.dispatchEvent(notificationEvent)

2. 模拟用户点击

// 获取按钮元素
const button = document.getElementById('myButton')

// 创建鼠标点击事件
const clickEvent = new MouseEvent('click', {
  bubbles: true,
  cancelable: true,
  clientX: 100,  // 点击位置X坐标
  clientY: 50    // 点击位置Y坐标
})

// 触发点击事件
button.dispatchEvent(clickEvent)

3. 跨组件通信

// 模块A - 发布事件
function publishUpdate(data) {
  const event = new CustomEvent('dataUpdate', {
    detail: data,
    bubbles: true
  })
  document.dispatchEvent(event)
}

// 模块B - 订阅事件
document.addEventListener('dataUpdate', (e) => {
  updateUI(e.detail)
})

五、注意事项

  1. 事件传播:默认情况下自定义事件不会冒泡,需要显式设置bubbles: true12
  2. IE兼容性:IE9以下不支持EventCustomEvent构造函数,需要使用document.createEvent()方法2
  3. 性能考虑:频繁触发事件可能影响性能,应有节流机制3
  4. 事件类型:避免使用浏览器保留的事件名(如click、load等),以防止冲突1

六、兼容性写法

对于需要支持旧版浏览器的场景:

// 创建事件的兼容性写法
let event
if (typeof Event === 'function') {
  event = new Event('customEvent')
} else {
  event = document.createEvent('Event')
  event.initEvent('customEvent', true, true)
}

// 触发事件
element.dispatchEvent(event)

七、总结

dispatchEvent是JavaScript事件系统中的强大工具,它:

  1. 支持自定义事件系统的实现1
  2. 允许模拟用户操作进行测试3
  3. 实现松耦合的组件通信12
  4. 可以控制事件传播行为(冒泡/捕获)2

掌握dispatchEvent方法能够让你的前端代码更加灵活和可维护,是实现复杂交互和组件通信的重要基础。

Footnotes

  1. JS中window.dispatchEvent的原理和使用初识 2 3 4 5

  2. js基础-Event 事件★★★_js dispatchevent preventdefault-CSDN博客 2 3 4 5 6 7

  3. JavaScript 中 event 方法如何使用 – PingCode 2

F2C-PTD工具将需求快速转换为代码实践

作者 KenXu
2025年9月15日 17:12

F2C的PTDTC(prompt to design to code)体系可以让用户免费通过自然语言的形式将需求快速转换为Figma设计,再通过工具将生成的Figma设计稿转换成高还原度代码。

接下来逐步介绍实现的过程,以下内容和文档均参考:f2c.yy.com/

前期准备

支持MCP的Agent

需要一个支持mcp调用的agent,包括但不限于IDE、插件等,例如:VSCodeTraeCursorComate。本文以AugmentCode为例。

Figma空设计稿

准备一个空的Figma设计稿,需要有编辑权限,方便工具写入设计稿元素。推荐使用Web端的Figma。

F2C的Chrome插件

提前下载好F2C的配套chrome浏览器插件,截止写稿前的版本为v2.2.0。安装后刷新之前准备好的Figma空文件,看到下图所示即为安装成功。

image-20250915155743277

需求转设计稿部分

步骤一:安装和配置F2C的PTD MCP工具

这个MCP能让用户通过自然语言的方式对话Figma,让AI分析你的需求并转换为Figma生成步骤生成设计稿。

安装

推荐全局安装 F2C PTD 的 MCP:

pnpm i -g @f2c/ptd

配置

请参考以下配置:

// 如果不使用设计组件库,可删除 `your_figma_personal_token` 配置。若需使用组件库,则需要配置。token的获取参考:https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens
{
  "F2C-PTD": {
    "command": "npx",
    "args": [
      "-y",
      "@f2c/ptd",
      "--server=f2c-ptd.yy.com",
      "--figma-api-key=your_figma_personal_token"
    ],
    "env": {
      "personalToken": "your_figma_personal_token"
    }
  }
}

当你安装和配置完毕之后,可以看到MCP是可使用状态并列举出可以使用的tool。

image-20250915160714559

步骤二:加入频道开启自然语言对话

链接频道

在Web Figma中打开前期准备中准备好的Chrome插件,切换到 Design Tab,并点击连接。

image-20250915161022552

点击后的状态如下图,我们可以看到插件出现了一个频道ID,点击即可复制ID。

image-20250915161106934

切换至Agent中,开启对话,输入如下内容(频道自己替换为自己的):

加入频道channel_1757923859807_288

加入频道成功可以看到下图所示:

image-20250915161553211

开始对话生成

这里将prompt放在这里仅供参考,值得注意的是可以Rule或者其它形式更好的约束AI生成,这部分内容可以是你具体的需求,参考内容放置在文章最后。

shadcn/ui 风格中后台 Dashboard 视觉设计规范 请设计一个遵循 shadcn/ui 设计语言的中后台仪表盘,其核心是极简、现代、专业且高度一致的视觉体验。

  1. 整体氛围与美学 基调:营造一种干净、宁静、专业的氛围。避免过度设计和视觉噪音。 质感:追求“数字玻璃”般的通透感。大量运用留白,元素之间有充足的呼吸空间。 细节:注重微小的视觉细节,如统一的圆角半径(柔和但不过分)、精细的 1px 边框(使用中性或浅灰色)、以及恰到好处的细微阴影(仅用于轻微的层叠感,避免厚重投影)。
  2. 色彩体系 主色调: 以纯净的白色或极浅的灰白色作为背景基底。 文字和主要线条采用深灰色或接近黑色的深色(非纯黑),确保阅读舒适。 辅助与强调: 使用一个低饱和度的单一主色(如蓝紫色或青蓝色)作为唯一的强调色,仅用于关键按钮、选中状态和核心数据图表,起到画龙点睛的作用。 状态颜色(成功-绿色、警告-黄色、危险-红色)应清晰可辨,但饱和度不宜过高,保持整体调性的和谐。 中性色:广泛使用不同深浅的灰色来区分内容层级,例如用浅灰色表示边框、分隔线和次要信息,形成丰富的层次感而不显杂乱。
  3. 布局与结构 经典双栏:采用左侧垂直导航栏 + 右侧主内容区的布局。 导航栏:窄而简洁,图标与文字结合,选中项通过左侧一条细长的彩色高亮条和微妙的背景色变化来标识,避免使用大面积填充。 主内容区:所有信息模块都封装在卡片(Card) 内。卡片具有统一的内边距、圆角和轻柔的边框,悬浮时有极其轻微的抬升感(由细微阴影体现)。 响应式:在移动设备上,导航栏应能优雅地收起为汉堡菜单,主内容区的卡片自动调整为单列排列。
  4. 核心模块视觉描述 概览指标卡:每张卡片展示一个核心数据。数值字体较大且加粗,位于卡片中央偏上。下方配以小型的趋势折线图或一个带箭头的小标签(绿色向上/红色向下)显示增长率,颜色使用主色或状态色。 数据图表区:占据一张大卡片。图表本身设计简洁,坐标轴线条纤细,网格线若有则非常淡。图例清晰,颜色搭配协调,重点数据突出。 活动列表:以极简表格形式呈现。行与行之间通过细微的 border-bottom 分隔,悬停某一行时背景色发生轻微变化。时间戳使用较小字号和浅灰色。 快捷操作:使用无填充的“幽灵按钮”(Ghost Button)或带图标的文本链接,排列整齐,不喧宾夺主。
  5. 字体与排版 字体选择:使用无衬线字体,确保在各种尺寸下都清晰易读。 层级分明:标题、副标题、正文、说明文字之间有明确的字号和字重(bold, medium, normal)区分,引导用户的阅读视线。 对齐与间距:严格对齐,无论是左对齐还是居中对齐,都保持精确。段落和模块间的垂直间距宽松,水平间距也恰到好处。 最终目标:用户进入这个 Dashboard 时,首先感受到的是秩序、清晰和宁静。所有设计元素协同工作,让用户能快速、无干扰地获取信息,同时体会到一种低调而精致的现代美感。

移动端 Dashboard 页面元素细节 (shadcn/ui 风格)

设计一个专为移动设备优化的中后台 Dashboard,所有元素需适应小屏幕,确保信息清晰、操作便捷。

1. 整体布局与导航

  • 主导航

    :采用底部标签栏(Bottom Tab Bar)。

    • 位置:固定在屏幕最底部。
    • 样式:半透明或纯白背景,带有细微上边框 (border-t)。包含 3-5 个核心功能图标(如仪表盘、订单、用户、设置),配以简洁文字标签。
    • 交互:选中的标签项使用主色调高亮图标和文字,提供明确反馈。
  • 次级导航/筛选

    :位于页面顶部,紧接状态栏下方。

    • 样式:一个轻量级的横向滚动条或分段控件(Segmented Control)。
    • 内容:用于切换视图(如“今日”、“本周”、“本月”)或数据类别(如“全部订单”、“待处理”、“已完成”)。选中项通过背景色填充或下划线标识。
  • 主内容区:从顶部导航下方开始,一直延伸到标签栏上方,充分利用垂直空间。内容以单列流式布局排列。

2. 核心模块元素细节

  • 概览指标卡 (KPI Cards)

    • 布局:通常为全屏宽度的横幅或并排的两个小卡片(在稍大屏幕)。
    • 视觉:卡片边界清晰,有统一的内边距。内部采用两行布局:
      • 上行:指标名称(较小字号,浅灰色 text-muted-foreground)。
      • 下行:数值(大号、加粗字体,深色前景),其后紧跟一个微小的趋势图标(↑↓)和变化百分比(绿色/红色)。
    • 交互:卡片整体可点击,跳转到更详细的数据页。
  • 数据图表 (Charts)

    • 类型:优先使用高度压缩的图表,如微型折线图 (Sparkline)水平柱状图
    • 尺寸:宽度占满容器,高度适中(约 100-150px)。
    • 简化:省略复杂的坐标轴标签和图例。仅显示核心趋势。必要时,可通过点击图表区域展开为全屏模态图进行详细分析。
    • 配色:使用主色或状态色,保持简洁。
  • 列表与表格 (Lists & Tables)

    • 形式:将传统的表格转换为卡片式列表项 (Card List Items)

    • 每行结构:一个可点击的

      <ListItem>
      

      包含:

      • 左区:一个小型图标或头像(代表用户、订单类型等)。
      • 中区:主要信息(如订单ID、用户名)居左对齐,副信息(如时间、状态摘要)以小号浅色字体显示在主信息下方。
      • 右区:辅助信息(如金额)或一个“>”箭头图标,表示可进入详情页。
      • 底部分隔线:每个列表项底部有一条非常细的浅灰色线 (border-b),最后一项除外。
    • 状态标识:关键状态(如“待支付”、“已发货”)使用小型 Badge 组件,颜色对应状态色,放置在列表项的右上角或信息区内。

  • 快捷操作 (Quick Actions)

    • 位置:常置于页面右下角的浮动操作按钮 (FAB - Floating Action Button)
    • 样式:一个圆形按钮,填充主色,中心是“+”号或其他相关图标。悬浮于内容之上,易于触及。
    • 备选方案:也可作为一组小型的“幽灵按钮”(Ghost Buttons)水平排列在某个模块的上方。

3. 通用 UI 元素风格

  • 字体:字号适中,确保在小屏幕上易读。避免过小的说明文字。

  • 圆角:所有卡片、按钮、输入框保持统一的圆角大小(如 rounded-lg)。

  • 阴影:移动端谨慎使用阴影。卡片可有极轻微的底部阴影以示层叠,但不应过于厚重。

  • 图标:使用线条简洁、辨识度高的图标集。大小统一。

  • 加载与空状态:设计优雅的加载骨架屏(Skeleton Screen)和空状态插画,提示用户当前无数据或正在加载。

对话后Agent会自动寻找F2C提供的Tool来绘制Figma设计稿,对话内容和绘制效果如下图。

image-20250915164015677

image-20250915164344192

设计稿转代码部分

当我们有了设计稿之后就可以生成代码了,生成代码参考F2C官网有两种方式。

使用F2C的MCP生成(推荐)

使用MCP生成配置上会繁琐一点,但是生成的代码还原度高、可维护性强、还可以智能适配你的技术栈。教程和实战文章贴在这里了,这里不在赘述:

使用Chrome插件生成

前期准备的Chrome插件不仅能够链接Agent和Figma绘制设计稿,还可以拥有Figma的Dev Mode能力,当然包括生成代码了。

参考文章:F2C-Chrome插件-Figma免费的DevMode来了!插件官网

直接选中刚刚生成的设计稿图层,点击代码生成、下载和预览。参考下图:

image-20250915165128799

生成效果预览,注意这不是简单的一张图片,是代码。

image-20250915165259396

至此我们完成了PTDTC的实战。

总结&未来

F2C的生态非常完善,你可以需求生成设计稿,也可以将现有设计稿生成高质量代码,相比于Figma官方提供的需要席位费的MCP和系列工具,F2C一直是免费试用。有兴趣的大佬们可以尝试一下。

当然目前PTD尚处于Beta阶段,未来会着眼于设计稿的生成质量总结Rule和Prompt,提供一套完整的、稳定的方案。未来也会联合组件(例如:antd、shadcn/ui等)进行1比1的PTDTC的流程,目前内部已经在运行中。

参考文章

用户实战视频

HarmonyOS 5.0应用开发——V2装饰器@local的使用

作者 高心星
2025年9月15日 17:07

V2装饰器@local的使用

【高心星出品】

概念

组件内部状态管理 @Local是专为@ComponentV2组件设计的装饰器,用于声明组件私有状态。被装饰的变量必须在组件内部初始化,禁止从父组件外部传入初始值(如Child({count: 10})的写法会报错),确保状态封装性。

观测能力

  • 支持类型:基本类型(number、string、boolean)、Object、class、Array、Set、Map、Date等内嵌类型,以及联合类型1。

  • 变化触发机制:

    • 简单类型(如number):赋值操作触发UI刷新(如this.count++)。
    • 对象类型:仅整体赋值时触发(如替换整个对象this.obj = new MyClass())。
    • 数组/集合类型:整体赋值或调用特定API(如push()set())时触发。

与@State的对比

特性 @Local(V2) @State(V1)
初始化 强制内部初始化 允许外部传入覆盖初始值
作用域 组件内部私有 可跨组件传递
性能优化 更精细的观测,减少不必要刷新 可能因外部修改导致过度渲染
适用版本 API 12+,ComponentV2组件 旧版本组件体系

使用场景

基本状态管理:

Button绑定local装饰的变量count,count值改变引起button刷新。

@Entry
@ComponentV2
struct Index {
  @Local count: number = 1;

  build() {
   Column(){
     Button('点击次数:'+this.count)
       .width('60%')
       .onClick(()=>{
         this.count+=1
       })
   }
    .height('100%')
    .width('100%')
    .justifyContent(FlexAlign.Center)
  }
}

装饰数组的情况:

button绑定数据源datas的长度,list绑定数据源datas,当数据源调用push api或者元素更新的时候会引起UI刷新。

@Entry
@ComponentV2
struct Localpage {
  @Local datas:number[]=[1,2,3,4,5,6,7,8,9,10]
  build() {
   Column({space:20}){
     Button('列表长度:'+this.datas.length)
       .width('60%')
       .onClick(()=>{
         // 调用api会被观察
        // this.datas.push(this.datas.length+1)
        //  更新数组项也会被观察
         this.datas[this.datas.length-1]=0
       })
     List(){
       Repeat<number>(this.datas).each((item:RepeatItem<number>)=>{
         ListItem(){
           Text('列表项:'+item.item)
             .fontSize(30)
             .fontWeight(FontWeight.Bolder)
             .padding(10)
             .textAlign(TextAlign.Center)
         }
       })
     }
     .width('100%')
     .divider({strokeWidth:2})
   }
    .height('100%')
    .width('100%')
  }
}

不可以从父组件向子组件传值

@ComponentV2
struct child {
  @Local count: number = 10

  build() {
    Column() {
      Button('child count: ' + this.count)
        .width('60%')
        .onClick(() => {
          this.count += 1
        })
    }
    .width('100%')
    .padding(20)
  }
}

@Entry
@ComponentV2
struct Localpage1 {
  @Local count: number = 11

  build() {
    Column() {
      // 有问题不能外部传值
      // child({count:11})
      // child({count:this.count})
      //   没问题
      child()
    }
    .justifyContent(FlexAlign.Center)
    .height('100%')
    .width('100%')
  }
}

后端转全栈之Next.js文件约定

2025年9月15日 16:45

本文概括

  • Page Router vs App Router:老版用 pages 目录,新版用 app 目录并通过约定文件定义路由和特殊功能。
  • page.tsx:定义具体页面,对应一个路由。
  • layout.tsx:定义可复用的页面布局,子页面会被包裹其中,根布局必须包含 html 和 body
  • template.tsx:类似布局但不会保留状态,每次路由切换都会重新渲染。
  • loading.tsx:在页面数据加载时显示的过渡界面。
  • error.tsx:客户端错误边界组件,捕获运行时错误并支持 reset 重试。
  • global-error.tsx:根目录的全局错误捕获页面。
  • not-found.tsx:定义 404 页面,用于未匹配路由或显式调用 notFound()

老版本的Next.js使用的是Page Router,在 pages 目录下,每个js文件就是一个路由,这就导致一些组件不能写在 pages 目录下,新版本换成了App Router ,文件放在 app目录下,目录下的 page.tsx 就是代表一个路由,Next.js约定了一些特殊的文件:

布局(layout.tsx)、模板(template.tsx)、加载状态(loading.tsx)、错误处理(error.tsx)、404(not-found.tsx)页面(page.tsx)

页面page.tsx

每个目录下的 page.tsx 会映射到一个路由,需要导出一个默认函数,例如:

export default function Page(){
return <>Next.js</>
}

布局layout.tsx

layout.tsx 文件导出一个React组件,接受 chidren 作为参数,表示的是子页面内容,子页面会拥有layout里的布局,也就是layout会包裹着page页面

export default function Layout({ children }: { children: React.ReactNode }) {
    return (
        <div>
            <h1>Test Layout</h1>
            {children}
        </div>
    )
}

根布局要求:

  • 必须有根布局 app/layout.tsx
  • 必须包含 html 和 body 标签

模版template.tsx

模版和layout类似,会包裹每个页面,但是和layout的区别是,模版不会维持状态,每次进入一个新的路由都会重新初始化,模版会被layout包裹起来。

例如在layout文件里写一个 表单,那么通过Link跳转到子路由,表单里的内容不会变,如果是使用template,那么就会重新渲染,表单里的数据消失

例如 layout.tsx

'use client'
import React, { useState } from 'react'

export default function Layout({ children }: { children: React.ReactNode }) {
    const [count, setCount] = useState(0)

    return (
        <div>
            <h1>Test Layout</h1>
            <>Layout Count: {count}</>
            <button onClick={() => setCount(count + 1)}>Layout数字增加</button>
            <br />
            {children}
        </div>
    )
}

template.tsx

'use client'
import React, { useState } from 'react'
export default function Template({ children }: { children: React.ReactNode }) {
    const [count, setCount] = useState(0)

    return (
        <div>
            <>Template Count: {count}</>
            <h1>Test Template</h1>
            <button onClick={() => setCount(count + 1)}>Template数字增加</button>
            {children}
        </div>
    )
}

当我们增加了数字之后,在test目录路由下跳转,会发现layout里的数字不变,template里的数字会清空

加载loading.tsx

loading.tsx 是加载页面,例如:

export default function Loading() {
    return (
        <div>
            <h1>Loading</h1>
        </div>
    )
}

这样在 page.tsx 加载数据的时候,在没拿到数据之前,就会显示loading的内容:

import Link from 'next/link'

async function getUser() {
    await new Promise((resolve) => setTimeout(resolve, 2000))
    return {
        name: 'cxk',
    }
}

export default async function Page() {
    const { name } = await getUser()
    return (
        <div>
            <Link href="/test/test2">跳转Page2</Link>
            <Link href="/test">跳转Page</Link>
            {name}
        </div>
    )
}

如果一个目录下有很多约定的文件,那么他们的层级是:

nextjs.org/docs/app/ge…

Next.js文件约定

image.png

错误error.tsx

错误页面必须是客户端组件, error.tsx 接受一个error和reset

  • error: Error

    捕获到的错误对象,可能带 digest(Next.js 内部生成的唯一标识符)。

  • reset: () => void

    调用它可以 重置错误边界,让 Next.js 重新尝试渲染页面(比如在用户点 “Retry” 按钮时)。

'use client'

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
    const handleReset = () => {
        console.log('reset')
        reset()
    }
    return (
        <div>
            <h1>Error</h1>
            <p>{error.message}</p>
            <button onClick={handleReset}>Reset</button>
        </div>
    )
}

可以在页面获取数据的时候抛出异常试试:

async function getUser() {
    await new Promise((resolve) => setTimeout(resolve, 2000))
    throw new Error('test error')
    return {
        name: 'cxk',
    }
}

export default async function Page() {
    const { name } = await getUser()
    return (
        <div>
            <Link href="/test/test2">跳转Page2</Link>
            <Link href="/test">跳转Page</Link>
            {name}
        </div>
    )
}

注意:错误边界不可以捕获同级的layout和tempalte,必须在父级的error.tsx去捕获,因为 ErrorBoundary 被Layout和Template包裹了。

根目录的捕获可以使用 global-error.tsx 进行捕获

404not-found.tsx

未找到页面会显示404页面的内容,触发情况主要有两种:

  • 组件调用 notFound 函数
  • 路由地址不匹配
❌
❌