阅读视图

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

Astro 项目升级全栈:EdgeOne Pages 部署指南

背景介绍

最近用腾讯云的 EdgeOne Pages (下文称 Pages)部署了个人站点,记录一下从 SSG 升级到 SSR 的过程。Pages 是腾讯云推出的网站托管服务,支持 SSR 和边缘函数,国内访问稳定性相比而言会更好一点。Astro 是一个"内容优先"的现代 Web 框架,特别适合博客、文档等内容型网站,默认输出零 JavaScript 的静态 HTML,同时也支持 SSR 模式。我的个人博客就是使用的 Pages 进行托管,Pages 之前只支持 SSG 模式,对初期来说我的站点完全够用了,但随着网站内容增多、访问量上升,我希望通过 SSR 获得更好的 SEO 效果。刚好最近看到 Pages 支持了 Astro 的 SSR 模式,正好借此机会升级并记录实践过程。

基本信息

示例项目

本文使用 demo-portfolio 项目作为示例,一个基于 Astro 的个人作品集网站。项目 fork 自原作者仓库,我对其进行了 Astro 版本升级和 bug 修复,以更好地适配 Pages 的 SSR 模式(欢迎给原作者 star)。

Astro 适配器

适配器(Adapter)是 Astro 用于适配不同部署平台的插件,负责将项目转换为目标平台所需的格式。Pages 提供了官方适配器 @edgeone/astro,支持在边缘计算环境中运行 SSR 模式。

Pages 脚手架

Pages 提供命令行工具,通过 npm install edgeone -g 安装,支持项目初始化、本地调试和一键部署。

实践过程

项目准备

首先从 GitHub 下载示例项目到本地:

git clone https://github.com/nuonuo-888/portfolio-sofidev-garrux
cd portfolio-sofidev-garrux
npm install

项目配置

原始 SSG 模式

项目的核心配置文件是 astro.config.mjs,使用 SSG 模式时的配置如下:

import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import tailwind from "@astrojs/tailwind";
import sitemap from "@astrojs/sitemap";

export default defineConfig({
  site: "https://examples.com/", // 网站的部署URL,用于生成sitemap和RSS
  output: "static", // 输出模式:static表示静态站点生成(SSG)
  integrations: [react(), tailwind(), sitemap()], // 集成插件:React组件支持、Tailwind CSS、站点地图生成
});

使用 npm run build 构建后,会在 dist/ 目录生成以下结构:

dist/
├── _astro/              # Astro 构建生成的资源文件
├── 404.html            # 404 错误页面
├── about/              # 关于页面
├── assets/             # 静态资源文件(SVG 图标)
├── blog/               # 博客文章目录
├── docs/               # 文档文件
├── img/                # 图片资源
├── index.html          # 首页
├── favicon.svg         # 网站图标
├── sitemap-0.xml       # 站点地图文件
└── sitemap-index.xml   # 站点地图索引

这些文件可以直接部署到任何静态托管服务(如 CDN、对象存储),无需服务器运行时支持。

升级为 SSR 模式

现在我们将项目从 SSG 模式升级为 SSR 模式。首先需要安装 Pages 的适配器:

npm install @edgeone/astro

然后修改 astro.config.mjs 配置文件:

import { defineConfig } from "astro/config";
import edgeone from "@edgeone/astro"; // 引入EdgeOne适配器
import react from "@astrojs/react";
import tailwind from "@astrojs/tailwind";
import sitemap from "@astrojs/sitemap";

export default defineConfig({
  site: "https://examples.com/",
  output: "server", // 从 'static' 改为 'server' 启用SSR
  adapter: edgeone(), // 配置EdgeOne适配器
  integrations: [react(), tailwind(), sitemap()],
});

升级为 SSR 模式需要修改两个配置:将 output'static' 改为 'server' 来启用服务端渲染,以及添加 adapter: edgeone() 来配置 EdgeOne 适配器。

配置完成后,执行构建命令:

npm run build

构建完成后会在 .edgeone/ 目录生成以下结构:

.edgeone/
├── assets/                    # 静态资源文件
└── server-handler/            # 服务器端处理文件

其中 assets/ 目录包含所有静态文件(CSS、JS、图片等),server-handler/ 目录包含服务端渲染代码,用于在 Pages 边缘节点上动态处理请求和渲染页面。

构建产物之所以生成到 .edgeone/ 目录,是因为适配器的作用就是将 Astro 的构建结果转换为目标部署平台所需的格式和目录结构。不同平台的适配器会生成对应的目录,例如 @astrojs/vercel 适配器会生成 .vercel/ 目录,@astrojs/netlify 适配器会生成 .netlify/ 目录,这样各平台的部署工具就能识别并正确部署这些构建产物。

适配器参数配置

@edgeone/astro 适配器支持传入以下参数来自定义构建行为:

  • includeFiles(可选):强制包含的文件列表,支持 glob 模式匹配
  • excludeFiles(可选):排除的文件列表,主要用于排除 node_modules 中的特定文件,支持 glob 模式匹配

配置示例:

import edgeone from "@edgeone/astro";

export default defineConfig({
  adapter: edgeone({
    outDir: ".edgeone",
    includeFiles: ["src/locales/**", "public/config.json"],
    excludeFiles: ["node_modules/.cache/**"],
  }),
});

注意:根据 Pages 官方文档 说明,当前版本暂不支持 Astro 的 Image 组件,请使用常规的 <img> 标签来显示图片。

本地验证

在部署前,建议先在本地运行项目确保一切正常:

npm run dev

启动成功后,访问 http://localhost:4321 即可预览网站效果:

首页预览

确认本地运行无误后,就可以进行部署了。

部署步骤

Pages 提供两种部署方式:

方式一:命令行部署(推荐)

首先全局安装命令行工具:

npm install edgeone -g

在项目根目录执行部署命令:

edgeone pages deploy --name my-personal-website --token <your-token>

参数说明:

  • --name:指定项目名称
  • --token:EdgeOne API Token,用于身份验证(可在 Pages API 管理页面 创建)

部署中的输出信息:

[cli][✔] Using provided API token for deployment...
[cli][✔] Deploying /Users/your-mac-account-name/*/portfolio-sofidev-garrux/.edgeone to project my-personal-website (Production environment, global area)...
[cli]❗️ Project my-personal-website doesn't exist. Creating new project.
[cli][CreatePagesProject] Creating new project with name: my-personal-website in global area
[cli][✔] Using Project ID: pages-******
[cli]No existing .env file found, will create new one
[cli]Pulling environment variables...
[cli]No environment variables found.
[cli][Uploader] Uploading file: [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
[cli][✔] File uploaded successfully
[cli][✔] Creating deployment in Production environment...
[cli][✔] Created deployment with Deployment ID: ******
[cli][DeployStatus] Deploying.....

部署成功后的输出信息:

[cli][✔] Deploy Success
[cli][✔] Deployment to EdgeOne Pages completed successfully! To view your project's live URL.
EDGEONE_DEPLOY_URL=https://my-personal-website.edgeone.site?eo_token=******
EDGEONE_DEPLOY_TYPE=preset
EDGEONE_PROJECT_ID=pages-******
[cli][✔] You can view your deployment in the EdgeOne Pages Console at:
https://console.cloud.tencent.com/edgeone/pages/project/*****

部署成功后,你可以访问 Pages 控制台 查看新部署的项目,看到项目列表中出现你的项目即表示发布成功。

在控制台的项目列表中,点击进入你的项目详情页,可以查看项目的预览地址(Preview URL),通过该地址即可访问你部署的网站。

方式二:GitHub 自动部署

访问 Git 项目部署页面,关联 GitHub 账户并选择仓库,配置完成后每次推送代码即可自动触发部署,类似 Vercel、Netlify 的工作流程。

验证 SSR 部署

部署完成后,你可以通过 curl 命令在终端验证 SSR 是否正常工作:

curl https://my-personal-website.edgeone.site

如果返回完整的 HTML 内容,说明服务端渲染已成功运行。

通过中间件验证 SSR

为了更直观地验证 SSR 是否正常工作,你可以在项目中添加 Astro 中间件,在响应头中加入自定义信息和服务端渲染时间。

在项目根目录创建 src/middleware.js 文件:

export function onRequest(context, next) {
  // 记录请求开始时间
  const startTime = Date.now();

  // 继续处理请求
  const response = next();

  // 在响应头中添加自定义信息
  response.headers.set("X-Rendered-By", "EdgeOne-Pages-SSR");
  response.headers.set("X-Render-Time", `${Date.now() - startTime}ms`);

  return response;
}

重新构建并部署项目后,使用 curl 命令查看响应头:

curl -I https://my-personal-website.edgeone.site

你会在响应头中看到:

X-Rendered-By: EdgeOne-Pages-SSR
X-Render-Time: 15ms

这些自定义响应头证明了页面是在服务端动态渲染的,X-Render-Time 显示了服务端渲染所花费的时间。

总结

以上就是使用 Pages 部署 Astro SSR 项目的全过程。从 SSG 升级到 SSR 的步骤比较简单:安装适配器、修改配置文件、重新构建部署。SSR 模式相比 SSG 在 SEO 和首屏加载上会有一些优势,适合内容型网站。

希望这篇文章能对同样想要将 Astro 项目部署到 Pages 的开发者提供参考,如果遇到问题欢迎在评论区交流。

HTML基本格式 - 第一个HTML网页

HTML基本格式 - 第一个HTML网页

一、第一个HTML网页

编写网页的步骤:

  1. 创建一个新文件。
  2. 利用记事本打开。
  3. 编写HTML代码。
  4. 保存并且修改纯文本文档的扩展名为.html(文件命名一定要使用英文)。
  5. 利用浏览器打开编写好的文档.

使用HTML编写网页的基本结构(格式):

<!DOCTYPE html>
<html>
  <head>
     <title></title>
  </head>
  <body> 
  </body>
</html>

HTML基本格式中每个标签的含义:

通过观察,可以发现HTML基本结构中,所有标签都是成对出现的,这些成对出现的标签中,有一个带斜杠,有一个不带斜杠。

不带斜杠的标签是开始标签,带斜杠的标签是结束标签。

html标签:

  • 作用:用于告诉浏览器这是一个网页(html文档)。

  • 注意点:其他所有的标签都必须写在html标签里面。

head标签:

  • 作用:用于给网站添加一些配置信息。

  • 使用场景:可以给网站规定标题;指定标签页中的小图标;还可以添加网站SEO相关信息(指定网站关键字/指定网站的描述信息);外挂一些外部的.css/.js文件;添加一些浏览器适配相关的内容。

  • 注意点:一般情况下,写在head标签当中的内容,都不是用来显示给用户查看。

title标签:

  • 作用:专门用于指定网站的标题,并且这个指定的标题将来还会作为用户保存网站的默认标题。

  • 注意点:title标签必须写在head标签里面.

body标签:

  • 作用:专门用来定义HTML文档中需要显示给用户查看的内容(文字/图片/音频/视频……)

  • 注意点:

    • 虽然有时候可能将内容写到了别的地方,在网页中也可以正常展示,但是最好不要这样写,最好将网页需要显示的内容写在body标签里面。
    • 一对html标签中(一个<html>开始标签和一个</html>结束标签中)只能有一对body标签。

二、字符集问题

当使用浏览器打开html文件的时候,我们可能会发现网页展示的内容是乱码,往往可以通过修改浏览器设置当中的字符集选项,即可正确展示想要展示的内容。这个时候就需要我们在开发过程中给网页指定一个默认的字符集,使用meta标签。

为什么网页会出现乱码现象?

因为我们在编写网页的时候没有指定字符集。

如何解决乱码现象?

head标签当中添加<meta charset = 'GBK' />,指定字符集

什么是字符集?

字符集就是字符的集合。由于不同字符集,对于同一个字符映射的编码是不同的,故在浏览器渲染的时候,使用相同的编码到不同的字符集查找字符的时候,就有可能出现乱码的情况。

而我们指定字符集,就是为了明确告诉浏览器,我们在开发过程中使用的是哪个字符集,便于浏览器更好的映射,找到正确的字符。我们常见的字符集:GBK、UTF-8

GBK(GB2312)和UTF-8的区别:

  • GBK(GB2312)里面存储的字符比较少,仅仅存储了汉字和一些常用的外文,体积比较小。
  • UTF-8存储了世界上所有的字符。体积比较大。
  • 如果网站仅仅包含中文,推荐使用GB2312,因为体积更小,访问速度更快;如果网站除了中文以外,还包含了其他国家的语言,那么推荐使用UTF-8。

meta标签:

  • 作用:就是指定当前网页的字符集。

  • 注意点:

    • 在html的文件中,指定的字符集必须和保存html文件时的字符集保持一致,否则还是会出现乱码。
    • 仅仅指定字符集不一定能解决乱码问题,还需要保存文件的时候,文件保存时的字符集和指定的字符集保持一致,才能保证没有乱码问题。

三、标签的分类

分类:

  • 单标签:只有开始标签,没有结束标签。

  • 双标签:有开始标签和结束标签的。常见的双标签:htmlbody……

标签间的关系:

  • 并列关系(兄弟/平级):比如headbody标签就是并列关系。

  • 嵌套关系(父子/上下级):比如headtitle就是父子关系。

四、DTD文档声明

什么是DTD文档声明?

由于HTML有很多个版本规范,每个版本的规范之间又有一定的差异,所以为了让浏览器能够正确的编译、解析、渲染网页,我们需要在HTML文件第一行告诉浏览器,我们当前是用哪一个版本的规范来编写的,浏览器只要知道了我们是使用哪一个版本的规范编写之后,他就能正确的编译、解析、渲染网页。

DTD文档声明的格式

每一个不同版本的规范,都有不同的DTD文档声明。因为HTML5的DTD文档声明是向下兼容的,所以使用HTML5的DTD文档声明可以兼容渲染XHTML和HTML的其他版本。

格式:<!DOCTYPE html>或者<!doctype html>

DTD文档声明的注意点:

  • 任何一个标准的HTML网页,第一行一定是DTD文档声明,也就是说DTD文档声明必须写在HTML网页第一行。
  • DTD文档声明不区分大小写。
  • DTD文档声明不是一个标签。
  • 虽然DTD文档声明的作用是告诉浏览器,我们这个网页使用哪个版本资源编写,以便于方便浏览器解析和渲染,但是浏览器并不会完全依赖DTD文档声明,浏览器有一套自己的机制。故DTD文档声明不写网页也可以正常运行,但是根据规范,我们仍需在第一行进行声明。

其他DTD文档声明规范:

HTML5之前有2大种规范, 每种规范中又有3小种规范

大规范 小规范
HTML Strict (严格的)
HTML Transitional(过度的,普通的,宽松的)
HTML Frameset(带有框架的页面)
XHTML Strict (严格的)
XHTML Transitional(过度的,普通的,宽松的)
XHTML Frameset(带有框架的页面)
  • HTML的DTD文档声明和XHTML的DTD文档声明有何区别?

    • XHTML本身规定比如标签必须小写、必须严格闭合、必须使用引号引起属性等等, 而HTML会更加松散没有这么严格。
  • 什么是小规范?

    • Strict表示严格的, 这种模式里面的要求更为严格.这种严格主要体现在有一些用于修改文本样式的标签不能使用,例如font标签/u标签等

    • Transitional表示普通的, 这种模式是没有一些别的要求,例如可以使用font标签、u标签等

    • Frameset表示框架, 在框架的页面使用

常见的DTD文档声明:

<!-- HTML4.01 -->
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
"http://www.w3.org/TR/html4/loose.dtd">

<!-- XHTML 1.0 -->
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<!-- HTML5 -->
<!DOCTYPE html>

五、HTML、XHTML和HTML5的区别

  • HTML语法非常宽松容错性强。
  • XHTML更为严格,他要求标签必须小写,必须严格闭合,标题中的属性必须使用引号。
  • HTML5是HTML的下一个版本所以除了非常宽松容错性强以外,还增加许多新的特性。

六、.htm和.html的区别

DOS系统只允许后缀名有三位,但是后来Windows支持后缀名有四位,本质并无其他区别。

参考链接:

W3School官方文档:www.w3school.com.cn

【Promise.withResolvers】发现这个api还挺有用

Jym好😘,我是珑墨。

在 es 的异步编程世界中,Promise 已经成为处理异步操作的标准方式。然而,在某些场景下,传统的 Promise 构造函数模式显得不够灵活。Promise.withResolvers 是 ES2024(ES14)中引入的一个静态方法,它提供了一种更优雅的方式来创建 Promise,并同时获得其 resolve 和 reject 函数的引用。

look:

什么是 Promise.withResolvers

Promise.withResolvers 是一个静态方法,它返回一个对象,包含三个属性:

  • promise: 一个 Promise 对象
  • resolve: 用于解决(fulfill)该 Promise 的函数
  • reject: 用于拒绝(reject)该 Promise 的函数

基本语法

const { promise, resolve, reject } = Promise.withResolvers();

这个方法的核心优势在于:你可以在 Promise 外部控制其状态,这在许多场景下非常有用。


为什么 Promise.withResolvers挺实用?

先看传统 Promise 的局限性

Promise.withResolvers 出现之前,如果我们想要在 Promise 外部控制其状态,通常需要这样做:

let resolvePromise;
let rejectPromise;

const myPromise = new Promise((resolve, reject) => {
  resolvePromise = resolve;
  rejectPromise = reject;
});

// 现在可以在外部使用 resolvePromise 和 rejectPromise
setTimeout(() => {
  resolvePromise('成功!');
}, 1000);

这种方法虽然可行,但存在以下问题:

  1. 代码冗余:每次都需要创建临时变量,会导致一坨地雷
  2. 作用域污染:需要在外部作用域声明变量
  3. 不够优雅:代码结构不够清晰
  4. 容易出错:如果忘记赋值,会导致运行时错误

Promise.withResolvers解决了啥?

Promise.withResolvers 解决了上述所有问题:

const { promise, resolve, reject } = Promise.withResolvers();

// 简洁、清晰、安全
setTimeout(() => {
  resolve('成功!');
}, 1000);

语法和用法

基本语法

const { promise, resolve, reject } = Promise.withResolvers();

返回值

Promise.withResolvers() 返回一个普通对象,包含:

  • promise: 一个处于 pending 状态的 Promise 对象
  • resolve: 一个函数,调用时会将 promise 变为 fulfilled 状态
  • reject: 一个函数,调用时会将 promise 变为 rejected 状态

基本示例

示例 1:简单的延迟解析

const { promise, resolve } = Promise.withResolvers();

// 1 秒后解析 Promise
setTimeout(() => {
  resolve('数据加载完成');
}, 1000);

promise.then(value => {
  console.log(value); // 1 秒后输出: "数据加载完成"
});

示例 2:处理错误

const { promise, resolve, reject } = Promise.withResolvers();

// 模拟异步操作
setTimeout(() => {
  const success = Math.random() > 0.5;
  if (success) {
    resolve('操作成功');
  } else {
    reject(new Error('操作失败'));
  }
}, 1000);

promise
  .then(value => console.log(value))
  .catch(error => console.error(error));

示例 3:多次调用 resolve/reject 的行为

const { promise, resolve, reject } = Promise.withResolvers();

resolve('第一次');
resolve('第二次'); // 无效,Promise 状态已确定
reject(new Error('错误')); // 无效,Promise 状态已确定

promise.then(value => {
  console.log(value); // 输出: "第一次"
});

重要提示:一旦 Promise 被 resolve 或 reject,其状态就确定了,后续的 resolve 或 reject 调用将被忽略。


与传统方法的对比

场景 1:事件监听器中的 Promise

传统方法

function waitForClick() {
  let resolveClick;
  let rejectClick;
  
  const promise = new Promise((resolve, reject) => {
    resolveClick = resolve;
    rejectClick = reject;
  });
  
  const button = document.getElementById('myButton');
  const timeout = setTimeout(() => {
    button.removeEventListener('click', onClick);
    rejectClick(new Error('超时'));
  }, 5000);
  
  function onClick(event) {
    clearTimeout(timeout);
    button.removeEventListener('click', onClick);
    resolveClick(event);
  }
  
  button.addEventListener('click', onClick);
  
  return promise;
}

使用 Promise.withResolvers

function waitForClick() {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const button = document.getElementById('myButton');
  const timeout = setTimeout(() => {
    button.removeEventListener('click', onClick);
    reject(new Error('超时'));
  }, 5000);
  
  function onClick(event) {
    clearTimeout(timeout);
    button.removeEventListener('click', onClick);
    resolve(event);
  }
  
  button.addEventListener('click', onClick);
  
  return promise;
}

优势

  • 代码更简洁
  • 不需要在外部作用域声明变量
  • 结构更清晰

场景 2:流式数据处理

传统方法

function createStreamProcessor() {
  let resolveStream;
  let rejectStream;
  
  const promise = new Promise((resolve, reject) => {
    resolveStream = resolve;
    rejectStream = reject;
  });
  
  // 模拟流式处理
  const chunks = [];
  let isComplete = false;
  
  function processChunk(chunk) {
    if (isComplete) return;
    chunks.push(chunk);
    
    if (chunk.isLast) {
      isComplete = true;
      resolveStream(chunks);
    }
  }
  
  function handleError(error) {
    if (isComplete) return;
    isComplete = true;
    rejectStream(error);
  }
  
  return { promise, processChunk, handleError };
}

使用 Promise.withResolvers

function createStreamProcessor() {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const chunks = [];
  let isComplete = false;
  
  function processChunk(chunk) {
    if (isComplete) return;
    chunks.push(chunk);
    
    if (chunk.isLast) {
      isComplete = true;
      resolve(chunks);
    }
  }
  
  function handleError(error) {
    if (isComplete) return;
    isComplete = true;
    reject(error);
  }
  
  return { promise, processChunk, handleError };
}

实际应用场景

场景 1:用户交互等待

// 等待用户确认操作
function waitForUserConfirmation(message) {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const modal = document.createElement('div');
  modal.className = 'confirmation-modal';
  modal.innerHTML = `
    <p>${message}</p>
    <button class="confirm">确认</button>
    <button class="cancel">取消</button>
  `;
  
  modal.querySelector('.confirm').addEventListener('click', () => {
    if (modal.parentNode) {
      document.body.removeChild(modal);
    }
    resolve(true);
  });
  
  modal.querySelector('.cancel').addEventListener('click', () => {
    if (modal.parentNode) {
      document.body.removeChild(modal);
    }
    reject(new Error('用户取消'));
  });
  
  document.body.appendChild(modal);
  
  return promise;
}

// 使用
waitForUserConfirmation('确定要删除这个文件吗?')
  .then(() => console.log('用户确认'))
  .catch(() => console.log('用户取消'));

场景 2:WebSocket 消息等待

class WebSocketManager {
  constructor(url) {
    this.ws = new WebSocket(url);
    this.pendingRequests = new Map();
    this.requestId = 0;
    
    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      const { requestId, response, error } = data;
      
      const pending = this.pendingRequests.get(requestId);
      if (pending) {
        this.pendingRequests.delete(requestId);
        if (error) {
          pending.reject(new Error(error));
        } else {
          pending.resolve(response);
        }
      }
    };
  }
  
  sendRequest(message) {
    const { promise, resolve, reject } = Promise.withResolvers();
    const requestId = ++this.requestId;
    
    this.pendingRequests.set(requestId, { resolve, reject });
    
    this.ws.send(JSON.stringify({
      requestId,
      message
    }));
    
    // 设置超时
    setTimeout(() => {
      if (this.pendingRequests.has(requestId)) {
        this.pendingRequests.delete(requestId);
        reject(new Error('请求超时'));
      }
    }, 5000);
    
    return promise;
  }
}

// 使用
const wsManager = new WebSocketManager('ws://example.com');
wsManager.sendRequest('获取用户信息')
  .then(data => console.log('收到响应:', data))
  .catch(error => console.error('错误:', error));

场景 3:文件上传进度

function uploadFileWithProgress(file, url) {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const xhr = new XMLHttpRequest();
  const formData = new FormData();
  formData.append('file', file);
  
  xhr.upload.addEventListener('progress', (event) => {
    if (event.lengthComputable) {
      const percentComplete = (event.loaded / event.total) * 100;
      console.log(`上传进度: ${percentComplete.toFixed(2)}%`);
    }
  });
  
  xhr.addEventListener('load', () => {
    if (xhr.status === 200) {
      resolve(JSON.parse(xhr.responseText));
    } else {
      reject(new Error(`上传失败: ${xhr.status}`));
    }
  });
  
  xhr.addEventListener('error', () => {
    reject(new Error('网络错误'));
  });
  
  xhr.addEventListener('abort', () => {
    reject(new Error('上传已取消'));
  });
  
  xhr.open('POST', url);
  xhr.send(formData);
  
  // 返回 Promise 和取消函数
  return {
    promise,
    cancel: () => xhr.abort()
  };
}

// 使用
const { promise, cancel } = uploadFileWithProgress(file, '/api/upload');
promise
  .then(result => console.log('上传成功:', result))
  .catch(error => console.error('上传失败:', error));

场景 4:可取消的异步操作

function createCancellableOperation(operation) {
  const { promise, resolve, reject } = Promise.withResolvers();
  let cancelled = false;
  
  operation()
    .then(result => {
      if (!cancelled) {
        resolve(result);
      }
    })
    .catch(error => {
      if (!cancelled) {
        reject(error);
      }
    });
  
  return {
    promise,
    cancel: () => {
      cancelled = true;
      reject(new Error('操作已取消'));
    }
  };
}

// 使用
const { promise, cancel } = createCancellableOperation(
  () => fetch('/api/data').then(r => r.json())
);

// 3 秒后取消
setTimeout(() => cancel(), 3000);

promise
  .then(data => console.log('数据:', data))
  .catch(error => console.error('错误:', error));

场景 5:队列处理

class TaskQueue {
  constructor() {
    this.queue = [];
    this.processing = false;
  }
  
  add(task) {
    const { promise, resolve, reject } = Promise.withResolvers();
    
    this.queue.push({
      task,
      resolve,
      reject
    });
    
    this.process();
    
    return promise;
  }
  
  async process() {
    if (this.processing || this.queue.length === 0) {
      return;
    }
    
    this.processing = true;
    
    while (this.queue.length > 0) {
      const { task, resolve, reject } = this.queue.shift();
      
      try {
        const result = await task();
        resolve(result);
      } catch (error) {
        reject(error);
      }
    }
    
    this.processing = false;
  }
}

// 使用
const queue = new TaskQueue();

queue.add(() => fetch('/api/task1').then(r => r.json()))
  .then(result => console.log('任务1完成:', result));

queue.add(() => fetch('/api/task2').then(r => r.json()))
  .then(result => console.log('任务2完成:', result));

深入理解:工作原理

Promise.withResolvers 的实现原理

虽然 Promise.withResolvers 是原生 API,但我们可以通过理解其等价实现来加深理解:

// Promise.withResolvers 的等价实现
function withResolvers() {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

内存和性能考虑

Promise.withResolvers 的实现是高度优化的。它:

  1. 避免闭包开销:原生实现避免了额外的闭包创建
  2. 内存效率:直接返回引用,无需额外的变量存储
  3. 性能优化:浏览器引擎级别的优化

与 Promise 构造函数的关系

// 这两种方式是等价的(在功能上)
const { promise, resolve, reject } = Promise.withResolvers();

// 等价于
let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

Promise.withResolvers 提供了:

  • 更简洁的语法
  • 更好的可读性
  • 标准化的 API

浏览器兼容性和 Polyfill

浏览器支持

Promise.withResolvers 是 ES2024 的特性,目前(2024年)的支持情况:

  • ✅ Chrome 119+
  • ✅ Firefox 121+
  • ✅ Safari 17.4+
  • ✅ Node.js 22.0.0+
  • ❌ 旧版本浏览器不支持

Polyfill 实现

如果需要在不支持的浏览器中使用,可以使用以下 polyfill:

if (!Promise.withResolvers) {
  Promise.withResolvers = function() {
    let resolve, reject;
    const promise = new Promise((res, rej) => {
      resolve = res;
      reject = rej;
    });
    return { promise, resolve, reject };
  };
}

使用 Polyfill 的完整示例

// 在项目入口文件添加
(function() {
  if (typeof Promise.withResolvers !== 'function') {
    Promise.withResolvers = function() {
      let resolve, reject;
      const promise = new Promise((res, rej) => {
        resolve = res;
        reject = rej;
      });
      return { promise, resolve, reject };
    };
  }
})();

// 现在可以在任何地方使用
const { promise, resolve, reject } = Promise.withResolvers();

使用 core-js

如果你使用 core-js,可以导入相应的 polyfill:

import 'core-js/actual/promise/with-resolvers';

最佳实践和注意

1. 避免重复调用 resolve/reject

const { promise, resolve, reject } = Promise.withResolvers();

resolve('第一次');
resolve('第二次'); // 无效,但不会报错

// 最佳实践:添加状态检查
let isResolved = false;
function safeResolve(value) {
  if (!isResolved) {
    isResolved = true;
    resolve(value);
  }
}

2. 处理错误情况

const { promise, resolve, reject } = Promise.withResolvers();

try {
  // 某些可能抛出错误的操作
  const result = riskyOperation();
  resolve(result);
} catch (error) {
  reject(error);
}

3. 清理资源

function createResourceManager() {
  const { promise, resolve: originalResolve, reject: originalReject } = Promise.withResolvers();
  const resources = [];
  
  function cleanup() {
    resources.forEach(resource => resource.cleanup());
  }
  
  // 创建包装函数,确保在 resolve 或 reject 时清理资源
  const resolve = (value) => {
    cleanup();
    originalResolve(value);
  };
  
  const reject = (error) => {
    cleanup();
    originalReject(error);
  };
  
  return { promise, resolve, reject };
}

4. 类型安全(TypeScript)

在 TypeScript 中,Promise.withResolvers 的类型定义:

interface PromiseWithResolvers<T> {
  promise: Promise<T>;
  resolve: (value: T | PromiseLike<T>) => void;
  reject: (reason?: any) => void;
}

// 使用
const { promise, resolve, reject }: PromiseWithResolvers<string> = 
  Promise.withResolvers<string>();

5. 避免内存泄漏

// 不好的做法:持有大量未完成的 Promise,没有清理机制
const pendingPromises = new Map();

function createRequest(id) {
  const { promise, resolve } = Promise.withResolvers();
  pendingPromises.set(id, { promise, resolve });
  return promise;
  // 问题:如果 Promise 永远不会 resolve,会一直占用内存
}

// 好的做法:设置超时和清理机制
const pendingPromises = new Map(); // 在实际应用中,这应该是类或模块级别的变量

function createRequestWithTimeout(id, timeout = 5000) {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const timeoutId = setTimeout(() => {
    if (pendingPromises.has(id)) {
      pendingPromises.delete(id);
      reject(new Error('请求超时'));
    }
  }, timeout);
  
  pendingPromises.set(id, {
    promise,
    resolve: (value) => {
      clearTimeout(timeoutId);
      pendingPromises.delete(id);
      resolve(value);
    },
    reject: (error) => {
      clearTimeout(timeoutId);
      pendingPromises.delete(id);
      reject(error);
    }
  });
  
  return promise;
}

6. 与 async/await 结合使用

async function processWithResolvers() {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  // 在异步操作中控制 Promise
  setTimeout(() => {
    resolve('完成');
  }, 1000);
  
  try {
    const result = await promise;
    console.log('结果:', result);
  } catch (error) {
    console.error('错误:', error);
  }
}

总结下

Promise.withResolvers 是 es 异步编程的一个重要补充,它解决了在 Promise 外部控制其状态的需求。通过本文的详细讲解,我们了解到:

核心要点

  1. 简洁性:提供了更优雅的 API 来创建可外部控制的 Promise
  2. 实用性:在事件处理、流式处理、WebSocket 等场景中非常有用
  3. 标准化:作为 ES2024 标准的一部分,提供了统一的解决方案

适用场景

  • ✅ 需要在 Promise 外部控制其状态
  • ✅ 事件驱动的异步操作
  • ✅ 流式数据处理
  • ✅ 可取消的异步操作
  • ✅ 队列和任务管理

注意

  • ⚠️ 浏览器兼容性(需要 polyfill 或现代浏览器)
  • ⚠️ 尤其得避免重复调用 resolve/reject
  • ⚠️ 注意资源清理和内存管理

参考资料


原来Webpack在大厂中这样进行性能优化!

性能优化方案

优化分类:

  1. 优化打包后的结果(分包、减小包体积、CDN 服务器) ==> 更重要
  2. 优化打包速度(exclude、cache-loader)

代码分割(Code Splitting)

一、主要目的

  • 减少首屏加载体积:避免一次性加载全部代码
  • 利用浏览器缓存:第三方库(如 React、Lodash)变动少,可单独缓存
  • 按需加载/并行请求:路由、组件、功能模块只在需要时加载(按需加载或者并行加载文件,而不是一次性加载所有代码)

二、三种主要的代码分割方式

1. 入口起点(Entry Points)手动分割

通过配置多个 entry 实现。

// webpack.config.js
module.exports = {
  entry: {
    main: './src/main.js',
    vendor: './src/vendor.js', // 手动引入公共依赖
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },
};

缺点:

  • 无法自动提取公共依赖(比如 mainvendor 都用了 Lodash,会重复打包)
  • 维护成本高

上面写的是通用配置,但我们在公司一般会分别配置开发和生产环境的配置。大多数项目中,entry 在 dev 和 prod 基本一致,无需差异化配置。差异主要体现在 output 和其他插件/加载器行为上。

// webpack.config.prod.js
module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: 'js/[name].[contenthash:8].js', // 生产环境用 [contenthash](而非 [hash] 或 [chunkhash]),确保精准缓存
    chunkFilename: 'js/[name].[contenthash:8].js',
    path: path.resolve(__dirname, 'dist'), // 必须输出到磁盘用于部署
    publicPath: '/static/', // 用于 CDN 或静态资源服务器
    clean: true, // 清理旧文件
  },
};
// webpack.config.dev.js
module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'js/[name].js',  // 开发环境若加 hash,每次保存都会生成新文件,可能干扰热更新或者devtools混乱
    chunkFilename: 'js/[name].js',
    path: path.resolve(__dirname, 'dist'), // 通常仍写 dist,但实际不写入磁盘(webpack-dev-server 默认内存存储),节省IO,提高编译速度
    publicPath: '/', // 与 devServer 一致
    // clean: false (默认)
  },
};
2. SplitChunksPlugin(推荐!自动代码分割)

自动提取公共模块和第三方库。webpack 已默认安装相关插件。

默认行为(仅在 production 模式生效):

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'async', // 默认只分割异步模块
    },
  },
};

常用配置:

// webpack.config.prod.js
optimization: { 
  // 自动分割
  // https://twitter.com/wSokra/status/969633336732905474
  // https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366    
  splitChunks: {
    // chunks: async | initial(对通过的代码处理) | all(同步+异步都处理)
    chunks: 'initial',
    minSize: 20000, // 模块大于 20KB 才分割(Webpack 5 默认值)
    maxSize: 244000, // 单个 chunk 最大不超过 244KB(可选)
    cacheGroups: { // 拆分分组规则
      // 提取 node_modules 中的第三方库
      vendor: {
        test: /[\\/]node_modules[\\/]/, // 匹配符合规则的包
        name: 'vendors', // 拆分包的name 属性
        chunks: 'initial',
        priority: 10, // 优先级高于 default
        enforce: true,
      },
      // 提取多个 chunk 公共代码
      default: {
        minChunks: 2, // 至少被 2 个 chunk 引用
        priority: -20,
        reuseExistingChunk: true, // 复用已存在的 chunk
        maxInitialRequests: 5, // 默认限制太小,无法显示效果
        minSize: 0, // 这个示例太小,无法创建公共块
      },
    },
  },
  // runtime相关的代码是否抽取到一个单独的chunk中,比如import动态加载的代码就是通过runtime 代码完成的
  // 抽离出来利于浏览器缓存,比如修改了业务代码,那么runtime加载的chunk无需重新加载
  runtimeChunk: true,
}

在开发环境下 splitChunks: false, 即可。

生产环境:

  • 生成 vendors.xxxx.js(第三方库)
  • 生成 default.xxxx.js(项目公共代码)
  • 主 bundle 体积显著减小
3. 动态导入(Dynamic Imports)—— 按需加载

使用 import() 语法(符合 ES Module 规范),实现懒加载。

Webpack 会为每个 import() 创建一个独立的 chunk,并自动处理加载逻辑。

三、魔法注释(Magic Comments)—— 控制 chunk 名称等行为

// 自定义 chunk 名称(便于调试和长期缓存)
const module = await import(
  /* webpackChunkName: "my-module" */
  './my-module'
);

其他常见注释:

  • /* webpackPrefetch: true */:空闲时预加载(提升后续访问速度)
  • /* webpackPreload: true */:当前导航关键资源预加载(慎用)
// 预加载“下一个可能访问”的页面
import(
  /* webpackChunkName: "login-page" */
  /* webpackPrefetch: true */
  './LoginPage'
);

详细比较:

  • preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。
  • preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。

CND

内容分发网络(Content Delivery Network 或 Content Distribution Network)

它是指通过相互连接的网络系统,利用最靠近每个用户的服务器;更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户;提供高性能、可扩展性及低成本的网络内容传递。

工作中,我们使用 CDN 的主要方式有两种:

  1. 打包所有静态资源,放到 CDN 服务器,用户所有资源都是通过 CND 服务器加载的
    1. 通过 output.publicPath 改为自己的的 CDN 服务器,打包后就可以从上面获取资源
    2. 如果是自己的话,一般会从阿里、腾讯等买 CDN 服务器。
  2. 一些第三方资源放在 CDN 服务器上
    1. 一些库/框架会将打包后的源码放到一些免费的 CDN 上,比如 JSDeliver、bootcdn 等
    2. 这样的话,打包的时候就不需要对这些库进行打包,直接使用 CDN 服务器中的源码(通过 externals 配置排除某些包)

CSS 提取

将 css 提取到一个独立的 css 文件。

npm install mini-css-extract-plugin -D
// webpack.config.prod.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  mode: 'production',
  module: {
    rules: [
      // 生产环境:使用 MiniCssExtractPlugin.loader
      {
        test: /\.css$/i,
        use: [
          MiniCssExtractPlugin.loader, // 替换 style-loader
          'css-loader',
          'postcss-loader',
        ],
      },
      {
        test: /\.s[ac]ss$/i,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'postcss-loader',
          'sass-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:8].css',
      chunkFilename: 'css/[name].[contenthash:8].css',
    }),
  ],
};

Terser 代码压缩

Terser 可以帮助我们压缩、丑化(混淆)我们的代码,让我们的 bundle 变得更小。

Terser 是一个单独的工具,拥有非常多的配置,这里我们只讲工作中如何使用,以一个工程的角度学习这个工具。

真实开发中,我们不需要手动的通过 terser 来处理我们的代码。webpack 中 minimizer 属性,在 production 模式下,默认就是使用的 TerserPlugin 来处理我们代码的。我们也可以手动创建 TerserPlugin 实例覆盖默认配置。

// webpack.prod.js 
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  mode: 'production',
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        parallel: true, // 多核 CPU 并行压缩,默认为true,并发数默认为os.cpus().length-1
        terserOptions: {
          compress: { // 压缩配置
            drop_console: true,
            drop_debugger: true, // 删除debugger
            pure_funcs: ['console.info', 'console.debug'], // 只删除特定的函数调用
          },
          mangle: true, // 是否丑化代码(变量)
          toplevel: true, // 顶层变量是否进行转换
          keep_classnames: true, // 是否保留类的名称
          keep_fnames: true, // 是否保留函数的名称
          format: {
            comments: /@license|@preserve/i, // 保留含 license/preserve 的注释(某些开源库要求保留版权注释)
          },
        },
        extractComments: true, // 默认为true会将注释提取到一个单独的文件(这里用于保留版权注释),false表示不希望保留注释
        sourceMap: true,   // 需要 webpack 配置 devtool 生成 source map
      }),
    ],
  },
};

不要在开发环境启动 terser,因为:

  • 压缩会拖慢构建速度
  • 混淆后的代码无法调试
  • hmr 和 source-map 会失效

CSS 压缩

CSS 压缩通常是去除无用的空格等,因为很难去修改选择器、属性的名称、值等;我们一般使用插件 css-minimizer-webpack-plugin;他的底层是使用 cssnano 工具来优化、压缩 CSS(也可以单独使用)。

使用也是非常简单:

minimizer: [
  new CssMiniMizerPlugin()({
    parallel: true
  })
]

Tree Shaking 摇树

详情见之前文章:《简单聊聊 webpack 摇树的原理》

HTTP 压缩

HTTP 压缩(HTTP Compression)是一种 在服务器和客户端之间传输数据时减小响应体体积 的技术,通过压缩 HTML、CSS、JavaScript、JSON 等文本资源,显著提升网页加载速度、节省带宽。

一、主流压缩算法

算法 兼容性 压缩率 速度 说明
gzip ✅ 几乎所有浏览器(IE6+) 最广泛使用,Web 标准推荐
Brotli (br) ✅ 现代浏览器(Chrome 49+, Firefox 44+, Safari 11+) ⭐ 更高(比 gzip 高 15%~30%) 较慢(压缩),解压快 推荐用于静态资源
deflate ⚠️ 支持不一致(部分浏览器实现有问题) 已基本淘汰,不推荐使用

二、工作原理(协商压缩)

HTTP 压缩基于 请求头 ↔ 响应头协商机制:

  1. 客户端请求(表明支持的压缩格式)
GET /app.js HTTP/1.1
Host: example.com
Accept-Encoding: gzip, deflate, br // 客户端支持的压缩算法列表
  1. 服务端响应(返回压缩后的内容)
HTTP/1.1 200 OK
Content-Encoding: br  // 服务端使用的压缩算法
Content-Type: application/javascript
Content-Length: 102400  // 注意:这是压缩后的大小!

...(二进制压缩数据)...
  • 浏览器自动解压,开发者无感知

三、如何启用 HTTP 压缩?

我们一般会优先使用 Nginx 配置做压缩(生产环境最常用),这样就无需应用层处理。

除此之外,我们还会进行预压缩 + 静态文件服务,这主要就是 webpack 要做的工作。

在构建阶段(Webpack/Vite)就生成 .gz.br 文件,部署到 CDN 或静态服务器。

// webpack.config.js
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
  plugins: [
    // 生成 .gz 文件
    new CompressionPlugin({
      algorithm: 'gzip',
      test: /\.(js|css|html|svg)$/,
      threshold: 8192, // 大于 8KB 才压缩
      minRatio: 0.8,  // 至少的压缩比例
    }),
    // 生成 .br 文件(需额外安装)
    new CompressionPlugin({
      algorithm: 'brotliCompress',
      test: /\.(js|css|html|svg)$/,
      compressionOptions: { level: 11 }, // 最高压缩率
    }),
  ],
};

Nginx 配合预压缩文件:

gzip_static on;    # 优先返回 .gz 文件
brotli_static on;  # 优先返回 .br 文件

打包分析

打包时间分析

我们需要借助一个插件 speed-measure-webpack-plugin,即可看到每个 loader、每个 plugin 消耗的打包时间。

// webpack.config.js
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');

const smp = new SpeedMeasurePlugin();

const config = {
  // 你的正常 Webpack 配置
  entry: './src/index.js',
  module: { /* ... */ },
  plugins: [ /* ... */ ],
};

// 仅当环境变量 ANALYZE_SPEED=1 时包裹配置
module.exports = process.env.ANALYZE_SPEED ? smp.wrap(config) : config;

打包文件分析

方法一、生成 stats.json 文件
"build:stats": "w--config ./config/webpack.common.js --env production --profile --json=stats.json",

运行 npm run build:stats,可以获取到一个 stats.json 文件,然后放到到 webpack.github.com/analyse 进行分析。

方法二、webpack-bundle-analyzer

更常用的方式是使用 webpack-bundle-analyzer 插件分析。

// webpack.prod.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  mode: 'production',
  plugins: [
    // 其他插件...
    new BundleAnalyzerPlugin({
      analyzerMode: 'static', // 生成静态 HTML 报告(默认)
      openAnalyzer: false,    // 不自动打开浏览器
      reportFilename: 'bundle-report.html',
      generateStatsFile: true, // 可选:同时生成 stats.json
      statsFilename: 'stats.json',
    }),
  ],
};

MCP理论和实战,然后做个MCP脚手架吧

引言: 本文介绍了目前MCP Server的开发方式和原理,包括streamable HTTP和STDIO两种。并提供了一个npm脚手架工具帮你创建项目,每个模板项目都是可运行的。

streamable HTTP

原理分析

抓包「握手」

MCP Client总共发了三次请求,MCP Server响应2次。实际的握手流程是4次握手,第5次请求是为了通知后续的信息(比如进度,日志等。 目前规范实现来看,第5次握手不影响正常功能)

使用wiresshark抓包结果如下:

image.png

image.png

从官网的「initialization」流程来看,也就是4次(第5次未来应该会被普遍实现)

image.png

第1次 Post请求,initialize 方法

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "sampling": {},
      "elicitation": {},
      "roots": {
        "listChanged": true
      }
    },
    "clientInfo": {
      "name": "inspector-client",
      "version": "0.17.2"
    }
  }
}

第2次 :200 OK,响应体如下

{
  "result": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "tools": {
        "listChanged": true
      }
    },
    "serverInfo": {
      "name": "weather",
      "version": "0.0.1"
    }
  },
  "jsonrpc": "2.0",
  "id": 0
}

第3次 :Post请求,notifications/initialized方法

{"jsonrpc":"2.0","method":"notifications/initialized"}

第4次 :202 Accepted,无响应体

第5次 :Get请求,此时要求服务端一定是SSE传输了-accept: text/event-stream

GET /mcp HTTP/1.1
accept: text/event-stream

总结「握手」流程

  1. POST /mcp (initialize)

    • 客户端:你好,我是 Inspector Client,我想初始化。
    • 服务器:收到,这是我的能力列表(200 OK)。
    • 状态:JSON-RPC 会话开始。
  2. POST /mcp (notifications/initialized)

    • 客户端:我已经收到你的能力了,初始化完成。
    • 服务器:收到 (202 Accepted)。
    • 状态:逻辑握手完成。
  3. GET /mcp (Header: accept: text/event-stream)

    • 目的:客户端现在试图建立长连接通道,以便在未来能收到服务器发来的通知(比如 notifications/message 或 roots/listChanged)。如果没有这个通道,服务器就变成了“哑巴”,无法主动联系客户端。

后续通信

tools/list (列出工具)

client->server 请求

请求头:

POST /mcp HTTP/1.1
accept: application/json, text/event-stream
accept-encoding: gzip, deflate, br
content-length: 85
content-type: application/json
mcp-protocol-version: 2025-06-18
user-agent: node-fetch
Host: localhost:3000
Connection: keep-alive

请求数据:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {
    "_meta": {
      "progressToken": 1
    }
  }
}

P.S. params中的progressToken是可以用于后续的进度通知的(通过SSE)

server->client 响应

响应头:

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json
Date: Thu, 27 Nov 2025 11:52:31 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

响应体:

{
  "result": {
    "tools": [
      {
        "name": "get_weather_now",
        "title": "Get Weather Now",
        "description": "Get current weather for a location (city name)",
        "inputSchema": {
          "$schema": "http://json-schema.org/draft-07/schema#",
          "type": "object",
          "properties": {
            "location": {
              "description": "Location name or city (e.g. beijing, shanghai, new york, tokyo)",
              "type": "string"
            }
          },
          "required": [
            "location"
          ]
        }
      }
    ]
  },
  "jsonrpc": "2.0",
  "id": 1
}

这里列出一个工具:

  • get_weather_now,我们自己定义/注册的工具。我们可以拿到它的titledescriptioninputSchema,这些语义信息可以帮助LLM理解这个工具。
tools/call (调用tool)

这里通过 mcp inspector 工具调用了get_weather_now,请求体如下:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "_meta": {
      "progressToken": 2
    },
    "name": "get_weather_now",
    "arguments": {
      "location": "北京"
    }
  }
}

响应体:

{
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Weather for 北京, CN:\nCondition: 晴\nTemperature: 3°C\nLast Update: 2025-11-27T19:50:14+08:00"
      }
    ]
  },
  "jsonrpc": "2.0",
  "id": 2
}
方法小总结

上面我们列出了两种常见的方法

  • tools/list。MCP Client在向LLM发请求携带列出的tool,LLM会告诉客户端调用的tool name,然后由MCP client来触发tool调用。
  • tools/call。MCP Client告诉MCP Server 调用哪个tool。

可以结合官网的这张示意图,调用tool就是一次request/response。如果是长任务,可以通过_meta.progressToken作为关联,通过SSE持续通知进度(还记得「握手」流程的第5次握手吗)

image.png

代码实战 - 天气工具

准备天气API

这里我使用了心知天气的API,然后自己封装一个node API。 src/core/seniverse.ts

import * as crypto from 'node:crypto';
import * as querystring from 'node:querystring';
/**
 * 查询天气接口
 */
const API_URL = 'https://api.seniverse.com/v3/';
export class SeniverseApi {
    publicKey;
    secretKey;
    constructor(publicKey, secretKey) {
        this.publicKey = publicKey;
        this.secretKey = secretKey;
    }
    async getWeatherNow(location) {
        const params = {
            ts: Math.floor(Date.now() / 1000), // Current timestamp (seconds)
            ttl: 300, // Expiration time
            public_key: this.publicKey,
            location: location
        };
        // Step 2: Sort keys and construct the string for signature
        // "key=value" joined by "&", sorted by key
        const sortedKeys = Object.keys(params).sort();
        const str = sortedKeys.map(key => `${key}=${params[key]}`).join('&');
        // Step 3: HMAC-SHA1 signature
        const signature = crypto
            .createHmac('sha1', this.secretKey)
            .update(str)
            .digest('base64');
        // Step 4 & 5: Add sig to params and encode for URL
        // querystring.encode will handle URL encoding of the signature and other params
        params.sig = signature;
        const queryString = querystring.encode(params);
        const url = `${API_URL}weather/now.json?${queryString}`;
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            return await response.json();
        }
        catch (error) {
            console.error("Error making Seniverse request:", error);
            return null;
        }
    }
}

src/core/index.ts

import { SeniverseApi } from './seniverse.js';

export const seniverseApi = new SeniverseApi(
  process.env.SENIVERSE_PUBLIC_KEY || '',
  process.env.SENIVERSE_SECRET_KEY || '',
);

搭建streamable HTTP类型的MCP

1.使用express提供后端服务,然后设置/mcp endpoint(一般来说MCP client默认就是访问这个endpoint). 2.在MCP协议中,握手/工具调用等都是通过这个一个endpoint来完成的。

3.封装逻辑 封装了一个MyServer

  • run方法启动HTTP服务
  • init方法注册工具

4.核心是McpServerStreamableHTTPServerTransport两个API

  • McpServer: 负责注册tool.
  • StreamableHTTPServerTransport: 接管了/mcp endpoint的通信逻辑
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { z } from "zod";
import "dotenv/config";
import { seniverseApi } from "./core/index.js";

export class MyServer {
  private mcpServer: McpServer;
  private app: express.Express
  constructor() {
    this.mcpServer = new McpServer({
      name: "weather",
      version: "0.0.1",
    });

    // Set up Express and HTTP transport
    this.app = express();
    this.app.use(express.json());

    this.app.use('/mcp', async (req: express.Request, res: express.Response) => {
        // Create a new transport for each request to prevent request ID collisions
        const transport = new StreamableHTTPServerTransport({
            sessionIdGenerator: undefined,
            enableJsonResponse: true
        });

        res.on('close', () => {
            transport.close();
        });

        await this.mcpServer.connect(transport);
        await transport.handleRequest(req, res, req.body);
    });

  }

  /**
   * 在端口运行Server, 通过HTTP stream传输数据
   */
  async run(): Promise<void> {
    const port = parseInt(process.env.PORT || '3000');
    this.app.listen(port, () => {
        console.log(`Demo MCP Server running on http://localhost:${port}/mcp`);
    }).on('error', error => {
        console.error('Server error:', error);
        process.exit(1);
    });
    
  }

  /**
   * 初始化,注册工具
   */
  async init(): Promise<void> {
    // Register weather tool
    this.mcpServer.registerTool(
      "get_weather_now",
      {
        title: "Get Weather Now",
        description: "Get current weather for a location (city name)",
        inputSchema: {
          location: z.string().describe("Location name or city (e.g. beijing, shanghai, new york, tokyo)")
        }
      },
      async ({ location }) => {
        
        const weatherData = await seniverseApi.getWeatherNow(location);
        if (!weatherData || !weatherData.results || weatherData.results.length === 0) {
          return {
            content: [
              {
                type: "text",
                text: `Failed to retrieve weather data for location: ${location}. Please check the location name and try again.`,
              },
            ],
          };
        }

        const result = weatherData.results[0];
        const weatherText = `Weather for ${result.location.name}, ${result.location.country}:\n` +
                            `Condition: ${result.now.text}\n` +
                            `Temperature: ${result.now.temperature}°C\n` +
                            `Last Update: ${result.last_update}`;
        return {
          content: [
            {
              type: "text",
              text: weatherText,
            },
          ],
        };
      },
    );
  }
}

效果如下:

image.png

注意左侧侧边栏:

  • Transport Type选择Streamable HTTP
  • URL 填写你的express 服务地址和endpoint。

stdio

原理分析

我在项目中,通过监听process.stdin,查看通信Message

// 监听 stdin 输入,可以在inspector面板的"notifications/message"中看到(作为debug用)
    process.stdin.on("data", async (data) => {
      const input = data.toString().trim();
      console.error(input);
    });

通过mcp-inspector工具就可以观察到通信信息了,往下看👁

tools/list

image.png

tools/call

image.png

结合官网的stdio通信原理图

image.png

可以总结如下:

  • 连接一个stdio MCP服务,不同于streamable HTTP MCP服务需要进行「握手」,只要开启一个子进程(subprocess),就表示连接成功。
  • 后续的通信的信息格式遵循json-rpc:2.0,通过读写process.stdinprocess.stdout完成通信。

代码实战 - 统计文件数

比较简单,可以参考我的这篇博客 Node写MCP入门教程,基于StdioServerTransport实现的统计目录下文件夹的MCP Server,并且介绍了mcp inspector的调试和Trae安装使用。

创建MCP项目的脚手架

每次写个新MCP Server都要搭建项目模板,这种重复的工作当然该做成工具辣! 我自己写了一个create-mcp脚手架 Githubcreate-mcp cli工具已经发布在npm上了,可以npm安装使用。

cli 原理

1.脚手架原理,首先准备两个模板项目

  • template-stdio 模板
  • template-streamable 模板

2.然后用Node写一个cli工具,使用了以下依赖,通过命令行交互的方式创建项目

pnpm i minimist prompts fs-extra chalk

3.根据你选择的项目名称和模板,帮你拷贝模板,修改为你的「项目名称」

觉得这个cli项目不错的话,给个免费的star吧~ 👉 Github

使用 cli

使用@caikengren-cli/create-mcp创建项目

npx @caikengren-cli/create-mcp

image.png

然后依次分别运行下面两个命令

# 编译ts/运行node
pnpm dev

# 打开 mcp-inspector工具调试
pnpm inspect

参考

mcp官网
mcp中文网
mcp: typescript-sdk

FinClip助力银行整合多个App,打造一站式超级应用

近日,“银行APP关停潮”引发关注,在银行业数字化转型的深水区,早期"一个业务一个App"的发展模式已不再适用当下。专家提出,银行要优化移动生态,集中资源升级主App,实现“一站式”服务,强化客户分层与个性化服务。

由于用户需求日益多元化、场景化,银行App亟需从"金融工具"向"金融+本地生活"演进,某银行前瞻性地启动"超级App"战略,与FinClip展开深度合作。

项目背景

该银行拥有庞大的客户基础,服务数亿个人客户和一千多万公司客户,员工数十万名,业务覆盖公司金融、个人金融及资金资管等领域。在数字化浪潮中,该行始终走在创新前沿,但其庞杂的IT系统与众多独立App,也面临着协同效率、运营成本、用户体验待优化等挑战。

1、如何打造超级App,满足多网点、多地域消费者,千人千面的需求?

2、如何优化前端框架,以更敏捷的方式保障业务部门的需求上线? 

3、《个保法》背景下,银行如何有效保障App数据安全与用户隐私?

解决方案:携手FinClip,打造银行超级APP

基于凡泰极客服务金融机构的深厚经验,该行携手 FinClip,依托超级应用智能平台技术,对旗下数十个App进行了梳理与重组,解决移动端整合的核心痛点。最终形成了定位清晰、协同发展的三大主力App,全面覆盖手机银行、信贷与生活服务等,不仅在银行核心业务基础上,极大地拓展了生态服务,也显著提升了客户体验。

图片

►【手机银行App】统一入口,打造核心金融阵地

作为该行的统一移动服务入口,手机银行App全面整合现金、储蓄、转账、理财、投资等核心金融工具,成为用户管理个人资产的核心平台。同时,通过构建用户会员成长体系,如积分、等级与权益的动态关联,有效提升用户活跃度。

►【信贷服务App】专注普惠金融,引入数字人智能导览

该APP专注服务对公企业主及有信贷需求的个人用户。针对客户时间紧、产品理解难的核心痛点,通过FinClip引入“数字人”业务导览,并将核心贷款功能前置首页,极大降低用户理解与操作门槛,提升业务效率,真正实现“让信贷办理更简单”。

►【生活服务App】生活消费中心,构建金融消费场景

FinClip超级应用智能平台,提供了开放的生态架构与统一的开发标准,降低第三方服务入驻门槛。助力银行引入海量外部生活服务小程序,如美食、外卖、电影、出行等,将金融能力(支付、优惠、分期)无缝嵌入到用户的日常消费行为中,形成了"生活-支付-金融"的完美闭环。 

图片

新架构的整合,避免了单一超级App的臃肿,又解决了App分散导致的用户体验割裂,助力银行服务更精准。

三大技术赋能:双端敏捷开发,全域生态联动

► 快速响应业务需求,提升银行运营能力

通过集成FinClip SDK,银行App具备了小程序运行能力,各类服务应用和营销活动可实现快速上架与下架。同时,利用灰度发布功能,可以实现针对不同客群的精准推送。

例如,在利率调整政策发布后,银行可通过SDK灰度发布能力,快速上线利率查询功能。小程序技术的松耦合特性,帮助该行更快实现IT开发、功能上线,降低运营风险,让响应更敏捷。

图片

► 增强社交连接,助推银行业务拓展

FinClip 兼容小程序语法,开发者为微信、支付宝开发的小程序,经过简单的适配,甚至无需改造即可在该行App中运行。助力行方将原有微信小程序生态平移至银行APP,同时提升社交平台业务拓展能力,实现公域流量到私域的转化。

例如,在信用卡开卡营销活动中,行方将微信生态中原有的“推荐有礼”小程序一键平移至自有App,老客户在App内可直接将活动分享至微信好友。好友点击链接进入微信小程序完成申请,流程数据实时同步回App,助力行方高效构建“App—微信—App”的社交裂变闭环,实现私域流量的低成本转化与沉淀。

图片

► 构建安全可靠的"沙箱环境",全面满足监管要求

FinClip为小程序运行提供了安全的沙箱环境,确保第三方服务代码在隔离环境中运行,无法威胁到银行主体App的安全。

在《个人信息保护法》等法规背景下,FinClip提供了完善的隐私合规支持,帮助该行对小程序的数据采集、使用行为进行监控和管理,确保用户隐私数据不被滥用,全面满足监管要求。

图片

四大价值,助力银行业务、服务、合规升级

目前,三大超级App已成为该行移动端服务的核心载体,有效驱动了业务创新加速、用户活跃度提升与生态能力增强,为数字化转型注入了持续动能。

提升用户活跃与留存:通过场景融合与服务整合,月活跃用户超千万,小程序用户数环比增长20%,用户满意度和粘性显著提升。

增强业务敏捷:业务需求平均上线周期缩短70%以上,政策响应速度快人一步,市场竞争力大幅增强。

降低运营成本:生态引入成本降低60%-80%,现有小程序生态迁移成本近乎为零,资源利用效率显著提升。 

保障安全合规:建立完善的数据安全防护体系,实现业务创新与风险控制的平衡,为可持续发展奠定基础。

未来,“金融+场景”融合模式将不断深化。FinClip将持续携手金融机构,依托云原生、中台化、组件化的敏捷技术架构,结合跨生态、高可用、可扩展的场景服务能力,以及全链路、可审计、强管控的安全合规体系,共建更开放、更智能、更合规的数字金融新生态。

📩 联系我们,欢迎来FinClip官网免费注册体验。

小程序如何一键生成鸿蒙APP?FinClip助力企业快速布局Harmony OS生态

随着华为鸿蒙生态的加速完善,搭载Harmony OS的设备数量已突破数亿,鸿蒙系统作为第三大移动操作系统的地位已然确立。对企业而言,这既是巨大的增量市场,也意味着新的挑战——如何以最低成本、最高效率快速布局鸿蒙生态?

企业布局鸿蒙生态的三大痛点

尽管鸿蒙生态机遇明确,但企业要想从0开始构建原生应用,仍面临重重挑战:

1、多端开发成本高:为鸿蒙生态单独组建开发团队,并持续维护迭代,所需的人力、时间与资金投入,让许多企业难以承担。

 

2、上线周期长:按照常规开发流程,一个功能完整的App从开发到上架至少需要数月时间,很可能错过市场最佳窗口期。

 

3、技术门槛高:HarmonyOS 6不再兼容安卓应用,企业需要适配全新的技术栈,学习成本不容忽视。

FinClip解决方案:一键将小程序转为鸿蒙APP

针对上述痛点,FinClip超级应用智能平台提供了一条高效路径:借助小程序容器技术,将企业已有的、现成的小程序,直接封装、迁移,成为一个可以在鸿蒙上独立运行的APP。 

图片

这种方式最大的好处就是效率和成本。 

它几乎完整复用了,企业过去在小程序上的所有投入和积累,不需要重复造轮子。毫不夸张地说,可能别人招聘鸿蒙工程师的流程还没走完,你的APP已经完成了上架!

如某全屋智能APP,包含设备控制、AI对话、商城购物等数十个页面,常规开发需数月,通过FinClip仅用2天即完成鸿蒙适配。 

某航司APP,支持购票、选座、退改签等完整功能,3天内便从小程序转化为体验流畅的鸿蒙原生应用。 

这些应用与它们已有的微信小程序版本在界面和交互上高度一致,实现了“一次开发,多端部署” 的理想状态。

图片

FinClip的技术优势,不止于“转换”

FinClip不仅解决“有无问题”,更确保企业获得优质的技术体验与运营效能:

►一次开发,多端运行

FinClip小程序API和组件与微信保持高度一致,企业现有小程序几乎无需修改即可转化为鸿蒙APP,同时支持iOS、Android、鸿蒙,真正实现全端覆盖。简单来说,你现有的微信小程序,几乎无需修改,就能通过FinClip变成三个平台上的独立App。这不仅是技术的跨越,更是商业逻辑的升维。

图片

►用户体验大幅升级

基于FinClip构建的业务模块,小程序加载速度较传统H5提升约60%,支持地图、蓝牙、WebRTC等多种扩展能力,用户操作流畅度显著改善。支持本地缓存,启动更快,运行更流畅;能获取更多系统权限,实现更复杂的产品设计。

►全生命周期管理

安全沙箱技术:确保每个小程序在安全环境中独立运行,保障用户数据与信息安全; 

审核机制严格:小程序上下架审核、内容审核,确保业务敏捷与安全合规; 

灰度发布功能:支持A/B测试与热更新,极大缩短开发周期并提升运营效率。

鸿蒙生态将进入加速普及的快车道。 

面对这个明确的趋势,企业无需焦虑,也无需重金押注。

FinClip提供的,正是一条高效、低成本的捷径。 无需抛弃已有的巨大投入,便能轻松登录包括鸿蒙在内的三大操作系统,将“借场开店”的小程序,升级为拥有自主阵地的全域App。

欢迎来FinClip官网注册体验,免费。

搭建简易版monorepo + turborepo

背景

  • 项目结构:pnpm Monorepo
    • packages/ui:React 组件库(使用 Vite + TS 打包)
    • apps/react-demo:React 应用,依赖 @my-org/ui
  • 目标:
    • ✅ 开发环境:修改 ui 源码 → 自动热更新到 react-demo
    • ✅ 生产构建:react-demo 能正确打包 @my-org/ui

遇到的问题 & 解决方案

❌ 问题 1:生产构建时报错 —— @my-org/ui 无法解析

// 错误信息
[vite]: Rolldown failed to resolve import "@my-org/ui" ...

🔎 根本原因:

  • react-demonode_modules/@my-org/ui/没有 ****dist/ ****目录
  • 导致 Vite 找不到 JS 入口文件(如 ui.js

✅ 解决步骤:

  1. 确认 ****packages/ui/package.json ****包含 ****"files": ["dist"]
    → 否则 pnpm 不会把 dist 链接到 consumer 的 node_modules
  2. 先构建 UI 库

pnpm --filter @my-org/ui build
  1. 确保 ****react-demo ****声明了依赖

pnpm add @my-org/ui@workspace:* --filter react-demo
  1. 强制刷新链接

pnpm install --force

💡 关键认知:pnpm workspace 链接 ≠ 实时目录映射,它只链接 package.json 中声明的文件(通过 files),且需在 dist 存在后执行 install

❌ 问题 2:package.json 入口文件名与实际输出不一致

  • 配置写的是:

"main": "./dist/index.js"
  • 但 Vite 默认输出:

dist/ui.js
dist/ui.mjs

🔎 后果:

  • 即使 dist 被链接,Vite 仍尝试加载不存在的 index.js → 模块解析失败

✅ 解决方案(二选一):

方案 操作
A(推荐) 修改 package.json指向真实文件: "main": "./dist/ui.js"
B 修改 vite.config.ts强制输出 index.jsfileName: (format) => index.${format === 'es' ? 'mjs' : 'js'}``

✅ 最终选择 方案 A,避免改构建配置,更简单直接。

❌ 问题 3:即使 files 正确,dist 仍不出现

运行 pnpm install --force 后,node_modules/@my-org/ui/dist 依然不存在。

🔎 深层原因:

  • react-demo/package.json 未声明对 ****@my-org/ui ****的依赖
  • pnpm 不会自动链接未声明的 workspace 包

✅ 解决:


pnpm add @my-org/ui@workspace:* --filter react-demo

→ 显式建立依赖关系,pnpm 才会创建 symlink 并包含 dist/

📌 这是 Monorepo 的核心规则: “未声明 = 不存在”

✅ 问题 4:开发环境如何实现热更新?

生产构建成功后,需支持开发时实时编辑 ui 组件。

✅ 解决方案:

  1. ****react-demo/vite.config.ts ****中添加 alias

resolve: {
  alias: {
    '@my-org/ui': path.resolve(__dirname, '../../packages/ui/src')
  }
}
  1. 启动开发服务器

pnpm --filter react-demo dev

💡 原理:Vite 直接编译 ui/src 源码(而非 dist),天然支持 HMR 和 TSX。


🧪 验证清单(最终状态)

检查项 命令 预期结果
UI 库已构建 ls packages/ui/dist ui.js, ui.mjs, *.d.ts
依赖已声明 grep "@my-org/ui" apps/react-demo/package.json "@my-org/ui": "workspace:*"
链接已同步 ls apps/react-demo/node_modules/@my-org/ui/dist 文件存在
生产构建成功 pnpm --filter react-demo build 无报错,生成 dist/
开发热更新 修改 ui/src/Button.tsx→ 浏览器自动刷新 ✅ 实时生效

📚 经验总结

场景 关键配置
生产构建 package.jsonmain/module必须匹配真实文件名 + "files": ["dist"]
依赖链接 Consumer 必须在 package.json中显式声明 workspace:*依赖
开发体验 通过 Vite alias指向 src,绕过 dist,实现 HMR
构建顺序 build ui→ 再 pnpm install→ 最后 build app

鸿蒙6开发中,UI相关应用崩溃常见问题与解决方案

大家好,我是 V 哥。 在鸿蒙应用开发中,UI相关的应用崩溃是开发者常遇到的问题。虽然目前公开资料主要基于HarmonyOS 4.0及Next版本,但其核心调试方法和常见问题类型对未来的鸿蒙6开发具有重要参考价值。以下是根据现有技术文档整理的常见UI崩溃问题及其解决方案。

联系V哥获取 鸿蒙学习资料

🐞 一、常见UI稳定性问题与解决方案

1. JS_ERROR(JavaScript/ArkTS运行时错误)

这是UI层最高频的崩溃类型,通常由代码逻辑不严谨导致。

  • 典型问题

    • 读取undefined/null的属性:例如 TypeError: Cannot read property 'x' of undefined。这常发生在未对数组或对象进行判空就直接访问其属性时。
    • 未捕获的第三方库异常:调用第三方SDK或API时,未使用try-catch进行异常保护,导致异常冒泡至顶层引发崩溃。
    • 页面生命周期管理不当:页面销毁后,未清除的定时器或异步回调仍在尝试访问已释放的页面级变量。
  • 解决方案

    • 使用可选链操作符(?.):安全地访问深层属性。例如,将 let val = sceneContainerSessionList.needRenderTranslate; 改为 let val = sceneContainerSessionList?.needRenderTranslate;
    • 强化异常捕获:对所有可能出错的第三方API调用或异步操作使用try-catch。
        try {
            wifiManager.on('wifiStateChange', handleData);
        } catch (error) {
            console.error("模块异常:", error);
            // 执行优雅降级逻辑
        }
*   **及时清理资源**:在页面的 `onPageHide` 或组件的 `aboutToDisappear` 生命周期中,清除定时器、解绑事件监听器。

2. APP_FREEZE(应用冻结/无响应)

主线程被长时间阻塞,导致界面卡死,最终触发系统超时机制(通常为6秒)而崩溃。

  • 典型问题

    • 在主线程执行耗时操作:如复杂的计算、大量的同步I/O操作、庞大的数据循环处理等。
    • 过度嵌套或复杂的UI布局:布局层级过深,导致测量和渲染耗时过长。
  • 解决方案

    • 使用Worker线程:将耗时任务移至Worker线程执行。
        // 主线程
        let worker = new Worker("workers/calc.js");
        worker.postMessage(data);
        worker.onmessage = (result) => { updateUI(result); };

优化UI布局减少布局嵌套:使用扁平化布局,避免不必要的StackColumn等容器嵌套。建议嵌套深度不超过5层。 使用弹性布局单位vp:替代固定像素px,结合媒体查询实现跨设备适配。 利用LazyForEach与组件复用:对于长列表,使用LazyForEach进行懒加载,并用@RecycleItem装饰器复用组件项,极大降低渲染压力。

3. OOM(内存溢出)与 RESOURCE_LEAK(资源泄漏)

应用内存使用超出系统限制,或资源未正确释放,导致内存逐渐耗尽而崩溃。

  • 典型问题

    • 图片资源未释放:加载大量大图而未及时销毁。
    • 监听器或回调未解绑:全局事件、广播接收器等在组件销毁后未移除,导致对象无法被垃圾回收。
    • 数据缓存无限增长:未使用LRU等策略管理缓存大小。
  • 解决方案

    • 使用内存分析工具(DevEco Studio Profiler)
      1. 运行应用,在DevEco Studio中点击 ProfileMemory
      2. 执行怀疑泄漏的操作(如反复进入退出页面)。
      3. 点击 Dump Java Heap 获取堆快照,对比操作前后的内存变化,定位未被释放的对象引用链。
    • 规范资源生命周期管理:在onDestroy或组件析构函数中,确保解绑所有监听器、关闭文件句柄、释放Bitmap等资源。
    • 优化图片加载:根据显示尺寸压缩图片,使用合适的图片格式(如WebP),并考虑使用第三方库管理图片生命周期。

4. CPP_CRASH(Native层崩溃)

通常由C/C++代码(如NDK、第三方Native SDK)中的错误引起。

  • 典型问题

    • Use-After-Free:Native对象(如OH_NativeXComponent或其回调函数)被提前释放,但后续代码仍尝试访问它。
    • 空指针解引用、栈溢出
  • 解决方案

    • 确保Native对象生命周期:应用必须保证OH_NativeXComponent_Callback等回调对象在组件的onSurfaceDestroy回调执行前一直有效。
    • 添加Native层崩溃捕获:注册信号处理函数,在崩溃时记录日志以便分析。
    • 谨慎调用Native API:调用前做好参数校验,确保指针有效性。

🔧 二、崩溃问题的通用诊断流程

  1. 获取崩溃日志

    • 方法一(推荐):使用DevEco Studio的 FaultLog 工具一键提取。连接设备后,在Logcat的FaultLog选项卡中查看详细的崩溃堆栈信息。
    • 方法二:通过hdc命令行工具抓取:hdc_std shell hilog -w | grep "CRASH"
  2. 分析日志关键信息

    • 堆栈跟踪(Stacktrace):这是定位问题的核心。在Debug模式下可直接跳转到出错代码行;Release模式需使用SourceMap文件反解混淆。
    • 崩溃类型(FAULT_TYPE)错误信息:直接指出是JS错误、Native错误还是超时等。
  3. 使用性能剖析工具

    • Memory Profiler:监控内存趋势,捕捉泄漏。
    • ArkUI Inspector:检查UI组件层级和属性,排查布局问题。

💡 三、预防性编码最佳实践

  • 启用全局异常拦截:在应用入口处设置全局错误监听,捕获未处理的异常并上报,避免应用直接闪退。
  • 代码规范:采用严格的TypeScript/ArkTS编码规范,开启所有静态检查选项。
  • 定期进行性能测试:在开发周期中,使用Profiler工具对关键路径进行性能分析和内存检查。

希望这份详细的指南能帮助您有效解决和预防鸿蒙应用开发中的UI崩溃问题!如果遇到具体的技术难题,查阅华为开发者联盟的官方文档通常是最可靠的途径。

WX20250512-113156@2x.png

鸿蒙6开发视频播放器的屏幕方向适配问题

大家好,我是 V 哥, 在鸿蒙6开发中,屏幕方向适配是提升用户体验的重要环节。下面我将通过一个完整的视频播放器示例,详细讲解ArkTS中横竖屏切换的实现方案。

联系V哥获取 鸿蒙学习资料

一、基础概念理解

1.1 屏幕方向类型

鸿蒙系统支持四种屏幕方向:

  • PORTRAIT(竖屏):屏幕高度大于宽度
  • LANDSCAPE(横屏):屏幕宽度大于高度
  • PORTRAIT_INVERTED(反向竖屏)
  • LANDSCAPE_INVERTED(反向横屏)

1.2 适配策略

  • 静态配置:通过配置文件锁定基础方向
  • 动态调整:运行时感知设备旋转并智能适配

二、静态配置实现

2.1 修改module.json5配置

src/main/module.json5文件中配置UIAbility的方向属性:

{
  "module": {
    "abilities": [
      {
        "name": "EntryAbility",
        "orientation": "landscape", // 可选:portrait|landscape|unspecified
        "metadata": [
          {
            "name": "ohos.ability.orientation",
            "value": "$profile:orientation"
          }
        ]
      }
    ]
  }
}

参数说明

  • portrait:锁定竖屏
  • landscape:锁定横屏
  • unspecified:跟随系统(默认)

三、动态横竖屏切换实现

3.1 创建方向工具类

新建utils/OrientationUtil.ets文件:

// OrientationUtil.ets
import window from '@ohos.window';
import display from '@ohos.display';

export class OrientationUtil {
  // 设置窗口方向
  static async setPreferredOrientation(windowClass: window.Window, orientation: window.Orientation) {
    try {
      await windowClass.setPreferredOrientation(orientation);
      console.info('屏幕方向设置成功:', orientation);
    } catch (error) {
      console.error('设置屏幕方向失败:', error);
    }
  }

  // 获取当前设备方向
  static getCurrentOrientation(): string {
    const displayInfo = display.getDefaultDisplaySync();
    return displayInfo.width > displayInfo.height ? 'landscape' : 'portrait';
  }

  // 横屏模式配置
  static readonly LANDSCAPE: window.Orientation = window.Orientation.LANDSCAPE;
  
  // 竖屏模式配置  
  static readonly PORTRAIT: window.Orientation = window.Orientation.PORTRAIT;
  
  // 跟随传感器自动旋转(受旋转锁控制)
  static readonly FOLLOW_SENSOR: window.Orientation = window.Orientation.FOLLOW_SENSOR;
}

3.2 视频播放页面实现

创建pages/VideoPlayback.ets主页面:

// VideoPlayback.ets
import { OrientationUtil } from '../utils/OrientationUtil';
import window from '@ohos.window';
import common from '@ohos.app.ability.common';
import mediaquery from '@ohos.mediaquery';

@Entry
@Component
struct VideoPlayback {
  @State currentOrientation: string = 'portrait';
  @State isFullScreen: boolean = false;
  private context: common.UIContext = getContext(this) as common.UIContext;
  private windowClass: window.Window | null = null;
  private mediaQueryListener: mediaquery.MediaQueryListener | null = null;

  // 页面初始化
  aboutToAppear() {
    this.initWindow();
    this.setupOrientationListener();
  }

  // 初始化窗口
  async initWindow() {
    try {
      this.windowClass = await window.getLastWindow(this.context);
      AppStorage.setOrCreate('windowClass', this.windowClass);
      this.currentOrientation = OrientationUtil.getCurrentOrientation();
    } catch (error) {
      console.error('窗口初始化失败:', error);
    }
  }

  // 设置方向监听器
  setupOrientationListener() {
    // 监听窗口尺寸变化
    this.windowClass?.on('windowSizeChange', () => {
      this.currentOrientation = OrientationUtil.getCurrentOrientation();
      console.info('屏幕方向变化:', this.currentOrientation);
    });

    // 媒体查询监听横屏事件
    const mediaQuery = mediaquery.matchMediaSync('(orientation: landscape)');
    this.mediaQueryListener = mediaQuery;
    mediaQuery.on('change', (result: mediaquery.MediaQueryResult) => {
      if (result.matches) {
        console.info('当前为横屏模式');
      } else {
        console.info('当前为竖屏模式');
      }
    });
  }

  // 切换全屏/竖屏模式
  async toggleFullScreen() {
    if (!this.windowClass) return;

    if (this.isFullScreen) {
      // 退出全屏,切换回竖屏
      await OrientationUtil.setPreferredOrientation(this.windowClass, OrientationUtil.PORTRAIT);
      this.isFullScreen = false;
    } else {
      // 进入全屏,切换为横屏并跟随传感器
      await OrientationUtil.setPreferredOrientation(this.windowClass, OrientationUtil.FOLLOW_SENSOR);
      this.isFullScreen = true;
    }
  }

  // 锁定横屏(不受传感器影响)
  async lockLandscape() {
    if (this.windowClass) {
      await OrientationUtil.setPreferredOrientation(this.windowClass, OrientationUtil.LANDSCAPE);
    }
  }

  // 页面布局
  build() {
    Column() {
      // 标题栏
      Row() {
        Text('视频播放器')
          .fontSize(20)
          .fontColor(Color.White)
      }
      .width('100%')
      .height(60)
      .backgroundColor('#007DFF')
      .justifyContent(FlexAlign.Start)
      .padding({ left: 20 })

      // 视频播放区域
      Column() {
        if (this.currentOrientation === 'landscape') {
          this.LandscapeVideoContent()
        } else {
          this.PortraitVideoContent()
        }
      }
      .layoutWeight(1)

      // 控制按钮区域(竖屏时显示)
      if (this.currentOrientation === 'portrait') {
        Column() {
          Button(this.isFullScreen ? '退出全屏' : '进入全屏')
            .width('90%')
            .height(40)
            .backgroundColor('#007DFF')
            .fontColor(Color.White)
            .onClick(() => this.toggleFullScreen())

          Button('锁定横屏')
            .width('90%')
            .height(40)
            .margin({ top: 10 })
            .backgroundColor('#FF6A00')
            .fontColor(Color.White)
            .onClick(() => this.lockLandscape())
        }
        .width('100%')
        .padding(10)
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  // 横屏内容布局
  @Builder LandscapeVideoContent() {
    Column() {
      // 模拟视频播放器
      Stack() {
        Image($r('app.media.video_poster'))
          .width('100%')
          .height(300)
          .objectFit(ImageFit.Contain)

        // 横屏控制条
        Row() {
          Button('退出全屏')
            .width(100)
            .height(30)
            .backgroundColor(Color.Orange)
            .fontColor(Color.White)
            .onClick(() => this.toggleFullScreen())
        }
        .width('100%')
        .justifyContent(FlexAlign.End)
        .padding(10)
      }

      // 视频信息
      Text('当前模式:横屏全屏播放')
        .fontSize(16)
        .margin({ top: 20 })
    }
  }

  // 竖屏内容布局
  @Builder PortraitVideoContent() {
    Column() {
      // 模拟视频播放器
      Image($r('app.media.video_poster'))
        .width('100%')
        .height(200)
        .objectFit(ImageFit.Cover)

      // 视频信息
      Text('视频标题:鸿蒙开发教程')
        .fontSize(18)
        .margin({ top: 10 })

      Text('视频描述:学习ArkTS横竖屏适配')
        .fontSize(14)
        .margin({ top: 5 })
        .fontColor(Color.Gray)
    }
    .padding(10)
  }

  // 页面销毁
  aboutToDisappear() {
    this.mediaQueryListener?.off('change');
    this.windowClass?.off('windowSizeChange');
  }
}

3.3 EntryAbility配置

更新entryability/EntryAbility.ets

// EntryAbility.ets
import UIAbility from '@ohos.app.ability.UIAbility';
import window from '@ohos.window';
import hilog from '@ohos.hilog';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
    hilog.info(0x0000, 'EntryAbility', 'Ability onCreate');
  }

  onWindowStageCreate(windowStage: window.WindowStage) {
    hilog.info(0x0000, 'EntryAbility', 'Ability onWindowStageCreate');

    // 设置窗口方向为跟随传感器
    windowStage.getMainWindow((err, windowClass) => {
      if (err) {
        hilog.error(0x0000, 'EntryAbility', 'Failed to get main window');
        return;
      }
      // 初始设置为竖屏,但允许跟随传感器旋转
      windowClass.setPreferredOrientation(window.Orientation.FOLLOW_SENSOR);
    });

    windowStage.loadContent('pages/VideoPlayback', (err, data) => {
      if (err) {
        hilog.error(0x0000, 'EntryAbility', 'Failed to load the content.');
      }
    });
  }
}

四、关键技术与原理分析

4.1 方向控制原理

  • setPreferredOrientation:核心方法,控制窗口显示方向
  • FOLLOW_SENSOR:跟随传感器自动旋转,受系统旋转锁控制
  • 媒体查询:监听屏幕方向变化事件

4.2 布局适配技巧

使用条件渲染和Flex布局实现响应式设计:

// 响应式布局示例
build() {
  Column() {
    if (this.currentOrientation === 'landscape') {
      // 横屏布局
      Row() {
        // 左右分栏
      }
    } else {
      // 竖屏布局  
      Column() {
        // 上下分栏
      }
    }
  }
}

五、完整项目结构

entry/src/main/ets/
├── entryability/EntryAbility.ets
├── pages/VideoPlayback.ets
├── utils/OrientationUtil.ets
└── resources/  // 资源文件

六、测试与调试要点

  1. 真机测试:在鸿蒙设备上测试旋转效果
  2. 旋转锁定:测试系统旋转开关的影响
  3. 折叠屏适配:考虑折叠态和展开态的不同场景

七、常见问题解决

问题1:旋转后布局错乱 解决:使用媒体查询监听方向变化,动态调整布局

问题2:旋转动画卡顿
解决:优化布局计算,避免复杂操作在旋转时执行

这个完整示例涵盖了鸿蒙6中ArkTS横竖屏适配的核心技术点,适合初学者逐步学习和实践。

卷二_副本2.jpg

vite联邦实现微前端(vite-plugin-federation)

使用vite-plugin-federation实现微前端的搭建开发

使用背景

老板说了,项目比较大,不好维护要拆分成多个子项目,这样每次维护发包时候及时报错了也不会影响其他的项目(我现在用的是microapp,因为之前他们用的iframe拆分了,只有弹框问题不好解决,用microapp改造下是最快的,插件那块microapp嵌套报错用了联邦实现了)。

注意:联邦搭建微前端适合新项目,如果已经有用iframe拆分实现微前端过的老项目还是用microapp比较好,啥都不用改直接引入就能实现微前端。

  • 官方描述的是去中心画,我还是按照老板现在想法实现1+n+n,
  1. 1个框架(登录,接口封装,layout布局等)只要搭建好基本上不会在改的(新开项目这个也直接能拿去用)
  2. n个应用(大屏,多个后台管理模块)就是改动相对较少的
  3. n个插件(要给不同客户部署,他们都会提自己需求,例如大屏的某块展示,客户不提我们就展示默认的,客户提了就渲染他们个性化的内容)

使用方法

  1. 首先创建两个项目:base,运营管理 全是vite+vue3 pnpm create vue
  2. 安装联邦pnpm add @originjs/vite-plugin-federation --save-dev
  3. 官方使用方法
  4. base项目可以dev启动,运营管理必须build之后用preview预览
// vite.config.js 运营管理
import federation from "@originjs/vite-plugin-federation";
export default {
    plugins: [
        federation({
            name: 'remote-app',
            filename: 'remoteEntry.js',
            // Modules to expose
            exposes: {
                './Button': './src/Button.vue',
            },
            shared: ['vue']
        })
    ]
}
// vite.config.js base项目
import federation from "@originjs/vite-plugin-federation";
export default {
    plugins: [
        federation({
            name: 'host-app',
            remotes: {
                remote_app: "http://localhost:5001/assets/remoteEntry.js",
            },
            shared: ['vue']
        })
    ]
}
// 页面使用
const RemoteButton = defineAsyncComponent(() => import("remote_app/Button"));
<template>
    <div>
        <RemoteButton />
    </div>
</template>

推荐使用方法(超级坑:他们中文文档没有介绍,找了好久发现写在了英文文档的最下面)

用上面这个方法,在做定制化时候,不好用,例如后台配置个性化接口时候,前端自动通过接口展示对应的个性化。所以需要有个能够动态加载组件方法。

运营管理那块导出不用修改,base的导入方式需要修改下

// vite.config.js base项目
federation({
  remotes:{
    "None": "" //这个不加就报错,他们issues里找到的别人的解决办法
  },
  shared: ['vue', 'pinia', 'vue-router'],
}),

先定一个公共的util方法获取动态组件和方法

//util.ts
import {
  __federation_method_getRemote as getRemote,
  __federation_method_setRemote as setRemote,
  __federation_method_unwrapDefault as unwrap,
} from 'virtual:__federation__'

interface RemoteOptions {
  url: string
  moduleName: string,
  type?: 'ts' | 'component'
}

export const getRemoteComponent = async (options: RemoteOptions): Promise<any> => {
  try {
      const { url, moduleName, type = 'component' } = options
      const remoteName = `remote_${Math.random().toString(36).slice(2)}`
      // 1. 注册 remote 信息
      setRemote(remoteName, {
        url: () => Promise.resolve(url),
        format: 'esm',
        from: 'vite',
      })

      // 2. 加载模块
      const mod = await getRemote(remoteName, `./${moduleName}`)
      console.log('======', type)
      if(type === 'ts') return mod
      // 3. 解包模块
      const Comp = await unwrap(mod)

      return Comp
  } catch (error) {

  }
}

//使用导出的方法
const util = await getRemoteComponent({
    url: 'http://localhost:20001/assets/remoteEntry.js', 
    moduleName: 'Util',
    type: 'ts'
})
console.log('util', util?.add(1, 2))
//引入组件
const remoteButton = getRemoteComponent({
  url: 'http://localhost:5001/assets/remoteEntry.js',
  moduleName: "Button"
})
<template>
<Suspense>
  <!-- 具有深层异步依赖的组件 -->
  <remoteButton />

  <!-- 在 #fallback 插槽中显示 “正在加载中” -->
  <template #fallback>
    Loading...
  </template>
</Suspense>
</template>

这样就可以通过base里面设置动态路由来加载不同子项目的组件了

我的示例

base项目内容

  1. 登录页面
  2. layout布局
  3. 获取用户菜单动态加载菜单路由
  4. 一些他自己的页面,用户管理,角色管理等
//模拟的用户菜单
export const getMenuList = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        {
          id: 1,
          title:'运营管理',
          children:[
            {
              id: 11,
              title:'车辆管理',
              name: 'CarManage',
              path: '/business/CarManage',
              component: 'CarManage',
              source: 'http://localhost:20001/assets/remoteEntry.js'
            },
            {
              id: 12,
              title:'车辆详情',
              name: 'CarDetail',
              path: '/business/CarDetail',
              component: 'CarDetail',
              source: 'http://localhost:20001/assets/remoteEntry.js'
            }
          ]
        },
        {
          id: 2,
          title:'用户管理',
          children:[
            {
              id: 21,
              title:'用户列表',
              name: 'UserList',
              path: '/user/UserList',
              component: '/user/UserListView',
            },
            {
              id: 22,
              title:'用户角色',
              name: 'UserRole',
              path: '/user/UserRole',
              component: '/user/UserRoleView',
            }
          ]
        }
      ])
    }, 1000)
  })
}
/**
 * 动态加载组件(支持本地和远程组件)
 */
const modules = import.meta.glob("../views/**/*.vue")
export function loadDynamicComponent(menu: MenuType) {
  if (menu.source && menu.component) {
    // 远程组件加载
    return () => getRemoteComponent({
      url: menu.source as string,
      moduleName: menu.component
    })
  } else if (menu.component) {
    // 本地组件加载 - 使用 import.meta.glob
    let componentPath: string

    if (menu.component.startsWith('/')) {
      // 如果是路径格式 (如 /user/UserList),转换为相对路径
      componentPath = `../views${menu.component}.vue`
    } else {
      // 如果是组件名格式 (如 UserList),添加路径前缀
      componentPath = `../views/${menu.component}.vue`
    }

    const moduleLoader = modules[componentPath]

    if (moduleLoader) {
      return moduleLoader
    } else {
      console.warn(`组件未找到: ${componentPath},可用组件:`, Object.keys(modules))
      return () => Promise.resolve({
        template: '<div>组件未找到</div>'
      })
    }
  }
  return undefined
}

运营管理内容

只需要把要导出的东西都导出就行


export const remoteExport = {
  CarManage: 'src/views/CarManage/CarManage.vue',
  CarDetail: 'src/views/CarManage/CarDetail.vue',
  Util: 'src/utils/index.ts'
}
// vite.config.ts
federation({
  name: 'remote-business',
  filename: 'remoteEntry.js',
  exposes: Object.fromEntries(
    Object.entries(remoteExport).map(([key, value]) => [`./${key}`, value])
  ),
  shared: ['vue', 'pinia', 'vue-router']
}),

image.png

到现在已经实现了微前端了

开发时候的优化

现在子项目必须build之后才能生成remoteEntry,也就没办法热更新。

可以使用通知,base项目全局刷新,实现伪热更新效果

参考下下面的插件

interface Options {
  role: 'remote' | 'host';
  host?: string;
}
export default function syncReloadPlugin(options: Options) {
  const role = options.role;
  const hostUrl = options.host;

  return {
    name: 'vite-plugin-sync-reload',

    apply(config: any, { command }: any) {
      if (role !== 'remote') return 'dev';
      return Boolean(command === 'build' && config.build?.watch);
    },

    async buildEnd(error: any) {
      if (role !== 'remote') return;
      if (error) return;

      try {
        await fetch(`${hostUrl}/__fullReload`);
        console.log(`[remote] 已通知 host 刷新`);
      } catch (e) {
        console.log(`[remote] 通知 host 失败(可能 host 未启动)`);
      }
    },

    configureServer(server: any) {

      if (role !== 'host') return;

      server.middlewares.use((req: any, res: any, next: any) => {

          // remote build 后会访问这里
          if (req.url === '/__fullReload') {

            console.log('[host] 收到 remote 通知,即将刷新页面');

            // 触发浏览器刷新
            setTimeout(() =>{
              server.hot.send({
                type: 'full-reload'
              });
            },100)

            res.end('Full reload triggered');
          } else {
            next(); // 继续下一个中间件
          }
        });
    }
  };
}

syncReloadPlugin({ role: 'host' }),
syncReloadPlugin({
  role: 'remote',
  host: 'http://localhost:20000'
})

我的示例代码

一个联邦实现微前端的示例代码 : github.com/5563/federa…

鸿蒙6开发中,通过文本和字节数组生成码图案例

大家好,我是 V 哥。

生成码图在很多应用中都很常见,今天的内容来给大家介绍鸿蒙6.0开发中通过文本/字节数组生成码图的详细案例解析,结合典型业务场景和技术实现要点:

联系V哥获取 鸿蒙学习资料


一、文本生成码图案例:商品条码生成(EAN-13)

业务场景
零售应用中为商品生成标准条码,用于库存管理和POS扫码结算。
技术实现

// 导入模块
import { generateBarcode } from '@kit.ScanKit';
import { image } from '@kit.ImageKit';

@Entry
@Component
struct ProductBarcode {
  @State pixelMap: image.PixelMap | undefined = undefined

  build() {
    Column() {
      Button('生成商品条码')
        .onClick(() => {
          // EAN-13规范:12位数字,首位非0
          const content: string = "694251983457"; 
          const options: generateBarcode.CreateOptions = {
            type: generateBarcode.ScanType.EAN_13, // 指定条码类型
            width: 300,  // 宽度需在[200,4096]范围内
            height: 150, // 高度可不等于宽度(条码为矩形)
            margin: 2    // 边距范围[1,10]
          };
          // 生成并渲染
          generateBarcode.createBarcode(content, options)
            .then((pixelMap: image.PixelMap) => {
              this.pixelMap = pixelMap;
            })
            .catch((error: BusinessError) => {
              console.error(`生成失败: ${error.code}`);
            });
        })
      // 显示生成的条码
      if (this.pixelMap) {
        Image(this.pixelMap)
          .width(300)
          .height(150)
      }
    }
  }
}

关键约束

参数 要求
内容长度 12位数字
首位数字 禁止为0
码类型 ScanType.EAN_13
尺寸范围 width/height ∈ [200,4096]

二、字节数组生成码图案例:交通卡二维码 业务场景
地铁APP生成加密的交通卡二维码,闸机设备专用解码器识别。
技术实现

// 导入模块(需包含buffer)
import { generateBarcode } from '@kit.ScanKit';
import { buffer } from '@kit.ArkTS';

@Entry
@Component
struct TransportCard {
  @State qrCode: image.PixelMap | undefined = undefined

  build() {
    Column() {
      Button('生成交通卡二维码')
        .onClick(() => {
          // 1. 准备字节数组(加密数据)
          const hexData: string = "0177C10DD10F776860..."; // 16进制字符串
          const contentBuffer: ArrayBuffer = buffer.from(hexData, 'hex').buffer;
          
          // 2. 配置参数(仅支持QR Code)
          const options: generateBarcode.CreateOptions = {
            type: generateBarcode.ScanType.QR_CODE,
            width: 300,   // 需满足 width=height
            height: 300,  // 正方形二维码
            errorCorrectionLevel: generateBarcode.ErrorCorrectionLevel.LEVEL_Q // 25%纠错
          };
          
          // 3. 生成二维码
          generateBarcode.createBarcode(contentBuffer, options)
            .then((pixelMap: image.PixelMap) => {
              this.qrCode = pixelMap;
            })
            .catch((error: BusinessError) => {
              console.error(`生成失败: ${error.code}`);
            });
        })
      // 显示二维码
      if (this.qrCode) {
        Image(this.qrCode)
          .width(300)
          .height(300)
      }
    }
  }
}

核心限制

参数 要求
数据类型 ArrayBuffer(字节数组)
唯一支持码类型 ScanType.QR_CODE
纠错等级与长度关系 LEVEL_Q ≤ 1536字节
尺寸要求 width必须等于height

三、技术对比与选型建议

维度 文本生成 字节数组生成
适用场景 明文字符(网址、ID等) 加密数据/二进制协议(如交通卡)
支持码类型 13种(含QR/EAN/Code128等) 仅QR Code
数据限制 按码类型限制长度(如QR≤512字符) 按纠错等级限制字节长度
颜色要求 建议黑码白底(对比度>70%) 同左
设备兼容性 全设备(Phone/Tablet/Wearable/TV) 同左

四、避坑指南

  1. 尺寸陷阱
    字节数组生成的二维码必须满足 width=height,否则抛出202(参数非法)错误。

  2. 纠错等级选择

    • LEVEL_L(15%纠错):数据≤2048字节 → 容错高/密度低
    • LEVEL_H(30%纠错):数据≤1024字节 → 容错低/密度高
      交通卡推荐LEVEL_Q(25%容错)
  3. 内容超长处理
    若文本超限(如Code39超80字节),需分段生成或改用QR Code。

  4. 渲染优化
    使用Image组件显示PixelMap时,添加背景色提升识别率:

   Image(this.pixelMap)
     .backgroundColor(Color.White) // 强制白底

通过合理选择生成方式并遵守参数规范,可满足零售、交通、支付等高可靠性场景需求,实际开发中建议参考华为官方示例工程验证设备兼容性。

HarmonyOS 6.0 蓝牙实现服务端和客户端通讯案例详解

大家好,我是 V 哥。 以下基于 HarmonyOS 6.0 的蓝牙 BLE 通讯案例详解,模拟心率监测场景,实现服务端(Peripheral)广播数据与客户端(Central)订阅数据的功能流程:

联系V哥获取 鸿蒙学习资料

关键步骤:

  1. 服务端(Peripheral):

    • 创建蓝牙服务(GATT Server)
    • 添加服务(Service)和特征(Characteristic)
    • 广播服务
    • 当客户端连接后,定期更新心率特征值并通过通知发送给客户端
  2. 客户端(Central):

    • 扫描BLE设备(按服务UUID过滤)
    • 连接目标设备
    • 发现服务及特征
    • 订阅特征通知
    • 接收特征值变化

以下是V哥整理的核心代码逻辑。

注意:由于HarmonyOS 6.0可能使用新的API包(如@ohos.bluetooth等),我们需要参考最新官方文档,但这里以搜索结果为基础,结合常见的BLE流程进行说明。


📡 一、服务端实现(广播心率数据) 1. 初始化蓝牙服务

import { bluetooth } from '@kit.ConnectivityKit';

// 定义服务UUID和特征值(需与客户端匹配)
const SERVICE_UUID = '0000180D-0000-1000-8000-00805F9B34FB'; // 标准心率服务UUID
const CHARACTERISTIC_UUID = '00002A37-0000-1000-8000-00805F9B34FB'; // 心率测量特征

// 创建GATT服务
let gattServer: bluetooth.GattServer = bluetooth.createGattServer();
let service: bluetooth.GattService = {
  serviceUuid: SERVICE_UUID,
  isPrimary: true,
  characteristics: [{
    characteristicUuid: CHARACTERISTIC_UUID,
    permissions: bluetooth.CharacteristicPermission.READ,
    properties: bluetooth.CharacteristicProperty.NOTIFY
  }]
};
gattServer.addService(service);

2. 开启广播并发送数据

// 启动BLE广播
let advertiseSetting: bluetooth.AdvertiseSetting = {
  interval: 320, // 广播间隔(单位0.625ms)
  txPower: 0,    // 发射功率
  connectable: true
};
gattServer.startAdvertising(advertiseSetting, {
  serviceUuids: [SERVICE_UUID] // 广播的服务标识
});

// 模拟心率数据发送(定时更新)
setInterval(() => {
  const heartRate = Math.floor(Math.random() * 40) + 60; // 生成60~100随机心率值
  const data = new Uint8Array([0x06, heartRate]); // 数据格式:Flags(06) + 心率值

  // 通知已连接的客户端
  gattServer.notifyCharacteristicChanged({
    serviceUuid: SERVICE_UUID,
    characteristicUuid: CHARACTERISTIC_UUID,
    deviceId: connectedDeviceId, // 连接的设备ID
    value: data.buffer            // ArrayBuffer格式数据
  });
}, 2000); // 每2秒发送一次

3. 处理客户端连接事件

gattServer.on('connectionStateChange', (device: bluetooth.Device, state: number) => {
  if (state === bluetooth.ProfileConnectionState.STATE_CONNECTED) {
    console.log(`设备已连接: ${device.deviceId}`);
    connectedDeviceId = device.deviceId; // 保存连接的设备ID
  } else if (state === bluetooth.ProfileConnectionState.STATE_DISCONNECTED) {
    console.log('设备已断开');
  }
});

📱 二、客户端实现(订阅心率数据)

1. 扫描并连接服务端

import { bluetooth } from '@kit.ConnectivityKit';

// 扫描指定服务的设备
let scanner: bluetooth.BLEScanner = bluetooth.createBLEScanner();
scroller.startScan({
  serviceUuids: [SERVICE_UUID] // 过滤目标服务
});

// 发现设备回调
scanner.on('deviceDiscover', (device: bluetooth.ScanResult) => {
  if (device.deviceName === "HeartRate_Server") { // 根据设备名过滤
    const gattClient: bluetooth.GattClientDevice = bluetooth.createGattClientDevice(device.deviceId);
    gattClient.connect(); // 连接服务端
  }
});

2. 订阅特征值通知

// 连接成功后订阅数据
gattClient.on('servicesDiscovered', () => {
  const service = gattClient.getService(SERVICE_UUID);
  const characteristic = service.getCharacteristic(CHARACTERISTIC_UUID);

  // 启用特征值通知
  characteristic.setCharacteristicChangeNotification(true).then(() => {
    characteristic.on('characteristicChange', (value: ArrayBuffer) => {
      const heartRate = new Uint8Array(value); // 解析心率值
      console.log(`实时心率: ${heartRate} BPM`);
    });
  });
});

3. 断开连接处理

gattClient.on('connectionStateChange', (state: number) => {
  if (state === bluetooth.ProfileConnectionState.STATE_DISCONNECTED) {
    console.log('已断开服务端连接');
    scanner.stopScan(); // 停止扫描
  }
});

🔑 三、关键知识点

  1. UUID 规范
    • 使用标准 UUID(如心率服务 0x180D)确保跨设备兼容性。
  2. 数据广播
    • 服务端通过 notifyCharacteristicChanged() 主动推送数据,客户端无需轮询。
  3. 权限配置
    • 需在 module.json5 中声明蓝牙权限:
     "requestPermissions": [{
       "name": "ohos.permission.USE_BLUETOOTH"
     }]
  1. 双机调试
    • 需两台 HarmonyOS 设备(或模拟器)分别运行服务端/客户端。

⚠️ 四、常见问题

  1. 连接失败
    • 检查设备是否开启蓝牙可见性,并确认 SERVICE_UUID 完全匹配。
  2. 收不到通知
    • 客户端需先调用 setCharacteristicChangeNotification(true) 订阅通知。
  3. 广播功耗优化
    • 调整 AdvertiseSetting.interval 可平衡广播频率与功耗。

🔄记住这张图,脑子跟着浏览器的事件循环(Event Loop)转起来了

一、前言

下面按照我的理解,纯手工画了一张在浏览器执行JavaScript代码的Event Loop(事件循环) 流程图。

后文会演示几个例子,把示例代码放到这个流程图演示其执行流程。

当然,这只是简单的事件循环流程,不过,却能让我们快速掌握其原理。

Event Loop.png

二、概念

事件循环JavaScript为了处理单线程执行代码时,能异步地处理用户交互、网络请求等任务 (异步Web API),而设计的一套任务调度机制。它就像一个永不停止的循环,不断地检查(结合上图就是不断检查Task QueueMicrotask Queue这两个队列)并需要运行的代码。


三、为什么需要事件循环

JavaScript是单线程的,这意味着它只有一个主线程来执行代码。如果所有任务(比如一个耗时的计算、一个网络请求)都同步执行,那么浏览器就会被卡住,无法响应用户的点击、输入,直到这个任务完成。这会造成极差的用户体验。

事件循环就是为了解决这个问题而生的:它让耗时的操作(如网络请求、文件读取)在后台异步执行,等这些操作完成后,再通过回调的方式来执行相应的代码,从而不阻塞主线程

四、事件循环流程图用法演示

演示一:小菜一碟

先来一个都是同步代码的小菜,先了解一下前面画的流程图是怎样在调用栈当中执行JavaScript代码的。

console.log(1)

function funcOne() {
  console.log(2)
}

function funcTwo() {
  funcOne()
  console.log(3)
}

funcTwo()

console.log(4)

控制台输出:

1 2 3 4

下图为调用栈执行流程

演示01.png

每执行完一个同步任务会把该任务进行出栈。在这个例子当中每次在控制台输出一次,则进行一次出栈处理,直至全部代码执行完成。

演示二:小试牛刀

setTimeout+Promise组合拳,了解异步代码是如何进入任务队列等待执行的。

console.log(1)

setTimeout(() => {
  console.log('setTimeout', 2)
}, 0)

const promise = new Promise((resolve, reject) => {
  console.log('promise', 3)
  resolve(4)
})

setTimeout(() => {
  console.log('setTimeout', 5)
}, 10)

promise.then(res => {
  console.log('then', res)
})

console.log(6)

控制台输出:

1 promise 3 6 then 4 setTimeout 2 setTimeout 5

流程图执行-步骤一:

先执行同步代码,如遇到异步代码,则把异步回调事件放到后台监听对应的任务队列

image.png

  1. 执行console.log(1),控制台输出1

  2. 执行定时器,遇到异步代码,后台注册定时器回调事件,时间到了,把回调函数() => {console.log('setTimeout', 2)},放到宏任务队列等待。

  3. 执行创建Promise实例,并执行其中同步代码:执行console.log('promise', 3),控制台输出promise 3;执行resolve(4),此时Promise已经确定为完成fulfilled状态,把promise.then()的回调函数响应值设为4

  4. 执行定时器,遇到异步代码,后台注册定时器回调事件,时间未到,把回调函数() => { console.log('setTimeout', 5) }放到后台监听。

  5. 执行promise.then(res => { console.log('then', res) }),出栈走异步代码,把回调函数4 => { console.log('then', 4) }放入微任务队列等待。

流程图执行-步骤二:

上面已经把同步代码执行完成,并且把对应异步回调事件放到了指定任务队列,接下来开始事件循环

image.png

  1. 扫描微任务队列,执行4 => { console.log('then', 4) }回调函数,控制台输出then 4

  2. 微任务队列为空,扫描宏任务队列,执行() => {console.log('setTimeout', 2)}回调函数,控制台输出setTimeout 2

  3. 每执行完一个宏任务,需要再次扫描微任务队列是否存在可执行任务(假设此时后台定时到了,则会把() => { console.log('setTimeout', 5) }加入到了宏任务队列末尾)。

  4. 微任务队列为空,扫描宏任务队列,执行() => { console.log('setTimeout', 5) },控制台输出setTimeout 5

演示三:稍有难度

setTimeout+Promise组合拳+多层嵌套Promise

console.log(1)

setTimeout(() => {
  console.log('setTimeout', 10)
}, 0)

new Promise((resolve, reject) => {
  console.log(2)
  resolve(7)

  new Promise((resolve, reject) => {
    resolve(5)
  }).then(res => {
    console.log(res)

    new Promise((resolve, reject) => {
      resolve('嵌套第三层 Promise')
    }).then(res => {
      console.log(res)
    })
  })

  Promise.resolve(6).then(res => {
    console.log(res)
  })

}).then(res => {
  console.log(res)
})

new Promise((resolve, reject) => {
  console.log(3)

  Promise.resolve(8).then(res => {
    console.log(res)
  })

  resolve(9)
}).then(res => {
  console.log(res)
})

console.log(4)

上一个演示说明了流程图执行的详细步骤,下面就不多加赘叙了,直接看图!

talk is cheap, show me the chart

image.png

上图,调用栈同步代码执行完成,开始事件循环,先看微任务队列,发现不为空,按顺序执行微任务事件:

嵌套02.png

上图,已经把刚才排队的微任务队列全部清空了。但是在执行第一个微任务时,发现还有嵌套微任务,则把该任务放到微任务队列末尾,然后接着一起执行完所有新增任务

嵌套03.png

最后微任务清空后,接着执行宏任务。到此全部事件已执行完毕!

控制台完整输出顺序:

1 2 3 4 5 6 7 8 9 10

演示四:setTimeout伪定时

setTimeout并不是设置的定时到了就马上执行,而是把定时回调放在task queue任务队列当中进行等待,待主线程调用栈中的同步任务执行完成后空闲时才会执行。

const startTime = Date.now()
setTimeout(() => {
  const endTime = Date.now()
  console.log('setTimeout cost time', endTime - startTime)
  // setTimeout cost time 2314
}, 100)

for (let i = 0; i < 300000; i++) {
  // 模拟执行耗时同步任务
  console.log(i)
}

控制台输出:

1 2 3 ··· 300000 setTimeout cost time 2314

下图演示了其执行流程:

setTimeout假延时.png

演示五:fetch网络请求和setTimeout

获取网络数据,fetch回调函数属于微任务,优于setTimeout先执行。

setTimeout(() => {
  console.log('setTimeout', 2)
}, 510)

const startTime = Date.now()
fetch('http://localhost:3000/test').then(res => {
  const endTime = Date.now()
  console.log('fetch cost time', endTime - startTime)
  return res.json()
}).then(data => {
  console.log('data', data)
})

下图当前Call Stack执行栈执行完同步代码后,由于fetchsetTimeout都是宏任务,所以走宏任务Web API流程后注册这两个事件回调,等待定时到后了,由于定时回调是个普通的同步函数,所以放到宏任务队列;等待fetch拿到服务器响应数据后,由于fetch回调为一个Promise对象,所以放到微任务队列。

fetch.png

经过多番刷新网页测试,下图控制台打印展示了setTimeout延时为510msfetch请求响应同样是510ms的情况下,.then(data => { console.log('data', data) })先执行了,也是由于fetch基于Promise实现,所以其回调为微任务。

b475cbb38b0161d3e7f5f97b45824b31.png

五、结语

这可能只是简单的JavaScript代码执行事件循环流程,目的也是让大家更直观理解其中原理。实际执行过程可能还会读取堆内存获取引用类型数据、操作dom的方法,可能还会触发页面的重排、重绘等过程、异步文件读取和写入操作、fetch发起网络请求,与服务器建立连接获取网络数据等情况。

但是,它们异步执行的回调函数都会经过图中的这个事件循环过程,从而构成完整的浏览器事件循环。

Mac 端企业微信调试工具开启指南:解决页面兼容性问题必备

前言:

本文主要分享以下两点:

  1. 如何打开 Mac 版企业微信中的调试控制台:由于 Mac 版企微调试工具的开启方式和 Windows 不一样,网上教程零散,所以整理了详细步骤,帮前端同学快速上手~

  2. 我遇到个坑:页面在 Windows 端企微正常,Mac 端打开时字体却闪一下变大,需要用企微调试工具定位问题,附解决方法。

一、背景介绍

随着公司企业微信的全员推广,内部业务页面逐步迁移至企微环境运行,既提升了协作效率,也对页面兼容性提出了更高要求。近期开发的页面在 Windows 端企微中表现正常,但 Mac 端企微打开时出现字体闪烁变大的异常,需通过 企微内置调试工具 定位问题。由于 Mac 版企微调试工具的开启路径与 Windows 端存在差异,特此整理详细操作流程,为同类场景提供参考。

二、打开步骤

  1. 首先 打开 debug模式:

    方法:同时按下快捷键 command + shift + control + D,会有debug模式开启的提示。

image.png

再按一次就是关闭提示:

image.png

  1. 然后点击左上方的“调试”菜单,即【调试】——>【浏览器、webView相关】——>【开启webView元素审查】。具体见下面截图:

1fe54945-47c8-44ca-bdce-052ac089e475.png

image.png

结束这个步骤之后,再次打开调试查看时,【开启webView元素审查】会变成【关闭webView元素审查】,这样就说明开启成功,即:

11b04d5a-014d-402e-860d-f8edc98253b6.png

  1. 最后 关闭 应用 重新打开 即可。这一步非常重要!如果不重新打开的话,右键时,也不会出来“检查因素”,即下面第四步就不会生效。

  2. 右键,出现 “检查因素”,打开就是调试控制台了:

4b1fab6a-d2f3-42a0-a125-f5d542413232.png

image.png

三、Mac的企业微信的网页,会默认给body加一个zoom属性

这一隐形的设置可能会成为你项目中bug的原因。就比如我项目中的问题,在 Windows 中,页面的字体没有问题,在 Mac 的企微中打开,页面中的字体会缩放一下,就是由于这个默认属性导致的。我需要对此单独处理一下,就能完美解决问题,解决方案如下图:

image.png

四、总结

这就是 Mac 端企业微信调试工具的完整开启步骤啦~ 按照流程操作后,就能像在浏览器控制台一样,调试企微内的页面样式、接口请求等,轻松定位字体异常、布局错乱、交互失效等问题。

如果你的工作中也需要在企微生态开发页面,本文的调试控制台开启方法能直接参考。若有其他企微调试的小技巧,也欢迎在评论区留言交流,一起避坑提效!

以上,希望对你有帮助!

CLI 工具开发的常用包对比和介绍

04-CLI 工具开发

CLI 工具开发涉及命令行交互、终端美化、文件操作和模板生成等核心功能。

📑 目录


快速参考

工具选型速查表

工具类型 推荐工具 适用场景 备选方案
命令行解析 commander 功能丰富、API 友好 yargs(灵活)、meow(轻量)
交互式输入 inquirer 功能全面、生态丰富 prompts(轻量)、enquirer
终端美化 chalk 功能丰富、API 友好 picocolors(极轻量)、kleur
加载动画 ora 简单易用 -
进度条 cli-progress 文件上传/下载进度 -
文件操作 fs-extra Promise API、功能增强 -
文件匹配 glob 通配符匹配 -
模板引擎 handlebars 轻量、逻辑少 ejs、mustache

快速开始

# 1. 安装核心工具
pnpm add commander inquirer chalk ora fs-extra glob handlebars

# 2. 创建 CLI 入口文件
# src/cli.ts

# 3. 配置 package.json bin 字段

命令行交互

commander(推荐)

commander 是一款 Node.js 命令行解析工具,核心用途是解析命令行参数,让 CLI 工具的命令行交互更友好、专业。

优势

  • ✅ API 友好,链式调用
  • ✅ 功能丰富,支持子命令、选项、帮助信息
  • ✅ 生态完善,文档详细
  • ✅ 自动生成帮助信息

劣势

  • ❌ 体积较大(相比 meow)
安装
pnpm add commander
pnpm add @types/commander -D
基础用法
// src/cli.ts
import { program } from 'commander';

program
  .version('1.0.0', '-v, --version')
  .description('一个基于 commander + inquirer 的 CLI 工具示例');

// 定义无参数命令
program
  .command('init')
  .description('初始化项目')
  .action(() => {
    console.log('开始初始化项目...');
  });

// 定义带选项的命令
program
  .command('build')
  .description('打包项目')
  .option('-e, --env <env>', '打包环境', 'development')
  .option('-o, --outDir <dir>', '输出目录', 'dist')
  .action((options) => {
    console.log('开始打包...');
    console.log('打包环境:', options.env);
    console.log('输出目录:', options.outDir);
  });

program.parse(process.argv);
选项配置
选项格式 说明 示例
.option('-s, --single') 布尔型选项(无参数,存在即 true) your-cli --single{ single: true }
.option('-n, --name <name>') 必填参数选项 your-cli --name test{ name: 'test' }
.option('-a, --age [age]') 可选参数选项 your-cli --age 25{ age: 25 };不传则为 undefined
.option('--env <env>', '描述', 'dev') 带默认值的选项 不传 --env 时,默认 { env: 'dev' }
高级用法
// 子命令
program
  .command('create <name>')
  .description('创建新项目')
  .option('-t, --template <template>', '模板类型', 'default')
  .action((name, options) => {
    console.log(`创建项目 ${name},使用模板 ${options.template}`);
  });

// 必需选项
program.requiredOption('-c, --config <path>', '配置文件路径').parse();

// 自定义帮助信息
program.addHelpText('after', '\n示例:\n  $ my-cli init\n  $ my-cli build --env production');

yargs

yargs 是功能强大的命令行解析工具,支持位置参数、命令补全等高级功能。

优势

  • ✅ 功能强大,支持位置参数
  • ✅ 灵活的配置方式
  • ✅ 支持命令补全

劣势

  • ❌ API 相对复杂
  • ❌ 学习曲线较陡
安装
pnpm add yargs
pnpm add @types/yargs -D
基础用法
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';

const argv = yargs(hideBin(process.argv))
  .option('name', {
    alias: 'n',
    type: 'string',
    description: '项目名称',
    demandOption: true,
  })
  .option('template', {
    alias: 't',
    type: 'string',
    default: 'default',
    description: '模板类型',
  })
  .command('init <name>', '初始化项目', (yargs) => {
    return yargs.positional('name', {
      describe: '项目名称',
      type: 'string',
    });
  })
  .parseSync();

console.log(argv);

meow

meow 是轻量级的命令行解析工具,适合简单场景。

优势

  • ✅ 轻量级,体积小
  • ✅ 配置简单
  • ✅ 自动处理帮助信息

劣势

  • ❌ 功能相对简单
  • ❌ 不支持复杂命令结构
安装
pnpm add meow
基础用法
import meow from 'meow';

const cli = meow(
  `
  用法
    $ my-cli <input>

  选项
    --name, -n  项目名称
    --template, -t  模板类型

  示例
    $ my-cli init --name my-project
`,
  {
    importMeta: import.meta,
    flags: {
      name: {
        type: 'string',
        alias: 'n',
      },
      template: {
        type: 'string',
        alias: 't',
        default: 'default',
      },
    },
  },
);

console.log(cli.input[0]); // 命令参数
console.log(cli.flags); // 选项

命令行解析工具对比

工具 体积 配置复杂度 功能丰富度 适用场景
commander 较大 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 功能丰富的 CLI 工具
yargs 较大 ⭐⭐ ⭐⭐⭐⭐⭐ 需要位置参数的场景
meow 轻量级 ⭐⭐⭐⭐⭐ ⭐⭐⭐ 简单 CLI 工具

选型建议

  • 功能丰富的 CLI 工具:commander(推荐)
  • 需要位置参数:yargs
  • 简单工具:meow

交互式输入

inquirer(推荐)

当命令行参数无法满足需求(如让用户选择框架、输入密码、确认操作)时,inquirer 提供「交互式输入」。

优势

  • ✅ 功能全面,支持多种交互类型
  • ✅ 生态丰富,插件多
  • ✅ 文档完善

劣势

  • ❌ 体积较大
  • ❌ 配置相对复杂
安装
pnpm add inquirer
pnpm add @types/inquirer -D
基础用法
import { program } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';

program
  .command('init')
  .description('初始化项目')
  .action(async () => {
    console.log(chalk.blue('📦 开始初始化项目...'));

    const answers = await inquirer.prompt([
      {
        type: 'input',
        name: 'projectName',
        message: '请输入项目名称:',
        default: 'my-project',
        validate: (value) => {
          if (!value.trim()) return '项目名称不能为空!';
          return true;
        },
      },
      {
        type: 'list',
        name: 'framework',
        message: '请选择项目框架:',
        choices: [
          { name: 'React + TypeScript', value: 'react-ts' },
          { name: 'Vue + TypeScript', value: 'vue-ts' },
          { name: 'Vanilla JS', value: 'vanilla' },
        ],
        default: 'react-ts',
      },
      {
        type: 'checkbox',
        name: 'modules',
        message: '请选择需要的功能模块:',
        choices: ['路由', '状态管理', 'UI 组件库', 'ESLint/Prettier'],
        default: ['路由', 'ESLint/Prettier'],
      },
      {
        type: 'confirm',
        name: 'initGit',
        message: '是否初始化 Git 仓库?',
        default: true,
      },
    ]);

    console.log(chalk.green('\n✅ 项目配置如下:'));
    console.log('项目名称:', answers.projectName);
    console.log('框架:', answers.framework);
    console.log('功能模块:', answers.modules.join(', '));
    console.log('初始化 Git:', answers.initGit ? '是' : '否');
  });

program.parse(process.argv);
核心交互类型
类型 用途 关键配置
input 普通文本输入(如项目名称、邮箱) message、default、validate
password 密码输入(输入内容隐藏) 同 input,自动隐藏输入
list 单选(如框架选择、环境选择) choices(选项数组)、default
checkbox 多选(如功能模块、依赖选择) choices、default(默认选中项)
confirm 二选一确认(是 / 否) message、default(true/false)
rawlist 带编号的单选(按数字选择) 同 list,选项前显示编号
autocomplete 带自动补全的输入(如文件路径) 需配合 inquirer-autocomplete-prompt 插件

prompts

prompts 是轻量级的交互式输入工具,API 简洁。

优势

  • ✅ 轻量级,体积小
  • ✅ API 简洁
  • ✅ 支持取消操作(Ctrl+C)

劣势

  • ❌ 功能相对简单
  • ❌ 生态较小
安装
pnpm add prompts
基础用法
import prompts from 'prompts';

const response = await prompts([
  {
    type: 'text',
    name: 'projectName',
    message: '项目名称',
    initial: 'my-project',
    validate: (value) => (value.trim() ? true : '项目名称不能为空'),
  },
  {
    type: 'select',
    name: 'framework',
    message: '选择框架',
    choices: [
      { title: 'React', value: 'react' },
      { title: 'Vue', value: 'vue' },
    ],
  },
]);

console.log(response);

enquirer

enquirer 是现代化的交互式输入工具,支持自定义提示符。

优势

  • ✅ 现代化设计
  • ✅ 支持自定义提示符
  • ✅ API 灵活

劣势

  • ❌ 文档相对较少
  • ❌ 生态较小
安装
pnpm add enquirer
基础用法
import { prompt } from 'enquirer';

const response = await prompt({
  type: 'input',
  name: 'projectName',
  message: '项目名称',
  initial: 'my-project',
});

console.log(response);

交互式输入工具对比

工具 体积 配置复杂度 功能丰富度 生态 适用场景
inquirer 较大 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 功能全面的 CLI 工具
prompts 轻量级 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ 简单交互场景
enquirer 中等 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ 需要自定义提示符

选型建议

  • 功能全面的 CLI 工具:inquirer(推荐)
  • 简单交互场景:prompts
  • 需要自定义提示符:enquirer

终端美化

chalk(推荐)

chalk 是 Node.js 终端日志彩色打印工具,核心用途是给终端输出的文字添加颜色、背景色、加粗 / 下划线等样式。

优势

  • ✅ 功能丰富,API 友好
  • ✅ 支持链式调用
  • ✅ 生态完善

劣势

  • ❌ 体积较大(相比 picocolors)
安装
pnpm add chalk
封装日志函数
// src/utils/logger.ts
import chalk from 'chalk';

export enum LogType {
  SUCCESS = 'success',
  ERROR = 'error',
  WARN = 'warn',
  INFO = 'info',
}

export const log = (message: string, type: LogType = LogType.INFO) => {
  const prefixMap = {
    [LogType.SUCCESS]: chalk.green('✅'),
    [LogType.ERROR]: chalk.bold.red('❌'),
    [LogType.WARN]: chalk.yellow('⚠️'),
    [LogType.INFO]: chalk.blue('ℹ️'),
  };

  const colorMap = {
    [LogType.SUCCESS]: chalk.green,
    [LogType.ERROR]: chalk.red,
    [LogType.WARN]: chalk.yellow,
    [LogType.INFO]: chalk.blue,
  };

  const prefix = prefixMap[type];
  const color = colorMap[type];
  console.log(`${prefix} ${color(message)}`);
};

export const logSuccess = (message: string) => log(message, LogType.SUCCESS);
export const logError = (message: string) => log(message, LogType.ERROR);
export const logWarn = (message: string) => log(message, LogType.WARN);
export const logInfo = (message: string) => log(message, LogType.INFO);
使用
import { logSuccess, logError, logWarn, logInfo } from './utils/logger';

logInfo('正在初始化项目...');
logWarn('当前 Node 版本低于推荐版本');
logSuccess('项目初始化完成!');
logError('配置文件缺失,请检查 config.json');

picocolors

picocolors 是极轻量的终端颜色库,体积仅 0.5KB。

优势

  • ✅ 极轻量(0.5KB)
  • ✅ API 简洁
  • ✅ 性能好

劣势

  • ❌ 功能相对简单
  • ❌ 不支持链式调用
安装
pnpm add picocolors
基础用法
import pc from 'picocolors';

console.log(pc.green('成功'));
console.log(pc.red('错误'));
console.log(pc.bold(pc.blue('加粗蓝色')));

kleur

kleur 是轻量级的终端颜色库,API 类似 chalk。

优势

  • ✅ 轻量级(体积小)
  • ✅ API 类似 chalk,迁移成本低
  • ✅ 支持链式调用

劣势

  • ❌ 功能相对简单
安装
pnpm add kleur
基础用法
import kleur from 'kleur';

console.log(kleur.green('成功'));
console.log(kleur.red('错误'));
console.log(kleur.bold().blue('加粗蓝色'));

ora(展示加载动画)

ora 是一款 Node.js 终端加载动画工具,核心用途是在耗时操作时显示「加载中」动画 + 提示文字。

安装
pnpm add ora
封装加载动画函数
// src/utils/loader.ts
import ora from 'ora';
import chalk from 'chalk';

export const withLoader = async <T>(message: string, asyncFn: () => Promise<T>): Promise<T> => {
  const spinner = ora(chalk.bold.blue(message)).start();
  try {
    const result = await asyncFn();
    spinner.succeed(chalk.green('✅ 操作完成!'));
    return result;
  } catch (error) {
    spinner.fail(chalk.bold.red(`❌ 操作失败:${(error as Error).message}`));
    throw error;
  }
};
使用示例
import { withLoader } from './utils/loader';

await withLoader('正在请求接口数据...', async () => {
  await new Promise((resolve) => setTimeout(resolve, 1500));
});
高级用法
import ora from 'ora';

const spinner = ora('加载中...').start();

// 更新文本
spinner.text = '处理中...';

// 成功
spinner.succeed('完成!');

// 失败
spinner.fail('失败!');

// 警告
spinner.warn('警告!');

// 信息
spinner.info('信息');

cli-progress(进度条)

cli-progress 用于显示文件上传/下载、批量处理等操作的进度。

安装
pnpm add cli-progress
基础用法
import cliProgress from 'cli-progress';

const bar = new cliProgress.SingleBar({
  format: '进度 |{bar}| {percentage}% | {value}/{total}',
  barCompleteChar: '\u2588',
  barIncompleteChar: '\u2591',
  hideCursor: true,
});

bar.start(100, 0);

// 模拟进度
for (let i = 0; i <= 100; i++) {
  await new Promise((resolve) => setTimeout(resolve, 50));
  bar.update(i);
}

bar.stop();

boxen(边框框)

boxen 用于在终端中创建带边框的文本框,适合显示重要信息。

安装
pnpm add boxen
基础用法
import boxen from 'boxen';
import chalk from 'chalk';

const message = boxen(chalk.green('✅ 项目初始化完成!'), {
  padding: 1,
  margin: 1,
  borderStyle: 'round',
  borderColor: 'green',
});

console.log(message);

cli-table3(表格展示)

cli-table3 用于在终端中展示表格数据,适合显示配置信息、对比数据等。

安装
pnpm add cli-table3
pnpm add @types/cli-table3 -D
基础用法
import Table from 'cli-table3';

const table = new Table({
  head: ['工具', '用途', '推荐度'],
  colWidths: [20, 30, 10],
});

table.push(
  ['commander', '命令行解析', '⭐⭐⭐⭐⭐'],
  ['inquirer', '交互式输入', '⭐⭐⭐⭐⭐'],
  ['chalk', '终端美化', '⭐⭐⭐⭐⭐'],
);

console.log(table.toString());

终端美化工具对比

工具 体积 功能 适用场景
chalk 较大 颜色、样式 功能丰富的 CLI 工具
picocolors 极轻量 基础颜色 对体积敏感的项目
kleur 轻量级 颜色、样式 轻量级 CLI 工具
ora 中等 加载动画 耗时操作提示
cli-progress 中等 进度条 文件处理进度
boxen 轻量级 边框框 重要信息展示
cli-table3 中等 表格 数据展示

选型建议

  • 功能丰富的 CLI 工具:chalk(推荐)
  • 对体积敏感:picocolors
  • 需要进度条:cli-progress
  • 需要表格展示:cli-table3

文件操作

fs-extra(操作文件系统)

fs-extra 在 Node.js 原生 fs 模块基础上做了增强,核心优势:

  • 完全兼容原生 fs 模块(可直接替换 fs 使用)
  • 所有 API 支持 Promise(无需手动封装 util.promisify)
  • 新增高频实用功能(递归创建目录、递归删除目录、复制文件 / 目录等)
安装
pnpm add fs-extra
pnpm add @types/fs-extra -D
核心用法
import fs from 'fs-extra';
import path from 'path';

// 递归创建目录
await fs.ensureDir(path.resolve(__dirname, 'a/b/c'));

// 递归删除目录
await fs.remove(path.resolve(__dirname, 'a'));

// 复制文件/目录
await fs.copy(src, dest);

// 写入 JSON 文件
await fs.writeJson(jsonPath, { name: 'test', version: '1.0.0' }, { spaces: 2 });

// 读取 JSON 文件
const config = await fs.readJson(jsonPath);

// 判断文件/目录是否存在
const exists = await fs.pathExists(path);
常用 API
API 名称 核心用途 优势对比(vs 原生 fs)
fs.ensureDir(path) 递归创建目录(不存在则创建,存在则忽略) 原生需手动递归,fs-extra 一键实现
fs.remove(path) 递归删除文件 / 目录(支持任意层级) 原生需先遍历目录,fs-extra 一键删除
fs.copy(src, dest) 复制文件 / 目录(自动创建目标目录) 原生需区分文件 / 目录,fs-extra 自动适配
fs.writeJson(path, data) 写入 JSON 文件(自动 stringify) 原生需手动 JSON.stringify,fs-extra 简化步骤
fs.readJson(path) 读取 JSON 文件(自动 parse) 原生需手动 JSON.parse,fs-extra 简化步骤
fs.pathExists(path) 判断文件 / 目录是否存在(返回 boolean) 原生需用 fs.access 捕获错误,fs-extra 直接返回

glob(匹配文件)

glob 解决「按规则批量查找文件」的需求,支持用通配符(如 ***?)匹配文件路径。

安装
pnpm add glob
pnpm add @types/glob -D
核心用法
import glob from 'glob';
import path from 'path';

// 同步匹配
const files = glob.sync('src/**/*.ts', {
  cwd: process.cwd(),
  ignore: ['src/test/**/*'],
});

// 异步匹配(推荐)
const files = await glob.promise('src/**/*.{ts,js}', {
  cwd: process.cwd(),
  dot: true,
});

// 流式匹配(适合大量文件)
const stream = glob.stream('src/**/*.ts');
stream.on('data', (filePath) => {
  console.log('匹配到文件:', filePath);
});
常见通配符规则
通配符 含义 示例 匹配结果
* 匹配当前目录下的任意字符(不含子目录) src/*.ts src/index.tssrc/utils.ts
** 匹配任意层级的目录(递归) src/**/*.ts src/index.tssrc/a/b/utils.ts
? 匹配单个字符 src/file?.ts src/file1.tssrc/file2.ts
[] 匹配括号内的任意一个字符 src/[ab].ts src/a.tssrc/b.ts
! 排除匹配的文件 src/**/*.ts + !src/test.ts 所有 .ts 文件,排除 src/test.ts

模板生成

handlebars(生成模板文件)

handlebars 是一款逻辑少、轻量型的模板引擎,核心用途是「将数据与模板结合,动态生成文本内容」。

安装
pnpm add handlebars
pnpm add @types/handlebars -D
基础用法
import handlebars from 'handlebars';

// 1. 定义模板
const template = `{
  "name": "{{ projectName }}",
  "version": "{{ version }}",
  "description": "{{ description }}"
}`;

// 2. 编译模板
const compiledTemplate = handlebars.compile(template);

// 3. 传入数据渲染
const data = {
  projectName: 'my-ts-pkg',
  version: '1.0.0',
  description: '基于 TS + 双模式的 npm 包',
};

const result = compiledTemplate(data);
console.log(result);
核心语法
  • 变量占位符{{ 变量名 }}(支持嵌套对象)
  • 条件判断{{#if 条件}}...{{else}}...{{/if}}
  • 循环遍历{{#each 数组}}...{{/each}}
  • 注释{{! 注释内容 }}
高级用法
// 注册辅助函数
handlebars.registerHelper('uppercase', (str) => {
  return str.toUpperCase();
});

// 使用辅助函数
const template = `{{ uppercase name }}`;

完整示例

结合所有工具,实现一个完整的 CLI 工具:

import { program } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import fs from 'fs-extra';
import glob from 'glob';
import handlebars from 'handlebars';
import path from 'path';
import boxen from 'boxen';

program.version('1.0.0', '-v, --version').description('CLI 工具示例');

program
  .command('init [projectName]')
  .description('初始化项目')
  .option('-t, --template <template>', '模板类型', 'default')
  .option('-y, --yes', '跳过交互式询问', false)
  .action(async (projectName, options) => {
    console.log(chalk.blue('📦 开始初始化项目...'));

    let answers: any = {};

    // 如果提供了项目名称且使用了 --yes,跳过交互
    if (projectName && options.yes) {
      answers = {
        projectName,
        framework: 'react-ts',
        modules: ['路由', 'ESLint/Prettier'],
        initGit: true,
      };
    } else {
      // 交互式询问
      answers = await inquirer.prompt([
        {
          type: 'input',
          name: 'projectName',
          message: '项目名称',
          default: projectName || 'my-project',
          validate: (value) => {
            if (!value.trim()) return '项目名称不能为空!';
            if (!/^[a-z0-9-]+$/.test(value)) {
              return '项目名称只能包含小写字母、数字和连字符!';
            }
            return true;
          },
        },
        {
          type: 'list',
          name: 'framework',
          message: '选择框架',
          choices: [
            { name: 'React + TypeScript', value: 'react-ts' },
            { name: 'Vue + TypeScript', value: 'vue-ts' },
            { name: 'Vanilla JS', value: 'vanilla' },
          ],
          default: 'react-ts',
        },
        {
          type: 'checkbox',
          name: 'modules',
          message: '选择功能模块',
          choices: ['路由', '状态管理', 'UI 组件库', 'ESLint/Prettier'],
          default: ['路由', 'ESLint/Prettier'],
        },
        {
          type: 'confirm',
          name: 'initGit',
          message: '是否初始化 Git 仓库?',
          default: true,
        },
      ]);
    }

    const spinner = ora(chalk.blue('正在生成项目文件...')).start();

    try {
      // 1. 检查目录是否存在
      const targetDir = path.resolve(process.cwd(), answers.projectName);
      if (await fs.pathExists(targetDir)) {
        spinner.fail(chalk.red(`目录 ${answers.projectName} 已存在!`));
        process.exit(1);
      }

      // 2. 创建项目目录
      await fs.ensureDir(targetDir);

      // 3. 读取模板文件
      const templateDir = path.resolve(__dirname, '../templates', options.template);
      const templateFiles = await glob.promise('**/*.hbs', {
        cwd: templateDir,
        dot: true,
      });

      // 4. 渲染模板并写入文件
      for (const templateFile of templateFiles) {
        const templatePath = path.resolve(templateDir, templateFile);
        const templateContent = await fs.readFile(templatePath, 'utf8');

        const compiled = handlebars.compile(templateContent);
        const renderedContent = compiled(answers);

        const targetFile = templateFile.replace(/\.hbs$/, '');
        const targetPath = path.resolve(targetDir, targetFile);

        await fs.ensureDir(path.dirname(targetPath));
        await fs.writeFile(targetPath, renderedContent, 'utf8');
      }

      // 5. 初始化 Git(如果选择)
      if (answers.initGit) {
        spinner.text = '正在初始化 Git 仓库...';
        // 这里可以调用 git 命令
      }

      spinner.succeed(chalk.green('项目初始化完成!'));

      // 6. 显示成功信息
      const successMessage = boxen(
        chalk.green(`✅ 项目 ${answers.projectName} 创建成功!\n\n`) +
          chalk.cyan(`cd ${answers.projectName}\n`) +
          chalk.cyan('npm install\n') +
          chalk.cyan('npm run dev'),
        {
          padding: 1,
          margin: 1,
          borderStyle: 'round',
          borderColor: 'green',
        },
      );

      console.log(successMessage);
    } catch (error) {
      spinner.fail(chalk.red(`初始化失败:${(error as Error).message}`));
      process.exit(1);
    }
  });

program
  .command('build')
  .description('构建项目')
  .option('-e, --env <env>', '构建环境', 'production')
  .option('-o, --outDir <dir>', '输出目录', 'dist')
  .action(async (options) => {
    const spinner = ora(chalk.blue('正在构建项目...')).start();

    try {
      // 模拟构建过程
      await new Promise((resolve) => setTimeout(resolve, 2000));

      spinner.succeed(chalk.green(`构建完成!输出目录:${options.outDir}`));
    } catch (error) {
      spinner.fail(chalk.red(`构建失败:${(error as Error).message}`));
      process.exit(1);
    }
  });

program.parse(process.argv);

最佳实践

  1. 错误处理:使用 try-catch 捕获错误,提供友好的错误提示
  2. 用户体验:使用 ora 显示加载状态,使用 chalk 美化输出
  3. 参数验证:在 inquirer 中使用 validate 验证用户输入
  4. 文件操作:使用 fs-extra 的 Promise API,避免回调地狱
  5. 模板管理:将模板文件放在独立目录,使用 glob 批量处理
  6. 命令结构:使用 commander 组织命令,保持清晰的命令层次
  7. 帮助信息:为每个命令添加清晰的描述和示例

常见问题

命令行解析相关问题

Q: commander 和 yargs 如何选择?

A:

  • commander:API 友好,适合大多数场景(推荐)
  • yargs:需要位置参数或复杂参数解析时使用

Q: 如何获取未定义的选项?

A:

// commander
program.parse();
const unknownOptions = program.opts();

// yargs
const argv = yargs.parse();
const unknown = argv._; // 未定义的参数

交互式输入相关问题

Q: inquirer 和 prompts 如何选择?

A:

  • inquirer:功能全面,生态丰富(推荐)
  • prompts:轻量级,简单场景使用

Q: 如何中断交互式输入?

A:

// inquirer 会自动处理 Ctrl+C
// prompts 需要手动处理
const response = await prompts({
  type: 'text',
  name: 'value',
  message: '输入值',
  onCancel: () => {
    console.log('已取消');
    process.exit(0);
  },
});

终端美化相关问题

Q: chalk 和 picocolors 如何选择?

A:

  • chalk:功能丰富,适合大多数场景(推荐)
  • picocolors:对体积敏感的项目使用

Q: 如何检测终端是否支持颜色?

A:

import chalk from 'chalk';

// chalk 会自动检测,不支持时自动禁用颜色
// 手动检测
const supportsColor = chalk.supportsColor;

文件操作相关问题

Q: fs-extra 和原生 fs 的区别?

A:

  • fs-extra:Promise API、递归操作、JSON 操作更便捷
  • 原生 fs:需要手动封装 Promise、手动递归

Q: glob 如何排除多个文件?

A:

const files = await glob.promise('src/**/*.ts', {
  ignore: ['src/test/**/*', 'src/**/*.test.ts'],
});

模板生成相关问题

Q: handlebars 和其他模板引擎的区别?

A:

  • handlebars:逻辑少、轻量级(推荐)
  • ejs:支持 JavaScript 代码,功能强大但体积大
  • mustache:无逻辑模板,但功能较少

Q: 如何在模板中使用辅助函数?

A:

handlebars.registerHelper('eq', (a, b) => a === b);

// 模板中使用
// {{#if (eq value "test")}}...{{/if}}

参考资源

vue3+ant-design-vue

1.:deep

scoped 会限制样式仅作用于当前组件的元素,deep() 是用于穿透 scoped 样式作用域的语法,核心作用是修改子组件 / 第三方组件(如 Ant Design Vue)内部的样式。

当 <style> 标签加上 scoped 时,Vue 会给当前组件的所有元素自动添加一个唯一的属性(如 data-v-xxxxxx),样式会被编译为「选择器 + 该属性」的形式(比如 .my-class[data-v-xxxxxx])。

子组件 / 第三方组件(如 Ant Design 的 Pagination)的内部元素不会继承这个属性,所以直接写子组件的类名(比如 .ant-pagination-options)会失效 —— 此时需要用 :deep() 包裹选择器,让样式 “穿透” 到子组件内部。

<template>
  <!-- 你的分页组件 -->
  <Pagination
    v-model:current="pageIndex"
    v-model:pageSize="pageSize"
    :total="total"
    show-size-changer
  />
</template>

这是没有添加样式时源代码

企业微信截图_17642939291330.png

<style lang="scss" scoped>
    :deep(.ant-pagination-options) {
        margin-left: 20px;
    }
</style>

进行样式穿透之后,样式就被加入进去,实现效果:条数选择间距变大 企业微信截图_1764293991149.png

关键注意事项

  1. 必须配合 scoped:只有 <style scoped> 才需要 :deep,全局样式(无 scoped)直接写选择器即可。

  2. 避免滥用:deep 会让样式作用于子组件,过度使用可能导致样式污染(建议配合父类名精准定位,比如 .custom-pagination:deep(...))。

  3. 选择器要精准:先通过浏览器开发者工具(F12)找到目标元素的类名(比如 Ant Design 分页的 “条数选择器” 类名是 .ant-pagination-options),再用 :deep 包裹。

  4. 若样式不生效,可在属性后加 !important 强制覆盖。

        <Pagination
            class="custom-pagination" //额外增加类名,防止使用:deep穿透造成污染
            v-model:current="pageIndex"
            v-model:pageSize="pageSize"
            :total="total"
            :show-total="(total) => `共 ${total} 条`"
        />
        
    .custom-pagination:deep(.ant-pagination-options) {
        margin-left: 80px;
    }
    
    

企业微信截图_17642944127493.png

效果:

企业微信截图_17642944306831.png

原本分页展示:

企业微信截图_176429448418.png

改自己的子组件样式

如果一个自定义子组件 <ChildComp>,其内部有类名 .child-item,父组件要修改它:

<template>
  <!-- 父组件中使用子组件 -->
  <ChildComp />
</template>

<style scoped>
/* 穿透到 ChildComp 内部,修改 .child-item 的样式 */
:deep(.child-item) {
  color: red; /* 子组件的 .child-item 文字变成红色 */
}
</style>

样式预处理器兼容(SCSS/Sass)

SCSS/Sass,:deep 的写法不变

<style scoped lang="scss">
.custom-pagination {
  // 嵌套写法也支持
  :deep(..custom-pagination) {
    margin-right: 10px;
    &:hover {
      border-color: red; // hover时的边框颜色
    }
  }
}
</style>

Vue2 与 Vue3 的写法区别

  • Vue2 中是 /deep/::v-deep>>>(不同预处理器写法不同)。
  • Vue3 在 <style scoped> 中统一推荐用 :deep() ,兼容性更好。

Transition

Vue3 中的 <Transition> 是内置的过渡动画组件,用于给 单个元素 / 组件 的 进入 / 离开 提供平滑动画效果(如淡入淡出、滑动等)。核心原理是通过动态添加 / 移除 CSS 类名,控制动画的生命周期。

1. 过渡生命周期与类名

Vue3 中过渡类名相比 Vue2 有调整(更语义化),共 6 个核心类名(默认前缀为 v-,可通过 name 属性自定义):

类名 作用时机 说明
v-enter-from 进入动画开始前(初始状态) 动画开始时添加,下一帧移除
v-enter-active 进入动画进行中(过渡状态) 动画开始时添加,动画结束后移除
v-enter-to 进入动画结束后(目标状态) 下一帧添加,动画结束后移除
v-leave-from 离开动画开始前(初始状态) 动画开始时添加,下一帧移除
v-leave-active 离开动画进行中(过渡状态) 动画开始时添加,动画结束后移除
v-leave-to 离开动画结束后(目标状态) 下一帧添加,动画结束后移除

简单示例:

 <!-- 币别搜索过滤框:货币符合触发搜索时调用,在预览页面显示。 Transition vue组件 实现过渡效果 -->
<Transition name="fade">
    <searching
        v-if="showSearchFilter"
        v-model:visible="showSearchFilter"
    />
</Transition>

/* 淡入淡出过渡效果 */
.fade-enter-from {
    opacity: 0; /* 初始状态:完全透明 */
}
.fade-enter-active {
    transition: opacity 0.3s ease-out; /* 进入动画:0.3 秒淡出效果 */
}
.fade-leave-from {
    opacity: 1; /* 离开开始状态:完全不透明 */
}
.fade-leave-active {
    transition: opacity 0.3s ease-in; /* 离开动画:0.3 秒淡入效果 */
    opacity: 0; /* 离开结束状态:完全透明 */
}

2. 触发条件

  • 条件渲染(v-if/v-show
  • 动态组件(<component :is="xxx">
  • 路由切换(结合 vue-router
  • 元素的 key 变化
<template>
  <!-- 1. 用 Transition 包裹目标元素 -->
  <Transition name="fade">
    <!-- 2. 触发条件:v-if/v-show -->
    <div v-if="isShow" class="box">过渡动画示例</div>
  </Transition>

  <button @click="isShow = !isShow">切换显示</button>
</template>

<script setup>
import { ref } from 'vue'
const isShow = ref(false) // 控制元素显示/隐藏
</script>

<!-- 3. 编写过渡动画 CSS -->
<style scoped>
.box {
  width: 200px;
  height: 200px;
  background: #409eff;
  color: white;
  text-align: center;
  line-height: 200px;
}

/* 进入动画:淡入 + 缩放 */
.fade-enter-from {
  opacity: 0; /* 初始透明 */
  transform: scale(0.8); /* 初始缩小 */
}
.fade-enter-active {
  transition: all 0.5s ease; /* 过渡时长和曲线 */
}
.fade-enter-to {
  opacity: 1; /* 结束不透明 */
  transform: scale(1); /* 结束原尺寸 */
}

/* 离开动画:淡出 + 缩放 */
.fade-leave-from {
  opacity: 1;
  transform: scale(1);
}
.fade-leave-active {
  transition: all 0.5s ease;
}
.fade-leave-to {
  opacity: 0;
  transform: scale(0.8);
}
</style>
  • name="fade":指定过渡类名前缀,此时类名从 v-xxx 变为 fade-xxx(避免全局样式冲突)。
  • 必须包裹 单个根元素(若需多个元素,用 <div> 包裹成一个根节点)。
  • 动画由 transition CSS 属性控制(也支持 animation 动画)。

三、使用 Animation 动画(而非 Transition)

如果需要更复杂的动画(如循环、关键帧),可使用 CSS animation 配合 <Transition>

<template>
  <Transition name="bounce">
    <div v-if="isShow" class="box">动画示例</div>
  </Transition>
</template>

<style scoped>
/* 定义关键帧动画 */
@keyframes bounce-in {
  0% { transform: scale(0); }
  50% { transform: scale(1.2); }
  100% { transform: scale(1); }
}

@keyframes bounce-out {
  0% { transform: scale(1); }
  50% { transform: scale(1.2); }
  100% { transform: scale(0); }
}

/* 进入动画:使用 animation */
.bounce-enter-active {
  animation: bounce-in 0.5s ease;
}
/* 离开动画:使用 animation */
.bounce-leave-active {
  animation: bounce-out 0.5s ease;
}

/* 可选:设置动画结束后的状态(避免闪回) */
.bounce-enter-to,
.bounce-leave-from {
  transform: scale(1);
}
</style>

常见场景拓展

1. 动态组件过渡

给动态切换的组件加过渡:

<template>
  <Transition name="fade" mode="out-in">
    <component :is="currentComponent" key="currentComponent" />
  </Transition>

  <button @click="currentComponent = currentComponent === 'A' ? 'B' : 'A'">
    切换组件
  </button>
</template>

<script setup>
import { ref } from 'vue'
import A from './A.vue'
import B from './B.vue'
const currentComponent = ref('A')
</script>
  • mode="out-in":过渡模式,先执行离开动画,再执行进入动画(避免两个组件重叠)。
  • 必加 key:确保组件切换时触发过渡。

2. 路由过渡(结合 vue-router)

给路由切换加全局过渡:

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component }">
    <Transition name="route-fade">
      <component :is="Component" />
    </Transition>
  </router-view>
</template>

<style>
/* 路由过渡样式 */
.route-fade-enter-from,
.route-fade-leave-to {
  opacity: 0;
  transform: translateX(20px);
}
.route-fade-enter-active,
.route-fade-leave-active {
  transition: all 0.3s ease;
}
</style>

3. 列表过渡(TransitionGroup)

<Transition> 仅支持单个元素,列表过渡需用 <TransitionGroup>(包裹多个元素,需给每个元素加 key):

<template>
  <TransitionGroup name="list" tag="ul">
    <li v-for="item in list" :key="item.id" class="list-item">
      {{ item.name }}
    </li>
  </TransitionGroup>

  <button @click="addItem">添加项</button>
</template>

<script setup>
import { ref } from 'vue'
const list = ref([{ id: 1, name: '项1' }, { id: 2, name: '项2' }])

const addItem = () => {
  list.value.push({ id: Date.now(), name: `项${list.value.length + 1}` })
}
</script>

<style scoped>
ul { list-style: none; padding: 0; }
.list-item {
  margin: 10px 0;
  padding: 10px;
  background: #f5f5f5;
}

/* 列表项过渡样式 */
.list-enter-from {
  opacity: 0;
  transform: translateY(10px);
}
.list-enter-active {
  transition: all 0.3s ease;
}
.list-leave-active {
  transition: all 0.3s ease;
  opacity: 0;
  transform: translateY(-10px);
}
</style>
  • tag="ul":指定 <TransitionGroup> 渲染为 <ul> 标签(默认不渲染根标签)。
  • 每个列表项必须有唯一 key

4.JavaScript 钩子(控制复杂动画)

如果需要通过 JS 控制动画(如回调、异步动画),可使用 <Transition> 的钩子函数:

<template>
  <Transition
    @enter="onEnter"
    @leave="onLeave"
    :css="false" <!-- 禁用 CSS 过渡,完全由 JS 控制 -->
  >
    <div v-if="isShow" ref="boxRef" class="box">JS 控制动画</div>
  </Transition>
</template>

<script setup>
import { ref } from 'vue'
const isShow = ref(false)
const boxRef = ref(null)

// 进入动画钩子
const onEnter = (el, done) => {
  el.style.opacity = 0
  el.style.transform = 'scale(0.8)'
  // 用 requestAnimationFrame 触发动画
  requestAnimationFrame(() => {
    el.style.transition = 'all 0.5s ease'
    el.style.opacity = 1
    el.style.transform = 'scale(1)'
    // 动画结束后调用 done() 通知 Vue
    el.addEventListener('transitionend', done)
  })
}

// 离开动画钩子
const onLeave = (el, done) => {
  el.style.transition = 'all 0.5s ease'
  el.style.opacity = 0
  el.style.transform = 'scale(0.8)'
  el.addEventListener('transitionend', done)
}
</script>
  • :css="false":必须设置,避免 Vue 自动添加 CSS 类名干扰。
  • 钩子函数的 done 参数:必须在动画结束后调用(如 transitionend 事件),否则 Vue 会认为动画立即结束。
  1. 必须包裹单个根元素<Transition> 只能有一个直接子元素(列表用 <TransitionGroup>)。
  2. key 的重要性:动态组件、路由、列表项必须加 key,否则 Vue 可能复用元素,不触发过渡。
  3. 过渡模式mode="out-in"(先出后进)、mode="in-out"(先进后出),避免组件重叠。
  4. 样式作用域:如果用 scoped 样式,需用 ::v-deep 或 :deep() 穿透(如修改第三方组件的过渡样式)。
  5. 禁用过渡:通过 :disabled="true" 动态禁用过渡(如某些条件下不需要动画)。

图片标签用 img 还是 picture?很多人彻底弄混了!

在网页开发中,图片处理是每个前端开发者都会遇到的基础任务。面对 <img><picture> 这两个标签,很多人存在误解:要么认为它们是互相替代的关系,要么在不合适的场景下使用了复杂的解决方案。今天,我们来彻底理清这两个标签的真正用途。

<img> 标签

<img> 是 HTML 中最基础且强大的图片标签,但它远比很多人想象的要智能。

基本语法:

<img src="image.jpg" alt="图片描述">

核心属性:

  • src:图片路径(必需)
  • alt:替代文本(无障碍必需)
  • srcset:提供多分辨率图片源
  • sizes:定义图片显示尺寸
  • loading:懒加载控制

<img> 的响应式能力被低估了

很多人认为 <img> 不具备响应式能力,这是错误的认知:

<img 
  src="image-800w.jpg"
  srcset="image-320w.jpg 320w,
          image-480w.jpg 480w,
          image-800w.jpg 800w"
  sizes="(max-width: 600px) 100vw,
         (max-width: 1200px) 50vw,
         33vw"
  alt="响应式图片示例"
>

这种写法的优势:

  • 浏览器自动选择最适合当前屏幕分辨率的图片
  • 根据视口大小动态调整加载的图片尺寸
  • 代码简洁,性能优秀

<picture> 标签

<picture> 不是为了替代 <img>,而是为了解决 <img> 无法处理的特定场景。

<picture> 解决的三大核心问题

1. 艺术指导(Art Direction) 在不同设备上显示不同构图或裁剪的图片:

<picture>
  <!-- 桌面端:宽屏全景 -->
  <source media="(min-width: 1200px)" srcset="hero-desktop.jpg">
  <!-- 平板端:适中裁剪 -->
  <source media="(min-width: 768px)" srcset="hero-tablet.jpg">
  <!-- 移动端:竖版特写 -->
  <img src="hero-mobile.jpg" alt="产品展示">
</picture>

2. 现代格式降级 优先使用高效格式,同时兼容老旧浏览器:

<picture>
  <source type="image/avif" srcset="image.avif">
  <source type="image/webp" srcset="image.webp">
  <img src="image.jpg" alt="格式优化示例">
</picture>

3. 复杂条件组合 同时考虑屏幕尺寸和图片格式:

<picture>
  <!-- 大屏 + AVIF -->
  <source media="(min-width: 1200px)" type="image/avif" srcset="large.avif">
  <!-- 大屏 + WebP -->
  <source media="(min-width: 1200px)" type="image/webp" srcset="large.webp">
  <!-- 大屏降级 -->
  <source media="(min-width: 1200px)" srcset="large.jpg">
  
  <!-- 移动端方案 -->
  <img src="small.jpg" alt="复杂条件图片">
</picture>

关键区别与选择指南

场景 推荐方案 原因
同一图片,不同分辨率 <img> + srcset + sizes 代码简洁,浏览器自动优化
不同构图或裁剪 <picture> 艺术指导必需
现代格式兼容 <picture> 格式降级必需
简单静态图片 <img> 无需复杂功能
兼容老旧浏览器 <img> 最广泛支持

常见误区纠正

误区一:<picture> 用于响应式图片

  • 事实: <img> 配合 srcsetsizes 已经能处理大多数响应式需求
  • 真相: <picture> 主要用于艺术指导和格式降级

误区二:<picture> 更现代,应该优先使用

  • 事实: 在不需要艺术指导或格式降级的场景下,<img> 是更好的选择
  • 真相: 合适的工具用在合适的场景才是最佳实践

误区三:响应式图片一定要用 <picture>

  • 事实: 很多响应式场景用 <img> + srcset 更合适
  • 真相: 评估需求,选择最简单的解决方案

场景分析

应该使用 <img> 的场景

网站Logo:

<img src="logo.svg" alt="公司Logo" width="120" height="60">

用户头像:

<img 
  src="avatar.jpg"
  srcset="avatar.jpg 1x, avatar@2x.jpg 2x"
  alt="用户头像"
  width="80" 
  height="80"
>

文章配图:

<img 
  src="article-image.jpg"
  srcset="article-image-600w.jpg 600w,
          article-image-1200w.jpg 1200w"
  sizes="(max-width: 768px) 100vw, 600px"
  alt="文章插图"
  loading="lazy"
>

应该使用 <picture> 的场景

英雄横幅(不同裁剪):

<picture>
  <source media="(min-width: 1024px)" srcset="hero-wide.jpg">
  <source media="(min-width: 768px)" srcset="hero-square.jpg">
  <img src="hero-mobile.jpg" alt="产品横幅" loading="eager">
</picture>

产品展示(格式优化):

<picture>
  <source type="image/avif" srcset="product.avif">
  <source type="image/webp" srcset="product.webp">
  <img src="product.jpg" alt="产品详情" loading="lazy">
</picture>

最佳实践

1. 始终遵循的规则

<!-- 正确:始终提供 alt 属性 -->
<img src="photo.jpg" alt="描述文本">

<!-- 错误:缺少 alt 属性 -->
<img src="photo.jpg">

<!-- 装饰性图片使用空 alt -->
<img src="decoration.jpg" alt="">

2. 性能优化策略

<!-- 优先加载关键图片 -->
<img src="hero.jpg" alt="重要图片" loading="eager" fetchpriority="high">

<!-- 非关键图片延迟加载 -->
<img src="content-image.jpg" alt="内容图片" loading="lazy">

<!-- 指定尺寸避免布局偏移 -->
<img src="product.jpg" alt="商品" width="400" height="300">

3. 现代图片格式策略

<picture>
  <!-- 优先使用AVIF,压缩率最高 -->
  <source type="image/avif" srcset="image.avif">
  <!-- 其次WebP,广泛支持 -->
  <source type="image/webp" srcset="image.webp">
  <!-- 最终回退到JPEG -->
  <img src="image.jpg" alt="现代格式示例">
</picture>

总结

<img><picture> 不是竞争关系,而是互补的工具:

  • <img>:处理大多数日常图片需求,特别是分辨率适配
  • <picture>:解决特定复杂场景,如艺术指导和格式降级

核心建议:

  1. 从最简单的 <img> 开始,只在必要时升级到 <picture>
  2. 充分利用 <img>srcsetsizes 属性
  3. 为关键图片使用 <picture> 进行格式优化
  4. 始终考虑性能和用户体验

掌握这两个标签的正确用法,你就能在各种场景下都做出最合适的技术选择,既保证用户体验,又避免过度工程化。

希望这篇指南能帮助你彻底理解这两个重要的HTML标签!

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot+Vue3 整合 SSE 实现实时消息推送》

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《SpringBoot 动态菜单权限系统设计的企业级解决方案》

《Vue3和Vue2的核心区别?很多开发者都没完全搞懂的10个细节》

Uptime Kuma修改作为内嵌页面的自适应

8bd3c9d3a7ce456aa6cdd4d140de4d67.png

/* ==================================
1. 基础样式重置(消除默认边距/溢出)
================================== */
/* 重置 html/body 默认边距,禁止页面自身滚动(由 iframe 控制滚动) */
html,
body {
  margin: 0;
  padding: 0;
  overflow: hidden;
}


/* ==================================
2. iframe 适配核心样式(页面缩放逻辑)
================================== */
/* 页面最外层容器缩放配置(适配 iframe 大小)
   注:若实际外层容器不是 .app,需替换为真实类名(如 .container、.status-page 等) */
.app {
  /* 1. 基准尺寸(需根据页面实际设计稿调整,此处为示例值) */
  width: 1000px;  /* 页面理想宽度(基准) */
  height: 600px;  /* 页面理想高度(基准) */
  margin: 0 auto; /* 可选:让容器水平居中(若 iframe 宽度大于基准宽度时生效) */

  /* 2. 按 iframe 宽度自动缩放(核心适配逻辑) */
  transform-origin: top left; /* 缩放原点:左上角(避免缩放偏移) */
  transform: scale(calc(100vw / 1000)); /* 缩放比例 = iframe 宽度 ÷ 基准宽度 */

  /* 3. 同步缩放高度(保证页面比例不变形) */
  height: calc(600 * (100vw / 1000) * 1px);
}


/* ==================================
3. 元素隐藏(按需隐藏指定标签/容器)
================================== */
/* 隐藏所有 h1 标签(全局) */
h1 {
  display: none !important;
}

/* 隐藏含“服务”文本的目标 h2 标签(精准匹配 data-v 属性和类名) */
h2.group-title[data-v-f71ca08e] {
  display: none !important;
}

/* 隐藏指定 alert 容器(匹配 data-v 属性和类名) */
div.alert-heading.p-2[data-v-b8247e57] {
  display: none !important;
}

/* 隐藏页面底部 footer 区域(全局) */
footer {
  display: none !important;
}


/* ==================================
4. 间距调整(覆盖默认 padding/margin)
================================== */
/* 清除 .p-4 类的所有内边距(上下左右均为 0) */
.p-4 {
  padding: 0 !important;
}

/* 清除 .mb-4 类的底部外边距(设为 0) */
.mb-4 {
  margin-bottom: 0 !important;
}

/* 调整 .mt-4 类的顶部外边距(从 1.5rem 改为 0.5rem) */
.mt-4 {
  margin-top: 0.5rem !important;
}
❌