普通视图

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

CSS 属性值 initial、unset 和 revert 的解析

2025年11月22日 22:17

大家好,我是前端架构师,关注微信公众号【程序员大卫】,免费领取前端最新面试资料。

背景

在 CSS 中,管理属性值和继承行为是创建健壮、可维护样式的基础。除了常见的具体数值(如 16px#333)或通用值(如 inherit)之外,CSS 还提供了一些特殊的关键字,它们能重置或恢复属性值到其“默认”或前一个状态。

其中,initialunsetrevert 是三个功能强大且容易混淆的关键字。理解它们的区别,能帮助您更好地控制样式,尤其是在处理第三方组件或大型应用中的样式冲突时。

核心区别一览

关键字 作用 行为细节 适用场景
initial 重置为初始值 将属性重置为其在 CSS 规范中定义的初始值(就像该属性从未被设置过一样)。 确保属性值是浏览器默认的“出厂设置”,不考虑继承。
unset 重置为未设置值 如果该属性是可继承的,则表现为 inherit(继承父元素的值)。
如果该属性是不可继承的,则表现为 initial
恢复属性到其自然的(默认的)继承行为。
revert 重置为上一个级联值 将属性重置为用户代理样式表(浏览器默认样式)、用户样式表作者样式表中应用过的上一个值。它会撤销当前样式块的更改,恢复到级联链中更早的值。 撤销对属性的特定覆盖,恢复到浏览器或用户定义的样式,常用于恢复浏览器默认样式。

关键字详解与示例

为了更好地理解,我们主要关注 color(可继承)和 border(不可继承)这两个属性。

1. initial:回到起点

initial 总是将属性值设置为 CSS 规范中定义的初始值。这个值是固定的,并且与该元素是否继承了父元素的值无关

示例:

/* 初始设置 */
div {
  color: blue;
  border: 1px solid black;
}

/* 应用 initial */
.initial-demo {
  /* color 是可继承属性,但 initial 强制设为初始值 (通常是 black) */
  color: initial;
  /* border 是不可继承属性,initial 强制设为初始值 (none) */
  border: initial;
}
元素 父元素 div 的样式 .initial-demo 的样式 最终值
color blue initial black (初始值)
border 1px solid black initial none (初始值)

2. unset:恢复自然状态

unset 是一个“智能”的重置。它根据属性的可继承性来决定行为:

  • 可继承属性: 表现为 inherit
  • 不可继承属性: 表现为 initial

示例:

/* 初始设置 */
div {
  color: blue;
  border: 1px solid black;
}

/* 应用 unset */
.unset-demo {
  /* color 是可继承属性,表现为 inherit */
  color: unset;
  /* border 是不可继承属性,表现为 initial */
  border: unset;
}
元素 父元素 div 的样式 .unset-demo 的样式 最终值
color blue unset \rightarrow inherit blue (继承自父元素)
border 1px solid black unset \rightarrow initial none (初始值)

3. revert:撤销作者样式

revert 是最独特的。它沿着 CSS 级联(Cascading)链向上查找,恢复到上一个被应用的值。它本质上是撤销了当前样式表中的属性定义,恢复到:

  1. 用户样式(如果用户设置了)。
  2. 浏览器默认样式(用户代理样式表)。
  3. 如果以上都没有,则恢复到 unset 的行为。

它主要用于恢复浏览器默认样式,例如,您不想让某个 <h1> 标签应用您的全局字体样式,希望它用回浏览器默认的粗体大字。

示例:

假设浏览器默认样式是:h1 { font-weight: bold; }

/* 作者样式表 (您的代码) */
h1 {
  /* 覆盖了浏览器默认样式 */
  font-weight: normal; 
}

.revert-demo {
  /* 撤销了上方的 font-weight: normal; */
  font-weight: revert;
}

在这个例子中,.revert-demo 元素会将 font-weight 恢复到 浏览器默认值 (bold),因为它撤销了作者样式表中的 normal 声明。

✅ 总结与应用建议

关键字 记忆点 应用场景
initial “绝对初始” 确保属性值是其 CSS 规范定义的原始默认值,不考虑上下文。
unset “智能重置” 恢复属性的自然继承行为(可继承则 inherit,不可继承则 initial)。
revert “撤销” 撤销作者样式,恢复到浏览器默认或用户样式。常用于希望保持浏览器默认外观的元素。

什么时候用哪个?

  • 如果您想确保一个属性值是固定的、明确的 CSS 初始值,使用 initial
  • 如果您想让一个属性遵循其固有的继承规则(即,可继承的就继承,不可继承的就使用初始值),使用 unset
  • 如果您想撤销您的样式对某个元素默认外观的覆盖,恢复到浏览器提供的默认样式,使用 revert
昨天 — 2025年11月22日技术

Ultracite:为 AI 时代打造的零配置代码规范工具

作者 JinSo
2025年11月22日 18:26

Ultracite 是什么?

Ultracite 是一个高度固定化、零配置的代码检查和格式化工具。它基于高性能的 Biome 构建,适用于所有前端项目,无论你使用 React、Vue、Angular 还是原生 JavaScript,都能让你和 AI 编程助手编写出一致且高质量的代码。

核心理念:约定优于配置

不同于 ESLint + Prettier 需要大量配置,Ultracite 采用了极简主义设计理念:

  • 零配置:开箱即用,无需编写任何配置文件
  • 固定规则:精心挑选的规则集,避免无谓的选择困扰
  • 统一标准:整个团队和 AI 助手都遵循相同的代码规范

快速开始

初始化

# 在项目中初始化 Ultracite
pnpm dlx ultracite init

Ultracite 提供了交互式的安装体验,让你根据项目需求选择功能:

❯ pnpm dlx ultracite init
┌
888     888 888    88888888888 8888888b.         d8888  .d8888b. 8888888 88888888888 8888888888
888     888 888        888     888   Y88b       d88888 d88P  Y88b  888       888     888
888     888 888        888     888    888      d88P888 888    888  888       888     888
888     888 888        888     888   d88P     d88P 888 888         888       888     8888888
888     888 888        888     8888888P"     d88P  888 888         888       888     888
888     888 888        888     888 T88b     d88P   888 888    888  888       888     888
Y88b. .d88P 888        888     888  T88b   d8888888888 Y88b  d88P  888       888     888
 "Y88888P"  88888888   888     888   T88b d88P     888  "Y8888P" 8888888     888     8888888888

│
●  Detected lockfile, using pnpm
│
◇  Remove existing formatters/linters (recommended for clean migration)?
│  Remove ESLint (dependencies, config files, VS Code settings)
│
◇  Which frameworks are you using (optional)?
│  React
│
◇  Which editors do you want to configure (recommended)?
│  VSCode / Cursor / Windsurf
│
◇  Which agents do you want to enable (optional)?
│  Cursor
│
◇  Which agent hooks do you want to enable (optional)?
│  Cursor
│
◇  Would you like any of the following (optional)?
│  Husky pre-commit hook, Lefthook pre-commit hook, Lint-staged
│
◑  ...省略安装步骤
◆  Successfully initialized Ultracite configuration!

初始化完成后,Ultracite 会根据你的选择自动完成以下配置:

  • 安装必要的依赖包
  • 生成编辑器配置文件
  • 创建 AI 代码规范提示词(这是 Ultracite 的核心特性之一)
  • 配置 Git Hooks 工具(Husky、Lefthook、Lint-staged)
  • 更新 package.json 脚本
  • 生成 biome.json 配置文件

关于 Git Hooks 工具的详细介绍,可以参考我之前的文章:在 Monorepo 中对代码进行规范(husky + lint-staged)

基本使用

# 检查代码问题
npx ultracite check

# 自动修复和格式化代码
npx ultracite fix

AI 友好特性

这是 Ultracite 的一大亮点 —— 它专门为 AI 编程时代设计,让 AI 助手也能遵循项目的代码规范。

AI 提示词集成

当你选择启用 AI agent(如 Cursor)时,Ultracite 会生成一份详细的代码规范提示词文件 .cursor/rules/ultracite.mdc。这份文件包含了完整的编码规范,涵盖:

  • 类型安全规范:要求使用明确的类型定义,避免 any 类型
  • 现代语法规范:优先使用箭头函数、可选链、解构赋值等现代特性
  • 异步编程规范:正确使用 async/await,妥善处理错误
  • React 最佳实践:函数组件、Hooks 规则、无障碍访问等
  • 代码组织原则:函数复杂度控制、早期返回、关注点分离

有了这份提示词,AI 助手生成的代码会自动遵循这些规范,大大减少了代码审查的工作量。

Cursor Hooks 集成

对于 Cursor 用户,Ultracite 还会生成 hooks 配置(.cursor/hooks.json):

{
  "version": 1,
  "hooks": {
    "afterFileEdit": [
      {
        "command": "npx ultracite fix"
      }
    ]
  }
}

这个配置实现了"保存即格式化"的效果 —— 每次你或 AI 编辑文件后,代码都会自动格式化,保持整个项目的一致性。

Git Hooks 集成

Ultracite 支持主流的 Git Hooks 工具,确保提交的代码都经过检查和格式化。

Husky 集成

生成的 .husky/pre-commit 文件会在提交前自动运行代码检查:

#!/bin/sh
# Exit on any error
set -e

# Check if there are any staged files
if [ -z "$(git diff --cached --name-only)" ]; then
  echo "No staged files to format"
  exit 0
fi

# Store the hash of staged changes to detect modifications
STAGED_HASH=$(git diff --cached | sha256sum | cut -d' ' -f1)

# Save list of staged files (handling all file states)
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
PARTIALLY_STAGED=$(git diff --name-only)

# Stash unstaged changes to preserve working directory
git stash push --quiet --keep-index --message "pre-commit-stash" || true
STASHED=$?

# Run formatter on the staged files
pnpm dlx ultracite fix
FORMAT_EXIT_CODE=$?

# Restore working directory state
if [ $STASHED -eq 0 ]; then
  # Re-stage the formatted files
  if [ -n "$STAGED_FILES" ]; then
    echo "$STAGED_FILES" | while IFS= read -r file; do
      if [ -f "$file" ]; then
        git add "$file"
      fi
    done
  fi

  # Restore unstaged changes
  git stash pop --quiet || true
fi

# Check if staged files actually changed
NEW_STAGED_HASH=$(git diff --cached | sha256sum | cut -d' ' -f1)
if [ "$STAGED_HASH" != "$NEW_STAGED_HASH" ]; then
  echo "✨ Files formatted by Ultracite"
fi

exit $FORMAT_EXIT_CODE

这个脚本会智能处理部分暂存的文件,确保只格式化你要提交的代码。

Lefthook 集成

如果你选择使用 Lefthook,会生成 lefthook.yml 配置:

pre-commit:
  jobs:
    - run: pnpm dlx ultracite fix
      glob:
        - "*.js"
        - "*.jsx"
        - "*.ts"
        - "*.tsx"
        - "*.json"
        - "*.jsonc"
        - "*.css"
      stage_fixed: true

Lefthook 的优势在于并行执行和更灵活的配置选项。

Lint-staged 集成

配合 lint-staged 可以只处理暂存的文件,在 package.json 中会添加:

"lint-staged": {
  "*.{js,jsx,ts,tsx,json,jsonc,css,scss,md,mdx}": [
    "pnpm dlx ultracite fix"
  ]
}

VSCode 深度集成

Ultracite 会自动为 VSCode(包括 Cursor、Windsurf)生成配置,实现保存时自动格式化:

// .vscode/settings.json
{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[javascript]": {
    "editor.defaultFormatter": "biomejs.biome"
  },
  "[typescript]": {
    "editor.defaultFormatter": "biomejs.biome"
  },
  "[javascriptreact]": {
    "editor.defaultFormatter": "biomejs.biome"
  },
  "[typescriptreact]": {
    "editor.defaultFormatter": "biomejs.biome"
  },
  "[json]": {
    "editor.defaultFormatter": "biomejs.biome"
  },
  "[jsonc]": {
    "editor.defaultFormatter": "biomejs.biome"
  },
  "[css]": {
    "editor.defaultFormatter": "biomejs.biome"
  },
  "[graphql]": {
    "editor.defaultFormatter": "biomejs.biome"
  },
  "typescript.tsdk": "node_modules/typescript/lib",
  "editor.formatOnSave": true,
  "editor.formatOnPaste": true,
  "emmet.showExpandedAbbreviation": "never",
  "editor.codeActionsOnSave": {
    "source.fixAll.biome": "explicit",
    "source.organizeImports.biome": "explicit"
  }
}

MCP(Model Context Protocol)支持

MCP 是一个开放标准,让 Claude、Cursor 等 AI 工具能够安全地连接外部数据源和系统。可以把它理解为一个"万能遥控器",让 AI 工具能够访问真实世界的数据和功能。Ultracite 通过支持 MCP 来增强你的 AI 开发工作流。

安装配置

  1. 选择你的 AI 工具

    确保你使用的 AI 开发工具支持 MCP:

    • Claude Desktop(免费,推荐初学者使用)
    • Cursor(AI 驱动的代码编辑器)
    • Windsurf by Codeium(AI 开发平台)
    • 其他支持 MCP 的工具
  2. 找到配置文件位置

    根据你的 AI 工具,需要编辑对应的配置文件:

    • Claude Desktop:

      • macOS: ~/Library/Application\ Support/Claude/claude_desktop_config.json
      • Windows: %APPDATA%\Claude\claude_desktop_config.json
    • Cursor.cursor/mcp.json

    • Windsurf.codeium/windsurf/mcp_config.json

    • 其他工具: 查看对应工具的 MCP 文档

  3. 添加 Ultracite 配置

    将以下配置复制到你的 MCP 配置文件中:

    {
      "mcpServers": {
        "ultracite": {
          "command": "npx",
          "args": [
            "-y",
            "mcp-remote",
            "<https://www.ultracite.ai/api/mcp/mcp>"
          ]
        }
      }
    }
    
  4. 重启 AI 工具

    关闭并重新打开你的 AI 应用,让配置生效。

  5. 验证连接

    通过询问你的 AI 助手来测试集成是否成功:

    "Ultracite 有哪些可用的规则?"

    如果配置成功,AI 应该能够列出并解释 Ultracite 的规则!

从 ESLint + Prettier 迁移

迁移过程非常简单:

  1. 运行 pnpm dlx ultracite init
  2. 选择移除现有的 ESLint 和 Prettier 配置
  3. 更新 package.json 中的脚本命令
  4. 删除旧的配置文件

Ultracite 的规则集已经涵盖了大多数常用的 ESLint 和 Prettier 规则,你几乎不需要任何额外配置。

总结

Ultracite 代表了代码质量工具的新方向:简单、快速、AI 友好。它特别适合:

  • 使用 AI 编程的开发者:通过提示词和 hooks 确保 AI 生成高质量代码
  • 追求效率的团队:零配置设计,减少工具链维护成本
  • 任何前端项目:无论使用什么框架,都能获得一致的代码质量
  • Monorepo 项目:原生支持,配置简单

相比传统的 ESLint + Prettier 组合,Ultracite 提供了更快的性能、更简单的配置、更统一的工具链,以及原生的 AI 编程支持。在 AI 辅助编程越来越普及的今天,选择一个 AI 友好的代码规范工具,能让你的开发效率更上一层楼。

为什么有些人边框不用border属性

作者 爆浆麻花
2025年11月22日 17:46

1) border 会改变布局(占据空间)

border 会参与盒模型,增加元素尺寸。

例如,一个宽度 200px 的元素加上 border: 1px solid #000,实际宽度会变成:

200 + 1px(left) + 1px(right) = 202px

如果不想影响布局,就很麻烦。

使用 box-shadow: 0 0 0 1px #000不会改变大小,看起来像 border,但不占空间。


2) border 在高 DPI 设备上容易出现“模糊/不齐”

特别是 0.5px border(发丝线),在某些浏览器上有锯齿、断线。

transform: scale(0.5) 或伪元素能做更稳定的发丝线。


3) border 圆角 + 发丝线 常出现不规则效果

border + border-radius 在不同浏览器的渲染不一致,容易出现不均匀、颜色不一致的问题。

outline / box-shadow 圆角更稳定。


4) border 不适合做阴影/多层边框

如果你需要两层边框:

双层边框用 border 很难做

而用:

box-shadow: 0 0 0 1px #333, 0 0 0 2px #999;

非常简单。


5) border 和背景裁剪一起用时容易出 bug

比如 background-clipoverflow: hidden 配合 border 会出现背景被挤压、不应该被裁剪却裁剪等问题。


6) hover/active 等状态切换时会“跳动”

因为 border 会改变元素大小。

例子:

.btn { border: 0; }
.btn:hover { border: 1px solid #000; }

鼠标移上去会抖动,因为尺寸变大了。

box-shadow 的话就不会跳。

总结

边框可以分别使用border、outline、box-shadow三种方式去实现,其中outline、box-shadow不会像border一样占据空间。而box-shadow可以用来解决两个元素相邻时边框变宽的问题。所以outline和box-shadow的兼容性和灵活性会更好一点

Next.js 从入门到精通(1):项目架构与 App Router—— 文件系统路由与目录结构全解析

2025年11月22日 17:44

大家好,我是jobleap.cn的小九。

如果你熟悉Python和FastAPI,会很容易接受Next.js的设计思路——两者都遵循“标准驱动、类型安全、高效务实”的核心哲学。对于FastAPI开发者来说,学习Next.js的关键认知转变有两点:一是Next.js的App Router并非单纯的前端UI库,而是“搭载React渲染引擎的Web服务器”,组件默认在服务端运行(类似Python视图函数);二是路由定义方式从“代码装饰器”转向“文件系统”,这种约定优于配置的设计,能让UI、布局与数据逻辑实现物理聚合,大幅提升开发效率。

1. 核心认知:文件系统 = URL路由

FastAPI通过@app.get("/path")这类装饰器显式定义路由,而Next.js App Router的核心规则是:文件夹结构直接映射为URL路径,无需额外配置路由表。

FastAPI与Next.js路由映射对比

FastAPI 装饰器写法 Next.js App Router 文件结构 说明
@app.get("/") app/page.tsx 网站首页(根路由)
@app.get("/posts") app/posts/page.tsx 帖子列表页(一级路由)
@app.get("/posts/{id}") app/posts/[id]/page.tsx 帖子详情页(动态路由,[id]为路径参数)
@app.get("/settings/profile") app/settings/profile/page.tsx 个人资料页(嵌套路由)

这里有个关键约定:page.tsx文件会被识别为可访问的路由终点。你可以在路由文件夹(如app/posts/)中自由放置工具函数(utils.ts)、UI组件(PostCard.tsx)等文件,它们不会被解析为路由。这种“相关资源就近存放”的方式称为Colocation(资源共存),是Next.js 13+的核心改进,能避免文件分散导致的查找成本,让项目结构更清晰。

2. 实战初始化:搭建社区项目骨架

接下来通过命令行初始化项目,全程适配Python开发者的工程化习惯(类型安全、目录分离等):

步骤1:创建Next.js项目

打开终端,执行以下命令创建名为next-community的项目(使用最新稳定版Next.js):

npx create-next-app@latest next-community

步骤2:交互式配置选择(适配Python开发者)

在命令行交互中,推荐如下选择,兼顾类型安全与工程化规范:

  • TypeScript: Yes(类似Python的Type Hints,提供更严格的类型校验,减少bug)
  • ESLint: Yes(代码规范检查工具,保持代码风格一致)
  • Tailwind CSS: Yes(原子化CSS框架,后续章节详解,高效编写样式)
  • src/ directory: Yes(将源码与配置文件分离,类似Python项目的src目录规范)
  • App Router: Yes(本章核心,必须选择)
  • Import alias: Yes(默认@/*别名,简化文件引用路径,避免相对路径混乱)

步骤3:启动项目

配置完成后,进入项目并启动开发服务器:

cd next-community  # 进入项目目录
code .             # 用VS Code打开(可选,替换为你的编辑器)
npm run dev        # 启动开发服务器,默认端口3000

访问http://localhost:3000,即可看到Next.js默认首页,接下来我们将基于这个骨架搭建社区功能。

3. 目录结构解析:App Router的核心约定

项目初始化后,重点关注src/app目录(对应FastAPI的“视图层+路由层”),整体目录结构如下(仅保留核心文件):

next-community/
├── next.config.mjs       # Next.js全局配置(类似FastAPI的config.py)
├── tailwind.config.ts    # Tailwind CSS配置
├── tsconfig.json         # TypeScript配置(类似Python的pyproject.toml类型配置)
└── src/
    └── app/              # 路由与页面核心目录
        ├── layout.tsx    # 全局布局(类似Jinja2的base.html模板)
        ├── page.tsx      # 首页内容(根路由对应的页面)
        ├── globals.css   # 全局样式文件
        └── fonts/        # 字体资源目录

核心约定文件详解(无需死记,理解逻辑即可)

Next.js通过固定文件名实现“约定优于配置”,以下4个文件是开发核心,且与FastAPI的开发逻辑高度呼应:

1. page.tsx:路由页面入口

  • 作用:路由的最终展示页面,默认是服务端组件(RSC),可直接在组件内编写数据获取逻辑(类似FastAPI的路径操作函数,既处理逻辑又返回响应)。
  • 关键:只有page.tsx会被暴露为可访问路由,文件名不可随意修改。

2. layout.tsx:页面布局容器

  • 作用:包裹page.tsx的“外壳”,负责公共UI的复用(如导航栏、页脚),支持嵌套继承。
  • 核心规则:
    • 根布局(app/layout.tsx)必须包含<html><body>标签,是所有页面的基础容器;
    • 子文件夹中的layout.tsx会自动嵌套在父级布局内(类似FastAPI的模板继承);
    • 适用场景:社区的全局导航栏、左侧分类栏等需要在多个页面复用的UI,均放在布局中。

3. loading.tsx:数据加载状态组件

  • 作用:当页面从数据库/接口获取数据时,Next.js会自动显示该组件(如加载动画),无需手动编写状态管理。
  • 与FastAPI对比:FastAPI需手动编写前端JS处理加载状态,Next.js通过约定文件自动实现,简化开发流程。

4. not-found.tsx & error.tsx:错误处理组件

  • not-found.tsx:对应404页面(路由不存在时触发);
  • error.tsx:处理页面渲染或数据获取时的500级错误;
  • 优势:错误处理与页面逻辑分离,且支持局部错误隔离(某子路由报错不影响全局)。

4. 动手实战:搭建社区基础路由与布局

基于上述约定,我们编写核心页面与布局,感受Next.js的路由工作流:

步骤1:清理首页默认代码

打开src/app/page.tsx,删除默认广告内容,编写简洁的首页:

// src/app/page.tsx
export default function Home() {
  return (
    <main className="p-10">
      <h1 className="text-3xl font-bold text-gray-900">欢迎来到 Next 开发者社区</h1>
      <p className="mt-4 text-gray-600">在这里交流技术、分享经验、共同成长</p>
    </main>
  );
}

步骤2:创建“帖子列表”路由

  1. src/app下新建posts文件夹;
  2. posts文件夹中创建page.tsx(路由入口):
// src/app/posts/page.tsx
export default function PostsPage() {
  return (
    <div className="p-10">
      <h2 className="text-2xl font-bold text-gray-900 mb-6">最新技术帖子</h2>
      {/* 帖子卡片示例 */}
      <div className="border rounded-lg p-4 shadow-sm mb-4">
        <h3 className="font-semibold text-lg">Next.js 服务端组件实战技巧</h3>
        <p className="mt-2 text-gray-600">详解RSC的使用场景与性能优化方案...</p>
      </div>
      <div className="border rounded-lg p-4 shadow-sm">
        <h3 className="font-semibold text-lg">FastAPI与Next.js对接最佳实践</h3>
        <p className="mt-2 text-gray-600">前后端分离架构下的数据交互方案...</p>
      </div>
    </div>
  );
}

此时访问http://localhost:3000/posts,即可看到帖子列表页,路由自动通过posts/page.tsx映射生成。

步骤3:实现全局布局(导航栏+页面容器)

修改src/app/layout.tsx,添加全局导航栏(类似Jinja2的base.html公共部分),让所有页面共享导航:

// src/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Link from "next/link"; // Next.js专用链接组件(优化跳转性能)

// 引入全局字体
const inter = Inter({ subsets: ["latin"] });

// 网站元数据(标题、描述,类似FastAPI的响应头配置)
export const metadata: Metadata = {
  title: "Next 开发者社区",
  description: "连接开发者的技术交流平台",
};

export default function RootLayout({
  children, // 页面内容注入点(所有page.tsx的内容会被渲染到这里)
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="zh-CN">
      <body className={inter.className}>
        {/* 全局导航栏(所有页面共享) */}
        <nav className="bg-black text-white p-4 flex items-center gap-6">
          <div className="font-bold text-xl">NextCommunity</div>
          <Link href="/" className="hover:text-gray-300 transition-colors">首页</Link>
          <Link href="/posts" className="hover:text-gray-300 transition-colors">浏览帖子</Link>
          <Link href="/login" className="ml-auto bg-white text-black px-4 py-2 rounded hover:bg-gray-100 transition-colors">登录</Link>
        </nav>
        
        {/* 页面内容容器(children为动态注入的页面内容) */}
        <div className="min-h-screen bg-gray-50">{children}</div>
      </body>
    </html>
  );
}

核心优势说明

  • 导航栏复用:所有页面都会自动包含顶部导航,无需重复编写(类似FastAPI的模板继承);
  • 无刷新跳转:使用Link组件跳转时,仅children部分更新,导航栏不重新渲染,既保留SPA的流畅体验,又拥有SSR的SEO优势;
  • 状态保留:跳转时导航栏的状态(如搜索框输入内容)不会丢失,提升用户体验。

5. 架构进阶:推荐的Feature-First目录组织

随着社区功能迭代(如帖子发布、评论、用户中心),仅靠app目录会导致代码混乱。参考Python项目的分层架构(Controller-Service-DAO),推荐在src下建立平行目录,实现“路由与业务逻辑分离”:

推荐目录结构

src/
├── app/              # 路由分发与页面入口(对应Controller/View层)
├── components/       # 可复用UI组件
│   ├── ui/           # 基础组件(按钮、输入框等通用组件)
│   └── business/     # 业务组件(帖子卡片、评论列表等社区专属组件)
├── lib/              # 工具库与配置(对应Utils层)
│   ├── db.ts         # 数据库连接(如Prisma/Drizzle)
│   └── utils.ts      # 通用工具函数(格式校验、日期处理等)
├── types/            # TypeScript类型定义(对应Pydantic模型)
└── services/         # 后端业务逻辑(对应Service层)
    └── posts.ts      # 帖子相关逻辑(创建、查询、删除等)

设计思路与FastAPI呼应

  • app/仅负责“路由映射”和“页面组装”,不包含复杂业务逻辑,类似FastAPI的Controller;
  • services/封装核心业务逻辑(如数据查询、权限校验),类似FastAPI的Service层,可被多个页面复用;
  • components/分离UI组件,实现“一次编写、多处使用”,避免代码冗余;
  • types/统一类型定义,类似Pydantic模型,确保前后端数据类型一致,减少类型错误。

这种结构的优势是:职责清晰、可维护性强,符合Python开发者熟悉的分层架构思维,降低项目迭代成本。

本章核心总结

  1. App Router核心规则:文件系统即路由,page.tsx是路由终点,文件夹结构直接映射URL;
  2. 核心约定文件layout.tsx(布局容器)、page.tsx(页面内容)、loading.tsx(加载状态)、not-found.tsx/error.tsx(错误处理),无需额外配置;
  3. 关键特性:Colocation(资源就近存放)、默认服务端组件(RSC)、布局嵌套继承,兼顾开发效率与性能;
  4. 思维转换:Next.js并非单纯前端框架,而是“前后端一体化”的Web服务器,可直接在组件中编写后端逻辑(下一章详解);
  5. 架构建议:采用Feature-First分层结构,分离路由、组件、业务逻辑,契合Python开发者的工程化思维。

下一步预告

当前页面的数据是静态模拟的,下一章《Next.js从入门到精通(2):路由处理器(Route Handlers)——用标准Request/Response写后端接口》将带你学习:如何在Next.js中编写类似FastAPI的API接口,实现数据的动态获取与提交,让社区项目真正“活”起来。

用 localStorage 打造本地待办清单:一个轻量级的前端实践

作者 www_stdio
2025年11月22日 16:41

用 localStorage 打造本地待办清单:一个轻量级的前端实践

在现代网页开发中,我们常常需要在浏览器端保存一些用户数据,比如用户的偏好设置、临时输入内容,或者像本文要实现的——一个本地待办事项列表(Todo List)。借助浏览器提供的 localStorage,我们可以轻松地将数据持久化存储在用户的设备上,即使关闭页面或重启浏览器,数据也不会丢失。

localStorage 是什么?

localStorage 是 Web Storage API 的一部分,它为每个域名提供了一块独立的存储空间。它的特点是:

  • 永久存储:除非用户手动清除或通过代码删除,否则数据不会过期。
  • 键值对结构:所有数据都以字符串形式存储,因此通常需要配合 JSON.stringify()JSON.parse() 来处理对象。
  • 同源策略限制:只能被同一协议、域名和端口下的页面访问。

实现一个本地待办清单

下面是一个完整的待办清单示例,使用 HTML、CSS 和原生 JavaScript 构建,并利用 localStorage 实现数据持久化。

HTML 结构

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>LOCAL TAPAS</title>
    <link rel="stylesheet" href="./common.css">
</head>
<body>
    <div class="wrapper">
        <h2>LOCAL TAPAS</h2>
        <ul class="plates">
            <li>Loading Tapas...</li>
        </ul>
        <form class="add-items">
            <input type="text" placeholder="Item Name" required name="item">
            <input type="submit" value="+ Add Item">
        </form>
    </div>
    <!-- JavaScript 脚本 -->
</body>
</html>

页面包含一个标题、一个待办项列表容器(<ul class="plates">)以及一个用于添加新事项的表单。

CSS 样式亮点

html {
    box-sizing: border-box;
    min-height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
    text-align: center;
}

*, *::before, *::after {
    box-sizing: inherit;
}

.plates li {
    display: flex;
}

.plates input {
    display: none;
}

.plates input + label:before {
    content: "⬜️";
    margin-right: 10px;
}

.plates input:checked + label:before {
    content: "✅";
}

这里有几个关键点:

  • 使用 flex 布局让页面整体居中,同时让每个待办项内部也采用弹性布局。
  • 利用 input[type=checkbox] 配合 label 和伪元素 ::before 实现自定义复选框样式。
  • outline 属性用于高亮输入框焦点状态,且不占用盒模型空间。
  • overflow: hidden 可防止子元素溢出父容器(虽然本例未直接使用,但属于常用技巧)。

注意:CSS 中并非所有属性都会继承。例如 font-sizecolor 会从父元素继承,而 backgroundwidthheight 等则不会。开发者需根据需求显式设置。

JavaScript 逻辑

核心逻辑围绕三个函数展开:

1. 初始化数据
const items = JSON.parse(localStorage.getItem('todos')) || [];

尝试从 localStorage 中读取名为 'todos' 的数据,若不存在则初始化为空数组。

2. 渲染列表
function populateList(plates = [], platesList) {
    platesList.innerHTML = plates.map((plate, i) => `
        <li>
            <input type="checkbox" data-index=${i} id="item${i}" ${plate.done ? 'checked' : ''} />
            <label for="item${i}">${plate.text}</label>
        </li>
    `).join('');
}

该函数接收待办项数组和 DOM 容器,动态生成带复选框的列表项,并通过 data-index 记录索引以便后续操作。

3. 添加与切换状态
function addItem(event) {
    event.preventDefault();
    const text = this.querySelector('[name=item]').value.trim();
    if (!text) return;
    items.push({ text, done: false });
    localStorage.setItem('todos', JSON.stringify(items));
    populateList(items, itemsList);
    this.reset();
}

function toggleDone(event) {
    if (event.target.tagName === 'INPUT') {
        const index = event.target.dataset.index;
        items[index].done = !items[index].done;
        localStorage.setItem('todos', JSON.stringify(items));
        populateList(items, itemsList);
    }
}
  • 表单提交时阻止默认刷新行为,提取输入值并存入 items 数组。
  • 点击复选框时,根据 data-index 更新对应项的完成状态。
  • 每次变更后立即同步到 localStorage,确保数据持久化。

最后绑定事件监听器:

addItems.addEventListener('submit', addItem);
itemsList.addEventListener('click', toggleDone);
populateList(items, itemsList); // 初次渲染

小结

这个“Local Tapas”(本地小食清单)虽小,却完整展示了前端开发中的多个核心概念:

  • 数据持久化:通过 localStorage 实现无服务器依赖的本地存储。
  • DOM 操作与事件委托:高效更新界面,避免重复绑定事件。
  • CSS 继承与布局:理解哪些样式可继承,合理使用 Flexbox 实现响应式结构。
  • 函数式思维:将逻辑封装为可复用函数,提升代码可读性与维护性。

无需后端、不依赖框架,仅用浏览器原生能力,就能构建一个实用又美观的交互应用——这正是现代 Web 开发的魅力所在。

vue3自定义v-model

作者 东华帝君
2025年11月22日 15:46

vue 3.0+

1. v-model='color'不自定义属性名

子组件

  • props.modelValue
  • emits("update:modelValue", color.value)
  1. 通过defineProps()拿到props
  2. 通过defineEmits()拿到emits
  3. 通过emit触发更新update:modelValue---- 当使用v-model=时 子组件拿到的是属性为modelValue 的值,这是固定的
<template>
  <label>颜色:<input v-model="color"  /></label>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, ref } from "vue";

const props = defineProps({
  modelValue: String,
});
const emits = defineEmits<{
  (e: "update:modelValue", value: string): void;
}>();
const color = computed({
    get:()=>props.modelValue,
    set:(value:string)=>emits('update:modelValue',value)
})

</script>

父组件 v-model="color"

<script setup lang="ts">
import Child from "@/components/child.vue";
import { ref } from "vue";
const color = ref("red");
</script>

<template>
  <Child v-model="color" />
  <div>color:{{ color }}</div>
</template>

2. v-model='color'自定义属性名

子组件
update:color

<template>
  <label>颜色:<input v-model="color" /></label>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, ref } from "vue";

const props = defineProps({
  color: String,
});
const emits = defineEmits<{
  (e: "update:color", value: string): void;
}>();
const color = computed({
    get:()=>props.modelValue,
    set:(value:string)=>emits('update:modelValue',value)
})

</script>

父组件 v-model:color="color"

<script setup lang="ts">
import Child from "@/components/child.vue";
import { ref } from "vue";
const color = ref("red");
</script>

<template>
  <Child v-model:color="color" />
  <div>color:{{ color }}</div>
</template>

vue 3.4+

子组件

<template>
  <label>颜色:<input v-model="color" /></label>
</template>
<script setup lang="ts">
import { defineModel } from "vue";

const color = defineModel({type:String})

父组件

<script setup lang="ts">
import Child from "@/components/child.vue";
import { ref } from "vue";
const color = ref("red");
</script>

<template>
  <Child v-model="color" />
  <div>color:{{ color }}</div>
</template>

从“版本号打架”到 30 秒内提醒用户刷新:一个微前端团队的实践

作者 鹏北海
2025年11月22日 13:51

从“版本号打架”到 30 秒内提醒用户刷新:一个微前端团队的实践

1. 背景与痛点

我们团队维护着一个微前端子应用集群,每个子应用都需要同时服务 dev / test / release / online 多套环境。分支策略(master / release / test / dev / hotfix / feature_x.x.x)加上 Jenkins 自动化,让“一天多次发布”成为常态。但真正影响交付效率的并不是发布次数,而是一个顽固的问题:测试同学常年停留在旧版本页面

1.1 真实场景

  • 测试在早上打开 dev 页面,下午我们发布了新的组件样式;
  • 他们继续在旧页面里回归,反馈的问题我们一眼看出“这是老版本”;
  • 群里喊“刷新一下”并不靠谱,于是“无效缺陷 + 反复沟通”成了常态。

更严重的一次事故,是我们在版本检查逻辑里同时使用了 webpack DefinePlugin 与自定义插件,各自调用了一次 getAppVersion()。结果前端控制台打印的是 0.8.3-release-202511210828,而 version.json 里是 0.8.3-release-202511210829。两边只差 1 秒钟,却让线上用户始终被提示刷新,形象地被团队称为“版本号打架”。

1.2 我们的诉求

  1. 用户在 30 秒内感知版本更新;
  2. 弹窗里能看到“当前版本 / 最新版本 / 环境”;
  3. 支持“立即刷新 / 稍后再说”,不给用户造成中断;
  4. 方案需兼容现有微前端架构与 CI/CD 流程,不依赖后端改造。

2. 方案探索与取舍

在动手前,我们列出几种可行方式:

方案 实现复杂度 实时性 依赖 适配场景 关键优缺点
纯前端轮询 version.json 中(30s) 前端 + Nginx 多环境微前端 成本最低;轻微网络开销
Service Worker/PWA 较高 现代浏览器 PWA 应用 缓存控制好,但改造量大
WebSocket 推送 最高 后端服务 强实时场景 需要额外服务端开发
后端接口统一管理 前后端 版本集中管理 带来跨团队耦合

综合团队资源与落地速度,我们选择了 纯前端轮询 + 静态版本文件 的做法,并明确两个原则:

  • 版本号唯一,可追溯基础版本号-环境-时间戳
  • 发布零侵入:Jenkins 仍旧运行 npm run build-xxx,无需新增步骤。

3. 技术方案总览

  1. 构建阶段生成 version.json:在 vue.config.js 中提前计算版本号,既注入到前端(process.env.APP_VERSION),也写入输出目录的 version.json
  2. 前端轮询比对:应用启动后每 30 秒请求一次 version.json,禁用缓存并携带时间戳,比较版本号;
  3. 交互提示:复用 Ant Design Vue 的 Modal.confirm,展示当前/最新版本与环境;
  4. 缓存策略:Nginx 对 HTML/version.json 禁止缓存,对 JS/CSS/图片继续长缓存;
  5. CI/CD 配合:所有环境沿用既有脚本,只是构建产物目录多了一份实时的 version.json

4. 关键落地细节

4.1 版本号只生成一次(Build-time Deterministic Versioning)

vue.config.js 抽象 buildEnvNamebuildVersion,并在 DefinePlugin 与生成 version.json 时复用:

const buildEnvName = getEnvName();
const buildVersion = getAppVersion();

module.exports = {
  configureWebpack: {
    plugins: [
      new webpack.DefinePlugin({
        "process.env.APP_VERSION": JSON.stringify(buildVersion),
        "process.env.APP_ENV": JSON.stringify(buildEnvName),
      }),
    ],
  },
  chainWebpack(config) {
    config.plugin("generate-version-json").use({
      apply(compiler) {
        compiler.hooks.done.tap("GenerateVersionJsonPlugin", () => {
          fs.writeFileSync(
            path.resolve(__dirname, "edu/version.json"),
            JSON.stringify(
              {
                version: buildVersion,
                env: buildEnvName,
                timestamp: new Date().toISOString(),
                publicPath: "/child/edu",
              },
              null,
              2
            )
          );
        });
      },
    });
  },
};

这样即使构建过程持续 5~10 分钟,注入的版本号和静态文件里的版本仍保持一致。这其实是把“构建产物视为不可变工件”的原则落地——保证任何使用该工件的入口看到的元数据都是同一个快照。

4.2 版本检查器(Runtime Polling & Cache Busting)

class VersionChecker {
  currentVersion = process.env.APP_VERSION;
  publicPath = "/child/edu";
  checkInterval = 30 * 1000;

  init() {
    console.log(`📌 当前前端版本:${this.currentVersion}${process.env.APP_ENV})`);
    this.startChecking();
    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "visible" && !this.hasNotified) {
        this.checkForUpdate();
      }
    });
  }

  async checkForUpdate() {
    const url = `${this.publicPath}/version.json?t=${Date.now()}`;
    const response = await fetch(url, { cache: "no-store" });
    if (!response.ok) return;
    const latestInfo = await response.json();
    if (latestInfo.version !== this.currentVersion && !this.hasNotified) {
      this.hasNotified = true;
      this.stopChecking();
      this.showUpdateModal(latestInfo.version, latestInfo.env);
    }
  }
}

这里有两个容易被忽略的细节:

  1. fetch 显式加 cache: "no-store",再叠加时间戳参数,防止 CDN / 浏览器任何一层干预;
  2. visibilitychange 监听,保证窗口重新激活时立即比对,避免用户在后台等了很久才看到弹窗。

入口 main.ts 在应用 mount 之后调用 versionChecker.init(),即可把整个检测链路串起来。

4.3 Nginx 缓存策略(Precise Cache Partition)

location / {
    if ($request_filename ~* .html$) {
        add_header Cache-Control "no-store, no-cache, must-revalidate";
    }
}

location /child/edu {
    if ($request_filename ~* .html$) {
        add_header Cache-Control "no-store, no-cache, must-revalidate";
    }
}

location ~* /child/edu/version.json$ {
    add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
    add_header Pragma "no-cache";
    add_header Expires "0";
    add_header Surrogate-Control "no-store";
}

这一层的思路是把资源分成两类:需要实时性(HTML、version.json)就 no-store,其余走长缓存。再配合 try_files 兜底 history 路由,微前端子应用的独立部署不会互相影响。

4.4 CI/CD 配置(Zero-touch Pipeline)

环境 构建命令 输出路径 说明
develop npm run build-develop /child/edu 日常开发验证
testing npm run build-testing /child/edu 集成测试
release npm run build-release /child/edu 预发布
production npm run build-production /child/edu 线上

所有命令都带 cross-env NODE_OPTIONS=--openssl-legacy-provider,以兼容不同系统的 OpenSSL 版本。更重要的是,这套方案没有“要求运维多做一步”——构建产物天然携带 version.json,任何环境拿到包即可上线。

5. 测试与验证

我们定义了一个完整的回归流程,确保方案不会给测试和上线带来额外负担:

  1. 首次访问:打开 dev 环境页面,确认控制台打印版本号,Network 里能看到 version.json 且响应头无缓存;

  2. 触发新版本:调整任意文案,重新发布,保持旧页面不刷新;

  3. 轮询验证:30 秒内弹出提示框,展示当前/最新版本和环境;

  4. 交互路径

    • 点击“立即刷新”:页面强制 reload,新版本生效;
    • 点击“稍后刷新”:记录取消动作并重新开启轮询;
  5. 边界场景:切 tab / 清缓存 / 新设备访问 / 短时间连续发布,均能正确感知最新版本。

6. 注意事项与常见问题

现象 可能原因 解决方案
没有弹窗 version.json 404 或版本未变 检查部署路径、确认构建是否生成文件
弹窗后刷新仍旧版本 静态资源被缓存 核实 Nginx 缓存策略、查看浏览器缓存设置
构建失败 cross-env 未安装或权限不足 补充依赖、确保 Jenkins 工作目录可写
持续误报更新 构建阶段多次生成版本号 vue.config.js 顶部缓存 buildVersion 并全局复用

7. 落地成效

  • 旧页面用户在 30 秒内收到提醒,测试效率显著提升;
  • “幽灵弹窗”彻底消失,版本对比逻辑稳定;
  • 方案只触碰前端与 Nginx 配置,发布流程无需改造;
  • 文档化后,其他子应用无需重复思考,直接复用。

8. 展望

下一步我们计划:

  1. 封装通用 SDK:抽象版本生成、轮询、弹窗逻辑,支持 Vue CLI / Vite;
  2. 可视化版本面板:在主应用汇总所有环境的版本和发布时间;
  3. 差异化策略:针对高优先级版本强制刷新,普通版本允许用户自行选择。

这次实践让我再次意识到:真正的坑往往藏在看似“微不足道”的细节里。当我们把问题和思考写成文档、沉淀成模板,团队就能以更小的代价获得更稳定的交付。如果你也在推进微前端版本同步,欢迎交流、互相借鉴。

Flutter组件封装:标签拖拽排序 NDragSortWrap

作者 SoaringHeart
2025年11月22日 12:53

一、需求来源

最近需要实现一个可拖拽标签需求,实现之后顺手封装一下,效果如下:

Simulator Screenshot - iPhone 16 - 2025-11-22 at 12.44.53.png

二、使用示例

//
//  DraggableDemo.dart
//  flutter_templet_project
//
//  Created by shang on 6/2/21 5:37 PM.
//  Copyright © 6/2/21 shang. All rights reserved.
//

import 'package:flutter/material.dart';
import 'package:flutter_templet_project/basicWidget/n_drag_sort_wrap.dart';
import 'package:flutter_templet_project/extension/extension_local.dart';

class DraggableDemo extends StatefulWidget {
  final String? title;

  const DraggableDemo({Key? key, this.title}) : super(key: key);

  @override
  _DraggableDemoState createState() => _DraggableDemoState();
}

class _DraggableDemoState extends State<DraggableDemo> with TickerProviderStateMixin {
  final scrollController = ScrollController();


  List<String> tags = List.generate(20, (i) => "标签$i");
  late List<String> others = List.generate(10, (i) => "其他${i + tags.length}");

  late var tabController = TabController(length: tags.length, vsync: this);

  bool canEdit = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title ?? "$widget"),
      ),
      body: buildBody(),
    );
  }

  Widget buildBody() {
    return Scrollbar(
      controller: scrollController,
      child: SingleChildScrollView(
        controller: scrollController,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            buildDragSortWrap(),
          ],
        ),
      ),
    );
  }

  Widget buildDragSortWrap() {
    return StatefulBuilder(
      builder: (BuildContext context, StateSetter setState) {
        tabController = TabController(length: tags.length, vsync: this);
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Material(
              child: Row(
                children: [
                  Expanded(
                    child: TabBar(
                      controller: tabController,
                      isScrollable: true,
                      tabs: tags.map((e) => Tab(text: e)).toList(),
                      labelColor: Colors.black87,
                      unselectedLabelColor: Colors.black38,
                      indicatorColor: Colors.red,
                      indicatorSize: TabBarIndicatorSize.label,
                      indicatorPadding: EdgeInsets.symmetric(horizontal: 16),
                    ),
                  ),
                  GestureDetector(
                    onTap: () {
                      DLog.d("more");
                    },
                    child: Container(
                      padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                      child: Icon(Icons.keyboard_arrow_down),
                    ),
                  )
                ],
              ),
            ),
            buildTagBar(
              onEdit: () {
                canEdit = !canEdit;
                setState(() {});
              },
            ),
            NDragSortWrap<String>(
              spacing: 12,
              runSpacing: 8,
              items: tags,
              itemBuilder: (context, item, isDragging) {
                return buildItem(
                  isDragging: isDragging,
                  item: item,
                  isTopRightVisible: canEdit,
                  topRight: GestureDetector(
                    onTap: () {
                      DLog.d(item);
                      tags.remove(item);
                      setState(() {});
                    },
                    child: Icon(Icons.remove, size: 14, color: Colors.white),
                  ),
                );
              },
              onChanged: (newList) {
                tags = newList;
                setState(() {});
              },
            ),
            Divider(height: 16),
            Wrap(
              spacing: 12,
              runSpacing: 8,
              children: [
                ...others.map(
                  (item) {
                    return buildItem(
                      isDragging: false,
                      item: item,
                      isTopRightVisible: canEdit,
                      topRight: GestureDetector(
                        onTap: () {
                          DLog.d(item);
                          others.remove(item);
                          tags.add(item);
                          setState(() {});
                        },
                        child: Icon(Icons.add, size: 14, color: Colors.white),
                      ),
                    );
                  },
                ),
              ],
            )
          ],
        );
      },
    );
  }

  Widget buildItem({
    required bool isDragging,
    required String item,
    bool isTopRightVisible = true,
    required Widget topRight,
  }) {
    return Badge(
      backgroundColor: Colors.red,
      textColor: Colors.white,
      offset: Offset(4, -4),
      isLabelVisible: isTopRightVisible,
      label: topRight,
      child: AnimatedContainer(
        duration: Duration(milliseconds: 150),
        padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
        decoration: BoxDecoration(
          color: isDragging ? Colors.green.withOpacity(0.6) : Colors.green,
          borderRadius: BorderRadius.circular(8),
        ),
        child: Text(
          item,
          style: TextStyle(color: Colors.white),
        ),
      ),
    );
  }

  Widget buildTagBar({required VoidCallback onEdit}) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10),
      clipBehavior: Clip.antiAlias,
      decoration: BoxDecoration(),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            clipBehavior: Clip.antiAlias,
            decoration: BoxDecoration(),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.start,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  '我的频道',
                  textAlign: TextAlign.center,
                  style: TextStyle(
                    color: const Color(0xFF303034),
                    fontSize: 15,
                    fontWeight: FontWeight.w600,
                  ),
                ),
                Text(
                  ' (点击编辑可排序)',
                  textAlign: TextAlign.center,
                  style: TextStyle(
                    color: const Color(0xFF7C7C85),
                    fontSize: 12,
                  ),
                ),
              ],
            ),
          ),
          GestureDetector(
            onTap: onEdit,
            child: Text(
              '编辑',
              textAlign: TextAlign.center,
              style: TextStyle(
                color: const Color(0xFF303034),
                fontSize: 14,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

三、源码

组件 NDragSortWrap

import 'package:flutter/material.dart';
import 'package:flutter_templet_project/extension/extension_local.dart';

class NDragSortWrap<T extends Object> extends StatefulWidget {
  const NDragSortWrap({
    super.key,
    required this.items,
    required this.itemBuilder,
    this.onChanged,
    this.spacing = 8,
    this.runSpacing = 8,
  });

  final List<T> items;
  final Widget Function(BuildContext context, T item, bool isDragging) itemBuilder;
  final void Function(List<T> newList)? onChanged;
  final double spacing;
  final double runSpacing;

  @override
  State<NDragSortWrap<T>> createState() => _NDragSortWrapState<T>();
}

class _NDragSortWrapState<T extends Object> extends State<NDragSortWrap<T>> {
  late List<T> _list;

  @override
  void initState() {
    super.initState();
    _list = List<T>.from(widget.items);
  }

  @override
  void didUpdateWidget(covariant NDragSortWrap<T> oldWidget) {
    super.didUpdateWidget(oldWidget);
    _list = List<T>.from(widget.items);
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: widget.spacing,
      runSpacing: widget.runSpacing,
      children: [
        for (int i = 0; i < _list.length; i++) _buildDraggableItem(context, i),
      ],
    );
  }

  Widget _buildDraggableItem(BuildContext context, int index) {
    final item = _list[index];

    return LongPressDraggable<T>(
      data: item,
      feedback: Material(
        color: Colors.transparent,
        child: widget.itemBuilder(context, item, true),
      ),
      childWhenDragging: Opacity(
        opacity: 0.3,
        child: widget.itemBuilder(context, item, false),
      ),
      onDragCompleted: () {},
      onDraggableCanceled: (_, __) {},
      child: DragTarget<T>(
        onAcceptWithDetails: (details) {
          final draggedItem = details.data;
          final oldIndex = _list.indexOf(draggedItem);
          final newIndex = index;

          final item = _list.removeAt(oldIndex);
          _list.insert(newIndex, item);
          setState(() {});

          widget.onChanged?.call(_list);
        },
        builder: (context, _, __) {
          return widget.itemBuilder(context, item, false);
        },
      ),
    );
  }
}

最后、总结

实现起来并不复杂,就是依赖官方组件 LongPressDraggable 提供的各种状态做数据处理,然后刷新页面即可。

github

把原型链画成地铁图:坐 3 站路就能看懂 JS 的“继承”怎么跑

2025年11月22日 11:43

前言

在 JavaScript 里,“原型”这个词听起来高大上,其实就是一个“默认备胎”:当对象自己找不到属性时,就沿着原型这条暗道去“亲戚家”借。没有类、没有蓝图,仅靠这条备胎链,就能把公共方法层层复用,让内存省一半、代码少一半。本文只聊“原型”本身——prototype__proto__ 这些眼前能用的工具,把“借东西”的流程画成一张家谱图,帮你先看清“亲戚”是谁、住哪、怎么串门。至于后面更高阶的封装、多态、模块化,等我们把这条链走熟再升级也不迟。

一: 原型 prototype

又称显示原型,函数天生拥有的一个属性 ,将构造函数中的一些固定的属性和方法挂载到原型上,在创建实例的时候,就不需要重复执行这些属性和方法了,我们先来创造一个环境,主角依然是我们的小米 su7 ,su7 的属性有无数个,但是各个车主只需要选择并改动的属性并没有那么多,这个时候我们就能用得到原型。

Car.prototype.name = 'su7-Ultra'
Car.prototype.lang = 4800
Car.prototype.height = 1400
Car.prototype.weight = 1.5

function Car(color) {
  this.color = color
}
const car1 = new Car('pink')
const car2 = new Car('green')
console.log(car1);

用原型之后我们只需要输入想要的颜色即可,不需要反反复复创建函数。同时挂载在原型上的属性是可以直接被实例对象访问到的(如下图)

原型1.png
并且实例对象无法修改 构造函数 原型上的属性值

Person.prototype.say = '我太帅了'
function Person() {
  this.name = '饶总'
}
const p = new Person()  
p.say = 'hello'
const p2 = new Person()
console.log(p2.say);

这个时候同时有两个 key 都为 say ,但 value 不相同,一个被挂在构造函数的原型上,一个被挂在第一个实例对象 p 上,按照上面说法实例对象无法修改构造函数原型上的属性值,但是打印出来真是这样吗,究竟是 '我太帅了' ,还是 'hello',我们来揭晓答案

原型2.png
果真是实例对象无法修改构造函数原型上的属性值。

二:对象原型 __proto__

又称隐式原型,每一个对象都拥有一个 __proto__ 属性,该属性值也是一个对象, v8在访问对象中的一个属性时,会先访问该对象中的显示属性,如果找不到,就回去对象的隐式原型中查找,实例对象的隐式原型 === 构造函数的显示原型,所以如果实例对象的隐式原型找不到那么就再会去构造函数的显示原型上找

这不得不再引出一个概念—— 原型链:v8 在访问对象中的属性时,会先访问该对象中的显示属性,如果找不到,就去对象的隐式原型上找,如果还找不到,就去__proto__.__proto__ 上找,层层往上,直到找到null为止。这种查找关系被称为原型链

为了更好的理解它,我们来举个继承例子

function Parent() {
  this.lastName = '张'
}
Child.prototype = new Parent()  // {lastName: '张'}.__proto__ = Parent.prototype
function Child() {
  this.age = 18
}
const c = new Child() 
console.log(c.lastName);

在实例对象中我们只能找到儿子的年龄属性,姓氏张是儿子从父亲那里继承的,我们要查到儿子的姓氏,根据原型链原理我们先从实例对象 c 中找有没有显示属性是关于姓氏的,很明显并没有,接着就去实例对象的隐式原型上找,也没有,最后就来到了构造函数的显示原型上查找,在代码的第四行可以看到构造函数的显示原型被赋值上了 lastName 属性,所以最终是否可以查找得到姓氏张呢?我们来直接看结果 原型3.png

好你说这个也太简单了吧,就父子继承而已。话不多说我再附上一串代码和打印结果

Grand.prototype.house = function() {
  console.log('四合院');
}
function Grand() {
  this.card = 10000
}
Parent.prototype = new Grand()  // {card: 10000}.__proto__ = Grand.prototype.__proto__ = Object.prototype.__proto__ = null
function Parent() {
  this.lastName = '张'
}
Child.prototype = new Parent()  // {lastName: '张'}.__proto__ = Parent.prototype
function Child() {
  this.age = 18
}
const c = new Child()  // {age: 18}.__proto__ = Child.prototype
console.log(c.card);
c.house()  
// console.log(c.toString());

原型4.png

这里我们要注意一点:如果让你查找一个整个页面都没有的属性又该会是什么打印结果呢?我们注意看上面最后一行注释掉的代码,他的输出结果如下

原型5.png

他是直接找到了全局的对象上,经历了一遍原型链查找在 Object.prototype上找到,如果再不找到最终就会停留在null上 ,下面放一张 js 界中广为流传的一张图,如果你能看懂那么你就是彻底会了!

原型链图.webp

三:new 在干什么?

这时候你会说什么?上篇文章不是讲了 new 究竟干了些什么吗,怎么又问,不必惊讶,其实上次没讲全,这次来带你真正看看 new 究竟究竟都干了些什么(这绝对是最终理解)直接一套小连招先上五个步骤

  1. 创建一个空对象
  2. 让构造函数中的 this 指向这个空对象
  3. 执行构造函数中的代码 (等同于往空对象中添加属性值)
  4. 将这个空对象的隐式原型(__proto__) 赋值成 构造函数的显示原型(prototype)
  5. 返回该对象

再上代码(加注释)

Car.prototype.run = function() {
  console.log('running');
}

function Car() {   // new Function()
  // const obj = {}      //1
  // Car.call(obj)  // call 方法将 Car 函数中的 this = obj    2
  this.name = 'su7'  // 3
  // obj.__proto__ = Car.prototype  // 4
  // return obj    5
}
const car = new Car() // {name: 'su7'}.__proto__  == Car.prototype
car.run()

最后输出

原型6.png

结语

  1. 显式原型(prototype)是函数自带的“样板房”,所有实例都能来蹭住。
  2. 隐式原型(__proto__)是实例手里的“门禁卡”,刷卡就能进样板房找方法。
  3. 原型链就是一张“门禁卡链”:刷不到就再刷上一层的卡,直到 null 到头。
  4. new 的五步曲:空对象→认证→绑卡→执行→返回,一口气把“样板房”继承给新实例。

把这四点串成一张地铁图,以后看任何“找不到属性”的问题,先问一句:它刷卡刷到第几站了?原型链通了,继承就不再是黑魔法。

从回调到async/await:JavaScript异步编程的进化之路

作者 xiaoxue_
2025年11月22日 11:28

从回调到async/await:JavaScript异步编程的进化之路

在JavaScript的世界里,异步编程是绕不开的核心命题。从最初的回调函数,到ES6的Promise,再到ES8的async/await,每一次语法升级都在解决前一阶段的痛点,让异步代码更贴近人类的线性思维。本文将结合文件读取的实际案例,带你看清JavaScript异步编程的进化脉络。

一、ES6之前:回调函数的“地狱”与坚守

在ES6引入Promise之前,JavaScript处理异步操作的唯一方案就是回调函数。其核心逻辑是:将异步操作完成后需要执行的代码,作为参数传入异步函数,当异步任务结束时,由JavaScript引擎自动调用这个回调函数。

以文件读取API fs.readFile 为例,传统回调写法如下:

fs.readFile('./1.html','utf-8',(err,data) => {
    if(err) {
        console.log(err);
        return;
    }
    console.log(data);
    console.log(111);
})

这种写法的优势是直观易懂,对于单一异步任务完全够用。但它的缺陷也极为明显:当多个异步任务存在依赖关系时,代码会嵌套成“回调地狱”。比如先读A文件,再根据A文件内容读B这段回调函数代码是ES6之前的主流异步写法,核心依赖Node.js的fs模块(文件系统模块)实现文件读取。我们从API参数到执行逻辑逐行拆解:

fs.readFile('./1.html','utf-8',(err,data) => { ... }) 中,fs.readFile 作为异步读取方法,接收三个关键参数:第一个参数'./1.html'是文件路径,指定读取当前目录下的1.html文件;第二个参数'utf-8'是编码格式,确保读取的二进制数据转为字符串而非Buffer对象;第三个参数是回调函数,这是异步的核心——JS引擎不会等待文件读取完成,而是继续执行后续代码,读取结束后自动调用此函数处理结果。

回调函数遵循“错误优先”规范,err参数优先接收错误信息:若读取失败(如文件不存在),err为错误对象,执行console.log(err)打印错误并通过return终止函数;若成功,err为null,data接收文件内容,随后打印内容与数字111。

这种写法对单一异步任务足够简洁,但多任务依赖时会陷入“回调地狱”。比如读完1.html后需根据内容读2.html,代码会嵌套成多层缩进,可读性与维护性急剧下降,这也催生了ES6的Promise方案。

二、ES6 Promise:异步流程的“标准化”升级

Promise是ES6为解决回调地狱推出的异步容器,它将异步操作的“成功/失败”状态标准化,通过链式调用替代嵌套。Promise封装代码,正是对文件读取异步任务的规范化改造:

// es6 Promise
const p = new Promise((resolve,reject) => {
    fs.readFile('./1.html', 'utf-8', (err, data) => {
        if (err) {
            reject(err); // 异步失败,传递错误
            return;
        }
        resolve(data); // 异步成功,传递结果
    })
})
 p.then(data => {
     console.log(data);
     console.log(111);
})

Promise构造函数接收一个“执行器函数”,该函数有两个内置参数resolvereject,均为函数:resolve用于标记异步成功,将结果数据传递给后续处理;reject用于标记失败,传递错误信息。

上述代码中,文件读取的回调逻辑被重构:失败时调用reject(err),成功时调用resolve(data),Promise实例p便承载了异步任务的状态。p.then(data => { ... })是结果处理方式,then方法接收resolve传递的数据,实现成功逻辑。若需处理错误,可链式调用.catch(err => { ... })捕获reject的错误。

Promise的核心优势是链式调用。若需连续读取两个文件,只需在第一个then中返回新的Promise,再链式调用then即可,代码始终保持扁平,彻底摆脱嵌套困境。但多个链式调用时,“then链”仍会略显冗余,ES8的async/await在此基础上实现了进一步优化。

三、ES8 async/await:异步代码的“同步化”终极方案

async/await是ES8推出的Promise语法糖,它让异步代码具备同步代码的线性逻辑,堪称异步编程的“终极形态”。async/await基于前文的Promise实例实现,大幅简化了结果获取逻辑:

// es8 async
const main = async() => {
    const html = await p;
    console.log(html);
}
main();

这段代码的核心是两个关键字的配合:async用于修饰函数(如这里的main函数),表明该函数是异步函数,其返回值必然是Promise;await只能在async函数内使用,用于等待Promise完成——它会“暂停”函数执行,直到Promise状态变为成功(fulfilled),再将resolve的数据赋值给左侧变量(如html)。

需要补充的是,实际开发中需完善错误处理:若Promise状态为失败(rejected),await会抛出异常,需用try/catch捕获,优化后的代码如下:

const main = async() => {
    try {
        const html = await p;
        console.log(html);
    } catch (err) {
        console.log('错误:', err); // 捕获reject的错误
    }
}
main();

async/await的价值不仅在于简洁,更在于逻辑贴近人类思维。比如连续执行三个异步任务,只需用三个await依次等待,代码顺序与任务执行顺序完全一致,无需关注Promise的链式调用细节。

四、进化总结:从工具到思维的贴近

回顾JavaScript异步的进化之路,每一步都是对“开发体验”的优化:回调函数是异步的基础工具,却违背线性思维;Promise通过标准化容器规范异步流程,解决嵌套问题;async/await则彻底抹平异步与同步的语法差异,让代码逻辑与人类思考顺序完全统一。

实际开发中无需拘泥于单一方案:简单异步任务可用回调;多任务依赖优先用Promise链式调用;复杂业务逻辑则首选async/await,兼顾可读性与维护性。理解三者的关联与演进逻辑,才能根据场景灵活选择最合适的异步方案,写出高效优雅的JavaScript代码。

如何快速实现响应式多屏幕适配

作者 MQliferecord
2025年11月22日 11:13

项目涉及的场景比较简单,所以我个人的配置也比较粗糙简单,如果要对于更详细的多端适配,可能需要更细致的设定,如果希望一键快速实现大体上过得去的pc端多端适配,可以用这个法子。

  1. 安装postcss(必须)+tailwindcss(可选)
  2. 安装postcss-plugin-px2rem(必须)
  3. 在豆包搜索postcss-plugin-px2rem如何配置应用在postcss.config.js文件里面按需配置(关键)
module.exports = {
  plugins: [
    require('postcss-plugin-px2rem')({
      rootValue: 16, // 根元素字体大小(默认 16px,即 1rem = 16px)
      unitPrecision: 5, // 转换后的 rem 保留小数位数(默认 5)
      propList: ['*'], // 需要转换的 CSS 属性(默认 ['*'],即所有属性)
      selectorBlackList: [], // 不转换的选择器(如 ['body'],则 body 下的 px 不转换)
      replace: true, // 是否直接替换原 px 值(默认 true,不保留原 px)
      mediaQuery: false, // 是否转换媒体查询中的 px(默认 false,不转换)
      minPixelValue: 0, // 最小转换像素值(默认 0,即所有 px 都转换)
      exclude: /node_modules/i // 排除的文件路径(如 node_modules 下的样式不转换)
    })
  ]
}

后续需要详细配置的话,需要关注的两个属性

selectorBlackList: [], 
mediaQuery: false, 
  1. 在App.vue 文件下添加(关键)
function setRootFontSize() {
  const screenWidth = document.documentElement.clientWidth;
  const rootFontSize = screenWidth / 7.5; 
  document.documentElement.style.fontSize = `${rootFontSize}px`;
}

setRootFontSize();
window.addEventListener('resize', setRootFontSize);

这里因为我们项目主要是大屏和超大屏工作,所以我针对我们的项目具体应用场景做了一下更改

function setRootFontSize() {
  const screenWidth = document.documentElement.clientWidth;
  if(screenWidth<1560){
      const rootFontSize = screenWidth / 75; 
      document.documentElement.style.fontSize = `${rootFontSize}px`;
  }
}

具体原理搜索rem是什么意思就行了

不用记复杂路径!3 步让你的 JS 脚本像 “vue create” 一样好用

2025年11月22日 10:44

一、开篇:为什么别人的命令能直接用?

日常开发中,你可能会有这样的疑问:为什么输入 vue create 就能快速创建项目,而自己写的 JavaScript 脚本,每次都要敲长长的 node ./xxx/xxx.js 才能运行?其实,这背后藏着 package.jsonbin 命令的 “小秘密”。

这篇文章就用最直白的方式,带你搞清楚 bin 命令、shebang 以及 npm link 的作用和用法,看完之后,你也能让自己的脚本像那些常用工具一样,一键就能运行。

二、package.json 的 bin 命令:让脚本 “一键启动”

1. 什么是 bin 命令?

简单来说,bin 命令就是给你的 JavaScript 脚本起一个 “简短别名”。有了这个别名,你不用再输入完整的脚本路径,直接喊出 “别名”,就能运行对应的脚本。

比如原本需要输入 node ./bin/my-script.js 才能执行的脚本,配置 bin 命令后,可能只需要输入 my-script 就能运行,大大减少了重复输入的麻烦。

2. 如何配置 bin 命令?

bin 命令的配置很灵活,主要分两种情况,根据你的脚本数量来选择即可:

情况一:只有一个脚本(简单写法)

如果你的项目里只有一个需要便捷运行的脚本,直接在 package.json 中写脚本的相对路径就行。这时,脚本的 “别名” 会默认和你项目的 “name” 字段一致(也就是 package.json"name": "xxx" 中的 xxx)。

举个例子:

{

&#x20; "name": "my-tool",

&#x20; "version": "1.0.0",

&#x20; "bin": "./bin/my-script.js"

}

上面的配置中,脚本 ./bin/my-script.js 的别名就是 my-tool,后续直接输入 my-tool 就能运行这个脚本。

情况二:多个脚本(对象写法)

如果项目里有多个需要便捷运行的脚本,就用对象的形式配置,键是你想给脚本起的 “别名”,值是脚本的相对路径。

举个例子:

{

&#x20; "name": "my-tool",

&#x20; "version": "1.0.0",

&#x20; "bin": {

&#x20;   "script-one": "./bin/1.js",

&#x20;   "script-two": "./bin/2.js"

&#x20; }

}

这样配置后,输入 script-one 就能运行 ./bin/1.js,输入 script-two 就能运行 ./bin/2.js,清晰又好记。

需要注意的是,脚本路径要从 package.json 所在的文件夹开始计算,比如 package.json 在项目根目录,脚本在根目录下的 bin 文件夹里,路径就写 ./bin/xxx.js,别写错了路径导致脚本找不到。

3. 配置完 bin 还不够,必须加 “shebang”

很多人配置完 bin 命令后,运行时会遇到 “命令未找到” 或 “无法执行脚本” 的错误,这大概率是因为没加 “shebang”。

为什么需要 shebang?

简单来说,shebang 是告诉系统 “用什么程序来运行这个脚本”。如果没有它,系统不知道该用 Node.js 还是其他程序来执行你的 JavaScript 脚本,自然会报错。

什么是 shebang?怎么加?

shebang 就是在你的 JavaScript 脚本文件的第一行,加上 #!/usr/bin/env node 这行代码。不用纠结这行代码的具体含义,直接复制粘贴到脚本第一行就行,它能兼容不同的电脑系统。

举个例子,你的脚本文件 ./bin/my-script.js 应该长这样:

\#!/usr/bin/env node

// 下面是你的脚本逻辑

console.log("脚本运行成功!");

为什么不能写死路径?

有些资料里可能会写 #!/usr/bin/node,但这种写法有个坑:它假设 Node.js 一定安装在 /usr/bin 这个路径下。但实际中,很多人会用 nvm(Node.js 版本管理器)来管理 Node.js,这时 Node.js 的安装路径可能是 ~/.nvm/versions/node/xxx/bin,或者 Windows 系统里的 C:\Program Files\nodejs。写死路径的话,换一台电脑可能就无法运行了,而 #!/usr/bin/env node 会让系统自动寻找 Node.js 的安装路径,避免这个问题。

4. 动手实操:3 步跑通自己的命令

看完理论,咱们来动手试一次,3 步就能让你的脚本实现 “一键运行”:

第一步:建文件夹和文件

先建一个项目文件夹,比如叫 my-first-cli,在里面新建两个东西:

  • 一个 package.json 文件(可以用 npm init -y 快速生成);

  • 一个 bin 文件夹,在 bin 文件夹里新建 cli.js 文件(这就是你的脚本文件)。

此时项目结构应该是这样:

my-first-cli/

├── bin/

│   └── cli.js

└── package.json

第二步:写脚本内容

打开 bin/cli.js,在第一行加上 shebang,再写点简单的逻辑,比如:

\#!/usr/bin/env node

console.log("我的第一个便捷脚本,运行成功啦!");

第三步:配置 package.json 的 bin 命令

打开 package.json,加上 bin 配置,比如:

{

&#x20; "name": "my-first-cli",

&#x20; "version": "1.0.0",

&#x20; "bin": {

&#x20;   "my-test": "./bin/cli.js"

&#x20; }

}

这里我们给脚本起的别名是 my-test,后续输入这个别名就能运行脚本。

三、shebang 详解:脚本的 “运行说明书”

1. 再深入理解:shebang 到底是什么?

前面我们知道了 shebang 是 #!/usr/bin/env node,但它本质上是 Unix/Linux 系统的一个通用规则 —— 只要脚本文件的第一行以 #! 开头,这行就是 shebang,用来指定执行这个脚本的程序。

对 JavaScript 脚本来说,shebang 就是告诉系统 “用 Node.js 来运行我”,相当于给脚本加了一份 “运行说明书”。需要注意的是,它不是注释,而是给系统看的指令,别把它删掉或放到其他行。

2. 为什么 #!/usr/bin/env node 能兼容所有系统?

这里拆解一下 #!/usr/bin/env node 的作用:

  • #!/usr/bin/envenv 是系统自带的一个工具,它能读取系统的 PATH 环境变量(PATH 里包含了系统所有可执行程序的安装目录);

  • nodeenv 会在 PATH 包含的目录里,找第一个名叫 node 的可执行文件,找到后就用这个 Node.js 来运行脚本。

不管你的 Node.js 装在 ~/.nvmC盘 还是其他路径,env 都能找到它,所以这行代码能兼容 Windows、macOS、Linux 等不同系统。

3. Windows 用户不用慌,shebang 兼容问题已解决

很多 Windows 用户会担心:Windows 系统不认识 shebang,会不会影响脚本运行?其实不用怕,npm 和 npx 已经帮我们处理了这个问题。

当你在 Windows 上运行通过 bin 命令配置的脚本时,npm 会自动忽略 shebang,直接用你电脑里当前的 Node.js 来执行脚本,所以不用额外做任何配置,和其他系统一样正常使用。

4. 不止 Node.js,其他脚本也用 shebang

shebang 不是 Node.js 专属的,其他类型的脚本也会用它来指定运行程序。比如:

  • Shell 脚本(.sh 文件):第一行通常是 #!/bin/bash,表示用 bash 程序来运行;

  • Python 脚本(.py 文件):第一行通常是 #!/usr/bin/env python3,表示用 Python3 来运行;

  • Perl 脚本(.pl 文件):第一行通常是 #!/usr/bin/perl,表示用 Perl 来运行。

了解这个知识点,以后看到其他类型的脚本,也能明白第一行代码的作用。

四、npm link:本地测试的 “神器”,不用反复装包

1. 为什么需要 npm link?

当你写完脚本并配置好 bin 命令后,肯定想测试一下能不能正常运行。如果每次修改代码后,都要先把项目发布到 npm 仓库,再用 npm install -g 安装到全局来测试,那也太麻烦了 —— 这时候,npm link 就能派上用场。

2. 什么是 npm link?

简单来说,npm link 是给你本地的项目创建一个 “快捷方式”,并把这个快捷方式链接到系统的全局 npm 目录里。这样一来,你不用发布项目,也不用反复安装,就能像使用 “已发布到 npm 仓库的工具” 一样,在任何目录下运行你的脚本,而且修改代码后,效果会实时生效,不用重新配置。

3. 动手实操:2 步用 npm link 测试命令

还是用前面的 my-first-cli 项目举例,2 步就能完成测试:

第一步:把项目链接到全局

打开终端,进入 my-first-cli 文件夹(也就是 package.json 所在的目录),输入命令:

npm link

执行完后,系统会提示 “链接成功”,此时你的 my-test 命令已经被添加到全局了。

第二步:测试命令

不用停留在 my-first-cli 文件夹,随便找一个其他目录(比如你的桌面),在终端输入:

my-test

如果看到输出 “我的第一个便捷脚本,运行成功啦!”,就说明测试成功了。

如果后续修改了 bin/cli.js 里的代码,比如把输出改成 “脚本更新啦!”,不用重新执行 npm link,直接在终端再次输入 my-test,就能看到更新后的效果,非常方便。

4. npm link 不止能测命令,还有 2 个常用场景

npm link 不止用来测试 bin 命令,在本地开发中还有两个非常实用的场景:

场景一:本地两个项目互相依赖

比如你有两个项目:

  • 项目 A:是一个工具包(比如叫 utils-package),里面有一些常用的工具函数;

  • 项目 B:是一个业务项目,需要用到项目 A 里的工具函数。

如果直接在项目 B 里用 npm install ../utils-package 安装项目 A,每次项目 A 的代码修改后,都要重新安装才能在项目 B 里看到更新,很麻烦。这时候用 npm link 就能解决:

  1. 进入项目 A 的文件夹,执行 npm link,把项目 A 链接到全局;

  2. 进入项目 B 的文件夹,执行 npm link utils-packageutils-package 是项目 A 的 package.json 里的 “name” 字段);

这样一来,项目 B 里就能像使用普通 npm 包一样,用 require('utils-package') 引入项目 A 的代码,而且项目 A 的代码修改后,项目 B 里能实时看到更新,不用反复安装。

场景二:调试已安装的依赖包

有时候你在项目里使用某个 npm 包(比如 lodash),发现某个功能有问题,想修改这个包的源码来调试(当然pnpm有更优雅的解决方案)。这时候也能用到 npm link

  1. 先从 GitHub 上下载这个包的源码(比如 lodash 的源码),解压后进入源码文件夹,执行 npm link,把修改后的源码链接到全局;

  2. 进入你的项目文件夹,执行 npm link lodash,把项目里的 lodash 替换成你本地修改后的源码;

  3. 调试完成后,再执行 npm unlink lodash,并重新执行 npm install lodash,恢复成官方的包即可。

5. 用完记得 “取消链接”,避免影响后续使用

当你测试完或者不再需要本地链接时,记得取消链接,避免本地的测试版本影响后续使用官方版本。

取消链接分两步:

  1. 进入你之前执行 npm link 的项目文件夹(比如 my-first-cli),执行 npm unlink,取消项目到全局的链接;

  2. 如果其他项目链接过这个项目(比如前面的项目 B 链接过项目 A),还要进入这些项目文件夹,执行 npm unlink 包名(比如 npm unlink utils-package),取消项目间的链接。

比如取消 my-first-cli 的链接:

\# 进入 my-first-cli 文件夹

cd my-first-cli

\# 取消全局链接

npm unlink

五、总结:3 个核心知识点要记牢

  1. bin 命令:给 JavaScript 脚本起 “简短别名”,配置后不用输完整路径,直接喊别名就能运行脚本,配置时注意路径别写错;

  2. shebang:脚本第一行的 #!/usr/bin/env node,告诉系统用 Node.js 运行脚本,别写死路径,兼容所有系统;

  3. npm link:本地测试和开发的 “神器”,能创建项目快捷方式,实现代码实时更新,不用反复安装,用完记得取消链接。

六、结尾:动手试试,一次就会

看完这篇文章,建议你动手实操一次:建一个简单的项目,配置 bin 命令、加 shebang、用 npm link 测试。过程中如果遇到报错,先检查三个地方:脚本路径对不对、shebang 加没加、link 有没有成功。

其实这些知识点都不复杂,关键是多动手,试一次就能完全掌握,以后开发脚本或工具时,就能像那些常用的 npm 包一样,实现 “一键运行”,大大提升开发效率。

前端开发规范

作者 凌晨起床
2025年11月22日 10:25

前端开发规范,涵盖代码细节、工程化、协作流程等维度,结合实际场景补充具体规则和示例。

一、HTML 规范

1. 文档结构与元信息

  • DOCTYPE 与命名空间:必须以 <!DOCTYPE html> 开头,html 标签需指定 lang(如 zh-CN 对应中文,en 对应英文),避免 xmlns 等冗余命名空间(HTML5 已废弃)。

    <!DOCTYPE html>
    <html lang="zh-CN"> <!-- 正确 -->
    <!-- <html lang="zh" xmlns="http://www.w3.org/1999/xhtml"> 错误(冗余xmlns) -->
    
    
  • head 标签顺序:按「关键优先级」排序:meta charset(必须首行,避免乱码)→ titlemeta:viewportmeta(SEO/安全相关)→ link(样式)→ script(异步脚本放底部)。

    <head>
    <meta charset="UTF-8">
    <title>首页 - 某某平台</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <meta name="keywords" content="前端, 规范">
    <meta name="description" content="详细的前端开发规范文档">
    <meta http-equiv="X-UA-Compatible" content="IE=edge"> <!-- 兼容IE -->
    <link rel="stylesheet" href="base.css">
    </head>
    

2. 语义化深度规范

  • 细分标签使用场景

    • <figure> + <figcaption>:包裹图片/图表+说明(如 figure > img + figcaption);

    • <time>:标记时间(需加 datetime 属性,如 <time datetime="2025-11-08">2025年11月8日</time>);

    • <address>:标记联系信息(仅用于页面作者/机构联系方式);

    • <dialog>:标记对话框(配合 open 属性控制显示,如 <dialog open>确认删除?</dialog>)。

  • 表单语义化

    • <fieldset> 包裹关联表单元素,<legend> 描述分组标题;

    • label 必须通过 forinputid 关联(或直接包裹 input),提升点击区域;

    • 优先使用语义化输入类型(type="email"/tel/number),而非通用 text

    <form>
        <fieldset>
        <legend>用户信息</legend>
        <div>
          <label for="username">用户名:</label>
          <input type="text" id="username" name="username">
        </div>
        <div>
          <label>
            <input type="checkbox" name="agree"> 同意协议
          </label>
        </div>
        </fieldset>
    </form>
    

3. 性能与兼容性

  • 避免冗余标签:不嵌套无意义的父级(如 <div><span>文本</span></div> 可简化为 <span>文本</span>,除非有样式需求)。

  • 图片优化

    • 必加 width/height 属性(避免布局偏移 CLS);

    • 优先使用 webp 格式,配合 picture 标签做降级:

      <picture>
        <source srcset="image.webp" type="image/webp">
        <img src="image.jpg" alt="示例图" width="200" height="150">
      </picture>
      

二、CSS/SCSS 规范

1. 命名体系(BEM 扩展)

  • 命名空间:在 BEM 基础上增加前缀,区分组件类型:

    • c-:通用组件(c-buttonc-card);

    • m-:模块(m-headerm-footer);

    • u-:工具类(u-mt10margin-top:10px);

    • is-/has-:状态(is-activehas-error)。示例:c-button__icon--large(组件-按钮的图标元素-大尺寸修饰符)。

  • 禁止样式污染:页面级样式需加页面前缀(如 page-home__title),避免影响全局;组件样式通过 CSS Modules 或 scoped(Vue)隔离。

2. 选择器与权重

  • 权重优先级:禁止使用「标签+ID」(div#box)或「多层类嵌套」(.a .b .c .d),权重控制在 (0,2,0) 以内(即最多 2 个类选择器,如 .a.b.a .b)。

  • 伪类/伪元素使用

    • 伪类(:hover/:focus)用于状态,伪元素(::before/::after)用于装饰性内容(需加 content: '');

    • 必写 :focus 样式(无障碍要求,如 outline: 2px solid #165DFF)。

3. SCSS 语法规范

  • 嵌套限制:最多嵌套 2 层(避免编译后生成冗余选择器):

    .c-card {
        padding: 16px;
        &__title { // 1层嵌套
            font-size: 18px;
            &--bold { // 2层嵌套(允许)
            font-weight: bold;
            }
        }
        // &__content .item { 错误(3层嵌套) }
    }
    
  • 变量与混合宏

    • 全局变量集中管理(如 _variables.scss 定义 $color-primary: #165DFF);

    • 重复逻辑用 @mixin(带参数),而非复制代码:

      @mixin flex-center {
          display: flex;
          justify-content: center;
          align-items: center;
      }
      .c-button { @include flex-center; }
      

4. 响应式与适配

  • 断点规范:统一使用移动优先策略,断点命名与值:

    // 从小到大:xs(手机) → sm(平板) → md(小屏桌面) → lg(大屏桌面)
    $breakpoints: (
      xs: 0,
      sm: 576px,
      md: 768px,
      lg: 1200px
    );
    @media (min-width: map-get($breakpoints, md)) { /\* 中等屏幕样式 \*/ }
    
  • 单位使用

    • 布局尺寸用 rem(根字体默认 16px,1rem=16px)或 vw(移动端);

    • 内边距/边框用 px(避免缩放模糊);

    • 字体大小用 rem(便于全局调整)。

三、JavaScript/TypeScript 规范(增强版)

1. 变量与类型

  • 类型约束:优先使用 TypeScript,明确变量类型(避免 any):

    // 正确:指定类型
    const username: string = '张三';
    const userList: User\[] = \[{ id: 1, name: '张三' }];
    
    // 错误:隐式any
    // const data = fetchData(); // 应改为 const data: Data = fetchData();
    
  • 对象/数组声明

    • 用字面量声明(const obj = {} 而非 new Object());

    • 数组初始化指定类型(const arr: number[] = [] 而非 []);

    • 禁止修改原数组/对象(用 map/filter 替代 for 循环修改,用扩展运算符复制:const newObj = { ...obj })。

2. 函数设计

  • 参数规范

    • 最多 3 个参数(超过用对象聚合):

      // 正确
      function getUser({ id, name, age }) { /\* ... \*/ }
      getUser({ id: 1, name: '张三' });
      
      // 错误(参数过多)
      // function getUser(id, name, age, gender, address) { ... }
      
    • 必传参数放前,可选参数放后,可选参数用 ? 标记(TS)。
  • 返回值

    • 函数必须有返回值(无意义返回 void);

    • 异步函数必须返回 Promise,且明确 resolve/reject 类型。

3. 异步处理

  • 错误捕获

    • async/await 必须用 try/catch 包裹(或用统一错误处理函数);

    • Promise 链式调用必须加 catch,禁止丢失错误:

      // 正确
      async function fetchData() {
          try {
              const res = await axios.get('/api/data');
              return res.data;
          } catch (err) {
              console.error('请求失败:', err);
              throw err; // 向上传递错误
          }
      }
      
      // 错误(未捕获错误)
      // axios.get('/api/data').then(res => res.data);
      
  • 并发控制

    • 并行请求用 Promise.all(全部成功)或 Promise.allSettled(允许部分失败);

    • 限制并发数(如用 p-limit 库,避免请求风暴)。

4. 模块化与依赖

  • 导入导出

    • 优先用 export default 导出单个组件/类,export 导出工具函数集合;

    • 导入路径:相对路径用 ./(同目录)、../(父目录),绝对路径配置别名(如 @/utils 指向 src/utils);

    • 禁止循环依赖(A 依赖 B,B 依赖 A)。

  • 依赖管理

    • 第三方库优先用 peerDependencies(如 UI 组件库),避免重复安装;

    • 工具函数优先封装到 utils,避免重复代码(如 formatDatedeepClone)。

四、工程化与工具链(增强版)

1. ESLint + Prettier 配置

  • 核心规则.eslintrc.js):

    module.exports = {
        extends: [
            'eslint:recommended',
            'plugin:react/recommended', // React项目
            'plugin:vue/vue3-essential', // Vue项目
            'prettier' // 关闭与Prettier冲突的规则
        ],
        rules: {
            'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn', // 生产环境禁止console
            'no-unused-vars': ['error', { vars: 'all', args: 'after-used' }], // 禁止未使用的变量
            'indent': ['error', 2, { SwitchCase: 1 }], // 缩进2空格
            'quotes': ['error', 'single'], // 单引号
            'semi': ['error', 'always'] // 强制分号
        }
    };
    
  • Prettier 配置.prettierrc):

    {
        "printWidth": 100, // 每行最多100字符
        "tabWidth": 2, // 制表符宽度2
        "singleQuote": true, // 单引号
        "trailingComma": "all", // 末尾逗号(如数组最后一项)
        "arrowParens": "always" // 箭头函数参数必加括号((x) => x)
    }
    

2. Git 工作流与提交规范

  • 分支策略(Git Flow 简化版):

    • main:生产环境代码,禁止直接提交;

    • develop:开发主分支,从 main 创建,用于集成功能;

    • feature/xxx:功能分支(从 develop 创建,完成后合并回 develop);

    • fix/xxx:修复分支(从 develop 创建,修复后合并回 develop);

    • release/v1.0.0:发布分支(从 develop 创建,测试通过后合并到 maindevelop)。

  • 提交信息增强:除基础 type(scope): desc 外,复杂提交需加「详细描述」和「关联issue」:

    feat(login): 支持手机号验证码登录
    *   新增验证码输入框组件
    
    *   集成短信发送API
    
    *   优化登录状态校验逻辑
    
    Close #123(关闭issue 123)
    
    

3. 构建与部署

  • 构建优化

    • 代码分割(splitChunks 提取公共库,如 reactvue);

    • 树摇(tree-shaking 移除未使用代码,需 ES 模块);

    • 图片压缩(image-webpack-loader)、CSS 提取(mini-css-extract-plugin)。

  • 环境区分:配置 env.development/env.production/env.test,区分 API 地址、日志级别等:

    // 开发环境
    VITE\_API\_BASE\_URL = '/api' // 本地代理
    // 生产环境
    VITE\_API\_BASE\_URL = '<https://api.example.com>'
    

五、框架特定规范(Vue3 + React)

1. Vue3 规范

  • 单文件组件(SFC)结构

    <template> <!-- 模板 --> </template>
    
    <script setup lang="ts"> <!-- 逻辑(优先setup语法糖) --> </script>
    
    <style scoped lang="scss"> <!-- 样式(scoped隔离) --> </style>
    
  • Composition API 规则

    • ref 用于基本类型,reactive 用于对象(避免 reactive 包裹基本类型);

    • 自定义钩子以 use 开头(如 useUserInfo);

    • 避免在 template 中直接使用 reactive 对象的属性(用 toRefs 解构)。

2. React 规范

  • 组件设计

    • 优先用函数组件 + Hooks,避免 class 组件;

    • 组件拆分:UI 组件(纯展示,如 Button)与容器组件(带逻辑,如 UserListContainer)分离;

    • Hooks 规则:只在顶层调用,只在函数组件中调用(禁止条件判断内使用)。

  • Props 传递

    • interface 定义 Props 类型(TS);

    • 非必要不传递 children(避免深层嵌套);

    • 大量 Props 用解构传递(<User {...userProps} />)。

六、无障碍性(A11y)与性能

1. 无障碍性

  • ARIA 属性

    • 动态内容用 aria-live(如通知:<div aria-live="polite">新消息</div>);

    • 表单错误提示关联 aria-describedby(如 <input aria-describedby="error-msg">)。

  • 键盘导航:所有交互元素(按钮、菜单)必须可通过 Tab 聚焦,且有明显焦点样式(outline 或自定义样式)。

2. 性能优化

  • 渲染优化

    • 避免频繁重排(如批量修改 DOM 前先脱离文档流);

    • Vue 用 v-memo 缓存静态节点,React 用 React.memo/useMemo 缓存组件/值。

  • 资源加载

    • 非首屏 JS 用 dynamic import 懒加载(import('./HeavyComponent'));

    • 图片用 loading="lazy" 懒加载(<img src="large.jpg" loading="lazy">)。

七、协作与代码审查

  • Code Review checklist

    1. 代码是否符合规范(命名、格式、注释);

    2. 逻辑是否清晰(是否有冗余/重复代码);

    3. 边界情况是否处理(空值、异常、大数);

    4. 性能是否有优化空间(是否有不必要的 DOM 操作/请求);

    5. 是否包含测试用例(核心逻辑需覆盖)。

  • 冲突处理:拉取代码前先 pull 最新 develop 分支,解决冲突时保留双方合理逻辑,避免暴力覆盖。

总结

规范的核心是「共识」与「落地」:

  1. 团队需共同制定规范(避免一刀切,保留合理灵活性);

  2. 用工具(ESLint、husky 等)自动化约束,减少人工成本;

  3. 定期复盘迭代(根据项目反馈调整规则)。

通过细化规范,可显著降低沟通成本,提升代码可维护性,尤其适合多人协作的中大型项目。

上架元服务-味寻纪 技术分享

作者 万少
2025年11月22日 11:02

项目概述

味寻纪-元服务是一款基于鸿蒙HarmonyOS Next的美食应用,集成了鸿蒙官方推荐的zrouter路由管理方案,并实现了完整的用户认证体系包括静默登录功能。项目采用现代的组件化架构,提供流畅的用户体验。

image-20251121103048327

zrouter核心特性与功能

image-20251121103359970

1. 统一路由管理

zrouter作为鸿蒙官方推荐的路由管理方案,为应用提供了强大的页面导航能力。在味寻纪项目中,zrouter承担了以下核心功能:

1.1 自动路由生成

通过router-register-plugin插件自动扫描生成路由构建器,项目中已自动生成了11个页面的路由文件:

// _generated目录下的自动生成的路由文件
entry\src\main\ets_generated\
├── ZRBrowseHistoryPage.ets      // 浏览历史页面
├── ZRCookingTechniqueDetailPage.ets  // 烹饪技法详情
├── ZRCookingToolDetailPage.ets       // 烹饪工具详情
├── ZREditProfilePage.ets             // 编辑资料页面
├── ZRFamilyMemberEditPage.ets        // 家庭成员编辑
├── ZRFamilyMemberListPage.ets        // 家庭成员列表
├── ZRFamilyMemberRecipesPage.ets     // 家庭成员菜谱
├── ZRFavoriteListPage.ets            // 收藏列表
├── ZRIngredientDetailPage.ets        // 食材详情
├── ZRRecipeDetailPage.ets            // 菜谱详情
├── ZRRecipeFilterPage.ets            // 菜谱筛选
├── ZRRecipeListPage.ets              // 菜谱列表
└── ZRTestPage.ets                    // 测试页面

image-20251121103117978

1.2 生命周期管理

zrouter内置了完整的页面生命周期管理,支持页面创建、销毁、返回等状态控制:

// 生命周期管理示例
import { Lifecycle, LifecycleEvent, LifecycleRegistry, ZRouterfrom "@hzw/zrouter";

@Builder
export function ZRTestPageBuilder() {
  ZRTestPage()
}

@ComponentV2
export struct ZRTestPage {
  @Local navDestinationIdstring = '';
  private pageStack: NavPathStacknull = null;

  @Lifecycle
  onCreate(want: Want, navStack: NavPathStack) {
    this.navDestinationId = want.parameters?.['navDestinationId'as string || '';
    this.pageStack = navStack;
    // 注册模板管理器
    ZRouter.templateMgr().register(this.navDestinationId)
  }

  @Lifecycle
  onShow() {
    // 页面显示时的逻辑
    if (this.pageStack) {
      const router = new LifecycleRegistry(this.navDestinationId);
      ZRouter.templateMgr().dispatch(this.navDestinationId, LifecycleEvent.ON_SHOW, router)
    }
  }

  @Lifecycle
  onBackPress(): boolean {
    // 处理返回键逻辑
    const router = new LifecycleRegistry(this.navDestinationId);
    const r = ZRouter.templateMgr().dispatch(this.navDestinationId, LifecycleEvent.ON_BACK_PRESS, router)
    return r || false;
  }
}

2. 强大的参数传递机制

zrouter提供了类型安全的参数传递功能,支持复杂的参数类型和结构。

2.1 基本参数传递
// 从首页跳转到菜谱详情页,并传递参数
ZRouter.getInstance().setParam({ recipeId: recipe.id }).push('RecipeDetailPage');

// 从筛选页面跳转到列表页面,传递筛选条件
ZRouter.getInstance().setParam(params).push('RecipeListPage');
2.2 复杂参数传递
// 传递多个筛选参数
const filterParams = {
  category: '川菜',
  difficulty: '简单',
  flavor: '香辣',
  includeIngredients: '牛肉,青椒',
  excludeIngredients: '洋葱',
  minTime: 10,
  maxTime: 60,
  keyword: '麻婆豆腐'
};

ZRouter.getInstance().setParam(filterParams).push('RecipeListPage');
2.3 参数获取与类型转换
// 在目标页面中获取参数
@ComponentV2
export struct RecipeListPage {
  @Local selectModeboolean = false;
  @Local memberIdnumber = 0;

  aboutToAppear() {
    // 获取路由参数
    this.selectMode = ZRouter.getInstance().getParamByKey('selectMode'as boolean;
    this.memberId = ZRouter.getInstance().getParamByKey('memberId'as number;
    
    // 获取筛选参数
    const category = ZRouter.getInstance().getParamByKey('category'as string;
    const difficulty = ZRouter.getInstance().getParamByKey('difficulty'as string;
    const flavor = ZRouter.getInstance().getParamByKey('flavor'as string;
    const includeIngredients = ZRouter.getInstance().getParamByKey('includeIngredients'as string;
    const excludeIngredients = ZRouter.getInstance().getParamByKey('excludeIngredients'as string;
    const minTime = ZRouter.getInstance().getParamByKey('minTime'as number;
    const maxTime = ZRouter.getInstance().getParamByKey('maxTime'as number;
    const keyword = ZRouter.getInstance().getParamByKey('keyword'as string;
  }
}

3. 声明式路由配置

zrouter支持简洁的装饰器语法,让路由配置更加优雅:

import { Routefrom '@hzw/zrouter';

@Route({ name'TestPage', useTemplatetrue })
@Component
export struct TestPage {
  @State messagestring = 'This is Test Page';

  build() {
    NavDestination() {
      Column({ space20 }) {
        Text(this.message)
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
        
        Text('ZRouter 引入成功!')
          .fontSize(18)
          .fontColor('#4CAF50')
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
    }
    .title('测试页面')
    .width('100%')
    .height('100%')
  }
}

项目集成实践

1. 依赖配置

在项目的oh-package.json5中添加zrouter依赖:

{
  "dependencies": {
    "@hzw/zrouter": "^1.8.2"
  }
}

![依赖配置示意图]图3:oh-package.json5中的zrouter依赖配置

2. 初始化配置

EntryAbilityonCreate方法中初始化zrouter:

import { ZRouter } from '@hzw/zrouter';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 初始化 ZRouter
    ZRouter.initialize((config) => {
      config.context = this.context;
      config.isLoggingEnabled = true// 开发环境开启日志
      config.isHSPModuleDependent = false// 如果有HSP模块设置为true
    });
  }
}

3. 导航容器配置

在主页面中使用zrouter的导航栈:

import { ZRouterfrom '@hzw/zrouter';

@ComponentV2
export struct Index {
  build() {
    Navigation(ZRouter.getNavStack()) {
      HomePage()
    }
    .mode(NavigationMode.Stack)
  }
}

4. 代码混淆适配

在混淆规则文件obfuscation-rules.txt中配置zrouter相关的保护规则:

# ZRouter 混淆配置
-keep-file-name
Index
_generated
ZR*

这确保了zrouter生成的页面名称和路由配置在混淆后仍能正常工作。

image-20251121103208018

静默登录功能实现

除了zrouter路由管理,项目还实现了完整的用户认证体系,其中静默登录是一大亮点功能。

image-20251121103220738

1. 静默登录核心逻辑

/**
 * 静默登录 - 不显示登录界面
 * @param enableSilentLogin 是否启用静默登录
 * @returns 登录是否成功
 */
async performSilentLogin(enableSilentLoginboolean = true): Promise<boolean> {
  // 如果未启用静默登录,直接返回
  if (!enableSilentLogin) {
    hilog.info(DOMAIN'AuthManager''静默登录已关闭');
    return false;
  }

  try {
    hilog.info(DOMAIN'AuthManager''开始静默登录');

    // 创建静默登录请求
    const loginRequest = new authentication.HuaweiIDProvider().createLoginWithHuaweiIDRequest();
    loginRequest.forceLoginfalse// 静默登录
    loginRequest.state = util.generateRandomUUID();

    const controller = new authentication.AuthenticationController();
    const response = await controller.executeRequest(loginRequest);

    const loginWithHuaweiIDResponse = response as authentication.LoginWithHuaweiIDResponse;
    const state = loginWithHuaweiIDResponse.state;

    // 验证 state
    if (state && loginRequest.state !== state) {
      hilog.error(DOMAIN'AuthManager''状态验证失败');
      return false;
    }

    const credential = loginWithHuaweiIDResponse.data;
    if (!credential) {
      hilog.error(DOMAIN'AuthManager''未获取到凭证');
      return false;
    }

    // 调用后端API完成登录
    const loginResponse = await huaweiLogin({
      authorizationCode: credential.authorizationCode || '',
      openID: credential.openID || '',
      unionID: credential.unionID || '',
      idToken: credential.idToken
    });

    // 保存登录状态
    await this.saveLoginState(loginResponse);

    // 更新全局用户状态
    this.userStore.setUser(loginResponse.user);
    this.userStore.setLoggedIn(true);

    hilog.info(DOMAIN'AuthManager''静默登录成功: %{public}s', 
      loginResponse.user.nickname || loginResponse.user.username);
    
    return true;

  } catch (error) {
    const err = error as BusinessError;
    if (err.code === 1001502001) {
      // 用户未登录华为账号,这是正常情况
      hilog.info(DOMAIN'AuthManager''用户未登录华为账号');
    } else {
      hilog.error(DOMAIN'AuthManager''静默登录失败: %{public}s'JSON.stringify(error));
    }
    return false;
  }
}

2. 启动登录流程

在应用启动时,系统会自动执行以下登录逻辑:

  1. 本地状态恢复:优先从本地存储恢复登录状态
  2. 静默登录:如果本地恢复失败且开启静默登录,则执行静默登录
  3. 用户状态更新:更新全局用户状态管理
/**
 * 应用启动时的登录逻辑
 * 1. 先尝试从本地恢复登录状态
 * 2. 如果本地恢复成功,不再执行静默登录
 * 3. 如果本地恢复失败且开启了静默登录,则执行静默登录
 */
private async performStartupLogin(): Promise<void> {
  try {
    hilog.info(DOMAIN, 'EntryAbility''开始执行启动登录逻辑');

    // 1. 先尝试从本地恢复登录状态
    const restored = await this.authManager.restoreLoginState();
    if (restored) {
      hilog.info(DOMAIN, 'EntryAbility''从本地恢复登录状态成功');
      return;
    }

    // 2. 检查是否启用静默登录
    const enableSilentLogin = await this.storageManager.getBoolean(
      StorageKeys.ENABLE_SILENT_LOGIN, 
      true // 默认开启
    );

    if (!enableSilentLogin) {
      hilog.info(DOMAIN, 'EntryAbility''静默登录已关闭,跳过');
      return;
    }

    // 3. 执行静默登录
    const success = await this.authManager.performSilentLogin(enableSilentLogin);
    if (success) {
      hilog.info(DOMAIN, 'EntryAbility''静默登录成功');
    } else {
      hilog.info(DOMAIN, 'EntryAbility''静默登录失败或用户未登录');
    }

  } catch (error) {
    hilog.error(DOMAIN, 'EntryAbility''启动登录逻辑执行失败: %{public}s', JSON.stringify(error));
  }
}

image-20251121103306865

image-20251121103306865

3. 手动登录支持

除了静默登录,项目还支持手动登录,用户可以主动触发登录流程:

/**
 * 手动登录 - 显示登录界面
 * @returns 登录响应数据
 */
async performManualLogin(): Promise<LoginResponse | null> {
  try {
    hilog.info(DOMAIN, 'AuthManager''开始手动登录');

    // 创建登录请求
    const loginRequest = new authentication.HuaweiIDProvider().createLoginWithHuaweiIDRequest();
    loginRequest.forceLogin = true// 强制显示登录页面
    loginRequest.state = util.generateRandomUUID();

    const controller = new authentication.AuthenticationController();
    const response = await controller.executeRequest(loginRequest);

    const loginWithHuaweiIDResponse = response as authentication.LoginWithHuaweiIDResponse;
    const state = loginWithHuaweiIDResponse.state;

    if (state && loginRequest.state !== state) {
      hilog.error(DOMAIN, 'AuthManager''状态验证失败');
      return null;
    }

    const credential = loginWithHuaweiIDResponse.data;
    if (!credential) {
      hilog.error(DOMAIN, 'AuthManager''未获取到凭证');
      return null;
    }

    // 调用后端API完成登录
    const loginResponse = await huaweiLogin({
      authorizationCode: credential.authorizationCode || '',
      openID: credential.openID || '',
      unionID: credential.unionID || '',
      idToken: credential.idToken
    });

    // 保存登录状态
    await this.saveLoginState(loginResponse);

    // 更新全局用户状态
    this.userStore.setUser(loginResponse.user);
    this.userStore.setLoggedIn(true);

    hilog.info(DOMAIN, 'AuthManager''手动登录成功: %{public}s',
      loginResponse.user.nickname || loginResponse.user.username);

    return loginResponse;

  } catch (error) {
    const err = error as BusinessError;
    hilog.error(DOMAIN, 'AuthManager''手动登录失败: %{public}s', JSON.stringify(error));
    return null;
  }
}

核心亮点与优势

1. 零配置路由管理

  • 自动生成路由:通过插件自动扫描生成路由配置,无需手动维护路由表
  • 类型安全:参数传递和获取过程中提供完整的类型支持
  • 声明式配置:使用装饰器语法,让路由配置更加简洁直观

2. 强大的页面生命周期管理

  • 完整的生命周期钩子:支持页面创建、显示、隐藏、销毁等完整生命周期
  • 灵活的事件处理:内置多种事件类型,便于处理复杂的页面交互逻辑
  • 返回键管理:提供统一的返回键处理机制

3. 智能参数传递机制

  • 类型转换支持:自动处理基本类型和复杂对象的序列化/反序列化
  • 参数验证:内置参数验证机制,提高应用稳定性
  • 状态管理集成:与鸿蒙的AppStorageV2等状态管理方案完美集成

4. 生产级混淆适配

  • 混淆保护规则:提供完整的混淆配置建议
  • 文件名称保护:确保混淆后路由名称的正确映射
  • 编译时优化:支持编译时的路由优化和压缩

5. 无缝认证集成

  • 静默登录体验:应用启动时自动完成身份验证,用户无感知
  • 本地状态恢复:优先使用本地存储的用户信息,提升启动速度
  • 灵活认证策略:支持静默登录和手动登录的灵活切换

性能与优化

1. 路由性能优化

  • 懒加载支持:zrouter支持页面懒加载,减少应用启动时间
  • 路由缓存:内置路由状态缓存,提升页面切换性能
  • 内存管理:智能的内存管理机制,避免内存泄漏

2. 开发体验优化

  • 开发日志:开发模式下提供详细的路由操作日志
  • 热更新支持:支持开发时的路由热更新
  • 调试工具:提供丰富的调试工具和状态监控

3. 兼容性保障

  • 版本兼容:支持不同版本的HarmonyOS系统
  • API适配:自动适配不同API版本的功能差异
  • 向后兼容:确保老版本代码的兼容性问题

最佳实践建议

1. 路由命名规范

  • 使用语义化的页面名称
  • 统一命名风格(如驼峰命名法)
  • 避免使用特殊字符

2. 参数设计原则

  • 传递最小必要信息
  • 避免传递过大的对象
  • 合理设计参数结构

3. 页面生命周期管理

  • 合理使用生命周期钩子
  • 避免在生命周期中执行耗时操作
  • 及时清理资源,避免内存泄漏

4. 安全考虑

  • 对敏感参数进行加密处理
  • 验证参数的合法性
  • 避免在URL中暴露敏感信息

总结

味寻纪-元服务项目成功集成了zrouter路由管理方案,并实现了完善的静默登录功能。通过zrouter的强大功能,项目获得了:

  • 统一且强大的路由管理:简化了页面导航逻辑,提升了开发效率
  • 类型安全的参数传递:减少了运行时错误,提升了代码质量
  • 完整的生活周期管理:提供了更好的用户体验
  • 生产级的混淆支持:确保了应用的安全性
  • 无缝的认证体验:静默登录提升了用户使用体验

zrouter作为鸿蒙官方推荐的路由解决方案,为HarmonyOS Next应用开发提供了强大而稳定的路由管理能力,是构建高质量鸿蒙应用的重要工具。通过味寻纪项目的实践,我们可以看到zrouter在实际项目中的强大功能和良好体验。

群晖 DSM 更新后 Cloudflare DDNS 失效的排查记录

作者 子洋
2025年11月22日 10:29

前言

前两天我的群晖 NAS 提示 DSM 有新版本更新。由于已经好久没更新,一瞄发行说明发现新 feature 和 bugfix 还挺多,想着时间也不短了,那就顺手更一下吧。

系统更新得倒是很顺利,更新后啥异常也没发现,内网/外网访问都正常,我也就没再管。

直到昨晚事情才开始不对:我突然发现通过 DDNS 完全无法访问 NAS 上的任何应用。不过当时家里网络卡得要命,我也没太当回事,只以为是网络抽风。

但今天一早起来网络速度恢复,可域名依然不通,这时候我才知道绝对不是网络速度的问题了,这才开始正式排查。

排查链路复盘

下面是我这次遇到问题后的完整排查过程。一方面是给可能遇到同样坑的朋友提供一个参考思路,另一方面也是给自己做个记录,方便以后再遇到类似情况时能更快定位问题。

1. 先确认 NAS 服务本身是否正常

第一步很简单: 通过内网 IP 访问 NAS,一切正常,系统服务都在。

2. 再确认公网访问是否正常

用家里的 公网 IP 直连 NAS 暴露出去的端口,能通。说明 ISP 没问题,端口也没被封。

3. 检查群晖 DSM 自带的 DDNS 状态

打开 控制面板 → 外部访问 → DDNS

果然看到 DDNS 状态一直卡在 “正在连接...”, 点“测试连接”,转半天圈,最后报 连接超时

至此基本确认问题出在 DDNS。

4. 怀疑是否是 NAS 上的代理导致的问题

我之前遇到过: 只要开了系统代理 → 重启 NAS → 自定义 DDNS 会无法连接

必须是:

  1. 先连上 DDNS
  2. 再手动开启系统代理

这很奇怪,但确实存在这个问题,我也一直没有去解决,所以这次我也先排查了一遍代理设置,结果发现代理是关闭的,这条可能性排除了。

5. 怀疑 Cloudflare 又宕机?

突然想起前两天 Cloudflare 宕机,难不成又宕机了?

我登录 CF 控制台看了眼,没有任何告警。为了确认,我还顺手访问了几个托管在 Vercel、经过 Cloudflare 的服务,一切正常,说明 Cloudflare 没锅。

6. 终于把怀疑对象指向 DSM 更新

这时我不禁想起了福尔摩斯法则:

“当你排除了所有不可能的,剩下的即使再离谱,也必然是真相。”

我先是在 Nas 上添加 DDNS 的地方看了一下服务提供商,果然,没有之前自定义的 Cloudflare 了。

我回忆了一下,群晖默认不支持 Cloudflare DDNS,需要自己手动部署第三方脚本,比如很多玩家都用的 SynologyCloudflareDDNS

但我这是两三年前配置的了,早忘得干干净净,只记得当时是用 SSH 登进去扔了些脚本、改了 ddns_provider 配置,我只好去网上重新查教程。

7. 转折:ddns_provider 配置“消失了”

网上的做法都差不多,我也照着检查了一下 NAS 的 /etc/ddns_provider.conf

结果一打开就发现:我之前手动添加的 Cloudflare provider 配置彻底没了。

八成是这次 DSM 更新覆盖了系统文件,把自定义的 DDNS provider 给清干净了。

于是我把缺失的配置重新补上、保存,然后再回到 DSM 的 DDNS 界面一看,果然有变化: 列表里多出了一个“访问 DDNS 供应商的网站”的按钮。

这说明新增的 provider 已成功加载(因为在 ddns_provider.conf 里设置 website 字段后,DSM 会自动在面板中显示这个入口)。

Pasted image 20251122094435.png

再测试连接,结果:还是失败。

8. 再一查:原先放在 /sbin 的脚本也没了

我继续顺着配置里引用的脚本路径查下去(就是 SynologyCloudflareDDNS 的核心脚本)。

结果:指定路径下根本没有脚本! 我甚至怀疑是不是我之前放在其他地方了,于是全盘搜索了一遍。

找不到,根本找不到,基本可以确认整个 /sbin 目录都被更新重置了。

至此终于搞清楚问题根因:

DSM 更新时覆盖了系统目录,导致自定义 DDNS Provider 配置 + 脚本全部消失。

9. 修复:重新上传脚本 + 重新配置 provider

我重新下载脚本,上传到 /sbin,给可执行权限,再更新 ddns_provider 配置。

测试连接,这次成功了,DDNS 恢复正常!

10. 为什么前两天还能用?

现在回头一想,为什么更新当天没问题?

很简单:因为我的公网 IP 那几天没变。 DDNS 虽然挂了,但只要 Cloudflare 上的 DNS A 记录还指向老 IP,访问就不会出问题。

直到昨天公网 IP 有一点点变动(可能只差一两位),我登陆 CF 又没仔细看 A 记录,这才导致域名完全失效。

总结

这次问题的根因其实非常隐蔽:

  • DSM 更新 → 覆盖系统目录
  • 自定义 DDNS Provider 配置被删
  • /sbin 下的 Cloudflare 更新脚本也清空
  • 但因为公网 IP 没变,所以问题延后了两天才暴露
  • 最后一度误以为是 Cloudflare、代理或网络的问题

最终的解决办法就是:重新放回脚本 + 重写 ddns_provider.conf。

React Suite v6:面向现代化的稳健升级

作者 郭小铭
2025年11月22日 10:25

React Suite (rsuite) v6 正式发布了。这一版本聚焦于现代化改造:重构底层样式系统、提供新的布局能力,并整体提升响应式体验和开发流程。v6 代表 React Suite 在稳定性的前提下,持续向更具适应性的 UI 方案演进。

React Suite v6 Banner

1. 样式系统的全面重构:拥抱 CSS 变量

v6 最重大的底层变革是将样式系统从 Less 彻底迁移到了 SCSS,并全面采用 CSS 变量 (CSS Variables) 作为主题定制的核心方案。开发者只需覆盖变量值,就能在运行时动态切换品牌色、间距或圆角,无需重新编译或配置额外的构建流程。

完整的变量清单与使用方式,可参考 CSS Variables 文档,也可以借助 Palette 工具 可视化调整品牌配色。

其他样式系统改进

  • 逻辑属性 (Logical Properties):全面采用 CSS 逻辑属性(如 margin-inline-start 代替 margin-left),原生支持 RTL(从右到左)排版。
  • rem 单位:字体大小、间距等尺寸从 px 转换为 rem,更好地支持响应式排版和无障碍缩放。

2. 拥抱 AI 辅助编程

React Suite v6 不仅关注组件本身,更致力于提升 AI 时代的开发效率。我们引入了对 AI 编程助手的原生支持,让 Cursor、Windsurf 等工具能更懂 RSuite。

LLMs.txt 支持

我们遵循 llms.txt 标准,为文档站添加了 /llms.txt 文件。这是一个专为大语言模型 (LLM) 优化的文档索引。

当你在 Cursor 或其他 AI 工具中引用 RSuite 文档时,AI 可以通过这个文件快速获取组件的 API 定义、最佳实践和代码示例,从而生成更准确、符合 v6 规范的代码。

MCP Server 集成

我们推出了官方的 Model Context Protocol (MCP) Server。

通过 MCP,你的 AI 助手可以直接连接到 RSuite 的知识库。这意味着:

  • 实时检索:AI 可以直接读取最新的组件文档和 API,无需手动复制粘贴。
  • 上下文感知:在编写代码时,AI 能自动推荐适合当前场景的组件和属性。
  • 减少幻觉:基于官方文档的上下文投喂,大幅降低 AI 生成过时或错误代码的概率。

3. 布局能力的原子化:引入 Box 与 Center

为了让布局更加灵活高效,v6 引入了基础的布局原子组件,让开发者能够像搭积木一样构建复杂的 UI。

Box 组件

Box 是构建布局的基石,它允许你直接在组件上通过 props 控制样式,无需编写额外的 CSS 类。

import { Box, Button } from 'rsuite';

function App() {
  return (
    <Box p={20} m={10} bg="gray-100" borderRadius={8} display="flex" justifyContent="space-between">
      <h2>Welcome to v6</h2>
      <Button appearance="primary">Get Started</Button>
    </Box>
  );
}

Center 组件

垂直水平居中一直是 CSS 中的高频需求,现在你可以使用 Center 组件轻松实现:

import { Center } from 'rsuite';

<Center height={200} className="bg-blue-50">
  <div>Perfectly Centered Content</div>
</Center>;

4. 响应式设计的全面增强

在移动互联网时代,跨端适配至关重要。v6 对核心组件进行了响应式能力的增强。

  • Grid 系统升级:重构了 RowCol,提供更灵活的断点控制对象语法。

    <Row align="center" justify="space-between">
      <Col span={{ xs: 24, md: 12, lg: 8 }}>...</Col>
      <Col span={{ xs: 24, md: 12, lg: 8 }}>...</Col>
    </Row>
    
  • Navbar & Sidenav:新增了对移动端的自适应支持,使用 Navbar.Content 替代了废弃的 pullRight,布局更清晰。

    <Navbar>
      <Navbar.Brand>BRAND</Navbar.Brand>
      <Navbar.Content>
        <Nav>...</Nav>
      </Navbar.Content>
      <Navbar.Content>
        <Avatar />
      </Navbar.Content>
    </Navbar>
    
  • Picker 组件:所有的 Picker 组件现在都拥有了更好的移动端适配体验,在小屏幕设备上会自动切换为更友好的交互模式。

5. 全新的组件与 Hooks

除了布局组件,v6 还带来了一系列实用的新组件和 Hooks,进一步丰富了组件库的能力。

新增组件

  • SegmentedControl:分段控制器,提供更现代的选项卡切换体验,适用于筛选、视图切换等场景。

  • PasswordInput:专用的密码输入框,内置显示/隐藏密码切换功能,提升用户体验。

  • PinInput:PIN 码/验证码输入组件,支持自动聚焦和粘贴分割,适用于验证场景。

  • Textarea:独立的多行文本输入组件,提供更好的样式控制和一致性。

  • Kbd:用于展示键盘按键,文档站和快捷键提示的福音。

  • Link:提供统一样式的链接组件,支持自定义颜色和无障碍访问。

  • Menu & MegaMenu:全新的菜单系统,支持更复杂的导航结构,轻松构建大型应用导航。

  • Form.Stack:让表单布局排列更加整洁有序,替代了 Form 组件上的布局属性。

    <Form>
      <Form.Stack layout="horizontal" spacing={20}>
        <Form.Group>
          <Form.Label>Username</Form.Label>
          <Form.Control name="username" />
        </Form.Group>
        {/* ... */}
      </Form.Stack>
    </Form>
    

强大的 Hooks

  • useDialog:通过函数调用方式管理对话框,告别繁琐的 visible state 管理。

    const dialog = useDialog();
    
    const handleClick = async () => {
      const result = await dialog.confirm({
        title: '确认操作',
        children: '您确定要执行此操作吗?'
      });
      if (result) {
        console.log('Confirmed');
      }
    };
    
  • useFormControl:轻松创建自定义的表单控件,自动处理验证状态和错误信息。

6. 开发者体验 (DX) 的极致追求

我们深知开发者的快乐不仅仅来自于好用的组件,更来自于流畅的开发流程。

  • 全面拥抱 Vitest:我们将测试框架从 Karma/Mocha 迁移到了 Vitest,测试速度大幅提升,开发反馈更加即时。
  • TypeScript 类型增强:优化了所有组件的类型导出,新增 Schema 类型命名导出,智能提示更加精准。
  • Bundle Size 优化
    • 引入 size-limit,严格把控包体积。
  • 生态支持:新增 Bun 安装指南,紧跟前端工具链发展潮流。
  • 开发调试useToaster 增加了环境检查,当在 CustomProvider 上下文之外使用时会发出友好警告,帮助快速定位问题。

7. 更多细节改进

  • Badge:新增 sizeoutlineplacement 等属性,支持更丰富的展示形态。

    <Badge content="New" size="lg" outline />
    <Badge content={99} shape="square" placement="bottomEnd" />
    
  • Breadcrumb:默认样式调整,更符合现代设计规范。

  • DatePicker:优化了 Toolbar 布局,交互更符合直觉。

  • Progress:新增 indeterminate 加载动画状态,以及支持分段进度条 (sections),展示更丰富的信息。

  • TreePicker:新增 onlyLeafSelectable 属性,满足仅允许选择叶子节点的业务场景。

  • Button:新增 toggle 状态支持。

  • InputGroup:优化了 inside 模式下的按钮样式,视觉更加协调。

  • 依赖升级:Date-fns 4.x, Prettier 3.x 等核心依赖全面升级。


立即开始

React Suite v6 现已通过 npm 发布。

npm install rsuite@latest

我们准备了详细的迁移指南,帮助您从 v5 平滑升级。

欢迎在 GitHub 上给我们 Star,或者在 Discussion 中分享您的使用体验!

React Suite Team

每日一题-使所有元素都可以被 3 整除的最少操作数🟢

2025年11月22日 00:00

给你一个整数数组 nums 。一次操作中,你可以将 nums 中的 任意 一个元素增加或者减少 1 。

请你返回将 nums 中所有元素都可以被 3 整除的 最少 操作次数。

 

示例 1:

输入:nums = [1,2,3,4]

输出:3

解释:

通过以下 3 个操作,数组中的所有元素都可以被 3 整除:

  • 将 1 减少 1 。
  • 将 2 增加 1 。
  • 将 4 减少 1 。

示例 2:

输入:nums = [3,6,9]

输出:0

 

提示:

  • 1 <= nums.length <= 50
  • 1 <= nums[i] <= 50

多语言的爱意告白

2025年11月21日 22:35

这张图片以黑色为背景,中央突出显示白色的 “小鱼” 字样,周围环绕着多语言的 “我喜欢你”“我爱你”“和我交往吧”“在一起” 等表达爱慕与交往意愿的文字,文字颜色多样,营造出一种充满爱意的视觉氛围。

image.png

import pygame
import random
import sys
import math
import os

# 初始化pygame
pygame.init()
# 窗口设置
width, height = 1000, 800
screen = pygame.display.set_mode((width, height))

# 改进的字体设置函数
def get_font(size):
    font_paths = [
        "C:/Windows/Fonts/simhei.ttf", "C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/simsun.ttc",
        "/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/Arial.ttf",
        "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
    ]
    for font_path in font_paths:
        if os.path.exists(font_path):
            try:
                return pygame.font.Font(font_path, size)
            except:
                continue
    try:
        return pygame.font.SysFont("Arial", size)
    except:
        return pygame.font.Font(None, size)

# 创建字体对象(弹幕字体稍大,保证清晰)
font = get_font(20)
title_font = get_font(36)
danmu_font = get_font(20)  # 弹幕字体大小适中
# 新增名字专用字体(更大更醒目)
name_font = get_font(60)  # 字体大小60,突出显示

# 多语言“我爱你”文本库(原内容完全保留)
love_texts = [
    "Mola le offi la tee", "mihai amestiah sind", "milvini un ne seibesc", "Le sakam", "Mlohuujam",
    "Te sakam", "chami dài e n ki n M", "Te dua", "Té okabescu", "Tá grá agam duit", "Kocham Ci",
    "Saya cintakan kamu", "我爱你", "Te ambesc", "Mahal kita", "Jeg elsker deg", "Mihaji té", "Meng sona",
    "Szerethékda", "Jag Tjukin", "Teja amerhi", "L kertesi skarji", "joga nem lesen", "nyolyomni",
    "I love you", "Je t'aime", "Ich liebe dich", "Ti amo", "Te amo", "あいしてる",
    "我爱你", "我中意你", "我爱侬", "我欢喜你", "我爱你", "我愛你", "勾买蒙", "额爱你","我待见你"
    "恩欢喜你", "我爱列", "Ik hou van je", "Saya cintakan mu", "Ti amo", "Jeg elsker dig",
    "Aku cinta padamu", "Saya cinta kamu", "Ljubim te", "俺喜欢", "我稀罕你", "俺稀罕你","阿秋拉嘎"
]

# 爱心坐标生成(原逻辑完全保留,未做任何修改)
love_points = []
for t in range(1000):
    theta = t / 1000 * 2 * math.pi
    x = 15 * (pow(math.sin(theta), 3))
    y = 10 * math.cos(theta) - 5 * math.cos(2 * theta) - 2 * math.cos(3 * theta) - math.cos(4 * theta)
    x = x * 18 + width // 2
    y = -y * 18 + height // 2
    love_points.append((int(x), int(y)))

# 文字对象列表(原逻辑完全保留,未做任何修改)
text_objects = []
colors = [(255, 182, 193), (255, 105, 180), (255, 20, 147),(218,112,214)]
for i, text in enumerate(love_texts):
    color = colors[i % len(colors)]
    try:
        if not isinstance(text, str):
            text = str(text)
        text_surface = font.render(text, True, color)
    except Exception as e:
        print(f"渲染失败 '{text}': {e}")
        text_surface = font.render("Love", True, color)
    idx = random.randint(0, len(love_points) - 1)
    x, y = love_points[idx]
    speed_x = random.uniform(-0.15, 0.15)
    speed_y = random.uniform(-0.15, 0.15)
    text_objects.append({
        "surface": text_surface, "text": text, "x": x, "y": y,
        "speed_x": speed_x, "speed_y": speed_y, "target_idx": idx, "color": color
    })

# ===================== 弹幕功能(按要求优化)=====================
# 弹幕仅保留3条中文文案
danmu_texts = ["我爱你", "在一起", "我喜欢你","和我交往吧","阿秋拉嘎"]
# 弹幕仅一种粉色(鲜艳且协调)
danmu_color = (255,239,213)  # 热粉色

# 弹幕对象类(速度加快,淡入淡出节奏紧凑)
class Danmu:
    def __init__(self):
        self.text = random.choice(danmu_texts)
        self.font_size = random.randint(18, 24)  # 字体大小随机
        self.font = get_font(self.font_size)
        self.color = danmu_color
        # 随机位置(全屏分布,支持左右/上下双向运动)
        if random.random() > 0.5:
            # 从左向右运动
            self.x = random.randint(-100, -20)
            self.y = random.randint(50, height - 50)
            self.speed_x = random.uniform(3, 4)  # 加快水平速度
            self.speed_y = random.uniform(-0.5, 0.5)  # 轻微垂直偏移
        else:
            # 从右向左运动
            self.x = random.randint(width + 20, width + 100)
            self.y = random.randint(50, height - 50)
            self.speed_x = random.uniform(-5, -3)  # 加快水平速度
            self.speed_y = random.uniform(-0.5, 0.5)  # 轻微垂直偏移
        self.alpha = 0  # 初始透明度为0
        self.life = random.randint(80, 150)  # 生命周期缩短(速度快对应短生命周期)
        self.life_count = 0
        self.surface = self._render_text()

    def _render_text(self):
        surf = self.font.render(self.text, True, self.color)
        surf.set_alpha(self.alpha)
        return surf

    def update(self):
        self.life_count += 1
        # 快速淡入(前20%生命周期完成淡入)
        if self.life_count < self.life * 0.2:
            self.alpha = min(255, self.alpha + 15)  # 加快淡入速度
        # 快速淡出(后30%生命周期完成淡出)
        elif self.life_count > self.life * 0.7:
            self.alpha = max(0, self.alpha - 10)  # 加快淡出速度
        # 更新透明度和位置(速度加快)
        self.surface.set_alpha(self.alpha)
        self.x += self.speed_x
        self.y += self.speed_y
        # 生命周期结束或超出屏幕则重置
        if self.life_count >= self.life or self.x < -200 or self.x > width + 200:
            self.__init__()

    def draw(self, screen):
        rect = self.surface.get_rect(center=(self.x, self.y))
        screen.blit(self.surface, rect)

# 初始化弹幕(初始30条,数量适中不遮挡爱心)
danmu_list = [Danmu() for _ in range(30)]
# 弹幕生成计时器(生成频率加快)
danmu_spawn_timer = 0
danmu_spawn_interval = 20  # 每20帧(约0.3秒)新增1条弹幕

# 原标题与说明文字(保留)
try:
    title_text = title_font.render("", True, (255, 255, 255))
except:
    title_text = title_font.render("I Love You + Pink Danmu", True, (255, 255, 255))
instruction_font = get_font(16)
instruction_text = instruction_font.render("按ESC键退出", True, (150, 150, 150))

# 生成居中显示的名字(白色,屏幕正中间)
name_text = name_font.render("小鱼", True, (255, 255, 255))  # 白色文字
# 计算名字居中坐标(屏幕正中心)
name_x = width // 2 - name_text.get_width() // 2
name_y = height // 2 - name_text.get_height() // 2

# 主循环(仅添加名字绘制,不改动原有内容)
clock = pygame.time.Clock()
running = True
while running:
    # 事件处理(原逻辑保留)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                running = False

    # 黑色背景(原逻辑保留)
    screen.fill((0, 0, 0))

    # 绘制标题、说明文字(原逻辑保留)
    screen.blit(title_text, (width // 2 - title_text.get_width() // 2, 20))
    screen.blit(instruction_text, (width - instruction_text.get_width() - 10, height - 30))

    # 绘制爱心轨迹文字(原逻辑完全保留,未做任何修改)
    for obj in text_objects:
        text_rect = obj["surface"].get_rect(center=(obj["x"], obj["y"]))
        screen.blit(obj["surface"], text_rect)
        target_x, target_y = love_points[obj["target_idx"]]
        obj["x"] += (target_x - obj["x"]) * 0.02 + obj["speed_x"]
        obj["y"] += (target_y - obj["y"]) * 0.02 + obj["speed_y"]
        obj["target_idx"] = (obj["target_idx"] + 1) % len(love_points)
        if obj["x"] < 0 or obj["x"] > width:
            obj["speed_x"] *= -1
        if obj["y"] < 50 or obj["y"] > height - 50:
            obj["speed_y"] *= -1

    # ===================== 绘制居中名字(新增代码)=====================
    screen.blit(name_text, (name_x, name_y))  # 在屏幕正中间绘制白色名字

    # ===================== 弹幕更新与绘制(按要求优化)=====================
    # 加快弹幕生成频率
    danmu_spawn_timer += 1
    if danmu_spawn_timer >= danmu_spawn_interval:
        danmu_list.append(Danmu())
        danmu_spawn_timer = 0
        # 限制弹幕总数(最多50条,避免过度拥挤)
        if len(danmu_list) > 50:
            danmu_list.pop(0)

    # 更新并绘制所有弹幕(速度加快,粉色系,仅中文文案)
    for danmu in danmu_list:
        danmu.update()
        danmu.draw(screen)

    # 显示帧率(原逻辑保留)
    fps_text = font.render(f"FPS: {int(clock.get_fps())}", True, (100, 100, 100))
    screen.blit(fps_text, (10, height - 30))

    pygame.display.flip()
    clock.tick(60)

pygame.quit()
sys.exit()
❌
❌