普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月14日掘金 前端

使用Fetch API 探索前后端数据交互

2026年3月14日 01:27

image

前言

  在当今的 Web 开发中,前端与后端的数据交互是构建动态应用的核心。API 是连接不同软件应用的重要桥梁,允许开发者通过 HTTP 请求与服务器交互,高效调用API数据对于构建现代 Web 应用至关重要。传统的页面刷新方式已经无法满足用户对流畅体验的需求,而 Fetch API 的出现为 JavaScript 带来了全新的生命力。

一、Fetch API 概述

1.1 Fetch API 是什么❓

  Fetch API 是现代浏览器提供的一个用于发起网络请求的接口,用于发起 HTTP 请求。它提供简洁的异步API,使开发者能够以更现代的方式与服务器交互。它是传统的 XMLHttpRequest 的替代品,提供了更简洁、更强大的功能。基于 Promise 实现,使异步操作更加直观。

image

  相比于传统的 XMLHttpRequest 更加强大、灵活且易于使用。Fetch 基于 Promise 设计,使得异步请求的处理更加优雅。

特性 Fetch API XMLHttpRequest
语法 基于 Promise,更简洁 回调函数,较复杂
请求/响应对象 标准化 非标准化
默认携带 Cookie 不携带 携带
超时控制 需要额外实现 原生支持
取消请求 使用 AbortController 原生支持
进度事件 有限支持 完整支持

1.2 Fetch 的基本语法

  Fetch API 的基本用法是通过调用 fetch() 函数并传入一个 URL 作为参数来发起网络请求。该函数返回一个Promise对象,可以在其then()方法中处理请求成功的情况,在catch()方法中处理请求失败的情况。Fetch API 最基本的形式如下所示。

fetch(url, options)
.then(response => response.json()) // 解析 JSON 数据
  .then(data => console.log(data))   // 处理数据
.catch(error => console.error('出现错误:', error)); // 错误处理

  上述代码示例展示了使用 Fetch API 发起一个请求,返回的 Promise 解析为响应对象,进而能访问响应体数据。处理响应体通常包含 JSON 数据,通过 .json() 方法解析。如果请求失败,fetch 返回的 promise 会拒绝,并将错误信息传给 catch 方法。

1.3 fetch 配置选项

  fetch 接受第二个可选参数,一个可以控制不同配置的对象,常见属性如下表所示。

配置项 简要描述 常用值
method 请求的 HTTP 方法,默认方法为GET GET、POST、PUT、PATCH、DELETE
headers 请求中 HTTP 标头
body 请求体。
请注意,使用 GET 和 HEAD 方法的请求不能有正文
mode 指定请求的模式。 cors:默认值,允许跨域请求
same-origin:只允许同源请求。
no-cors:不能添加跨域的复杂标头,相当于提交表单所能发出的请求
credentials 指定是否发送 Cookie same-origin:默认值,同源请求时发送 Cookie,跨域请求时不发送
include:不管同源请求,还是跨域请求,一律发送 Cookie
omit:一律不发送
cache 指定如何处理缓存 default:默认值,先在缓存里面寻找匹配的请求
no-store:直接请求远程服务器,并且不更新缓存
reload:直接请求远程服务器,并且更新缓存
no-cache:
force-cache:缓存优先,只有不存在缓存的情况下,才请求远程服务器
only-if-cached:只检查缓存,如果缓存里面不存在,将返回504错误
redirect 如何处理 HTTP 重定向响应,默认设置为follow follow、error、manual
referrer 包含请求的反向链接的字符串,默认为空字符串
referrerPolicy 指定用于请求的反向链接政策
signal AbortSignal 对象实例,支持接口中止请求
priority 指定当前请求相对于其他同类请求的优先级,
默认设置为auto
high、low、auto

二、Fetch API 的基本使用

  Fetch API 支持多种 HTTP 请求方法,如GET、POST、PUT、DELETE等。默认情况下,fetch() 函数会发送 GET 请求。如果需要发送其他类型的请求,可以在fetch() 函数的第二个参数中指定请求的配置对象。

2.1 发起 GET 请求

  GET 请求是最常见的请求类型,用于从服务器获取数据。在Fetch API中,构造一个 GET 请求的URL是一件非常简单的事情。首先需要了解的是,GET请求的参数通常是通过URL的查询字符串(query string)部分传递给服务器的。

fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('出现错误:', error));

  在上述例子中,fetch 执行 GET 请求,在构建URL时,需要确保查询参数是经过URL编码的,以避免查询字符串解析错误。一旦发起 GET 请求,就需要处理服务器返回的响应数据,Fetch API 返回的 response 是一个 Response 对象,可以使用以下任一方法获取响应内容:

方法 简要说明
response.text() 返回一个使用以文本为响应正文解析的 Promise
response.json() 返回一个使用从 JSON 响应中解析的对象解析的 Promise
response.blob() 返回一个使用以 Blob 对象为响应正文解析的 Promise
response.ArrayBuffer() 返回一个使用以 ArrayBuffer 实例为响应正文解析的 Promise
response.formData() 返回一个使用以 FormData 对象为响应正文解析的 Promise

2.2 发起 POST 请求

  POST 请求用于向服务器发送数据,如提交表单或调用 API 提交数据到服务器。通过 Fetch API 调用 POST 请求需要构造一个包含请求体的对象,并将这个对象作为第二个参数传递给 fetch 函数。

const userData = {
  username: 'example',
  email: 'example@example.com'
};

fetch('https://api.example.com/users', {
  method: 'POST',
  body: JSON.stringify(userData)
})
.then(response => response.json())
.then(data => console.log('Success:', data))
.catch(error => console.error('出现错误:', error));

  使用 fetch() 发送 POST 请求的关键是指定要发送至服务器的数据,它可以采用多种格式,包括 JSON、FormData 和文本格式。在上述示例中,我们通过设置 method 为 POST 来发送 POST 请求,并在请求体 body 中发送 JSON 格式的数据。fetch 函数会将这些信息发送到服务器,并等待响应。

2.3 使用 async/await

  Fetch API 支持 async/await 语法,可以更简洁地处理异步操作,如下所示。fetch 接收到的 response 是一个 Stream 对象,response.json() 是一个异步操作,取出所有内容,并将其转为 JSON 对象。

const response = await fetch(url, options);
const data = await response.json();

三、Fetch API 的响应处理

3.1 处理 HTPP 响应

  fetch 请求成功以后,得到的是一个 Response 对象,它对应服务器的 HTTP 响应。

const res=await fetch(url)

  Response 包含的数据通过 Stream 接口异步读取,但它还有一些同步属性,对应 HTTP 回应的标头信息(Headers),如下表所示。

标头属性 类型 简要说明
Response.ok boolean 表示请求是否成功,true 对应的 HTTP 请求状态码200-299,false对应其他的状态码
Response.status number 返回一个数字,表示HTTP响应的状态码
Response.statusText string 表示HTTP响应的状态信息,例如请求成功以后,服务器返回 OK
Response.url string 返回请求的URL。如果URL存在跳转,该属性返回的是最终的URL
Response.type string 返回的是请求的类型。可能为以下值:
 basic:普通请求,即同源请求
 cors:跨域请求
 error:网络错误,主要用于Service Worker
 opaque:如果fetch请求的type属性为no-cors,就会返回这个值,表示发出的是简单的跨域请求
 opaqueredirect:如果fetch请求的redirect属性设为manual,就会返回这个值
Response.redirected boolean 表示请求是否有过重定向

image

3.2 处理请求响应状态

  在处理请求响应时,我们首先检查响应状态是否成功(response.ok),如果不成功则抛出错误。fetch 发出请求后,只有网络错误或无法连接时才会报错,即使服务器返回的状态码 是4xx或5xx。只有通过 Response.status 属性得到 HTTP 响应的真实状态码时,才能判断请求是否成功。

fetch('https://api.example.com/data')
  .then(response => {
    if (!response.ok) {
      throw new Error('网络请求错误,' + response.statusText);
    }
    return response.json();
  })
  .then(data => console.log('Success:', data))
  .catch(error => console.error('出现错误:', error));

3.3 处理不同的响应类型

  当接收到服务器的响应后,通常需要解析响应体。Response 对象根据服务器返回的不同类型的数据,提供了不同的读取方法。这几个方法都是异步的,返回的都是 Promise 对象。必须等到异步操作结束,才能得到服务器返回的完整数据。

方法 简要说明
response.text() 获取文本字符串,主要用于获取文本数据,比如 HTML 文件
response.json() 获取 JSON 对象,主要用于获取服务器返回的 JSON 数据
response.blob() 获取二进制 Blob 对象
response.formData() 获取 FormData 表单对象,主要用于拦截用户提交的表单,修改某些数据后再提交给服务器
response.arrayBuffer() 得到二进制 ArrayBuffer 对象,主要用于获取流媒体文件

  Fetch API 可以处理多种响应格式:

// 处理JSON响应
fetch('/api/data.json')
  .then(response => response.json())
  .then(data => console.log(data));

// 处理文本响应
fetch('/api/data.txt')
  .then(response => response.text())
  .then(text => console.log(text));

// 处理Blob响应(如图片)
fetch('/image.png')
  .then(response => response.blob())
  .then(blob => {
    const objectURL = URL.createObjectURL(blob);
    document.getElementById('image').src = objectURL;
  });

注意,Response 是一个 Stream 对象,而 Stream 对象只能读取一次,读取完就没了。这意味着,上面的几个读取方法,只能使用一个,否则会报错。

四、高级 Fetch 用法

4.1 设置请求头

  在使用Fetch API进行请求时,可以通过 Headers 对象来设置请求头。每个请求或响应都有一个与之关联的 Headers 对象,这个对象包含了请求头和响应头,例如 Content-Type、Authorization 等。

fetch('https://example.com/api', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer your-token'
    },
    body: JSON.stringify({ name: 'John', age: 30 })
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('出现错误:', error));

  Response 对象还有一个 Response.headers 属性,指向一个Headers 对象,对应HTTP响应的所有标头。Headers 对象提供了以下方法来操作标头:

方法 简要说明
Headers.get() 根据指定的键名,返回键值
Headers.has() 返回一个布尔值,表示是否包含某个标头
Headers.set() 将指定的键名设置为新的键值,如果该键名不存在则会添加
Headers.append() 添加标头
Headers.delete() 删除标头
Headers.keys() 返回一个遍历器,可以依次遍历所有键名
Headers.values() 返回一个遍历器,可以依次遍历所有键值
Headers.entries() 返回一个遍历器,可以依次遍历所有键值对([key, value])
Headers.forEach() 依次遍历标头,每个标头都会执行一次参数函数

  上面的有些方法可以修改标头,那是因为继承自 Headers 接口。有些标头不能通过headers属性设置,比如Content-Length、Cookie 、Host等等。它们是由浏览器自动生成,无法修改。这些方法中,最常用的是 response.headers.get(),用于读取某个标头的值。

let response =  await  fetch(url);  
response.headers.get('Content-Type')

4.2 设置请求体参数

  当需要发送POST请求时,经常需要向服务器发送一些数据。使用Fetch API可以很便捷地通过 body 属性发送请求体。

// 发送JSON数据
const data = JSON.stringify({ name: 'John', age: 30 });
 
const options = {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json'
  },
  body: data
};
 
fetch('https://example.com/api/users', options)
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));
 
// 发送表单数据
const formdata = new FormData();
formdata.append('username', 'john');
formdata.append('email', 'john@example.com');
 
fetch('https://example.com/api/register', {
  method: 'POST',
  body: formdata
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));

4.3 设置请求超时

  Fetch API 本身不支持超时设置,但可以通过 AbortController 实现:

const controller = new AbortController();
const signal = controller.signal;

// 设置5秒超时
const timeoutId = setTimeout(() => controller.abort(), 5000);

fetch('https://api.example.com/data', { signal })
  .then(response => response.json())
  .then(data => {
    clearTimeout(timeoutId);
    console.log(data);
  })
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Request timed out');
    } else {
      console.error('Other error:', error);
    }
  });

4.4 跨域请求

  如果需要进行跨域请求,可以在服务器端设置 CORS(Cross-Origin Resource Sharing)。在前端,也可以通过 credentials 选项来指定是否发送 cookies 等凭据。

fetch('https://example.com/api', {
    method: 'GET',
    credentials: 'include' // 允许跨域请求时携带 cookie
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('出现错误:', error));

4.5 上传文件

  如果表单里面有文件选择器,使用 Fetch 上传文件时,可以构造出一个表单,进行上传。

const fileInput = document.querySelector('input[type="file"]');

const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('username', 'exampleUser');

fetch('https://api.example.com/upload', {
  method: 'POST',
  body: formData
  // 注意:不要手动设置Content-Type头,浏览器会自动设置正确的boundary
})
.then(response => response.json())
.then(data => console.log('Upload success:', data))
.catch(error => console.error('Upload error:', error));

4.6 请求取消

  使用 AbortController 取消正在进行的请求:

const controller = new AbortController();

// 开始请求
fetch('https://api.example.com/data', {
  signal: controller.signal
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => {
  if (error.name === 'AbortError') {
    console.log('Request was aborted');
  } else {
    console.error('Error:', error);
  }
});

// 在某个事件中取消请求
document.getElementById('cancel-button').addEventListener('click', () => {
  controller.abort();
});

4.7 并发请求

  使用 Promise.all 处理多个并发请求:

async function fetchMultipleResources() {
  try {
    const [usersResponse, postsResponse] = await Promise.all([
      fetch('https://api.example.com/users'),
      fetch('https://api.example.com/posts')
    ]);

    if (!usersResponse.ok || !postsResponse.ok) {
      throw new Error('One or more requests failed');
    }

    const users = await usersResponse.json();
    const posts = await postsResponse.json();

    console.log('Users:', users);
    console.log('Posts:', posts);

    // 合并数据并更新UI
    displayCombinedData(users, posts);
  } catch (error) {
    console.error('Error fetching data:', error);
  }
}

function displayCombinedData(users, posts) {
  // 实现数据合并和显示逻辑
}

五、总结

  Fetch API 是一个强大而简洁的网络请求 API,它基于 Promise 实现,提供了更好的可读性和可维护性。通过学习和掌握 Fetch API 的基本用法和请求方法,我们可以更轻松地发起网络请求并处理响应结果。同时,我们也需要注意 Fetch API 的一些限制和常见问题,并采取相应的措施来解决它们。

image

用 Cursor 半年了,效率还是没提升?是因为你没用对这 7 个功能

作者 清汤饺子
2026年3月13日 23:06

这篇文章,写给那些已经会用 Cursor、已经在用 AI 辅助编程的前端工程师。 我结合 Cursor 官方文档和实战经验,聊聊哪 7 个功能真正能提升效率。

我踩过的坑

刚开始用 Cursor 的时候,我只会:

"帮我写个登录表单。" "这个报错怎么解决?" "帮我写个 README。"

然后就没有然后了。

我说句扎心的话:这跟你用 ChatGPT 搜代码有啥区别,本质上都是"依赖 AI",只是换了个更智能的工具。

爽了一段时间后,我开始反思:我这几个月效率好像确实提升了,但好像也没有质的飞跃

直到我真正用好这 7 个功能。


1. Rules:一次配置,终身受益

Cursor 官方文档-Rules

效果:不用每次重复解释项目规范

我踩过的坑

刚开始用 Rules,我把所有规范塞进一个文件:

# 项目规范
- 使用 React 18 + TypeScript
- 使用 Tailwind CSS
- 组件放在 components/ 目录
- hooks 放在 hooks/ 目录
- 使用 Zustand 做状态管理
- 使用 TanStack Query 做数据请求
- ...(此处省略100条)

结果:AI 根本记不住,每次还是要重复解释。

正确做法:按场景拆分

.cursor/rules/
├── 01-项目概述.md      # 技术栈、定位
├── 02-编码规范.md      # 命名、格式
├── 03-项目结构.md      # 目录规范
├── 04-组件规范.md      # 组件写法
├── 05-API规范.md       # 接口请求
└── README.md           # 索引

一个真实的 Rules 文件示例

---
description: 编码规范
globs: ["**/*.{ts,tsx,js,jsx}"]
---

# 编码规范

## 命名规范
- 变量/函数:camelCase
- 常量:UPPER_SNAKE_CASE
- 接口/类型:PascalCase
- 组件:PascalCase

## 函数规范
- 优先使用箭头函数
- 使用函数式组件,不使用 class 组件
- 每个函数不超过 50 

## 必写项
- 必写 TypeScript 类型
- 必写错误处理
- 减少 any 的使用

## 禁止项
- 禁止 console.log(用 logger 代替)
- 禁止 var
- 禁止 ==(用 ===)

Rules 的 4 种应用方式

方式 说明 适用场景
Always Apply 每次对话都应用 通用规范
Apply Intelligently AI 自动判断是否相关 中性规范
Apply to Specific Files 特定文件才应用 特定格式
Apply Manual 手动 @ 调用 特定场景

我的效果:配置好之后,再也不用每次解释"我们用 React + TypeScript + Tailwind"。新人加入项目,AI 自动知道规范。


2. Plan Mode:减少返工

Cursor 官方博客-Plan Mode

效果:一次想清楚再做,减少推翻重来

Cursor 的 3 种模式

模式 快捷键 说明
Chat Mode 默认 普通对话,AI 收到指令直接执行
Plan Mode Shift+Tab 计划模式,AI 先做计划等你确认
Agent Mode Ctrl+K Agent 模式,AI 自动执行多步骤任务

我踩过的坑

以前我习惯一次性把需求全丢过去:

"帮我写一个完整的用户管理页面,包含列表,新增、编辑、删除,用 React + TypeScript + Tailwind,需要有权限控制,有表单校验..."

结果:AI 咔咔一顿写,写了几百行代码。然后:

  • 代码有 bug
  • 样式不对
  • 逻辑不对

正确做法:用 Plan Mode

Shift+Tab 开启计划模式,AI 会:

  1. 先研究你的代码库
  2. 问澄清问题
  3. 创建详细的实现计划
  4. 等你批准后再写

什么时候用什么模式?

场景 推荐模式 原因
复杂功能、多文件改动 Plan Mode 先计划,避免返工
简单修改、单文件 Chat Mode 直接执行更快
自动化任务、多步骤 Agent Mode 自动执行完整流程

一个真实的 Plan Mode 对话

我想要做一个用户管理页面,包含:
1. 用户列表(分页、搜索)
2. 新增用户
3. 编辑用户
4. 删除用户(需要二次确认)
5. 权限控制(admin 才能删除)

先帮我分析一下实现思路和步骤。

AI(Plan Mode)

# 用户管理页面实现计划

## 技术方案
- 使用 TanStack Table 做表格
- 使用 React Hook Form + Zod 做表单
- 使用 Radix UI Dialog 做弹窗

## 实现步骤

### 第一步:用户列表页面
- 创建 `src/app/users/page.tsx`
- 实现表格组件,支持分页、搜索

### 第二步:新增用户
- 创建 `src/components/users/UserForm.tsx`
- 实现表单校验

### 第三步:编辑用户
- 复用 UserForm 组件

### 第四步:删除用户
- 添加二次确认弹窗

### 第五步:权限控制
- 根据用户角色控制按钮显示

## 需要确认的问题
1. 分页是前端还是后端?
2. 搜索是按用户名还是邮箱?

:确认方案,AI 开始写代码。

官方提示:保存计划

点击 "Save to workspace" 可以把计划保存到 .cursor/plans/,方便:

  • 团队协作
  • 中断后继续
  • 后续参考

我的效果:以前写 10 个功能,有 6 个要返工;现在 10 个功能,只有 1-2 个需要调整。


3. MCP:闭环工作流

来源:Cursor 官方文档-MCP

效果:AI 能看到真实世界,不只是代码

什么是 MCP?

MCP = Model Context Protocol(模型上下文协议)

你可以理解为:AI 版的"小程序"

  • 手机 App → 需要安装微信、抖音、淘宝...
  • AI 工具 → 需要安装 MCP 来连接各种服务

没有 MCP:AI 只能看到代码,看不到设计稿、报错、日志...

有 MCP:AI 可以读取 Figma、Sentry、GitHub...

官方支持的 MCP

MCP 效果
Figma 设计稿直接转代码
Sentry 报错自动调查原因
GitHub 自动创建 PR
Slack 读取/发送消息
Notion 读取项目文档
Datadog 日志分析

我最常用的 MCP 组合

日常开发

  • Figma → 读设计稿
  • GitHub → 管代码
  • Sentry → 查报错

我的效果:以前要手动复制设计稿、查报错、跑 CI;现在 AI 都能自己完成。


4. 设计稿直出:10 倍速

来源:Cursor 官方博客-include-image

效果:AI 看图写代码,比文字描述快 10 倍

基础用法

直接把设计稿截图发给 AI:

帮我实现这个登录页面

AI 能识别:

  • 布局结构
  • 颜色配色
  • 间距尺寸
  • 字体大小

直接写出对应的 React + Tailwind 代码。

进阶用法:Figma MCP

这是我的最爱,用了之后就回不去了。

第一步:安装 Figma MCP

  1. 打开 Cursor 设置 → MCP
  2. 点击 "Add new MCP server"
  3. 输入 Figma MCP 服务器地址:
https://mcp.figma.com/mcp
  1. 登录 Figma 账号授权

第二步:使用 Figma MCP

读取设计稿

Figma 读取这个文件:https://www.figma.com/file/xxx/xxx

实现页面

根据这个设计稿实现登录页面

问 AI 问题

这个按钮的颜色是什么?
这个组件的间距是多少?

第三步:Figma MCP 能做什么

功能 说明
读取层级结构 获取设计稿的组件树
获取组件属性 颜色、字体、间距、尺寸
导出截图 自动截图给 AI 参考
生成代码 直接输出 React/Vue/HTML 代码

一个真实的使用案例

Figma 读取这个设计稿:https://www.figma.com/file/abc123/Login-Page
根据设计稿实现登录页面

AI

好的,我来分析设计稿...

## 设计稿分析

### 页面结构
- 外层容器:居中,灰色背景 (#F5F5F5)
- 登录卡片:白色背景,圆角 8px,阴影
- 输入框:高度 44px,圆角 4px,灰色边框
- 按钮:主色 #0066FF,白色文字,高度 44px

### 颜色
- 主色:#0066FF
- 背景:#F5F5F5
- 卡片:#FFFFFF
- 文字:#333333
- 副文字:#666666

### 布局
- 页面居中
- 卡片最大宽度 400px
- 内边距 32px
- 输入框间距 16px

我现在开始写代码...

然后 AI 直接写出了完整的登录页面代码。

我的效果:一个页面从 2 小时 → 15 分钟。


5. 自定义命令:告别重复

来源:Cursor 官方博客-git-workflows

效果:一键执行复杂工作流

我踩过的坑

以前每次创建 PR,都要手动敲:

git status
git diff
git add .
git commit -m "feat: xxx"
git push
# 打开浏览器,创建 PR...

每次都要重复 5-10 分钟。

正确做法:自定义命令

创建 .cursor/commands/

.cursor/commands/
├── pr.md        # 自动创建 PR
├── fix-issue.md # 自动修复 Issue
└── review.md   # 自动代码审查

一个真实的 PR 命令

# .cursor/commands/pr.md

创建一个 PR:

## 步骤

1. 用 git status 查看当前状态
2. 用 git diff 查看变更内容
3. 根据变更内容生成合适的 commit message
4. git add . 
5. git commit -m "xxx"
6. git push
7. 用 gh pr create 创建 PR
8. 返回 PR URL

## 要求
- commit message 要清晰描述改动
- PR 标题用中文,描述用英文

使用方式

/pr

AI 自动完成整个流程,最后给你 PR 链接。

常用命令推荐

命令 功能
/pr 自动创建 PR
/fix-issue [number] 自动修复 Issue
/review 运行 linter + 简单审查
/update-deps 更新依赖并测试

我的效果:以前每次都要手动敲 5 条命令,现在 1 条 /pr 搞定。


6. TDD:代码质量

来源:Cursor 官方博客-test-driven-development

效果:AI 有明确目标,代码质量更高

我踩过的坑

以前让 AI 写代码:

  • AI 写完就不管了
  • 没有测试,不知道对不对
  • 后来改需求,全部重写

正确做法:TDD

第一步:先写测试

帮我写一个用户验证的测试用例:
- 输入有效邮箱,返回 true
- 输入无效邮箱,返回 false
- 输入空字符串,抛出异常

使用 Jest + React Testing Library
不要写实现代码,只写测试

第二步:确认测试失败

AI 写出测试后,运行测试,确认失败。

运行测试,确认测试失败

第三步:让 AI 写实现

现在根据测试写实现代码
不要修改测试,只写实现
直到所有测试通过

第四步:提交

测试通过后,提交测试和实现

TDD 的核心优势

AI 需要明确目标才能迭代成功。

  • 写代码没有目标 → AI 随意发挥 → 质量不稳
  • 有测试作为目标 → AI 迭代改进 → 质量保证

我的效果:Bug 减少了 70%,代码可维护性大幅提升。


7. @Branch:不丢状态

来源:Cursor 官方博客-managing-context

效果:AI 始终知道你在做什么

我踩过的坑

  • 对话太长了,AI 开始"失忆"
  • 切换功能后,AI 还在聊之前的话题
  • 每次都要重新解释上下文

正确做法:用好上下文管理

@Branch:了解当前工作

@Branch 这个分支做了什么改动?
@Branch 帮我 review 这个分支的代码

@Past Chats:引用过去对话

@Past Chats 之前那个登录功能是怎么实现的?

适时开启新对话

需要开启新对话的场景

  • 切换到不同的任务/功能
  • AI 看起来困惑或重复犯错
  • 完成了某个逻辑单元的工作

继续当前对话的场景

  • 迭代同一个功能
  • AI 需要之前的上下文
  • 调试刚写好的代码

小技巧:监控对话长度

如果发现 AI 效率下降(开始重复问题、忘记之前的上下文),就是该开新对话的时候了。

我的效果:以前经常"AI 不记得我刚才说什么";现在几乎没有这种情况。


认知升级

很多人还在想:怎么让 AI 帮我写更多代码?

但真正拉开差距的思维是:怎么让 AI 帮我构建 核心竞争力

AI 是 杠杆 ,放大你的能力,不是替代你的能力。

  • AI 写代码,你来审核 → 质量提升
  • AI 写测试,你来设计用例 → 覆盖率提升
  • AI 跑 CI,你来分析结果 → 效率提升

核心永远是:你来做决策,AI 来执行。


写在最后

回到最初的问题:前端工程师还要学 AI 吗?

我的答案是:不是要学,是要精通。

真正拉开差距的,不是"会用 AI",而是"用好 AI"。

这 7 个功能,才是效率翻倍的关键。


参考资料:

如果觉得有帮助,欢迎点赞、收藏。

自动导入 AutoImport:告别手动引入依赖,优化Vue3开发体验

2026年3月13日 22:28

image

前言

  模块化已经是现代 Web 开发必不可少的开发方式,频繁引入依赖包是一个常见的操作。但是,手动引入依赖包往往繁琐,尤其是当依赖包数量较多时,会显著降低开发效率。如果正在用 Vue3 开发项目时,每写一个页面,都要重复引入 ref、reactive等等API。代码开头总是一堆 import 语句,不仅繁琐,还容易因为漏写导致运行时错误。更头疼的是,团队协作时,不同成员可能引入方式不一致,代码风格难以统一。这种重复劳动其实完全可以避免,unplugin-auto-import 插件就是专门解决这个痛点的利器。它能帮助我们在项目中,自动导入常用的使用的第三方库的 API,就可以方便我们开发,提升开发效率。

一、自动导入的价值:不止是少写几行代码

1.1 理解核心

  在深入具体配置之前,我们需要先扭转一个观念:unplugin-auto-import 不仅仅是一个 “帮助开发者写 import 语句” 的工具,更是一个模块解析与依赖管理的智能层。它的核心价值在于,通过声明式的配置,将开发者从繁琐的、重复的模块导入工作中解放出来,同时确保类型安全和代码整洁,但“智能”的前提是精准的规则定义。这感觉就像你每次想用家里的电视遥控器,都得先跑到储物柜里把它拿出来,用完了再放回去。明明遥控器就该放在茶几上,随手就能拿到。unplugin-auto-import 这个插件,干的就是这个“把遥控器放到茶几上”的活儿。它能自动帮你完成这些常用 API 的导入,让你在代码里直接使用 ref、onMounted、useRouter 等,就像它们是天生的全局变量一样。

  并非所以依赖都适合自动导入,项目内的代码可能就不一定适合自动引入。因为自动引入后,就能像全局变量那样直接使用,但从开发的角度就会丢失依赖链路,虽然另外生成了 Typescript 声明文件,IDE 能够正常识别, 但对于新加入项目的小伙伴来说,他们不一定知道是自动引入,因此可能会降低了一些可读性。那么,什么样的内容适合自动引入?简单来说,那些被广泛认知和使用、不用关注实现、不变的内容,不会影响可读性,不会影响开发,不会对开发者心智造成影响,就适合自动引入。

1.2 为什么需要自动导入 🤔

  在传统的前端开发中,我们经常需要手动导入各种函数和组件。在开发过程中我们需要自己去导入ref、reactive、computed等响应式说明,这些重复的导入语句不仅让代码变得冗长,还增加了维护成本。所以为了减少每个文件中的声明,就引入 Auto-Import 去解决这个问题。先看一个对比,这是配置前的典型代码:

<script setup lang="ts">
import { computed, ref } from 'vue';

const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>

  使用 unplugin-auto-import 插件后,不需要再去引入,同样的功能只需要:

<script setup lang="ts">
const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>

  变化看似不大,但实际开发中的体验提升是显著的:

  • 减少认知负担:不用再记忆每个 API 来自哪个包,这些细节交给工具处理。
  • 降低出错概率:不会因为忘记导入某个 API 而出现运行时错误,只需要在配置文件中更新预设,所有文件都会自动适应新的导入方式。
  • 提升编码流畅度:新成员加入项目,不用先花时间熟悉项目的 import 规范,直接开始写业务逻辑即可。想到什么直接写,不用在文件顶部和代码主体间来回跳转。
  • 类型安全的保障:对于 TypeScript 项目,unplugin-auto-import 会自动生成类型声明文件(通常是 auto-imports.d.ts),确保即使没有显式导入,也能获得完整的类型提示和检查。

二、从零开始配置:避开那些常踩的坑

  理论说再多,不如动手试一下。我们先来把插件跑起来,感受一下“开箱即用”的爽快感。

2.1 安装依赖

  在开始动手修改配置文件之前,我们有必要先理清几个核心概念和它们之间的关系。对于 Vite 项目,自动导入生态主要依赖于两个社区明星插件:unplugin-vue-components 和 unplugin-auto-import,以及一个由官方提供的“粘合剂”:unplugin-icons。

  • unplugin-vue-components 的职责是 “自动按需引入Vue组件”,这个插件会在背后悄悄帮你完成导入和注册组件这两件事。
  • unplugin-auto-import 的职责则更进一步,它专注于 “自动导入 Composition API、工具函数等”

  在开始复杂配置前,请确保项目已正确安装并集成了 unplugin-auto-import。对于 Vite 项目,基础安装和引入如下:

npm install --save-dev unplugin-vue-components
npm install --save-dev unplugin-auto-import
npm install --save-dev unplugin-icons

  这里安装的是开发依赖,因为自动导入是构建时和开发时工具,不会打包进生产代码。

2.2 基本配置

  安装好依赖只是第一步,正确的配置才是让一切运转起来的关键。我们将在 vite.config.ts 或 vite.config.js 文件中进行配置,这是配置的入口,在其中引入 AutoImport ,同时配置相关信息。

// vite.config.ts
import { defineConfig } from 'vite'
// 1. 引入 auto-import 插件
import AutoImport from 'unplugin-auto-import/vite'
 
export default defineConfig({
  plugins: [
    AutoImport({ /* options */ }),
  ]
})

  还需要在 tsconfig.json 文件里加入。

{
  "include": ["src/**/*.ts", "src/types/**/*.d.ts"]
}

2.3 核心配置

预设支持

  unplugin-auto-import内置了丰富的预设,支持多种流行库和框架,如Vue、Vue-router、pinia等,这些预设可以通过 imports 选项轻松配置:

AutoImport({
  imports: [ // 选择需要配置的插件
    'vue', // 自动导入 Vue 3 的 Composition API,如 ref, reactive, computed 等
    'vue-router', // 自动导入 Vue Router 4 的 API,如 useRouter, useRoute
    'pinia'// 自动导入 Pinia 的 API,如 defineStore, storeToRefs
  ]
})

  看,配置就是这么简单。核心就是 imports 数组,只要把想自动导入的包名写进去就行。

类型定义生成

  解决了运行时的导入问题,接下来是类型系统的挑战。TypeScript 需要知道这些“凭空出现”的标识符的类型是什么,否则就会报红,失去代码提示,unplugin-auto-import通过生成全局类型声明文件来解决这个问题。

  启用dts选项可以自动生成类型定义文件,提升开发体验:

AutoImport({
  // 为 TypeScript 生成全局类型声明文件
  dts: 'src/type/auto-imports.d.ts',  // 或者设置为 true,会在根目录生成
})

  看,配置就是这么简单。dts 选项是关键,它告诉插件为 TypeScript 生成类型声明文件的位置。有了这个文件,IDE 才能正确识别这些自动导入的变量。如果为 true,则会在导入冲突时,生成一个 auto-imports.d.ts 和一个 components.d.ts(如果用了组件自动导入)。也可以设置为一个自定义的文件名,我个人的习惯是把 dts 文件放在 “src/type” 目录下,并把它加入到 .gitignore 中,因为它是生成文件,不应该被提交。

  为了让 TypeScript 识别这个全局声明文件,还需要确保它被包含在 tsconfig.json 的 include 或 files 配置中。通常,生成的路径会自动被 Vite 的 TypeScript 插件处理,但手动检查一下是好的习惯。

{
  "include": [
    "src/type/auto-imports.d.ts" // 确保这一行存在
  ]
}

  这种机制的美妙之处在于,它实现了开发时无感导入完整的类型安全的完美结合。你既享受了代码的简洁,又没有牺牲TypeScript带来的智能提示和错误检查能力。

ESLint集成

  在真实的工程化项目中,unplugin-auto-import 从来不是孤军奋战。它必须与 TypeScript 编译器、ESLint 代码检查工具完美配合,否则就会陷入“代码能跑,但编辑器一片红”的尴尬境地。ESLint 默认规则会检查未声明的变量,自动导入的变量在源代码中没有显式导入,ESLint 会认为它们是未定义的,从而抛出错误。为了避免 ESLint 报错,可以配置自动生成 ESLint 配置文件:

AutoImport({
  eslintrc: {
    enabled: true, // 开启生成ESLint配置的功能
filepath: './.eslintrc-auto-import.json', // 指定生成的配置文件路径
globalsPropValue: true, // 声明为全局只读变量
  }
})

  插件会在项目根目录生成类型文件 .eslintrc-auto-import.json ,确保该文件在 eslint 配置中被 extends。

// .eslintrc.js 或 .eslintrc.cjs
module.exports = {
  // ... 其他配置
  extends: [
    // ... 其他扩展
    // 添加这行
    './.eslintrc-auto-import.json' // 这是插件生成的另一个文件
  ]
}

  unplugin-auto-import 在生成 dts 文件的同时,通常也会在项目根目录生成一个 .eslintrc-auto-import.json 文件,里面定义了所有自动导入变量的规则。把它包含进你的 ESLint 配置,ESLint 就知道这些变量是全局可用的,不会报错误。

三、深度定制:让自动导入更贴合项目

  基础配置只能算“能用”,但要想“好用”,还得根据项目情况深度定制。unplugin-auto-import 提供了非常灵活的配置项,让我们一起来看看。

3.1 导入更多生态库

  现代 Vue 3 项目很少只用到核心库,工具库如 VueUse,UI 组件库如 Element Plus 的某些工具函数,都可以纳入自动导入。

import AutoImport from 'unplugin-auto-import/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
import IconsResolver from 'unplugin-icons/resolver';

AutoImport({
  imports: [
    'vue',
    'vue-router',
    'pinia',
    // 添加 VueUse,它是一个函数工具集合
    '@vueuse/core',
  ],
   resolvers: [
      // 自动导入 Element Plus 相关函数
      ElementPlusResolver(),
      IconsResolver({
        prefix: 'Icon'
      })
    ],
  dts: 'src/auto-imports.d.ts',
})

  对于 Element Plus 的这类 API,配置会稍微复杂一点,通常需要结合 unplugin-vue-components(用于自动导入组件)和 resolvers 选项,但核心思想不变:把重复的 import 从代码中抹去。

3.2 自动导入项目本地工具函数

  这才是提升团队开发效率的大杀器。想象一下,项目里封装了很多好用的 useFetch、useTable、useModal 这样的组合式函数,散落在 src/hooks 目录下。以前用的时候,总要去找路径然后导入。现在,可以全部自动导入。

AutoImport({
  imports: [ ... ], // 第三方库
  dts: 'src/auto-imports.d.ts',
  // 关键配置:自动扫描指定目录下的文件
  dirs: [
    './src/stores', // 自动导入 Pinia store 的 useStore 函数
    './src/utils', // 自动导入工具函数
    './src/hooks/**', // 使用 glob 模式匹配子目录
  ],
})

  配置好 dirs 后,插件会在构建时扫描这些目录下的 ts、js 文件,将默认导出的函数或变量自动添加到全局可用列表中。比如在 src/hooks/useDarkMode.ts 里导出了一个 useDarkMode 函数,那么在任意组件中,你就可以直接 const { isDark, toggle } = useDarkMode(),无需导入。

3.3 精细化控制

  随着导入的东西越来越多,生成的 auto-imports.d.ts 文件可能会非常庞大,有时会影响 IDE 性能(虽然通常影响不大),这就可以通过一些配置进行优化。

按需导入

  如果你觉得 VueUse 全部导入太多,可以只导入你确定会用到的。

imports: [
  'vue',
  'vue-router',
  'pinia',
  {
    '@vueuse/core': [
      'useMouse',
      'useLocalStorage',
      'useDark',
      'useClipboard',
      'useDebounceFn',
    ],
  }
]

解决命名冲突

  如果两个库导出了同名的函数(虽然少见),或者你不想用默认的变量名,可以使用 alias 配置别名。

AutoImport({
  imports: [
    { 'vue-router': ['useRouter', 'useRoute'] },
    { 'my-router': ['useRouter as useMyRouter'] }, // 假设有另一个库
  ]
})

四、总结

  unplugin-auto-import作为一款强大的自动导入工具,通过智能化的模块分析和导入管理,彻底改变了传统的手动import方式。它不仅支持多种构建工具和框架,还提供了丰富的自定义选项,满足不同项目的需求。无论是小型应用还是大型项目,unplugin-auto-import都能显著提升开发效率,让开发者更专注于业务逻辑的实现。

image

昨天 — 2026年3月13日掘金 前端

《实时渲染》第3章-图形处理单元-3.4可编程着色和及其API的演变

作者 charlee44
2026年3月13日 22:19

实时渲染

3. 图形处理单元

3.4 可编程着色和及其API的演变

可编程着色框架的想法可以追溯到1984年Cook的《shade trees》[287]。图3.4显示了一个简单的着色器及其相应的着色树。RenderMan着色语言[63, 1804]是在1980年代后期从这个想法发展而来的。它今天仍然用于电影制作渲染,以及其他不断发展的规范,例如开放着色语言(OSL)项目[608]。

图3.4. 一个简单的铜材质着色器的着色树,及其相应的着色器语言程序。(在Cook [287] 之后。)

消费级图形硬件于1996年10月1日由3dfx In-teractive首次成功推出。有关今年的时间表,请参见图3.5。他们的 Voodoo显卡能够以高品质和高性能渲染游戏Quake,因此很快被采用。该硬件自始至终都实现了一个固定功能的流水线。在GPU原生支持可编程着色器之前,曾多次尝试通过多个渲染通道实时实现可编程着色操作。Quake III:Arena脚本语言是1999年该领域第一个广泛的商业成功。正如本章开头提到的,NVIDIA的GeForce256是第一个被称为GPU的硬件,但它不可编程。但是,它是可配置的。

图3.5. 一些API和图形硬件发布的时间表。

2001年初,NVIDIA的GeForce 3是第一个支持可编程顶点着色器的GPU[1049],通过DirectX8.0和对OpenGL的扩展来开放接口。这些着色器是用类似汇编的语言编程的,驱动程序可以将其即时转换为微代码。像素着色器也包含在DirectX 8.0中,但像素着色器缺乏实际的可编程性——支持的有限“程序”被驱动程序转换为纹理混合状态,驱动程序又将硬件“寄存组合器”连接在一起。这些“程序”不仅长度有限(12条指令或更少),而且缺乏重要的功能。Peercy等人从他们对RenderMan的研究[1363]中发现,依赖纹理读取和浮点数据对真正的可编程性至关重要。

此时的着色器不允许流控制(分支),因此必须通过计算两个条件分支并在结果之间选择或插值来模拟条件。DirectX定义了着色器模型 (SM) 的概念,以区分具有不同着色器功能的硬件。2002年,DirectX9.0发布,包括Shader Model 2.0,它具有真正可编程的顶点和像素着色器。使用各种扩展在OpenGL下也公开了类似的功能。添加了对任意依赖纹理读取和16位浮点值存储的支持,最终完成了Peercy等人确定的一组要求。对着色器资源(例如指令、纹理和寄存器的限制有所增加,因此着色器能够实现更复杂的效果。还增加了对流控制的支持。着色器的长度和复杂性不断增加,使得汇编编程模型变得越来越繁琐。幸运的是,DirectX 9.0还包含 HLSL。这种着色语言是由Microsoft与NVIDIA合作开发的。大约在同一时间,OpenGL ARB(架构审查委员会)发布了GLSL,这是一种与OpenGL非常相似的语言 [885]。这些语言深受 C编程语言的语法和设计理念的影响,并包含来自RenderMan着色语言的元素。

Shader Model 3.0于2004年推出,增加了动态流控制,使着色器更加强大。它还将可选功能变成了要求,进一步增加了资源限制,并增加了对顶点着色器中纹理读取的有限支持。2005年末(微软的Xbox 360)和2006年末(索尼电脑娱乐的PLAYSTATION 3系统)推出新一代游戏机时,都配备了Shader Model 3.0级别的GPU。任天堂的Wii游戏机是最后一批著名的固定功能GPU之一,最初于 2006 年底发货。纯粹的固定功能管道在在之后已不复存在。着色器语言已经发展到可以使用各种工具来创建和管理它们的地步。图3.6显示了使用Cook着色树概念的此类工具的屏幕截图。

图3.6. 用于着色器设计的视觉着色器图形系统。各种操作都封装在功能框中,在左侧可选择。选中后,每个功能框都有可调参数,如右图所示。每个功能框的输入和输出相互链接以形成最终结果,显示在中心框的右下方。(截图来自“mental mill”,mental images inc。)

可编程性的下一个重要步骤也在2006年底附近发生。包含在DirectX 10.0[175]中的着色器模型4.0引入了几个主要功能,例如几何着色器和流输出。Shader Model 4.0包括一个适用于所有着色器(顶点、像素和几何)的统一编程模型,即前面描述的统一着色器设计。进一步增加了资源限制,并添加了对整数数据类型(包括按位运算)的支持。OpenGL 3.3中GLSL 3.30的引入提供了类似的着色器模型。

2009年DirectX 11和Shader Model 5.0发布,添加了曲面细分阶段着色器和计算着色器,也称为DirectCompute。该版本还专注于更有效地支持CPU多处理,这是第18.5节中讨论的主题。OpenGL在4.0版中添加了曲面细分,在4.3版中添加了计算着色器。DirectX和OpenGL的发展方式不同。两者都设置了特定版本发布所需的一定级别的硬件支持。Microsoft控制DirectX API,因此直接与AMD、NVIDIA和Intel等独立硬件供应商(IHV)以及游戏开发商和计算机辅助设计软件公司合作,以确定要公开的功能。OpenGL由硬件和软件供应商联盟开发,由非营利组织Khronos Group管理。由于涉及的公司数量众多,API功能通常在DirectX中引入后的某个时间出现在OpenGL的发行版中。但是,OpenGL允许特定于供应商或更通用的扩展,允许在发布正式支持之前使用最新的GPU功能。

API的下一个重大变化是AMD于2013年推出的Mantle API。与视频游戏开发商DICE合作开发,Mantle的想法是剥离大部分图形驱动程序的开销,并将此控制直接交给开发人员。除了这种重构之外,还进一步支持了有效的CPU多处理。这类新的API侧重于大大减少CPU在驱动程序中花费的时间,以及更高效的CPU多处理器支持(第18章)。 Mantle中首创的想法被微软采纳并于2015年作为DirectX 12发布。请注意,DirectX 12并不专注于展示新的GPU功能——DirectX 11.3展示了相同的硬件功能。这两个API都可用于将图形发送到虚拟现实系统,例如Oculus Rift和HTC Vive。然而,DirectX 12是对API的彻底重新设计,更好地映射到现代GPU架构。低开销驱动程序对于CPU驱动程序成本导致瓶颈的应用程序很有用,或者使用更多的CPU处理器来处理图形可以提高性能[946]。从早期的API移植可能很困难,而且一个不成熟的实现会导致较低的性能[249, 699, 1438]。

Apple于2014年发布了自己的低开销API,称为Metal。Metal最初可用于iPhone 5S和iPad Air等移动设备,一年后更新的Macintosh可通过 OS X El Capitan访问。除了效率之外,降低CPU使用率还可以节省电量,这是移动设备的一个重要因素。这个API有自己的着色语言,适用于图形和GPU计算程序。

AMD将其Mantle工作捐赠给了Khronos Group,后者于2016年初发布了自己的新API,称为Vulkan。与OpenGL一样,Vulkan可在多个操作系统上运行。Vulkan使用一种称为SPIRV的新高级中间语言,它用于着色器表示和通用GPU计算。预编译着色器是可移植的,因此可以在支持所需功能的任何GPU上使用[885]。Vulkan也可用于非图形GPU计算,因为它不需要显示窗口[946]。Vulkan与其他低开销驱动程序的一个显着区别在于,它旨在与从工作站到移动设备的各种系统一起使用。

在移动设备上,标准是使用OpenGL ES。“ES”代表嵌入式系统,因为此API是为移动设备开发的。当时的标准OpenGL在其某些调用结构中相当庞大和缓慢,并且需要支持很少使用的功能。OpenGL ES 1.0于2003年发布,是OpenGL 1.3的精简版本,描述了一个固定功能的管道。虽然DirectX的发布与支持它们的图形硬件的发布同步,但为移动设备开发图形支持并没有以同样的方式进行。例如,2010年发布的第一款iPad实现了 OpenGL ES 1.1。2007年,OpenGL ES 2.0规范发布,提供可编程着色。它基于OpenGL 2.0,但没有固定功能组件,因此不向后兼容 OpenGL ES 1.1。OpenGL ES 3.0 于2012 年发布,提供了多个渲染目标、纹理压缩、变换反馈、实例化以及更广泛的纹理格式和模式以及着色器语言改进等功能。OpenGL ES 3.1添加了计算着色器,3.2添加了几何和曲面细分着色器等功能。第23章讨论更详细的移动设备架构。

OpenGL ES的一个分支是基于浏览器的 API WebGL,通过JavaScript调用。该API的第一个版本于2011年发布,可用于大多数移动设备,因为它在功能上等同于OpenGL ES 2.0。与OpenGL一样,扩展可以访问更高级的GPU功能。WebGL 2假定支持OpenGL ES 3.0。

WebGL特别适合在课堂上试验特性或使用:

  • 它是跨平台的,适用于所有个人计算机和几乎所有移动设备。
  • 驱动程序批准由浏览器处理。即使一个浏览器不支持特定的GPU或扩展,通常另一个浏览器会支持。
  • 代码是解释性的,而不是编译性的,开发时只需要一个文本编辑器。 • 大多数浏览器都内置了调试器,可以检查在任何网站上运行的代码。 • 例如,可以通过将程序上传到网站或Github来部署程序。

更高级别的场景图和效果库(例如three.js [218])可以轻松访问各种更复杂的效果的代码,例如阴影算法、后处理效果、基于物理的着色和延迟渲染。

TodoList应用:SPA应用首屏性能优化实践

2026年3月13日 18:19

hello,大家好,我是唐叔,今天想介绍的是 SPA - 单页应用的首屏性能问题优化的一些实践,也是我理解的 SPA 应用最大的技术难点吧。

SPA 的优势很明显:一次加载,无缝交互,适合多端复用。但代价也很大——首屏需要加载所有资源,一旦代码庞大,白屏时间就会变长。这就是我们常说的“首屏性能问题”。

下面就以唐叔最近一直在开发的 TodoList 应用为例,分享几种优化手段。

优化方式 1:骨架屏

严格说,骨架屏并不是优化性能,而是优化用户体验。

在数据加载完成前,先展示一个灰色的“页面轮廓”,避免用户面对白屏。

第一步,前端 html 页面,在 HTML 中预留骨架屏结构,具体可以基于你原本页面的框架去处理:

第二步,在编写 JS 初始化操作时,先显示骨架屏,然后加载数据,加载完数据再隐藏骨架屏。

大体编码是这样的:

class App {
   // 初始化应用
    async init() {
        try {
          // 显示加载状态
 Utils.setLoading(true);
            // 加载数据
        } catch (error) {
            // 异常报错
        } finally {
            // 隐藏加载状态
            Utils.setLoading(false);

            // 隐藏骨架屏
            const skeletonScreen = document.getElementById('skeleton-screen');
            if (skeletonScreen) {
                skeletonScreen.style.display = 'none';
            }
        }
    }
}

document.addEventListener('DOMContentLoaded', () => {
    // 显示骨架屏
    const skeletonScreen = document.getElementById('skeleton-screen');
    if (skeletonScreen) {
        skeletonScreen.style.display = 'flex';
    }

    // 延迟初始化,确保所有资源加载完成
    setTimeout(() => {
        app.init();
    }, 100);
});

额外建议:现在的开发更多是“思路驱动实现”,你可以用 AI 快速生成骨架屏代码,前提是你清楚它的原理。

优化方式 2:前端缓存

缓存虽然对首次加载无效,但对二次打开提升巨大。

常见前端存储方式主要有下述几类:

存储方式 容量 持久性 异步/同步 数据结构 同源限制
Cookie ~4KB 可设置 同步 字符串
localStorage 5-10MB 永久 同步 字符串
sessionStorage 5-10MB 会话级 同步 字符串
IndexedDB >250MB 永久 异步 结构化
Cache API 不定 永久 异步 Request/Response
FileSystem 不定 永久 异步 文件

TodoList 中,我们使用 localStorage 存储用户偏好(如主题、语言)。如果你开发的是轻量化应用,完全可以用 IndexedDB 作为整个应用的数据存储层。

💡 不过要补充说明的是,上述各类存储方式,可能存在浏览器兼容性问题。像 TodoList 用的后端是 pywebview,开启 localStorage 通过配置 private_mode

优化方式 3:路由懒加载

SPA 首屏并不需要加载所有页面模块时,可以把部分组件延迟到用户访问时再加载。

TodoList 移动端中,我正在做将「左侧抽屉弹窗」改为懒加载,等用户点击时再加载对应数据,减少首屏负担。

优化方式 N:其他策略

上述几种方式,是目前 TodoList 应用主用的优化方式,当然还有其他的方式,这里以我了解到的做展开介绍,当前不是专业前端可能了解的不多,其他同学知道的其他方式也可以在评论区补充说明。

  • 静态资源懒加载

    和路由懒加载类似,类似图片等高占用体积的资源,可以考虑使用这种方式,不过像 TodoList 应用,前端和后端都是写在一个包里面的,没有纯粹的后端服务,貌似用不了。

    <img src="placeholder.jpg" data-src="real-image.jpg" loading="lazy" />
    
  • Gzip 压缩

    现代构建工具(如 Webpack、Vite)会自动生成压缩后的代码包,服务端开启 Gzip 后即可生效,显著减少传输体积。不过 TodoList 是纯 HTML/CSS/JS 页面,估计还真的不好用这种方式。


以上是 TodoList 项目中正在实践的首屏优化策略。作为本专栏的开篇,希望能帮你少踩一些坑,也欢迎大家在评论区补充你常用的优化方式。

🚀 JavaScript 内存大揭秘:从“栈堆搬家”到“闭包时空胶囊”

作者 Lee川
2026年3月13日 18:07

🚀 JavaScript 内存大揭秘:从“栈堆搬家”到“闭包时空胶囊”

第一章:舞台搭建 —— 内存的三大分区

在代码运行之前,JavaScript 引擎先画好了三块地皮。请看这张图,这是所有故事发生的物理地基

c2e28f0b62e932380333c67696ea1599.jpg

  1. 🟠 代码空间 (Code Space):存放我们的剧本(源代码)。
  2. 🔴 栈空间 (Stack)“临时更衣室”
    • 特点:进出极快,空间小,自动整理。
    • 住谁?函数执行的上下文基本数据类型(数字、布尔值等)。
    • 规则:后进先出(LIFO),函数执行完,里面的东西立马被清空。
  3. 🔵 堆空间 (Heap)“大型仓库”
    • 特点:空间大,存取稍慢,需要保洁员(垃圾回收器 GC)定期打扫。
    • 住谁?对象、数组、函数等复杂的大件物品。

💡 核心隐喻

  • 是演员手里的提词卡(写着简单的数字或地址)。
  • 是后台巨大的道具库(放着复杂的布景和道具)。
  • 演员(变量)手里通常只拿着一张写有道具编号的卡片(引用地址),而不是直接把道具扛在肩上。

第二章:基本类型的“独立副本” —— 深度解析 1.js

让我们先看 1.js 的代码,看看它在栈空间里是怎么“变魔术”的。

📜 代码剧本 (1.js)

function foo() {
    var a = 1;      // 步骤 A
    var b = a;      // 步骤 B
    a = 2;          // 步骤 C
    console.log(a); // 输出 2
    console.log(b); // 输出 1  <-- 为什么 b 没变?
}
foo();

🎬 内存现场直播

步骤 A:var a = 1;

引擎在栈空间开辟了一个格子,贴上标签 a,里面直接放入数字 1

  • 栈状态[ a: 1 ]
  • 堆状态:空(基本类型不住堆)
步骤 B:var b = a; (关键瞬间!)

这是新手最容易误解的地方。

  • 错误理解ba 绑定了,ab 也变。
  • 真相:引擎在栈空间又开辟了一个全新的格子,贴上标签 b。它读取 a 格子里的值(也就是 1),然后复制了一份放到 b 的格子里。
  • 栈状态
    [ a: 1 ]
    [ b: 1 ]  <-- 这是一个独立的副本!
    
  • 此时,a 和 b 毫无关系,只是数值碰巧相同。
步骤 C:a = 2;

引擎找到标签 a 的格子,把里面的 1 擦掉,写上 2

  • 栈状态
    [ a: 2 ]  <-- 只有这里变了
    [ b: 1 ]  <-- b 毫发无损,因为它存的是独立的副本
    
🏁 结局
  • console.log(a) -> 读到 2
  • console.log(b) -> 读到 1

🧠 记忆口诀基本类型是“复印机”。 b = a 是把 a 的内容复印了一份给 b。以后 a 怎么改,跟 b 手里的复印件没关系。


第三章:引用类型的“共享地址” —— 深度解析 2.js

现在难度升级,看看 2.js 中的对象。这时候,堆空间登场了。

📜 代码剧本 (2.js)

function foo() {
    var a = {name: "极客时间"};  // 步骤 A
    var b = a;                    // 步骤 B
    a.name = '极客邦';            // 步骤 C
    console.log(a); 
    console.log(b);               // 输出什么?居然也变了?
}
foo();

🎬 内存现场直播

步骤 A:var a = {name: "极客时间"};
  1. 堆空间行动:引擎发现是个对象(大件物品),不能在栈里直接放。于是它在堆空间申请了一块地盘(假设地址是 1001),把 {name: "极客时间"} 这个对象存进去。
  2. 栈空间行动:在栈里创建变量 a。但是 a 里面不存对象本身,而是存那个对象的门牌号(地址) 1001
  • 栈状态[ a: 1001 (地址) ]
  • 堆状态地址 1001 -> { name: "极客时间" }
步骤 B:var b = a; (最关键的时刻!)
  • 动作:引擎在栈里创建变量 b。它读取 a 里的内容。

  • 注意a 里的内容是 1001(地址)。所以,引擎把 1001 复制给了 b

  • 结果ab 现在都拿着同一张写着 1001 的纸条。它们指向同一个堆内存地址。

  • 栈状态

    [ a: 1001 ]  \
                  +--> 指向堆里的同一个对象
    [ b: 1001 ]  /
    
  • 堆状态地址 1001 -> { name: "极客时间" }

步骤 C:a.name = '极客邦';
  • 动作:引擎通过 a 找到地址 1001,冲进堆空间,把那个对象里的 name 属性改成了 '极客邦'

  • 关键点:它修改的是堆里的实物,而不是栈里的地址。

  • 堆状态更新地址 1001 -> { name: "极客邦" } (实物被改了!)

🏁 结局
  • console.log(a):拿着地址 1001 去堆里看 -> 看到 { name: "极客邦" }
  • console.log(b):拿着地址 1001 去堆里看 -> 还是看到 { name: "极客邦" }

🧠 记忆口诀引用类型是“遥控器”。

  • ab 是两个不同的遥控器(栈里的变量)。
  • 但它们都对着同一台电视机(堆里的对象)。
  • 你用 a 遥控器换了台(修改属性),b 遥控器看到的画面自然也跟着变了。

第四章:闭包的“时空胶囊” —— 结合图片深度拆解

为什么函数执行完了,里面的变量还能被记住?这就是闭包的魔法。我们结合您提供的后三张图来还原这个过程。

场景设定

function foo() {
    var myName = "极客时间";
    var test1 = 1;
    
    function inner() {
        var test2 = 2;
        console.log(myName); // 这里的 myName 从哪来?
    }
    
    return inner; // 把内部函数扔出去
}

var bar = foo(); // foo 执行完了,按理说它的变量该消失了
bar();           // 但这里依然能打印 "极客时间"

第一阶段:函数执行中

foo() 正在运行时:

  1. 调用栈 (Call Stack) 压入了一个 foo 的执行上下文。
  2. 变量环境里记录了:
    • myName: "极客时间"
    • test1: 1
    • inner: 函数定义(包含了一个秘密武器:对外部作用域的引用
  3. 此时一切正常,myName 就安稳地待在 foo 的栈帧里。

第二阶段:返回与引用的建立

这是最神奇的一步!

  1. foo 函数执行结束,按常理,它的执行上下文应该从调用栈弹出,里面的 myName 应该被销毁。
  2. 但是! 因为 inner 函数(现在赋值给了全局变量 bar)在定义时,偷偷通过作用域链抓住了 foo 的变量环境。
  3. 内存迁移
    • 原本应该在栈里随函数结束而消失的 myNametest1,因为被 inner 引用了,引擎被迫将它们从栈空间“转移”或“保留”在堆空间中(或者说,包含这些变量的整个作用域对象被移到了堆上持久化)。
    • 如上图所示,clourse(foo) (即 inner) 在栈里,但它手里紧紧攥着一个地址 1003
    • 地址 1003 指向堆空间里的一个对象,里面赫然躺着 { myName: "极客时间", test1: 1 }

第三阶段:调用闭包

当我们调用 bar() (即 inner) 时:

  1. 引擎创建 inner 的执行上下文。
  2. 代码遇到 console.log(myName)
  3. 引擎在当前上下文没找到 myName
  4. 它顺着作用域链(那个秘密武器),找到了堆里地址 1003 对应的环境。
  5. 成功读取:"极客时间"。

🧠 闭包本质总结: 闭包不是某种特殊的语法,而是函数与其词法环境的组合

  • 普通函数:用完即走,栈帧清空,数据消失。
  • 闭包函数:因为“有人”(外部引用)还需要它内部的变量,所以引擎不敢清空栈帧,而是把这些变量打包扔到堆里长期保存,直到没人再需要这个函数为止。
  • 代价:这些变量会一直占用内存,直到 bar = null 断开引用,垃圾回收器才会来清理。

第五章:一图胜千言 —— 总结对比

为了让您彻底清晰,我们把刚才的分析浓缩成一张对比表:

特性 基本类型 (1.js) 引用类型 (2.js) 闭包 (5.html/6.html)
存储位置 只在栈 栈存地址,堆存实体 变量被强行保留在堆
赋值行为 值拷贝 (复印文件) 引用拷贝 (复制遥控器) 作用域捕获 (带走整个房间)
修改影响 互不影响 互相影响 (改的是同一份数据) 内部函数可读写外部私有变量
生命周期 函数结束即销毁 对象无引用时被 GC 回收 比定义它的函数活得更久
形象比喻 两个独立的苹果 两个人看同一个投影 把家里的家具搬到了公共仓库

💡 给开发者的终极建议

  1. 处理基本类型:放心大胆地赋值,不用担心改了一个影响另一个。
  2. 处理对象/数组:小心!b = a 之后,你以为你在操作 b,其实你可能在修改 a 的数据。如果需要独立副本,请使用扩展运算符 [...a]Object.assign 进行深拷贝/浅拷贝
  3. 使用闭包
    • 好处:创造私有变量,模拟类,函数柯里化。
    • 风险:如果不小心在闭包里引用了巨大的 DOM 节点或大对象,且长期不释放,会导致内存泄漏
    • 解决:不需要时,手动将引用置为 null (bar = null),告诉垃圾回收器“可以打扫了”。

希望这次结合内存动态流转生活化比喻的讲解,能让您对 JavaScript 的内存机制和闭包有透彻的理解!如果还有哪个环节觉得不够直观,请随时告诉我,我们可以针对那个点继续深挖。

低代码可视化平台的前端架构设计:从渲染引擎到插件系统

作者 可视之道
2026年3月13日 18:04

今天分享一个做内部的可视化搭建工具经验,从零开始设计前端架构,希望对同样在做类似事情的同学有帮助。

先说结论:分层是第一原则

做低代码可视化平台,最怕的就是"一锅粥"——渲染逻辑、交互逻辑、数据逻辑全搅在一起。等到要加新功能的时候,改一个地方崩三个地方。

经过几轮重构,我最终沉淀出一个四层架构:

┌─────────────────────────────────────┐
│           交互层 (Interaction)       │  拖拽、选中、缩放、快捷键
├─────────────────────────────────────┤
│           渲染层 (Renderer)          │  Canvas/SVG/DOM 渲染引擎
├─────────────────────────────────────┤
│           模型层 (Model)             │  组件树、Schema、状态管理
├─────────────────────────────────────┤
│           插件层 (Plugin)            │  扩展能力、生命周期钩子
└─────────────────────────────────────┘

每一层只关心自己的事,通过标准接口通信。下面逐层拆解。

一、模型层:一切的基础是 Schema

低代码平台的核心数据结构是一棵组件树,用 JSON Schema 描述。这棵树决定了画布上渲染什么、怎么渲染、数据怎么流转。

// 组件节点的核心数据结构
interface ComponentNode {
  id: string;                    // 唯一标识
  type: string;                  // 组件类型,如 'Button', 'Chart', 'Container'
  props: Record<string, any>;    // 组件属性
  style: CSSProperties;          // 样式
  children?: ComponentNode[];    // 子节点
  events?: EventBinding[];       // 事件绑定
  dataSource?: DataBinding;      // 数据源绑定
}

// 组件元数据:描述组件"能做什么"
interface ComponentMeta {
  name: string;
  category: string;              // 分类:基础组件、图表、容器...
  propsSchema: JSONSchema;       // 属性的 JSON Schema,用于自动生成配置面板
  slots?: string[];              // 插槽定义
  events?: string[];             // 可触发的事件
  thumbnail?: string;            // 缩略图
}

这里有个关键设计决策:Schema 是"单一事实来源"(Single Source of Truth) 。画布渲染、属性面板、代码生成、数据绑定,全部从这棵树派生。不要搞多份数据互相同步,那是噩梦的开始。

状态管理:不可变数据 + 命令模式

组件树的每次修改都通过命令(Command)执行,而不是直接 mutate:

class EditorStore {
  private state: EditorState;
  private history: Command[] = [];
  private cursor: number = -1;

  execute(command: Command) {
    // 执行命令
    this.state = command.execute(this.state);
    // 记录历史(支持撤销/重做)
    this.history = this.history.slice(0, this.cursor + 1);
    this.history.push(command);
    this.cursor++;
    // 通知订阅者
    this.notify();
  }

  undo() {
    if (this.cursor < 0) return;
    this.state = this.history[this.cursor].undo(this.state);
    this.cursor--;
    this.notify();
  }

  redo() {
    if (this.cursor >= this.history.length - 1) return;
    this.cursor++;
    this.state = this.history[this.cursor].execute(this.state);
    this.notify();
  }
}

用不可变数据(Immutable)的好处是:状态可追溯、撤销重做天然支持、脏检查高效。代价是每次修改都要创建新对象,但配合结构共享(Structural Sharing),性能完全可以接受。

二、渲染层:Canvas 还是 DOM?

这是做可视化平台绕不开的选择题。我的经验是:看场景

维度 DOM 渲染 Canvas 渲染 SVG 渲染
节点数上限 ~500 10000+ ~2000
交互复杂度 天然支持 需要自己实现事件系统 天然支持
文本排版 原生支持 痛苦 一般
动画性能 一般 优秀 一般
适用场景 表单搭建、页面搭建 工业组态、大屏、拓扑图 流程图、简单图形

如果你做的是类似"页面搭建器"(表单、后台页面),DOM 渲染就够了,React/Vue 的虚拟 DOM 已经帮你处理了大部分事情。

但如果是工业组态、SCADA、数据大屏这类场景,节点动辄上万,还有大量动画和实时数据刷新,Canvas 几乎是唯一选择。

Canvas 渲染引擎的核心循环

一个 Canvas 渲染引擎的骨架其实不复杂:

class RenderEngine {
  private canvas: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;
  private sceneGraph: SceneNode[];  // 场景图
  private dirty: boolean = true;
  private rafId: number = 0;

  // 渲染主循环
  private loop = () => {
    if (this.dirty) {
      this.clear();
      this.render(this.sceneGraph);
      this.dirty = false;
    }
    this.rafId = requestAnimationFrame(this.loop);
  };

  private render(nodes: SceneNode[]) {
    for (const node of nodes) {
      this.ctx.save();
      // 应用变换矩阵(位移、旋转、缩放)
      this.applyTransform(node.transform);
      // 调用节点自身的绘制方法
      node.draw(this.ctx);
      // 递归渲染子节点
      if (node.children) {
        this.render(node.children);
      }
      this.ctx.restore();
    }
  }

  // 标记脏区域,触发重绘
  markDirty() {
    this.dirty = true;
  }
}

但真正的难点在于:

  1. 事件系统:Canvas 没有 DOM 事件冒泡,你得自己实现 hitTest(点击检测)。常见方案是离屏 Canvas 颜色拾取,或者基于包围盒的空间索引(R-Tree / 四叉树)。
  2. 脏区域渲染:全量重绘在节点多的时候很浪费。记录哪些区域变了,只重绘变化的部分,能大幅提升性能。
  3. 分层渲染:把静态元素和动态元素放在不同的 Canvas 层上。静态层不需要频繁重绘,动态层(如动画、实时数据)独立刷新。
// 分层渲染示意
class LayeredRenderer {
  private staticCanvas: HTMLCanvasElement;   // 静态层:背景、固定元素
  private dynamicCanvas: HTMLCanvasElement;  // 动态层:动画、实时数据
  private interactCanvas: HTMLCanvasElement; // 交互层:选中框、拖拽辅助线

  renderStatic() {
    // 只在布局变化时重绘
    this.drawNodes(this.staticCanvas, this.staticNodes);
  }

  renderDynamic() {
    // 每帧或数据更新时重绘
    this.drawNodes(this.dynamicCanvas, this.dynamicNodes);
  }

  renderInteraction() {
    // 鼠标移动时重绘
    this.drawSelectionBox(this.interactCanvas);
    this.drawAlignGuides(this.interactCanvas);
  }
}

这个分层策略在实际项目中效果非常明显——我们的场景有 8000+ 节点,分层后帧率从 15fps 稳定到了 50fps 以上。

三、交互层:拖拽不只是 mousedown + mousemove

可视化编辑器的交互比想象中复杂得多。拖拽组件到画布、拖拽调整位置、拖拽调整大小、框选、对齐辅助线、吸附……每一个都是独立的交互状态。

我推荐用有限状态机(FSM) 来管理交互状态:

type InteractionState = 
  | 'idle'           // 空闲
  | 'dragging'       // 拖拽移动
  | 'resizing'       // 调整大小
  | 'selecting'      // 框选
  | 'connecting'     // 连线
  | 'panning';       // 画布平移

class InteractionFSM {
  private state: InteractionState = 'idle';

  transition(event: MouseEvent | KeyboardEvent) {
    switch (this.state) {
      case 'idle':
        if (isMouseDownOnNode(event)) this.state = 'dragging';
        else if (isMouseDownOnHandle(event)) this.state = 'resizing';
        else if (isMouseDownOnCanvas(event)) this.state = 'selecting';
        else if (isSpacePressed(event)) this.state = 'panning';
        break;
      case 'dragging':
        if (isMouseUp(event)) {
          this.commitDrag();
          this.state = 'idle';
        }
        break;
      // ... 其他状态转换
    }
  }
}

状态机的好处是:交互逻辑清晰、不会出现状态混乱(比如拖拽的时候突然触发了框选)、容易扩展新的交互模式。

四、插件层:微内核是终极答案

这是我认为整个架构中最重要的一层。

一个可视化平台要支持的功能太多了:不同类型的组件、不同的数据源、不同的导出格式、不同的交互工具……如果全部写在核心代码里,代码量会爆炸,而且每加一个功能都要改核心。

微内核 + 插件化是解决这个问题的经典模式。核心思路:

  • 内核只做三件事:插件管理、事件总线、服务注册
  • 所有业务功能都是插件:组件库是插件、数据源适配器是插件、导出器是插件、工具栏按钮也是插件
// 微内核定义
class EditorKernel {
  private plugins: Map<string, Plugin> = new Map();
  private hooks: Map<string, Function[]> = new Map();
  private services: Map<string, any> = new Map();

  // 注册插件
  use(plugin: Plugin) {
    plugin.install(this);
    this.plugins.set(plugin.name, plugin);
    return this;
  }

  // 注册钩子(类似 Webpack 的 tapable)
  hook(name: string, fn: Function) {
    if (!this.hooks.has(name)) this.hooks.set(name, []);
    this.hooks.get(name)!.push(fn);
  }

  // 触发钩子
  async callHook(name: string, ...args: any[]) {
    const fns = this.hooks.get(name) || [];
    for (const fn of fns) {
      await fn(...args);
    }
  }

  // 注册/获取服务
  provide(name: string, service: any) { this.services.set(name, service); }
  inject(name: string) { return this.services.get(name); }
}

// 插件接口
interface Plugin {
  name: string;
  dependencies?: string[];
  install(kernel: EditorKernel): void;
  activate?(): void;
  deactivate?(): void;
}

举个实际例子——一个"ECharts 图表组件"插件:

const echartsPlugin: Plugin = {
  name: 'echarts-components',
  dependencies: ['component-registry'],

  install(kernel) {
    const registry = kernel.inject('component-registry');

    // 注册一批 ECharts 组件
    registry.register('LineChart', {
      category: '图表',
      propsSchema: { /* ... */ },
      render: (props, bindData) => {
        const chart = echarts.init(container);
        chart.setOption(bindData ? mergeData(props, bindData) : props);
        return chart;
      }
    });

    registry.register('BarChart', { /* ... */ });
    registry.register('PieChart', { /* ... */ });

    // 监听数据更新事件,刷新图表
    kernel.hook('data:update', (nodeId, data) => {
      const chart = chartInstances.get(nodeId);
      if (chart) chart.setOption(data, { notMerge: false });
    });
  }
};

// 使用
const editor = new EditorKernel();
editor
  .use(corePlugin)           // 核心功能
  .use(componentRegistry)    // 组件注册中心
  .use(echartsPlugin)        // ECharts 图表
  .use(mqttDataSource)       // MQTT 数据源
  .use(exportHtmlPlugin)     // 导出 HTML
  .use(alignPlugin);         // 对齐辅助线

这个设计参考了 VS Code 和 Webpack 的插件体系。VS Code 的成功很大程度上归功于它的插件架构——核心编辑器很轻,语言支持、主题、调试器全是插件。Webpack 的 tapable 钩子系统也是同样的思路,整个构建流程都是通过钩子串起来的。

插件间通信:事件总线 vs 服务注入

插件之间不应该直接引用,而是通过两种方式通信:

  1. 事件总线:松耦合,适合"通知型"通信。比如"节点被选中了"、"数据更新了"。
  2. 服务注入:适合"能力型"通信。比如插件 A 需要用到插件 B 提供的"导出 PDF"能力。
// 事件总线:发布-订阅
kernel.hook('node:selected', (nodeId) => {
  // 属性面板插件监听,更新面板内容
  propertyPanel.update(nodeId);
});

// 服务注入:依赖查找
const exporter = kernel.inject('pdf-exporter');
await exporter.export(currentScene);

五、代码生成:从 Schema 到可运行代码

低代码平台的最终产物通常是可部署的代码。代码生成器的设计也很适合用插件化:

// 代码生成器也是插件
const vueCodegenPlugin: Plugin = {
  name: 'vue-codegen',
  install(kernel) {
    kernel.provide('codegen:vue', {
      generate(schema: ComponentNode): string {
        return `
<template>
  ${generateTemplate(schema)}
</template>

<script setup>
${generateScript(schema)}
</script>

<style scoped>
${generateStyle(schema)}
</style>`;
      }
    });
  }
};

不同的目标框架(Vue/React/原生 HTML)对应不同的代码生成插件,核心 Schema 不变,输出随意切换。

总结

回顾整个架构,核心思路就三个:

  1. Schema 驱动:用一棵 JSON 树描述一切,所有功能从这棵树派生
  2. 分层解耦:模型、渲染、交互、插件各司其职,通过接口通信
  3. 微内核 + 插件化:核心最小化,功能全部插件化,用钩子和服务注入串联

这套架构不是一开始就设计出来的,是经过三轮重构才稳定下来的。第一版是"能跑就行"的原型,第二版把渲染层抽出来了,第三版才引入了微内核。如果你也在做类似的项目,建议一开始就把 Schema 设计好,这是地基,后面怎么改都不怕。

渲染引擎和插件系统的选型,取决于你的业务场景。做页面搭建器,DOM + React/Vue 就够了;做工业组态/SCADA,Canvas 渲染引擎 + 微内核插件系统是更好的选择。

下一篇打算聊聊 SCADA Web 化的具体架构,特别是前端渲染层和数据采集层怎么解耦的问题。如果你也在做工业可视化相关的项目,欢迎留言交流。


我是一个专注前端可视化的技术人,分享可视化、Canvas、工业互联网相关的技术实践。关注我,一起在可视化的世界里折腾。

别再滥用 iframe 了!这些场景下它其实是最优解

作者 kyriewen
2026年3月13日 18:02

引言

“这个页面要嵌入另一个系统的报表,怎么办?”

“用 iframe 啊!”

“我想在咱们站里放个B站视频,但不想跳转。”

“用 iframe 啊!”

“微前端怎么搞?”

“用 iframe 啊!”——等等,微前端真的适合用 iframe 吗?

作为前端开发者,我们几乎每天都在和 iframe 打交道。它像一个“万能容器”,能轻松地把另一个页面塞进当前页面。但你真的了解它的能力边界吗?为什么有时候 iframe 会让页面卡顿?为什么有些网站死活不让你嵌入?为什么安全报告总提醒你注意 iframe 风险?

今天,我们抛开浅层用法,深入 iframe 的每一个毛孔,看看这个 25 岁的 HTML 元素,在现代前端开发中究竟扮演什么角色。

一、初见 iframe:不只是“页面里的页面”

1.1 基础语法

<iframe src="https://example.com" width="600" height="400"></iframe>

就这么简单,一个外部页面就被嵌入了。但它背后的行为远比看到的复杂:

  • 它创建了一个独立的浏览上下文(browsing context),有自己的历史记录、DOM 树、全局对象。
  • 可能与父页面同源(同协议、域名、端口),也可能跨域。
  • 它的加载、渲染、脚本执行几乎完全独立,但资源(如网络连接、线程)又共享自浏览器。

1.2 常用属性

除了 srcwidthheight,还有几个现代属性至关重要:

属性 作用
sandbox 对 iframe 内容施加额外限制(后文详述)
allow 控制特性权限,如麦克风、摄像头、全屏
allowfullscreen 是否允许全屏
loading 懒加载(lazy / eager
referrerpolicy 控制 Referer 头的发送策略

二、核心应用场景:什么时候非它不可?

2.1 嵌入第三方内容

视频(YouTube、B站)、地图(Google Maps)、社交帖子(Twitter、Instagram),这些平台提供的嵌入代码几乎都是 iframe。为什么?

  • 安全隔离:第三方脚本不能直接访问你的页面 DOM,防止恶意操作。
  • 样式独立:不会被你的 CSS 意外污染,也不用担心污染你的页面。
  • 功能完整:播放器、地图交互等复杂功能可以直接用对方提供的代码,不用自己实现。

2.2 广告系统

广告往往是跨域的,且需要沙盒化运行。iframe 天生适合:广告脚本在独立环境运行,无法窃取主站数据,同时又可以通过 postMessage 进行必要的通信(如上报尺寸变化)。

2.3 微前端架构的“兜底方案”

现代微前端框架(single-spa、qiankun)大多采用 JS 沙箱 + 路由分发的方式,但遇到老旧的、必须用全局变量或修改原型链的子应用时,iframe 成了最后的防线。虽然它有通信成本高、加载慢、UI 不同步等缺点,但胜在隔离彻底。

2.4 保持页面状态的“快照”

例如在线代码编辑器(CodePen、JSFiddle)的预览区域,用 iframe 执行用户代码,即使代码崩溃也不会影响主页面。

三、深入原理:iframe 与父页面的爱恨情仇

3.1 独立王国的边界

iframe 内部的所有内容(包括 JS 变量、定时器、事件监听)都局限在自己窗口内。但以下几个资源是跨上下文共享的:

  • 浏览器缓存src 里的资源会被正常缓存。
  • 网络连接:TCP 连接数限制是全局的,过多的 iframe 可能耗尽连接池。
  • localStorage / sessionStorage:同源 iframe 可以读写父页面的存储,跨域则不能(会抛出安全错误)。

3.2 通信:同源 vs 跨域

同源 iframe

父页面可以像操作自己的 DOM 一样操作 iframe 内部:

// 父页面
const iframe = document.getElementById('my-iframe');
iframe.contentDocument.getElementById('btn').click(); // 直接访问内部元素
iframe.contentWindow.someGlobalFunction(); // 调用内部全局函数

但要注意,必须等待 iframe 加载完成,否则 contentDocument 可能为空。

跨域 iframe

浏览器强制的同源策略会阻止父页面访问跨域 iframe 的 DOM。这时唯一安全的通信方式是 window.postMessage

父页面发送消息:

iframe.contentWindow.postMessage({
  type: 'UPDATE_USER',
  payload: { id: 123, name: 'Alice' }
}, 'https://iframe-domain.com'); // 目标源,必须指定

iframe 内监听消息:

window.addEventListener('message', (event) => {
  // 务必验证来源!
  if (event.origin !== 'https://parent-domain.com') return;
  
  if (event.data.type === 'UPDATE_USER') {
    // 更新界面
  }
});

安全原则: 永远检查 event.originevent.source,防止恶意页面冒充。

3.3 嵌套与层级

iframe 可以多层嵌套,形成“子→孙”结构。每一层都有独立的 window,但可以通过 window.parentwindow.top 访问父窗口和顶层窗口。跨域时访问这些属性也会被安全策略限制,只能通过 postMessage 向上传递。

四、安全性与沙盒:给 iframe 戴上枷锁

4.1 sandbox 属性

sandbox 是 iframe 最强大的安全工具,它可以启用一系列限制:

<iframe src="https://untrusted.com" sandbox></iframe>
<!-- 完全沙盒化:不允许脚本、表单、弹窗、导航等 -->

可以选择性放宽限制:

<iframe src="https://example.com" sandbox="allow-scripts allow-same-origin"></iframe>

常见 sandbox 值:

含义
allow-scripts 允许执行脚本
allow-same-origin 允许视为同源(如果不加,即使 URL 同源也会被当作跨域处理)
allow-forms 允许提交表单
allow-popups 允许弹窗(window.open
allow-modals 允许调用 alert() 等模态框
allow-orientation-lock 允许锁定屏幕方向
allow-pointer-lock 允许指针锁定
allow-top-navigation 允许导航到顶层窗口(危险)
allow-presentation 允许启动演示模式

重要: 不加 allow-same-origin 时,iframe 会被分配一个独特的来源(null),即使 URL 看起来同源。这是为了防止恶意脚本利用 iframe 绕过同源策略。

4.2 allow 属性(功能策略)

allow 属性用于控制更精细的权限,如摄像头、麦克风:

<iframe src="https://meet.example.com" 
        allow="camera; microphone; fullscreen">
</iframe>

这些权限需要配合 Feature Policy(现称 Permissions Policy)使用,浏览器会向用户请求授权。

4.3 防止页面被嵌入(X-Frame-Options 与 CSP)

如果你的页面不想被别人的 iframe 嵌入(比如防止点击劫持),可以设置响应头:

  • X-Frame-Options: DENY(完全禁止)或 SAMEORIGIN(只允许同源页面嵌入)
  • Content-Security-Policy: frame-ancestors 'self' https://example.com(更精细的控制)
X-Frame-Options: DENY
# 或
Content-Security-Policy: frame-ancestors 'none';

五、性能影响:看不见的代价

5.1 加载阻塞

<iframe> 的加载会阻塞主页面 onload 事件。即使使用 loading="lazy" 懒加载,也依然需要额外的连接开销。

5.2 内存占用

每个 iframe 都是一个独立的文档环境,会占用大量内存(尤其是包含复杂交互时)。过多 iframe 可能导致页面卡顿甚至崩溃。

5.3 最佳实践

  • 延迟加载:对不可见的 iframe(如下方广告)设置 loading="lazy"
  • 动态创建:只在需要时创建 iframe,用完及时销毁(iframe.remove())。
  • 限制数量:不要超过 2~3 个活跃 iframe。
  • 预先连接:如果知道 iframe 来源,可以用 <link rel="preconnect"> 提前建立连接。

六、现代替代方案:iframe 不是唯一解

场景 iframe 的问题 替代方案
嵌入外部页面 样式隔离但交互受限 Web Components(Shadow DOM)可以隔离样式,但不能隔离 JS
微前端 通信复杂、加载慢 single-spa、qiankun 等基于 JS 沙箱的微前端框架
显示富文本内容 需要安全展示用户生成内容 使用专门的 HTML 渲染库(如 DOMPurify)搭配 Shadow DOM
显示 PDF iframe 会接管整个窗口 <object><embed>,或使用 PDF.js 自建渲染
跨域通信 只能 postMessage 如果只是获取数据,可以用 Fetch API + CORS

但 iframe 在完全隔离不可信代码的场景中,依然不可替代。例如在线代码编辑器、广告系统。

七、常见问题与解决方案

Q1:如何让 iframe 高度自适应内容?

跨域 iframe 无法直接读取内部高度,但可以通过 postMessage 由内部通知父页面:

iframe 内:

const height = document.documentElement.scrollHeight;
window.parent.postMessage({ type: 'resize', height }, 'https://parent.com');

父页面:

window.addEventListener('message', (e) => {
  if (e.data.type === 'resize') {
    document.getElementById('my-iframe').style.height = e.data.height + 'px';
  }
});

Q2:为什么 iframe 内的 localStorage 无法共享?

跨域 iframe 的 localStorage 是与 iframe 的源绑定的,无法访问父页面源的数据。如果需要共享,可以考虑 postMessage 让父页面代为存储。

Q3:如何检测 iframe 是否加载完成?

iframe.onload = () => {
  console.log('iframe loaded');
};
// 或者用 addEventListener
iframe.addEventListener('load', () => {});

对于跨域 iframe,onload 仍然可触发,但不能访问内部文档。

Q4:如何防止 iframe 内的链接跳转导致父页面变化?

给 iframe 设置 sandbox(不加 allow-top-navigation)即可阻止链接导航到顶层。

Q5:如何让 iframe 内的 PDF 自动打印?

这无法直接控制,因为 PDF 插件是浏览器内置行为。但可以提示用户使用插件菜单。

八、总结:iframe 仍是工具箱里的重要角色

iframe 就像前端开发的“瑞士军刀”——看似笨重,但在需要强隔离的场景里,它依然是唯一可靠的选择。了解它的工作原理、性能影响和安全策略,你就能在合适的场景下让它发挥最大价值,而不是盲目滥用。

下一次当你想用 iframe 时,不妨问自己三个问题:

  1. 真的需要完全隔离吗?能否用 Web Components 替代?
  2. 如果非用不可,是否设置了合适的 sandboxallow 属性?
  3. 如何优化它的加载,避免拖慢主页面?

思考题:假设你想在自己的博客里嵌入一个来自“example.com”的页面,但对方设置了 X-Frame-Options: SAMEORIGIN,你有什么办法让它强制显示吗?为什么?

(答案下期揭晓,也欢迎留言讨论)


每日一问:你在实际开发中遇到过哪些 iframe 的坑?是怎么解决的?欢迎在评论区分享你的经验。

AI+Shopify 前端开发:实战一年后,聊聊 AI Agent 和前端的生死局

作者 sniper
2026年3月12日 14:36

作为一个折腾 Shopify 前端快 5 年的开发者,去年开始全面把 AI 工具融入工作流 —— 从最开始用 ChatGPT 写 Liquid 模板,到现在尝试 AI Agent 自动处理适配问题,这一年的变化让我既兴奋又焦虑。经常有同行问:“AI 都能自动生成代码、调试兼容了,前端是不是快死了?” 结合我在 Shopify 开发中的真实经历,今天就聊聊这个扎心的话题,再说说对 AI Agent 未来的思考。

先说说 AI 给 Shopify 前端开发带来的 “真实改变”

做过 Shopify 定制的都知道,Liquid 模板虽然不难,但重复工作是真的多 —— 比如每个店铺都要改的商品卡片、结账页面适配、移动端弹窗样式,以前一个项目光这些基础活就要占 30% 时间。现在我基本是 “自然语言甩需求”,比如跟 Copilot 说 “生成一个 Shopify 商品卡片,要显示变体选择,移动端点击图片放大,折扣标签用渐变红色,符合 Dawn 主题风格”,几十秒就能拿到可用的代码,改改变量名就能直接用。

更惊喜的是 AI 解决兼容性问题的能力。Shopify 店铺要适配不同浏览器、不同设备,以前调试移动端样式要反复刷新测试,现在用 AI 工具直接上传页面链接,它能自动识别出 iOS 端按钮错位、Chrome 浏览器字体不兼容的问题,还能给出修改后的 CSS 代码。上周帮客户做跨境店铺,AI 甚至提醒我 “Shopify 在欧洲地区的 Cookie 弹窗需要多语言支持”,连合规细节都考虑到了,这是以前自己做开发容易忽略的。

但这里必须说句大实话:AI 生成的代码 “能用但不精”。比如它会生成大量冗余的 CSS 样式,或者 Liquid 语法虽然正确,但没有考虑到 Shopify 的性能优化 —— 比如图片没有用 image_url 过滤尺寸,导致页面加载变慢;或者 AJAX 请求没有做错误处理,用户加入购物车失败时没有提示。这些细节还是需要开发者手动优化,AI 目前只能帮我们 “搭架子”,没法做到 “精装修”。

聊聊 AI Agent:前端开发的下一个风口,还是 “新瓶装旧酒”?

最近 AI Agent 的概念很火,简单说就是 “能自主完成复杂任务的 AI 助手”,而不是像现在这样需要我们一步步喂需求。我上个月测试了一个专门针对 Shopify 的 AI Agent 工具,体验下来既有惊喜也有失望。

惊喜的地方在于:它能自主理解复杂需求。比如我跟它说 “帮我优化这个 Shopify 店铺的前端性能,目标是 Google PageSpeed 得分超过 90 分”,它会自动分析页面 —— 识别出未压缩的 JS 文件、没有懒加载的图片、渲染阻塞的 CSS,然后生成一套完整的优化方案,甚至能自动修改 Liquid 模板里的代码,不需要我手动操作。更厉害的是,它还能联动 Shopify 的 API,比如自动获取商品图片的 URL,批量优化图片尺寸,这已经不是简单的 “代码生成”,而是 “全流程自动化” 了。

但失望的地方也很明显:AI Agent 的 “自主决策能力” 还很有限。比如我让它 “根据店铺的产品类型(美妆类目)设计一套新的首页 Banner 样式”,它生成的设计虽然美观,但完全没有考虑到美妆用户的浏览习惯 —— 比如没有突出产品的成分、功效,而是堆砌了太多装饰元素。还有一次,它自动修改了购物车页面的布局,结果导致结账按钮的位置超出了移动端的可视区域,反而影响了转化率。后来我发现,AI Agent 目前还没法理解 “业务逻辑” 和 “用户体验” 的深层关联,它只能基于已有的数据和规则做决策,缺乏对具体行业、具体用户的洞察。

不过我很看好 AI Agent 的发展趋势。现在的 AI 是 “工具”,而未来的 AI Agent 会是 “合作伙伴”—— 比如它能自主监控 Shopify 店铺的前端状态,发现问题后自动提醒开发者,甚至在开发者授权的情况下自主修复小问题;或者根据店铺的销售数据,自动调整前端布局,比如把热销商品的展示位置提前,优化用户的购买路径。但这个过程需要时间,目前的 AI Agent 还处于 “初级阶段”,离真正替代开发者还有很长的路要走。

核心疑问:AI 这么猛,前端真的会 “死” 吗?

这是我最近被问得最多的问题,我的答案很明确:前端不会死,但 “只会写代码的前端” 会被淘汰。

为什么这么说?结合 Shopify 开发的场景举几个例子:

  1. AI 能生成 Liquid 代码,但它没法理解 “品牌调性”—— 比如客户要做一个极简风格的小众品牌店铺,AI 生成的代码可能功能齐全,但缺乏设计感和独特性,这时候需要前端开发者结合品牌理念,优化界面的留白、字体、色彩搭配,让店铺有 “灵魂”;
  2. AI 能解决简单的兼容性问题,但它没法处理 “复杂的业务逻辑适配”—— 比如跨境电商店铺需要对接不同国家的支付接口、物流系统,前端需要根据用户的地理位置显示不同的支付方式、配送信息,这需要开发者理解业务流程,设计合理的交互逻辑,AI 目前还做不到;
  3. AI 能优化前端性能,但它没法平衡 “性能” 和 “用户体验”—— 比如压缩图片能提升加载速度,但过度压缩会影响图片清晰度,导致用户无法看清产品细节,这需要开发者根据实际情况做取舍,找到最佳平衡点。

再往深了想,前端开发的核心是什么?不是 “写代码”,而是 “通过技术解决用户需求,提升用户体验”。代码只是实现目标的工具,而 AI 正在把我们从 “工具人” 的角色中解放出来,让我们有更多时间去思考 “用户真正需要什么”“如何让店铺更有竞争力”。比如以前我要花大量时间写模板代码,现在可以把这些时间用在用户调研、交互设计上,甚至可以学习 Shopify 的后端 API、数据分析,成为一个 “全栈型” 的电商技术专家。

未来的前端开发者,更像是 “技术 + 业务 + 设计” 的复合型人才 —— 既要懂 AI 工具的使用,能用它高效完成基础工作;又要懂业务逻辑,能理解电商行业的需求和痛点;还要懂用户体验,能设计出符合用户习惯的界面。而那些只会机械写代码、不懂思考的开发者,确实会被 AI 替代。

最后总结一下

AI 给 Shopify 前端开发带来了巨大的效率提升,AI Agent 的发展更是让我们看到了全流程自动化的可能,但这并不意味着前端开发会消失。相反,AI 正在推动前端开发行业的 “升级”,淘汰落后的产能,让真正有价值的开发者脱颖而出。

作为开发者,我们不用害怕 AI,而是要拥抱它 —— 学习如何使用 AI 工具,把它变成自己的 “左膀右臂”;同时也要不断提升自己的核心竞争力,比如加深对业务的理解、提升设计能力、学习跨领域知识。只有这样,才能在 AI 时代立于不败之地。

最后,想问大家一个问题:你们在使用 AI 做 Shopify 开发时,遇到过哪些坑?或者对 AI Agent 的发展有什么不同的看法?欢迎在评论区交流~

前端工程化进阶:Monorepos 架构简析(水文)

作者 墨渊君
2026年3月12日 12:19

引言

对于 Monorepos 架构一直早有耳闻, 但是一直用不上也就懒得去了解, 刚好最近想要基于 prosemirror 写一个自己的富文本编辑器, 基本上是就是参考 tiptap 源码来一步步开发, 一为折腾二为学习。刚好 tiptap 其实就是 Monorepos 架构所以顺便简单研究研究, 故而有了这篇文章。

一、基本概念了解

再开始前先了解几种常见的架构

1.1 单体架构(Monolith)

所谓 Monolith 这个词字面意思是 单块的、整体的, 在软件架构里常用来指 单体架构 模式。

该模式其实就是将所有功能都打包在一个整体里, 作为一个应用进行部署和运行, 它和微服务应该是一个完全相反的一种模式。

  1. 主要特点就是:
  • 单一代码库: 所有功能模块(用户、订单、支付、库存…)都在同一个代码仓库、同一个项目里。
  • 单体部署: 打包后只产出一个可执行文件 / war 包 / docker 镜像, 直接上线。
  • 共享内存与数据库: 所有功能都是部署在一个机器上, 通常共享内存、共用一个数据库。
  1. 优点
  • 开发简单: 没有复杂的服务间通信。
  • 部署简单: 一个包就能跑起来, 不用维护一堆微服务。
  • 调试方便: 本地起一个服务就能跑全流程。
  • 性能好: 模块间是进程内调用, 不用走网络。
  1. 缺点
  • 耦合高: 改一处可能影响全局,难以模块化管理。
  • 扩展性差: 无法按模块独立扩容。
  • 技术栈受限: 所有功能只能用同一技术栈。
  • 发布成本高: 改一行代码也要重新打包发布整个应用。

当然 Monolith 其实和我们要聊的 Monorepos 没啥太大关系, 这里只是顺带了解了解。后面要聊的 multirepo / monorepo 讨论的更多是 代码仓库管理策略

1.2 多仓库模式(multirepos)

所谓 multireposMulti-repos 也就是多仓库模式, 说白了就是每个「项目/服务/模块/应用」都是单独放在一个代码仓库里。

当然这些代码仓库如果没有任何关联, 其实也没啥好说点。但是相反有些它们之间可能是有关联的, 甚至有很多业务逻辑都是相通的。比如我们一个项目有 PC 端、后台管理、移动端、小程序、APP 端等等, 甚至有些还有共用的组件库、工具库之类的。

再比如我们上面提到的 prosemirror 其实就是使用 multirepos 架构, 每个功能模块都是以独立仓库的形式存在, 如下图所示:

image

  1. 那么 multirepos 架构有舍特点呢?
  • 一仓一项目: 每个服务、库、UI 组件、工具包都有自己独立的 Git 仓库。
  • 独立版本管理: 每个仓库有自己的版本号、分支、发布流程。
  • 强解耦: 一个仓库的变更不直接影响其他仓库。
  1. multirepos 架构优点:
  • 边界清晰: 不同模块之间独立管理, 没有太多耦合。
  • 独立发布: 可以单独更新、发布某个仓库, 而不必影响其他仓库。
  • 权限控制简单: 敏感项目可以设置私有仓库, 权限隔离好做。
  • 适合多团队合作: 不同仓库(项目)由各自的团队负责, 团队之间互不干扰。
  1. multirepos 架构缺点
  • 协作成本高: 当某个需求改动需要涉及多个仓库时, 需要多次提 PR、多次发布、如果之间还存在耦合就容易出错。
  • 依赖管理麻烦: 仓库之间如果相互依赖, 则比较麻烦没次都需要手动发布版本、升级依赖。
  • 工具链碎片化: 每个仓库可能有不同的 lint / build / test 配置, 配置之间要做到一致性就比较麻烦。只要有一点调整, 就需要动所有仓库。

1.3 单仓库模式(Monorepos)

Monorepo 则是单仓库模式, 顾名思义就是直接将多个项目/服务/模块/包放到同一个仓库进行统一管理, 就好比如我有一个网站, 有前端项目有服务项目, 服务端也是用 JS(Node) 写的, 那我其实就可以将这两个项目放到同一个仓库进行一个管理, 这样的话很多工具函数、脚本、项目配置都是可以复用的。

Monorepos 模式其实就特别一些开源的项目, 上文说到的 tiptap 就是该模式, 一个完整的项目包含了基础的核心模块、还有各种功能扩展模块、同时还需要针对 RectVue、原生 HTML 提供对应的功能包... 同时这些模块之间很多

image

  1. Monorepos 架构特点
  • 单个代码仓库: 所有相关项目、包、模块都在一个 Git 仓库中进行统一管理。
  • 多包结构: 一般使用 packages/apps/ 等目录存放多个子项目, 每个子项目可以发布为单独的依赖包。
  • 共享依赖与工具链: 可统一使用同一套 lintbuildtestCI 等配置。
  • 快捷引用: 不同模块之间不需要手动执行 npm link 或者发布为 npm 包, 相互之间就可以直接快速引用。
  • 自动化构建与发布: 通常配合工具如 pnpm workspacelernanxturborepo 可以方便快捷的实现依赖管理、构建缓存、按需发布等等。
  1. multirepos 架构优点
  • 统一管理、提升协作效率: 所有模块在一个仓库内, 统一管理版本、代码规范、CI/CD 流程, 不需要在多个仓库中反复提 PR、切换分支。
  • 依赖共享, 减少重复安装: 可以通过 workspace(如 pnpm/yarn)共享依赖包, 减少磁盘占用、加快安装速度。
  • 模块间联动开发方便: 当多个模块有关联时, 可以直接联调, 无需手动发布中间版本, 改动立即生效。
  • 一致性更高: 所有子项目共用同一套配置(eslinttsconfigprettier 等) 风格统一、维护简单。
  • 自动化工具生态完善: 结合 pnpm workspacelernanxturborepo 等工具可轻松实现增量构建、缓存优化、按包发布、版本追踪等高级能力。
  1. multirepos 架构缺点
  • 仓库体积庞大: 所有模块都在一个仓库中, 代码量和依赖文件如果较多, 仓库就会变得庞大, 首次 clone、安装依赖的成本较高。
  • 权限与访问控制困难: 如果不同模块由不同团队进行维护, 那么将这些模块放在一个仓库中在权限划分上就比较麻烦(需用借助额外工具来实现)。
  • 构建复杂度提升: 需要配置高效的构建缓存与任务调度系统,否则容易导致全量构建慢。
  • Git 历史和分支管理复杂: 所有改动都在同一仓库中,提交记录庞大,版本回溯或分支策略需要严格规范。

二、Npm Workspaces

npm workspacenpm 7 引入的一个新功能, 它允许我们直接在一个单项目中同时管理多个独立的子项目(依赖包), 可以方便的集中管理它们之间的依赖, 减少重复并提升可维护性。

Npm 项目中, 我们也正是通过 Npm Workspaces 来实现 Monorepos 架构, 下面我们就简单介绍下 Npm Workspaces 的使用。

2.1 项目初始化

  1. 首先我们先初始化一个 Npm 项目:
npm init -y

初始化完成后, 会在项目跟目录下创建一个 package.json 文件

image

  1. 创建子包: 下面我们来创建两个子包 packages/a 以及 app/b
npm init -y -w packages/a
npm init -y -w app/b

执行上面代码将会:

  • 分别在 apppackages 目录下初始化两个子包(npm 项目)
  • 同时会在根 package.json 中, workspaces 配置中添加新的子包
  • 同时还会在根目录中, 将子包以软连接的形式安装到 node_modules

image

  1. 手动创建子包: 我们知道了调用 npm init -y -w app/b 会做哪些处理, 那么自然, 我们完全也可以自动手动添加子包, 下面我们来新增一个子包 packages/c
  • 新增目录 packages/c
  • 在目录 packages/c 下, 通过 npm init -y 初始化一个 npm 项目
  • package.json 中, workspaces 配置新增配置项 packages/c
  • 最后还需要在根目录下执行 npm install, 目的是为了在 node_modules 中为每个子包创建新的软连接

image

2.2 为子包安装依赖包

在上文我们使用 npm init -y -w app/b 添加来子包:

  • 该命令中 -w 参数用于指定子包, 而 -w app/b 其实是 --workspace=app/b 的缩写
  • npm init -y 则是我们要在子包中进行的操作

同理, 如果我们要为子包 app/b 安装依赖 dayjs 就可以直接在项目根目录执行 npm install dayjs -w app/b 即可

npm install dayjs -w app/b

而执行 npm install dayjs -w app/b 会自动完成:

  • 在顶层, 也就是根目录安装依赖 dayjs
  • 同时会更新子包 app/bpackage.json, 新增依赖包配置

image

2.3 使用子包

正如上文所说, 其实当我们执行 npm install 或者通过 npm init -y -w [子包] 初始化子包时, 都会在项目根目录 node_modules 中, 为每个子包创建一个软链接, 如下图所示最右边的箭头表示该依赖包是个软链接

image

而有了这个软链接, 就可以帮助我们在不同子包中进行相互引用: 如下图所示, 我们在子包 packages/c 中, 直接引用了子包 packages/bapp/b

image

当然这边每个子包的目录名不重要, 重点是每个子包中 package.json 中定义的 name 值, 也就是包名称, 这个包名称我们是可以随意修改的, 只要不重复继续, 包名修改后重新 npm install 即可(更新软链接)

image

对了, 项目根 package.json 中, workspaces 配置的是子包的路径, 所以只是改子包的名称, 这边是不需要动的。同时这边其实也可以直接使用通配符 *, 如下所示, packagesapp 目录下的所有项目都将会被作为子包进行加载

{
  ...
+ "workspaces": [
+   "packages/*",
+   "app/*"
+ ]
}

三、pnpm workspaces

除了使用官方 npm workspaces, 我们还可以使用第三方包管理工具, 比如 yarn 或者 pnpm 它们都实现了各种的一套 workspaces 协议, 下面我们简单介绍下 pnpm workspaces 至于 yarn 就不展开了

3.1 初始化项目

pnpm 中初始化相对来说会比较麻烦点, 一切都需要手动操作

  1. 初始化根项目: 在项目根目录初始化一个 npm 项目
pnpm init
  1. 创建 pnpm workspace 配置文件: 根目录创建配置文件 pnpm-workspace.yaml 所有和 workspace 相关的配置都在这边定义

image

  1. 手动创建 & 初始化子包: 是的这边子包需要我们手动在对应子包目录下进行创建、初始化(npm init y)

image

3.2 添加依赖

  1. 根目录安装依赖: 对于公用的依赖可以直接在项目根目录中进行安装, 这边可以直接在根目录执行 pnpm add 来进行安装, 或者在任意位置(子包、根目录)通过 -w 参数来安装根依赖包, 这里的 -w = workspace root
pnpm add react # 根目录执行
pnpm add react -w # 任意位置执行都行, 会在根目录安装依赖
  1. 为子包安装依赖: 除了直接在子包内通过执行 pnpm add 来安装项目依赖外, 其实我们还可以使用 --filter 来为子包安装依赖, 通过 --filter 就不限制目录了
pnpm add react # 子包内执行
pnpm add react --filter a # 任意位置执行都行, 通过「--filter a」来为子包「a」安装依赖

3.3 使用子包

在开始前我们需要了解下 workspace: 协议, 该协议是 pnpmmonorepo 中用于引用本地 workspace 子包的特殊语法。当我们在 monorepo 中, 一个子包依赖另一个包时, 就可以使用 workspace: 前缀来声明这是一个内部依赖。在 本地开发pnpm 会自动帮我们软链接到本地对应的子包, 而在 发布时 则会自动替换为实际的子依赖包。

如下所示:

  • 通过 --filter 来为某个子包安装依赖
  • 'c@workspace:*' 表示安装 workspace: 协议的子包 c, 需要注意的是这边要加引号 ''
pnpm add 'c@workspace:*' --filter a 

image

上文用的是 workspace:* 表示使用任意版本, 也就是最新的子包, 自然这边我们也可以限制子包的版本, 规则其实和 npm 依赖包版本号的规则差不多

# 不同的 workspace 版本协议
pnpm --filter pkg-b add 'pkg-a@workspace:*'   # 任意版本
pnpm --filter pkg-b add 'pkg-a@workspace:^'   # 匹配主版本
pnpm --filter pkg-b add 'pkg-a@workspace:~'   # 匹配次版本
pnpm --filter pkg-b add 'pkg-a@workspace:^1.0.0'  # 指定版本范围

四、参考

从“截图大法”到真实交互:B站专栏视频卡的技术革命

2026年3月12日 12:02

背景:从“伪造”卡片到真实交互

回望 B 站富文本编辑器的演进史,我们经历了一个从“无”到“有”,再从“有”到“优”的过程。在 UEditor 时代,我们解决了基本的文本编辑需求;在 Quill 时代,我们引入了 Delta 数据模型。

然而,在 Quill 时期,面对视频卡等复杂卡片,受限于 Quill 对 BlockNode 缺乏完善的支持,被迫采用“ Canvas 绘图伪造卡片” 的障眼法。今天,拥抱 ProseMirror 生态,这套“ 截图大法” 终于画上句号,取而代之的是支持真实交互的卡片渲染系统。

这场从“伪造”到“真实”的革命,不仅是一次技术栈的迁移,更是一次对技术债的降维打击。今天就带大家深入代码底层,看看我们是如何填平这个深坑的。

第一章:旧世界——那些年,我们用 Canvas “画”出来的视频卡

1.1 用户视角的“灵异”体验

你可能经历过这样的场景:在专栏里粘贴了一个视频链接,然后看着 Loading 转圈圈,心里默数两秒,“啪”的一下,编辑器里出现了一个视频卡片。

看起来很美?别急着夸。当你试图点击播放时,发现它毫无反应;当你试图修改标题时,发现根本选不中文字。这哪里是视频卡片,这分明就是一张死图!

是的,这就是我们不得不采用的 “Canvas 截图大法”

1.2 技术黑幕:Canvas 的“障眼法”

为了在 Quill 这个不支持复杂 Block Node 的编辑器里塞进一个视频卡,我们当年可是绞尽脑汁,最终设计了一套后续发现极其痛苦的 html2canvas 截图链路:

1. 隐式渲染: 在浏览器可视区域外(看不见的地方),用 HTML 偷偷画一个临时的卡片 DOM。

2. Canvas 截图: 调用 html2canvas 咔嚓一下,把这个 DOM 变成 Canvas。为了保证清晰度,通常需要设置 scale: 4

3. 图片生成: 将 Canvas 导出为 Base64 图片。

4. 上传替换: 把图片上传到 CDN,最后在编辑器里插一个静态的 <img> 标签。

图片

1.3 无法回避的四大痛点

说实话,每次写这段代码时,我的内心都是崩溃的。这种做法虽然暂时解决了跨平台兼容问题,但代价是沉重的:

  • 交互性丧失(Interactive Loss): 这仅仅是一张死图。所谓“所见即所得”其实是“所见即图片”。

  • 性能黑洞: 整个“API请求 → 绘制 → 截图 → 上传”的链路平均耗时 2秒 以上。严重打断写作心流。

  • 数据死锁: 卡片上的播放量、弹幕数永远停留在插入的那一刻。如果视频后续爆火,卡片信息也不会更新,甚至误导读者。

  • 存储浪费: 每一张生成的卡片图片都需要占用 CDN 空间,随着文章数量增长,这是巨大的隐形资源浪费。

第二章:病根诊断——当 Quill 遇上视频卡

为什么 Quill 做不好视频卡?这得从它的底层基因说起。

2.1 Delta 像“收银小票”,ProseMirror 像“乐高积木”

Quill 使用的是 Delta 数据模型。Delta 本质上是一个线性的操作记录,就像一张长长的收银小票🧾。

截屏2026-03-11 16.36.42.png

你想在这张薄薄的小票中间塞进一个立体、复杂的“视频播放器盒子”?太难了!Delta 天生就是扁平的,它很难描述复杂的嵌套结构。我们被迫使用的“截图大法”,其实就是在小票上画了个电视机的图案,而不是真的放了个电视机。

而 ProseMirror 使用的是 Document Model(文档树),它就像是乐高积木

// ProseMirror Tree: 结构化的树形数据
{  
  "type": "doc",  
  "content": [ 
    { "type": "paragraph", "content": [{ "type": "text", "text": "Hello" }] },
    {  
      "type": "videoCard", // 独立的块级节点   
      "attrs": { "bvid": "BV1xx..." },    
      "content": [] // 可以继续嵌套其他节点  
    }
  ]
}

你可以搭建一个名为"视频卡"的积木块,然后在里面随意嵌套"标题积木"、"封面积木"甚至"播放器积木"。这种树状结构天然就支持复杂的 Block Node(块级节点)

图片

2.2 技术对比表:为什么我们要换枪?

图片

2.3 选型博弈: 为什么是TipTap+ProseMirror?

在决定彻底抛弃 Quill 之前,我们对市面上的富文本技术方案进行了一次深度摸底。从底层技术演进来看,Web 富文本编辑器主要经历了三个维度的跃迁:

  • Level 0(强依赖 DOM):完全基于原生的 contenteditable,典型如 UEditor。技术门槛低,但跨端表现极其不可控。
  • Level 1(视图即数据):拥有自身抽象的数据模型,但依然依赖原生 DOM 渲染。典型如 Quill、Slate、Draft.js 及 ProseMirror。
  • Level 2(自排版自渲染):彻底抛弃 contenteditable,利用 Canvas/SVG 自研排版引擎,典型如 Google Docs。

从 B 站图文生态(专栏、动态)的实际业务诉求出发,L2 方案属于严重的性能与研发成本过剩,而 L0 方案早已无法满足现代组件的交互需求。因此,我们的主战场锁定在了 L1 级别的抽象数据模型方案。

在 L1 的终极对决中,面对生态优秀的 Lexical 和老牌的 Draft.js(往往强绑定 React),以及底层极其强大但 API 学习曲线陡峭的 ProseMirror,我们最终选择了 Tiptap + ProseMirror 的组合拳。

Tiptap 作为基于 ProseMirror 构建的 Headless(无头)框架,完美继承了其强大的文档树(Document Tree)和 Schema 规范,同时提供了一层极其优雅的 API 封装。这套“底层稳健兜底,上层开发丝滑”的设计,斩断了特定 UI 框架的强依赖,成为我们完成这次降维打击的最优解。

第三章:ProseMirror 核心实战——架构重组

既然痛点找准了,那就开干。我们设计了全新的 “编辑器-组件分离” 架构,利用 ProseMirror 强大的 NodeView 机制,彻底重构了卡片系统。

图片

3.1 架构革新:编辑器与组件的“分家”

在这个架构中,编辑器不再负责具体的 UI 渲染,而是专注于文档结构的管理。NodeView 充当了“桥接”的角色。

3.2 核心设计 I:Schema 定义(给积木定规矩)

首先,我们需要告诉编辑器,“视频卡”这个积木长什么样,有什么属性。

// schema/video-card.ts
const VideoCard = Node.create({
  name'videoCard',  
  group'block',     // 声明我是块级节点  
  atomtrue,         // 💡 关键点:原子化  
  draggabletrue,    // 可拖拽    
  
  // 定义数据属性  
  addAttributes() {
    return { 
      card_style: { defaultCardStyle.NORMAL },   // 卡片风格 
      info: { default: {} },             // 业务数据   
      status: { default'loading' }          // loading | loaded | error  
    }
  },    
  
  // 解析规则:怎么从 HTML 读出来
  parseHTML() { 
    return [{ tag'div[data-type="video-card"]' }] 
  },
  
  // 渲染规则:怎么存成 HTML  
  renderHTML({ node }) {  
    return ['div', { 'data-type''video-card''data-bvid': node.attrs.bvid }, 0]  
  }
})

🧐 Code Review:

  • atom: true 是这里的神来之笔。它告诉 ProseMirror:“这个节点是一个整体,光标不能跑进去,要么选中整个卡片,要么不选”。这完美符合卡片的交互逻辑,避免了光标在卡片内部乱窜的尴尬。
  • addAttributes 定义了卡片的数据模型,这些数据会直接映射到 UI 组件的 Props 中。

3.3 核心设计 II:NodeView(连接两个世界的桥梁)

接下来是重头戏 —— NodeView。它是连接 ProseMirror 数据层和 UI 渲染层的桥梁。我们要在这里把 UI组件挂载上去。

截屏2026-03-11 16.43.54.png

🧐 Code Review:

  • 这段代码实现了真正的“所编写即所得”。你在编辑器里看到的组件,就是发布后读者看到的组件,连代码都是同一份!
  • 通过事件监听,组件内部的操作(如点击删除、重试加载)可以反向控制编辑器的数据状态。

第四章:硬核填坑——从“能用”到“好用”

重构之路从不平坦,为了让这个系统真正“好用”,我们解决了不少棘手的工程问题。

4.1 隐秘的代价:插入极速,但运行态呢?

技术世界没有银弹。当我们为“极速插入”和“真实交互”欢呼时,隐秘的代价也随之而来——展示态(运行时)的性能崩盘风险。旧方案虽然插入慢,但在运行时只是一张死图,文章里塞入 50 个卡片依然能丝滑滚动。但新方案的每一个视频卡,都是一个包含了复杂 DOM 树、状态机、播放器的真实组件。如果放任不管,十几个播放器同时驻留内存,浏览器会直接崩溃 。

为了兜住这层底线,我们在架构上设计了两大“降落伞”:

4.2 把播放器“装”进编辑器(CardPlayer 管理器)

我们引入了双视图自由切换模式与 CardPlayer 实例池 :

  • NORMAL 模式:普通小卡,仅展示封面和元信息,不播放视频 。

  • ADVANCED 模式:点击后直接展开内嵌播放器,通过改变 card_style 属性无缝切换,受 CardPlayer 管理器控制 。

截屏2026-03-11 16.45.20.png

图片

4.2 极致性能优化(批量解析 + 三级缓存)

如果用户一次性粘贴 50 个链接怎么办?发 50 个 API 请求?服务器会报警的 !我们重构了链接解析层,引入了批量验证和共享缓存 。

截屏2026-03-11 16.46.03.png

🧐 Code Review:

  • 这里基于防抖,100ms 内的粘贴操作会被合并为一个请求(Batch API)。
  • 缓存是全局共享的。当用户在编辑器内反复撤销、重做或拖拽卡片时,直接命中缓存,实现 0 延迟渲染。

图片图片

第五章:核心创新点总结

5.1 智能链接解析与双向转换

我们不仅支持从“链接”变“卡片”,还支持完美的逆向转换。通过 resource_url 字段保存用户原始输入信息,确保数据 100% 完整。

图片

截屏2026-03-11 16.47.11.png

5.2 模板策略模式

我们抽象了 BaseCard 基类,利用策略模式处理不同类型的卡片渲染。无论是视频卡、专栏卡还是投票卡,都复用了同一套生命周期管理逻辑(mount → load → update → destroy),代码复用率提升了 60%

截屏2026-03-11 16.48.34.png

5.3 历史包袱的优雅着陆:旧专栏兼容

新架构固然强大,但对于一个拥有海量存量数据的平台来说,绝不能以牺牲历史数据为代价。同时,新编辑器生产的内容也必须完美融入现有的内容分发基建。为此,我们围绕 Opus 协议(B站图文统一发布协议) 设计了一套向下兼容历史、向上打通分发的全局策略:

  • 战略锚点:基于 Opus 图文统一发布协议的链路闭环

Opus 是我们内部定义的图文统一发布协议。为了无缝接入现有的动态分发渠道,确保高质量图文能够高效流转,新版编辑器在最终发布时,会将所有文档树数据全量转换为 Opus 格式。这不仅统一了底层标准,也让生产端到分发端的链路彻底打通。

  • 首选路径:历史专栏优先转出 Opus 无损还原

针对过去沉淀的千万级历史专栏,我们已经在服务端优先尝试将其向 Opus 格式进行转出与迁移。由于 Opus 是我们的标准协议,当这些转换成功的数据进入新版编辑器时,能够通过 Schema 的精准映射,100% 无损还原为内部的 Document Tree,让老文章直接享受最纯粹的组件化编辑体验。

  • 柔性兜底:不支持迁移场景的 H5 动态解析

然而,总有一些极其古老(例如夹杂着 UEditor 时代“野生标签”)且无法安全迁移为 Opus 格式的富文本黑盒。面对这些“硬骨头”,我们并没有采用高风险的“强洗数据”,而是让新版编辑器利用加载 H5 内容的方式进行动态兜底。通过触发节点中预设的 parseHTML 规则,在浏览器端实时将陈旧的 HTML 代码“翻译”成全新的规范化 Block Node,确保再老的专栏也能在新编辑器中顺利“复活”并进行二次编辑。

第六章:效果实测与总结

通过这次架构升级,我们将“插卡”这一高频操作的体验提升到了新的维度 。但在亮眼的数据背后,我们也完成了一次经典的工程性能博弈。来看一组真实的对比数据 :

图片图片

结论:走向“应用级”文档

从 Quill 到 ProseMirror 的迁移,不仅仅是更换了一个编辑器内核,更是我们对文档理解的一次升级。

文档不再只是静态内容的载体,而是动态应用的容器。

通过 Tiptap + ProseMirror 的现代化技术栈,我们成功将“低保真”的绘图式卡片,进化为具备完整生命周期、状态管理和复杂交互的“应用级”组件。这不仅解决了当下的性能痛点,更为未来引入投票、互动游戏等更复杂的业务卡片奠定了坚实的基础。

我们终于可以说:在 B 站的专栏编辑器里,你看到的,就是真实的 🎉(WYSIWYG)

-End-

作者丨泯泷

从 V8 引擎看 JS 代码是如何一步步变成机器指令的

作者 天涯学馆
2026年3月12日 11:15

今天咱们要深入 V8 引擎的“心脏”,看看一行 JavaScript 代码(比如 function add(a, b) { return a + b })是如何被“翻译”成 CPU 能懂的机器指令的。

这个过程涉及 解析(Parsing)、抽象语法树(AST)、字节码生成、JIT 优化编译 等核心环节。我会用 “代码示例+流程拆解+关键组件讲解” 的方式,带你从“输入代码”到“机器执行”全程跟踪,彻底搞懂 V8 的工作原理。

前置知识:V8 引擎的核心组件

在开始前,先明确 V8 引擎的几个关键“角色”(简化版):

组件 职责 关键产出物
解析器(Parser) 将 JS 代码文本转换为结构化的抽象语法树(AST) AST(抽象语法树)
解释器(Ignition) 读取 AST 并生成字节码(Bytecode),快速启动执行 字节码(轻量级中间代码)
优化编译器(TurboFan) 监控字节码执行,对“热点代码”(频繁执行的代码)生成优化的机器码 优化的机器码(高性能二进制指令)
执行引擎 执行字节码或机器码,操作内存、调用栈等底层资源 最终计算结果

一句话总结
JS 代码 → 解析器 → AST → 解释器(字节码)→ 优化编译器(机器码)→ 执行引擎(运行结果)。

第一步:解析(Parsing)——从代码文本到 AST

什么是 AST?

AST(Abstract Syntax Tree,抽象语法树)是一种 用树状结构表示代码语法结构 的数据。每个节点代表代码中的一个语法元素(如变量、函数、表达式)。

举个栗子
对于代码 function add(a, b) { return a + b },它的 AST 结构大致如下(用文字描述):

Program(根节点)
└── FunctionDeclaration(函数声明)
    ├── id: Identifier(函数名 "add")
    ├── params: FormalParameters(参数列表)
    │   ├── Identifier(参数 "a")
    │   └── Identifier(参数 "b")
    └── body: BlockStatement(函数体)
        └── ReturnStatement(返回语句)
            └── BinaryExpression(加法表达式)
                ├── left: Identifier(变量 "a")
                └── right: Identifier(变量 "b")

解析器如何生成 AST?

解析器的工作分为两步:词法分析(Lexical Analysis)语法分析(Syntactic Analysis)

(1)词法分析:将代码拆分为“词法单元”(Tokens)

词法分析器(Tokenizer)会将代码文本按“语法规则”切割成最小的有意义单元(Tokens)。例如:

function add(a, b) { return a + b }

会被拆分为以下 Tokens(简化版):

[ 'function', 'add', '(', 'a', ',', 'b', ')', '{', 'return', 'a', '+', 'b', '}' ]

(2)语法分析:将 Tokens 转换为 AST

语法分析器(Parser)根据 JS 语法规则(如 ECMAScript 标准),将 Tokens 组织成树状结构的 AST。如果代码语法错误(如少括号),这一步会抛出错误。

代码演示:用 V8 的解析器生成 AST
实际开发中,可以用 Chrome DevTools 的 ConsoleSources 面板查看 AST(需开启“Enable AST visualization”)。例如,输入以下代码并调试:

function add(a, b) { return a + b; }

DevTools 会显示类似以下的 AST 结构(简化):

▸ FunctionDeclaration {
  id: Identifier { name: 'add' },
  params: [
    Identifier { name: 'a' },
    Identifier { name: 'b' }
  ],
  body: BlockStatement {
    body: [
      ReturnStatement {
        argument: BinaryExpression {
          operator: '+',
          left: Identifier { name: 'a' },
          right: Identifier { name: 'b' }
        }
      }
    ]
  }
}

第二步:解释执行(Ignition)——从 AST 到字节码

为什么需要字节码?

直接将 AST 转换为机器码效率太低(需要处理平台差异、优化成本高)。因此,V8 选择先由解释器(Ignition)将 AST 转换为 字节码(一种轻量级的中间代码),再执行字节码。

字节码的特点:

  • 跨平台:不依赖具体 CPU 架构(如 x64、ARM);
  • 体积小:比机器码更紧凑,减少内存占用;
  • 快速生成:解释器可以快速启动,避免长时间编译等待。

解释器如何生成字节码?

Ignition 解释器会遍历 AST,并根据 V8 内置的“字节码指令集”(类似 CPU 的汇编指令,但更抽象)生成字节码。

举个栗子
对于 add(1, 2) 的调用,AST 中的 CallExpression 节点会被 Ignition 转换为以下字节码(简化):

PushNumber 1       // 将数字 1 压入栈
PushNumber 2       // 将数字 2 压入栈
CallFunction add   // 调用函数 add
Return             // 返回结果

字节码的执行流程

解释器执行字节码时,会维护一个 执行上下文栈(Call Stack),每个上下文包含变量环境、作用域链等信息。例如,调用 add(1, 2) 时:

  1. 压入全局执行上下文;
  2. 压入 add 函数执行上下文;
  3. 执行加法操作,将结果(3)压入栈;
  4. 弹出 add 上下文,返回结果到全局上下文。

第三步:JIT 优化(TurboFan)——从字节码到机器码

1. 为什么需要优化?

解释器执行字节码的速度较慢(相比机器码)。对于“热点代码”(如被重复调用多次的函数),V8 会用优化编译器(TurboFan)将其转换为 优化的机器码,大幅提升执行效率。

2. TurboFan 如何优化?

TurboFan 的核心是 类型反馈(Type Feedback):通过监控字节码的执行,收集变量的类型信息(如 a 总是数字),然后基于这些信息生成高度优化的机器码。

举个栗子
假设有一个函数 function sum(a, b) { return a + b },如果它被多次调用且 ab 总是数字:

  • 初始执行时,Ignition 生成通用字节码(处理所有可能的类型,如数字、字符串);
  • TurboFan 监控到 ab 始终是数字,生成优化的机器码(直接使用 CPU 的加法指令 ADD);
  • 后续调用该函数时,直接执行优化的机器码,跳过解释器的字节码步骤。

3. 优化的条件与限制

TurboFan 的优化需要满足 类型稳定(变量的类型不会意外变化)。如果类型发生变化(如 a 有时是数字,有时是字符串),V8 会触发 去优化(Deoptimization)

  • 停止使用优化的机器码;
  • 回退到解释器执行,并重新收集类型信息。

代码演示:类型稳定与去优化

function add(a, b) {
  return a + b;
}

// 第一次调用:类型稳定(数字)
add(1, 2);  // TurboFan 可能优化为机器码

// 第二次调用:类型变化(字符串)
add('1', '2');  // 触发去优化,回退到解释器

第四步:执行机器码——CPU 如何“理解”指令

1. 机器码的本质

机器码是 CPU 能直接执行的二进制指令(如 10001011),对应 CPU 的底层操作(如加减乘除、内存读写)。

2. 从字节码到机器码的转换

TurboFan 优化编译器会将字节码转换为与 CPU 架构匹配的机器码。例如,x64 架构的 CPU 执行 ADD 指令时,机器码可能是 01000000(具体二进制由 CPU 指令集决定)。

3. 执行流程示例

add(1, 2) 的优化机器码为例,CPU 会依次执行以下步骤:

  1. 从内存中读取 a(值为 1)和 b(值为 2);
  2. 执行 ADD 指令,将两个数相加(结果为 3);
  3. 将结果存入寄存器或内存;
  4. 返回结果。

完整流程总结

我们用一个完整的例子,串联所有步骤:

代码输入

function add(a, b) {
  return a + b;
}
console.log(add(1, 2));  // 输出 3

步骤 1:解析器生成 AST

Program
└── FunctionDeclaration (add)
    ├── id: "add"
    ├── params: [a, b]
    └── body: ReturnStatement (a + b)

步骤 2:Ignition 生成字节码

PushNumber 1       // 压入 1
PushNumber 2       // 压入 2
Add                // 执行加法(a + b)
Return             // 返回结果

步骤 3:TurboFan 优化为机器码(x64 示例)

; 假设 a 在寄存器 rax,b 在寄存器 rbx
mov rax, 1         ; 将 1 存入 rax
mov rbx, 2         ; 将 2 存入 rbx
add rax, rbx       ; rax = rax + rbx(结果 3)
ret                ; 返回 rax 的值

步骤 4:CPU 执行机器码

CPU 按顺序执行上述机器指令,最终将结果 3 写入内存,并输出到控制台。

V8 引擎执行流程全景图(Mermaid 架构图)

graph TD
    A[JS 代码文本] --> B[解析器 Parser]
    B --> C[抽象语法树 AST]
    C --> D[解释器 Ignition]
    D --> E[字节码 Bytecode]
    E --> F{是否热点代码?}
    F -->|是| G[优化编译器 TurboFan]
    F -->|否| H[执行引擎]
    G --> I[优化的机器码 Machine Code]
    I --> H
    H --> J[CPU 执行]
    E --> K[执行上下文栈]
    K --> H
    G --> L[类型反馈 Type Feedback]
    L --> G
    H --> M[去优化 Deoptimization]
    M --> D

图解说明

  • 横向流程:JS 代码从输入到最终被 CPU 执行的主路径;
  • 分支逻辑:热点代码触发优化(TurboFan),非热点代码直接由解释器执行;
  • 循环优化:优化后的机器码执行时仍会被监控,若类型变化则回退(去优化)。

分阶段深度解析

我们以一段简单的 JS 代码为例,全程跟踪其执行流程:

function add(a, b) {
  return a + b;
}
console.log(add(1, 2)); // 输出 3

阶段 1:解析器(Parser)——代码文本 → AST

关键步骤:

  1. 词法分析(Lexical Analysis)
    将代码文本按语法规则切割为“词法单元”(Tokens)。例如,function add(a, b) { return a + b } 会被拆分为:
['function', 'add', '(', 'a', ',', 'b', ')', '{', 'return', 'a', '+', 'b', '}']
  1. 语法分析(Syntactic Analysis)
    根据 ECMAScript 语法规则,将 Tokens 转换为树状结构的 AST。AST 是代码的“结构化表示”,后续所有操作(如优化、执行)都基于此。

Mermaid 子图:AST 结构

graph TD
    Root[Program] --> FuncDecl[FunctionDeclaration]
    FuncDecl --> Id[Identifier: add]
    FuncDecl --> Params[FormalParameters]
    Params --> ParamA[Identifier: a]
    Params --> ParamB[Identifier: b]
    FuncDecl --> Body[BlockStatement]
    Body --> ReturnStmt[ReturnStatement]
    ReturnStmt --> BinExpr[BinaryExpression: +]
    BinExpr --> Left[Identifier: a]
    BinExpr --> Right[Identifier: b]

总结:解析器输出 AST,这是后续所有处理的“蓝图”。

阶段 2:解释器(Ignition)——AST → 字节码

关键步骤:

  1. 遍历 AST
    Ignition 解释器通过深度优先遍历(DFS)访问 AST 的每个节点(如 FunctionDeclarationBinaryExpression)。

  2. 生成字节码
    根据 AST 节点的类型,对照 V8 内置的“字节码指令集”生成对应的字节码。例如:

    • FunctionDeclaration 节点生成“创建函数对象”的字节码;
    • BinaryExpression (+) 节点生成“加法操作”的字节码。

字节码示例(简化):

// 函数 add 的字节码
PushNumber 1       ; 将数字 1 压入栈
PushNumber 2       ; 将数字 2 压入栈
Add                ; 执行加法(弹出栈顶两个数,结果压回)
Return             ; 返回结果

执行上下文栈:

解释器执行字节码时,会维护一个 执行上下文栈(Call Stack),用于管理函数调用的状态(如变量环境、作用域链)。例如:

调用栈状态:
- 全局执行上下文(Global)
  └── add 函数执行上下文(Activation)
      ├── 参数:a=1, b=2
      ├── 局部变量:无
      └── 返回地址:全局上下文

总结:解释器快速生成字节码并执行,避免了直接编译机器码的高开销。

阶段 3:优化编译器(TurboFan)——字节码 → 优化机器码

关键概念:热点代码(Hot Code)

“热点代码”指被频繁执行的代码(如循环、高频函数)。V8 会监控字节码的执行次数,当达到阈值(如 10000 次)时,触发 TurboFan 优化。

优化流程:

  1. 类型反馈(Type Feedback)
    TurboFan 会记录字节码执行过程中变量的类型信息。例如,add 函数的参数 ab 总是被传入数字(number 类型)。
  2. 生成优化机器码
    基于类型反馈,TurboFan 生成高度优化的机器码。例如,若 ab 总是数字,机器码会直接使用 CPU 的 ADD 指令(无需类型检查)。

优化前后对比:

阶段 代码类型 执行逻辑 性能
解释器 通用字节码 处理所有可能的类型(数字、字符串、对象等) 较慢
优化编译器 优化的机器码 仅处理已知类型(如数字) 接近 C 语言

去优化(Deoptimization):

如果变量类型发生变化(如 add('1', '2') 传入字符串),V8 会触发去优化:

  1. 停止使用优化的机器码;
  2. 回退到解释器执行,并重新收集类型信息。

总结:TurboFan 通过类型反馈生成高效机器码,但依赖类型稳定;类型变化会触发去优化,影响性能。

阶段 4:执行引擎——机器码 → CPU 执行

关键步骤:

  1. 机器码加载
    优化的机器码会被加载到内存中,等待 CPU 执行。
  2. CPU 执行指令
    CPU 按顺序读取机器码的二进制指令,通过寄存器、ALU(算术逻辑单元)等部件完成计算。例如,ADD 指令会指示 CPU 将两个寄存器中的数值相加,结果存入目标寄存器。

示例:add(1, 2) 的机器码执行

假设 x64 架构 CPU 执行以下机器码(简化):

mov rax, 1    ; 将 1 存入寄存器 rax
mov rbx, 2    ; 将 2 存入寄存器 rbx
add rax, rbx  ; rax = rax + rbx(结果 3)
ret           ; 返回 rax 的值(3)

总结:CPU 直接执行机器码,这是 JS 代码最终“跑起来”的物理基础。

完整流程总结(附代码执行路径)

我们用流程图串联所有步骤,并标注关键数据结构:

graph LR
    A[JS 代码] --> B[解析器]
    B --> C[AST: 函数声明+加法表达式]
    C --> D[解释器 Ignition]
    D --> E[字节码: PushNumber/Add/Return]
    E --> F{是否热点代码?}
    F -->|是| G[TurboFan 优化]
    G --> H[类型反馈: a=number, b=number]
    H --> I[优化机器码: mov/add/ret]
    I --> J[执行引擎 → CPU]
    F -->|否| K[执行引擎 → CPU]
    J --> L[输出 3]
    K --> L
    L --> M[控制台打印 3]

关键结论

  • V8 通过“解释执行+JIT 优化”的混合模式,平衡了启动速度和长期性能;
  • 类型稳定是触发优化的关键,编写代码时应尽量避免变量类型频繁变化;
  • 去优化机制保证了代码的健壮性,但也提示开发者需关注类型一致性。

V8 优化的未来趋势

现代 V8 引擎(如 Chrome 120+)在原有流程上增加了更多优化:

  1. 内联缓存(Inline Caches, ICs)
    缓存高频函数的调用结果,避免重复查找(如 obj.x 的属性访问)。
  2. 并行编译
    利用多线程同时生成字节码和优化机器码,缩短启动时间。
  3. 预编译(Precompilation)
    在页面加载时提前编译部分代码(如懒加载的函数),减少运行时延迟。

常见问题

1. 为什么 V8 不直接编译为机器码?

  • 启动速度:解释器(Ignition)可以快速生成字节码并执行,避免长时间编译;
  • 内存占用:字节码比机器码更紧凑,减少内存使用;
  • 跨平台:字节码是平台无关的,而机器码需要针对不同 CPU 架构(x64、ARM)生成。

2. 去优化(Deoptimization)的影响

如果代码频繁触发类型变化(如变量类型不稳定),V8 会频繁去优化,导致性能下降。因此,保持变量类型稳定(如始终使用数字,避免混合类型)是优化 JS 性能的关键。

3. V8 的未来:更智能的优化

现代 V8 引擎(如 Chrome 100+)引入了更多优化技术:

  • 内联缓存(Inline Caches):缓存高频函数的调用结果,减少查找时间;
  • 并行编译:利用多线程同时生成字节码和优化机器码;
  • 预编译(Precompilation):在页面加载时提前编译部分代码,减少运行时延迟。

前端开发如何快速上手安卓APP开发?

作者 叁两
2026年3月12日 10:09

前言

最近要做一个AI项目,需要做一个安卓app,但没有安卓开发,我想了想,还是我来吧,就当学习了。

但从没碰过安卓开发,该如何入手呢?

遇事不决,先问AI!

我问ChatGPT:“前端开发转安卓开发,给我一个快速上手的学习教程。”

他立马给我设计了一个对比学习路径。

学习计划

前端开发者(Vue/JS)转 Android 开发,其实优势很大,因为很多概念是共通的:组件化、状态管理、异步请求、模块化等。

关键是 快速建立 Android 的“开发心智模型” ,再做几个小项目。


一、先理解 Android 开发整体结构

前端开发:

HTML
CSS
JavaScript
浏览器

Android 开发:

XML / Compose  → UI
Kotlin / Java  → 逻辑
Android SDK    → 系统能力
APK            → 打包安装

核心组成:

Activity    → 页面
Fragment    → 页面组件
View        → UI组件
Intent      → 页面跳转
Service     → 后台服务

简单理解:

前端 Android
页面 Activity
组件 Fragment
DOM View
路由 Intent
AJAX Retrofit
LocalStorage SharedPreferences

二、技术选型(建议)

现在 Android 主流技术:

语言:Kotlin
UI:Jetpack Compose
网络:Retrofit
架构:MVVM
异步:Coroutines

技术栈:

Kotlin
Jetpack Compose
MVVM
Retrofit
Room

三、开发环境搭建(1小时)

安装:

1 下载 Android Studio

官网
developer.android.com/studio

安装后会自动安装:

Android SDK
Android Emulator
Gradle

建议安装版本:

Android Studio Hedgehog 或更新

2 创建项目

打开 Android Studio:

New Project

选择:

Empty Activity

配置:

Language: Kotlin
Minimum SDK: API 24

项目结构:

app
 ├── java/com.example.demo
 │    └── MainActivity.kt
 ├── res
 │    ├── layout
 │    ├── drawable
 │    └── values
 └── AndroidManifest.xml

四、Kotlin 快速入门(2小时)

你会 JS,Kotlin 很容易。

变量

val name = "Echo"     // 常量
var age = 18          // 变量

类似:

const / let

函数

fun sum(a: Int, b: Int): Int {
    return a + b
}

简写:

fun sum(a: Int, b: Int) = a + b

class User(val name: String, val age: Int)

Lambda

类似 JS

val add = { a: Int, b: Int -> a + b }

五、Android 页面开发

1 Activity

MainActivity

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            Text("Hello Android")
        }

    }
}

2 UI组件

Compose写法:

@Composable
fun Greeting() {
    Text("Hello World")
}

按钮:

Button(onClick = { println("clicked") }) {
    Text("点击")
}

六、网络请求(调用AI)

添加依赖:

Retrofit

build.gradle:

implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

创建 API

interface ApiService {

    @POST("/chat")
    suspend fun chat(
        @Body req: ChatRequest
    ): ChatResponse

}

调用:

val response = api.chat(ChatRequest("你好"))

七、实现一个 AI 助手 APP

功能:

输入一句话
AI返回结果

UI:

+----------------+
| 输入框         |
| [发送]        |
|                |
| AI回答         |
+----------------+

Compose示例:

@Composable
fun ChatPage() {

    var input by remember { mutableStateOf("") }
    var answer by remember { mutableStateOf("") }

    Column {

        TextField(
            value = input,
            onValueChange = { input = it }
        )

        Button(onClick = {
            answer = "AI正在思考..."
        }) {
            Text("发送")
        }

        Text(answer)
    }
}

八、推荐学习资料

官方:

  • Android Developer

推荐书:

《Kotlin从入门到实践》

视频:

  • Google Android Basics

九、给你一个最关键建议

前端转 Android 不要先学太多理论

直接做:

AI助手 APP

做的过程中学:

Activity
Service
权限
网络
UI

效率最高。

2026 年 Next.js 站点的 SEO 优化指南

作者 Moment
2026年3月12日 09:20

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

如果你对 OpenClaw 也感兴趣,也欢迎添加我微信,我拉你进交流群

Next.js 是最流行的 React 框架,被广泛用来构建现代 Web 应用,自带不少开发者未必会用到的能力。搜索形态正在从传统搜索转向"零点击搜索",背后是 Google AI Overview、ChatGPT 等 AI 搜索产品在推动。这些平台会持续抓取站点,用元数据和内容做索引,结构清晰、易于抓取的内容更容易被引用、出现在 AI 结果里。要想在这场变化里站稳,就需要同时为人和 AI 爬虫优化页面与内容。

下面按最佳实践,从页面和内容两方面说说如何做好 SEO 与 GEO。

1. 用 Metadata API 做 SEO

元数据一直负责告诉外界页面的标题和描述。Next.js 用新的 Metadata API 取代了过去的 Head 组件,可以在服务端组件里声明,由服务端渲染输出,爬虫和社交平台抓取时拿到的就是完整、准确的标题与描述,不会出现空白或占位文案。

app/layout.tsx 或单页的 layout.tsx 里导出 metadata 对象即可。下面这段示例覆盖了站点标题模板、默认描述、搜索引擎验证、Open Graph 与 Twitter 卡片、以及规范链接。

import type { Metadata } from "next";

export const metadata: Metadata = {
  title: {
    template: "%s",
    default:
      "Texavor - GEO & Content Optimization Platform for Writers, Marketers & Developers",
  },
  description:
    "Build your AI content workflow. Discover topics across ChatGPT and Perplexity, and generate data-backed briefs.",
  verification: {
    google: "R53D-JHFSD93JDhjhds_ei99JFADSF", // 示例用占位,替换为你在 Search Console 获得的验证码
  },
  openGraph: {
    title:
      "Texavor - GEO & Content Optimization Platform for Writers, Marketers & Developers",
    description:
      "Build your AI content workflow. Discover topics across ChatGPT and Perplexity, and generate data-backed briefs.",
    // images: "/easywriteOpenGraph.png",
  },
  twitter: {
    card: "summary_large_image",
    title:
      "Texavor - GEO & Content Optimization Platform for Writers, Marketers & Developers",
    description:
      "Build your AI content workflow. Discover topics across ChatGPT and Perplexity, and generate data-backed briefs.",
    // images: "/easywriteOpenGraph.png",
  },
  alternates: {
    canonical: "/",
  },
};

可以配置的内容包括:titledescription(当前页的标题和描述)、openGraph(在 Facebook、LinkedIn 等社交平台分享时的预览信息)、twitter(Twitter 卡片类型、图片、标题、描述等)、canonical(规范地址,用于转载或重复内容时指向原始出处,也可以把当前页设为自己的 canonical)、verification(向搜索引擎提交的站长验证标签,会变成 head 里的 meta,可用来在 Google Search Console、Bing Webmaster、Yandex 等平台验证站点)。需要按请求动态生成元数据时,可在同页导出异步函数 generateMetadata,接收 paramssearchParams 等参数,返回结构相同的 Metadata 对象即可。

2. 使用服务端渲染(SSR)

爬虫更希望拿到"已经渲染好的"完整 HTML,而不是先看到加载态。用服务端渲染就不会先出一屏 loading,首屏 HTML 里已经包含主要内容,对收录和 AI 抓取都更友好。

Next.js App Router 下有三种常见用法,可按页面特性选一种。

  • SSR:每次请求在服务端渲染页面,把带数据的 HTML 直接返回,适合内容经常变的页面(例如带实时数据的仪表盘、个性化推荐)。
  • SSG:构建时生成 HTML,之后每次请求都直接返回这份静态页,适合几乎不变的页面,例如法律声明、关于我们、联系我们。
  • ISR:在构建时生成并缓存页面,请求时先返回缓存,再通过 revalidate 在指定时间后重新生成,适合博客这类更新有节奏的页面。

如下图所示。

20260312090838

图里把 SSR 每次请求渲染、SSG 构建时生成、ISR 先缓存再按时间重建的差异和适用场景都画出来了。看完可按页面类型选一种。

在页面文件顶部导出 revalidate,即可为该路由开启 ISR。下面的 3600 表示该页最多缓存 1 小时,超过后下次请求会触发重新生成。

export const revalidate = 3600;

除了 SEO,在 Vercel 等平台还能减轻服务器压力、控制成本。静态或 ISR 页面走 CDN,动态请求才回源。

3. 使用结构化数据(Schema 标记)

Schema 标记(结构化数据)是加在页面里的一段代码,用来帮助搜索引擎和问答引擎理解页面含义。它不仅能提升传统搜索表现,对 AI 搜索和 AI 回答的展示也很重要,因此是站点的重要一环。常见有两种形式:JSON-LD(用独立的 script type="application/ld+json" 注入,便于维护和扩展)、Microdata(用 itemscopeitemtypeitemprop 等属性写在 HTML 标签上,可读性和维护性较差)。当前更推荐 JSON-LD。

在页面组件里根据数据构建 JSON-LD 对象,用 dangerouslySetInnerHTML 写入 script 标签。注意把 < 转成 \u003c,避免被解析成 HTML 标签。

interface Product {
  id: string;
  name: string;
  image: string;
  description: string;
}

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const product = await getProduct(id);

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: product.name,
    image: product.image,
    description: product.description,
  };

  return (
    <section>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, "\u003c"),
        }}
      />
      {/* 页面其余内容 */}
    </section>
  );
}

Schema 类型可以有很多种。FAQ Schema 适合页面相关常见问题的问答结构。HowTo Schema 适合分步骤教程类页面。Article 与 Author Schema 把内容和作者关联起来,能强化 E-E-A-T(经验、专业、权威、可信)信号,对长文和博客尤其有用。延伸阅读可参考:How to Build Schema Markup for AEO、Common FAQ Schema Mistakes That Hurt Answer Engine Optimization、How to implement JSON-LD in your Next.js application。

4. 优化 Core Web Vitals

Core Web Vitals 是 Google 用来衡量页面体验的指标,主要看加载速度、视觉稳定性和可交互性。常见几个指标是:LCP(Largest Contentful Paint)即最大可见内容(卡片、图片或大段文字)加载完成的时间、CLS(Cumulative Layout Shift)即加载过程中布局发生意外偏移的程度、INP(Interaction to Next Paint)即用户操作(点击、触摸、按键)到页面给出反馈的时间。用 Next.js 的 Image 组件可以自动优化大图,有利于 LCP。

在需要展示图片的组件里引入 next/image,用 srcaltfill(或 width/height)即可。alt 务必填写,对无障碍和图片搜索都有帮助。

import Image from "next/image";

export function ArticleCover({
  image,
  title,
}: {
  image: string;
  title: string;
}) {
  return (
    <div style={{ position: "relative", width: "100%", aspectRatio: "16/9" }}>
      <Image src={image} alt={title} fill />
    </div>
  );
}

使用 next/image 的好处包括:自动压缩图片、懒加载(不可见时不加载,减轻首屏压力)、配合 fill 等属性可适配父容器,方便做响应式布局。在 Vercel 上部署时,next/image 会走 Vercel 的图片优化服务,免费 Hobby 计划每月有 5,000 次优化额度,超出后可能需要升级付费计划。

5. 动态生成 Sitemap

Sitemap 帮助爬虫发现所有可抓取页面,Google Search Console、Bing Webmaster 等都会用它来识别公开页面。在 app 下放一个 sitemap.tssitemap.js,Next.js 会自动把它当作 sitemap 的路由。这是特殊的 Route Handler,默认会被缓存,除非用了动态 API 或动态配置。

下面示例从 getAllPosts 拉取博客列表,把静态首页、博客索引页和每篇文章的 URL 拼成 sitemap 数组。lastModifiedchangeFrequencypriority 可按实际更新频率调整。

import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/posts";

interface Post {
  slug: string;
  updated_at: string;
}

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const staticPages: MetadataRoute.Sitemap = [
    {
      url: "https://www.texavor.com",
      lastModified: new Date(),
      changeFrequency: "daily",
      priority: 1.0,
    },
    {
      url: "https://www.texavor.com/blog",
      lastModified: new Date(),
      changeFrequency: "daily",
      priority: 0.8,
    },
  ];

  const posts = await getAllPosts();
  const postPages: MetadataRoute.Sitemap = posts.map((post: Post) => ({
    url: `https://www.texavor.com/blog/${post.slug}`,
    lastModified: new Date(post.updated_at),
    changeFrequency: "weekly",
    priority: 0.8,
  }));

  return [...staticPages, ...postPages];
}

可以把首页、关于我们等静态页和博客等动态页拼在一起,实现整站 sitemap 的动态生成。上线后可在 Search Console 中提交 sitemap URL,便于搜索引擎更快发现新页面。

常见问题

Next.js 对 SEO 的主要好处是什么?

通过 SSR、SSG、ISR 等渲染方式提升首屏 HTML 完整度和加载性能,并内置动态 sitemap、metadatagenerateMetadata 等能力,减少手写 head 和 sitemap 的重复劳动。

如何在 Next.js 里做自定义 sitemap?

app 下新增 sitemap.tssitemap.js,默认导出一个返回 MetadataRoute.Sitemap 数组的异步函数即可。如需使用项目根路径以外的 base URL,可在返回的每条记录里写完整绝对 URL。

Next.js 的 Metadata API 对 SEO 有什么帮助?

可以集中配置 titlemeta descriptioncanonical、Open Graph、Twitter 卡片、验证标签等,全部由服务端输出,爬虫和社交爬虫拿到的都是最终 HTML,不会因为客户端才注入而漏抓。

为什么结构化数据对 SEO 和 AI 搜索很重要?

JSON-LD 等结构化数据能把"这是一篇教程、作者是谁、步骤有哪些"等信息显式告诉搜索引擎和 ChatGPT 等 AI 问答平台,它们更准确理解页面内容后,更容易在摘要或回答中引用你的页面。

Core Web Vitals 如何影响 Next.js 站点的 SEO?

它们衡量加载速度、视觉稳定性和交互响应。用 next/image、SSR 或 ISR、以及合理的资源加载策略把 Core Web Vitals 做好,既能提升体验,也有利于排名和 AI 抓取时的"可读性"评估。

小结

在 Next.js 里做好 SEO,需要技术实现和内容结构一起抓。Metadata API、SSR 与 SSG 及 ISR、JSON-LD 结构化数据以及基于 Core Web Vitals 的性能优化,都能让应用更容易被搜索到、体验更好,并适应下一代搜索与 AI 检索。建议从元数据与 sitemap 先做齐,再按页面类型选好渲染策略,最后补上结构化数据和图片优化,逐步迭代即可。

5年前端,我为什么要all in AI Agent?

作者 wuhen_n
2026年3月13日 17:27

一个普通 Vue/Electron 工程师的转型自白


前言

我是一个普通的前端工程师。5年经验,公司开发框架也就是 Vue2/3 + TS。自己倒腾过Electron,uni-app,能写一些简单的功能模块。没进过大厂,没写过框架,不是什么技术大神。

每天的生活就是:上班写代码,下班刷掘金/知乎/CSDN,周末偶尔看看新技术。工资一般般,饿不死,也富不了。原本以为自己会这样一直干到退休。

直到去年,年奖金只有以往的一半了!


某一天的顿悟

和往常一样,看看手机,刷刷帖子,直到刷到:《35岁程序员裸辞两月,找不到工作,感慨程序员是碗青春饭》。

那一刻,我突然意识到:我也30多了,离35也不远了。

那天晚上,我失眠了。翻来覆去想几个问题:

  • 我的核心竞争力是什么?
  • 如果明天被裁,我能做什么?
  • 5年后,我还在写代码吗?

没有答案。只有焦虑。


一次偶然的机遇

随着公司业务发展,各部门都在鼓励使用 AI,自然而然的,我也分配了相关的任务:处理后端 AI 大模型的流式返回数据。这本来也是很简单的需求:

  • fetchEventSource 发送请求
  • onMessage() 接收并处理数据
  • onError() 处理异常/错误情况

此时,问题来了:后端返回的并不是的 text/event-stream 格式,而是 application/json;在网上查了一圈,知道可以在 onOpen() 里重新发一条 GET 请求来解决这个问题。

然后,又出现新的问题了:后端异常没有正常返回,全部要前端处理!烦躁之下,我打开了DeepSeek(之前只用它写过文档),10 秒钟,它给出了完整的解决方案。

我当时震惊的不是它写出来了,而是它写出来的代码,完全符合我的需求,而且比我预设需求的还要完整!

那一刻,我突然意识到:这东西真方便。

后来的开发中,我开始疯狂用它:

  • 让它生成Vue3组件,它懂得用<script setup>,知道我喜欢用ref而不是reactive
  • 让它写TS类型,它知道我的命名规范(IPropsTResponse
  • 让它解释一段看不懂的配置,它讲得比文档还清楚

我开始想:如果 AI 这么懂前端,那我是不是可以用它做更多事?


AI突围战”

从那天起,我给自己定了一个目标:用4个月时间,成为一个会“玩AI”的前端。

我不学 PyTorch,不学 Transformer,不调模型参数。我的路线很简单:

阶段 目标 时间
第一阶段 学会让AI按我的要求生成代码(Prompt工程) 3周
第二阶段 打通Electron + AI API,做桌面工具 4周
第三阶段 让AI能调用我写的函数(Function Calling) 6周
第四阶段 做一个真正能“干活”的Agent 8周

这4个月,我经历了什么?

第一周:信心满满 → 被 Prompt 折磨(AI 就是不按格式输出)
第二周:第一个 Electron + AI 应用跑通 → 激动得发朋友圈
第四周:Function Calling 总是失败 → 怀疑人生
第六周:第一个能用的 Agent 诞生(帮我处理Git) → 比发工资还开心
第十周:做的一个桌面助手被吐槽“鸡肋” → 反思产品思维
第十六周:现在,我能用Cursor + AI 在30分钟内开发一个小工具

这4个月,我学会了什么?

  • 不是:模型原理、Attention机制、微调技术
  • 而是:怎么让AI听我的话、怎么把AI集成进Electron、怎么让AI调用我的函数、怎么用AI帮我写代码

最重要的是:我不焦虑了,因为我知道,AI时代,前端不仅没有被淘汰,反而有了新的机会。


为什么说前端是AI时代的“天选之子”?

这4个月让我想明白一件事:

AI 是发动机,前端是驾驶舱。发动机很重要,但用户接触的是驾驶舱。

我们的优势是什么?

优势 说明
UI/UX思维 我们知道怎么让AI的“答案”变成好用的“产品”
TypeScript 严格的类型定义,让 AI 生成的代码更可控
Electron经验 桌面端是 AI 的下一站(隐私、离线、本地资源)
工程化能力 组件化、模块化,这些思维在 AI 应用开发中同样重要

我不是在安慰自己,而是在这4个月我见过太多 AI 应用翻车的案例:

  • 技术很强,但 UI 一塌糊涂 → 没人用
  • 模型很准,但交互反人类 → 用户流失
  • 功能很多,但不会产品化 → 自嗨

这些都是我们前端擅长的地方。


这个专栏要写什么

所以,我开了这个专栏。

它不是:

  • ❌ 大模型原理讲解(我不懂)
  • ❌ Python/PyTorch教程(我不会)
  • ❌ 教你成为AI科学家(做不到)

它是:

  • 一个普通前端的真实转型记录(不装逼,只记录自己踩过的坑)
  • 前端视角的AI应用开发实战(Vue3/TS/Electron)
  • Agent和Vibe Coding的落地经验(能跑起来的代码)

结语

前端已死”,这句话从10年前就开始兴起了,“死”了这么多年还没死透,我认为它就是有价值的。现如今,在 AI 的加持,对前端的要求会越来越高,但这条路我会继续走下去,也会把每一步都记录下来。

如果你也在这条路上,欢迎同行。

echart 移动端进行双指缩放时,当放大到最大级别后,手指没有离开屏幕,图表还会自动移动问题修复

作者 我爱切图
2026年3月13日 17:09

echarts.min.js 源码修复

version: 6.0.0
格式化后搜索 zoom: function

未修复源码 zoom 缩放关键部分

zoom: function(t, e, n, i) {
var r = this.range,
o = r.slice(),
a = t.axisModels[0];
if (a) return a = (0 < (e = bI[e](null, [i.originX, i.originY], a, n, t)).signal ? e
.pixelStart + e.pixelLength - e.pixel : e.pixel - e.pixelStart) / e.pixelLength * (
o[1] - o[0]) + o[0], n = Math.max(1 / i.scale, 0), o[0] = (o[0] - a) * n + a, o[1] =
(o[1] - a) * n + a, jC(0, o, [0, 100], 0, (t = this.dataZoomModel
.findRepresentativeAxisProxy().getMinMaxSpan()).minSpan, t.maxSpan), this.range = o,
r[0] !== o[0] || r[1] !== o[1] ? o : void 0
},

已修复源码

zoom: function(t, e, n, i) {
/* t: dataZoomModel (当前缩放组件的模型) */
/* e: coordSysType (坐标系类型,如 'grid') */
/* n: api (ECharts 实例 API) */
/* i: payload (包含缩放比例 scale 和 鼠标位置 originX/Y) */
var r = this.range, // 当前视图百分比范围 [start, end]
o = r.slice(), // 复制一份范围用于计算
a = t.axisModels[0]; // 获取关联的轴模型
if (a) {
/* 计算缩放中心点 a (百分比 0-100) */
/* bI[e](...) 根据像素位置 originX/Y 计算其在坐标轴上的位置比例 */
// a = (0 < (e = bI[e](null, [i.originX, i.originY], a, n, t)).signal ? e.pixelStart + e.pixelLength - e.pixel : e.pixel - e.pixelStart) / e.pixelLength * (o[1] - o[0]) + o[0];

/* 固定缩放中心点 a 为当前显示范围的中点 */
a = (o[0] + o[1]) / 2;

/* n: 缩放后的比例因子 (scale > 1 表示缩小视图/放大倍率) */
n = Math.max(1 / i.scale, 0);

/* s: 计算出的预期起始百分比 (start) */
/* l: 计算出的预期结束百分比 (end) */
var s = (o[0] - a) * n + a,
l = (o[1] - a) * n + a;

/* 防止遇到边界打断缩放 */
if (s < 0) {
l -= s;
s = 0;
}

if (l > 100) {
s -= l - 100;
l = 100;
}

/* u: 获取配置中的最小/最大缩放范围 (minSpan/maxSpan/minValueSpan...) */
var u = this.dataZoomModel.findRepresentativeAxisProxy().getMinMaxSpan(),
h = Math.abs(l - s); // 预期范围的跨度

/* 核心逻辑:如果缩放后的范围超出了 0-100 边界,或者违反了跨度约束,则直接放弃本次操作(返回空) */
/* 这样可以防止 ECharts 内部通过平移来修正边界(即漂移/抖动) */
if (
s < -0.1 ||
l > 100.1 ||
(null != u.minSpan && h < u.minSpan) ||
(null != u.maxSpan && h > u.maxSpan)
) return;

/* 如果符合条件,则更新当前范围并返回结果 */
return o[0] = s, o[1] = l, this.range = o, o
}
},

ps: 个人摸索,如有问题请告知 🙇

通用管理后台组件库-13-页签组件

作者 没想好d
2026年3月13日 16:51

页签组件

说明:实现头部页签相关功能,包括关闭、右键刷新/关闭右侧/关闭其他。

1.实现效果

image.png

2.页签组件HeaderTabs.vue

<template>
  <el-tabs closable type="card" class="my-tabs" v-on="forwardEvents" v-model="modelValue">
    <el-tab-pane v-for="item in data" :key="item.name" :name="item?.name as string">
      <template #label>
        <span class="custom-tab-label" @contextmenu.prevent="handleContextMenu($event, item)">
          {{ item.meta && $t(item.meta?.title as string) }}
        </span>
      </template>
    </el-tab-pane>
  </el-tabs>
  <el-dropdown
    ref="dropdownRef"
    :virtual-ref="triggerRef"
    :show-arrow="false"
    :popper-options="{
      modifiers: [{ name: 'offset', options: { offset: [0, 0] } }]
    }"
    virtual-triggering
    trigger="contextmenu"
    placement="bottom-start"
    @command="commandHandler"
  >
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item command="refresh">刷新</el-dropdown-item>
        <el-dropdown-item command="closeRight">关闭右侧</el-dropdown-item>
        <el-dropdown-item command="closeOther">关闭其他</el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

<script setup lang="ts">
import type { TabsPaneContext, TabsProps, DropdownInstance } from 'element-plus'
import type { AppRouteMenuItem } from '../menu/type'
import { forwardEventsUtils } from '@/utils/format'

interface HeaderTabsProps extends Partial<TabsProps> {
  data: AppRouteMenuItem[]
}
type TabPaneName = string | number
type HeaderTabsEvents = {
  tabClick: [pane: TabsPaneContext, event: Event]
  tabChange: [name: TabPaneName]
  tabRemove: [name: TabPaneName]
  tabAdd: []
  edit: [paneName: TabPaneName, action: 'add' | 'remove']
}
type TabAction = 'refresh' | 'closeRight' | 'closeOther'
type contextMenuCommand = {
  tabActions: [paneName: AppRouteMenuItem, action: TabAction]
}
defineProps<HeaderTabsProps>()

// 定义事件名称数组,包含标签页可能触发的所有事件类型
const eventName = ['tabClick', 'tabChange', 'tabRemove', 'tabAdd', 'edit', 'tabActions']

// 使用 defineEmits 定义组件可以触发的事件,类型为 HeaderTabsEvents
const emit = defineEmits<HeaderTabsEvents&contextMenuCommand>()
// 使用 forwardEventsUtils 函数处理事件转发,将 eventName 数组中定义的事件进行转发
const forwardEvents = forwardEventsUtils(emit, eventName)

const modelValue = defineModel<string>()

// 右键页签操作
const dropdownRef = ref<DropdownInstance>()
const position = ref({
  top: 0,
  left: 0,
  bottom: 0,
  right: 0
} as DOMRect)

const triggerRef = ref({
  getBoundingClientRect: () => position.value
})

const contextMenuTab = ref<AppRouteMenuItem>()
const handleContextMenu = (event: MouseEvent, tab: AppRouteMenuItem) => {
  const { clientX, clientY } = event
  position.value = DOMRect.fromRect({
    x: clientX,
    y: clientY
  })
  event.preventDefault()
  dropdownRef.value?.handleOpen()
  contextMenuTab.value = tab
}
const commandHandler = (command: TabAction) => {
  if (contextMenuTab.value) {
    emit('tabActions', contextMenuTab.value, command)
  }
}
</script>

<style scoped lang="scss">
.my-tabs {
  :deep(.el-tabs__header) {
    @apply p-0 m-0 border-b-none pl-1;
    .el-tabs__nav {
      @apply border-none;
    }
  }
  :deep(.el-tabs__item) {
    @apply py-0 h-[34px] px-4 mt-0!;
    border-radius: 4px;
    border: 1px solid var(--el-border-color) !important;
    transition: padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1) !important;
    margin-right: 5px;
    &.is-active {
      color: var(--el-color-primary) !important;
      background: var(--el-color-primary-light-9) !important;
      border: 1px solid var(--el-color-primary) !important;
    }
  }
  :deep(.el-tabs__nav-next, .el-tabs__nav-prev) {
    line-height: 35px !important;
  }
}
.el-dropdown {
  display: none !important;
}
</style>

3.tab状态存放在store中,tabs.ts

import type { AppRouteMenuItem } from '@/components/menu/type'
import { defineStore } from 'pinia'

export const useTabsStore = defineStore('tabs', {
  state: () => ({
    tabs: [] as AppRouteMenuItem[],
    current: ''
  }),
  actions: {
    addRoute(route: AppRouteMenuItem) {
      if (this.tabs.some((item) => item.name === route.name)) return
      this.tabs.push({ ...route })
    },
    removeRoute(path: string) {
      this.tabs = this.tabs.filter((item) => item.name !== path)
    },
    closeOther(path: string) {
      this.tabs = this.tabs.filter((item) => item.name === path)
    },
    closeRight(path: string) {
      const findIndex = this.tabs.findIndex((item) => item.name === path)
      this.tabs = this.tabs.slice(0, findIndex + 1)
    }
  },
  persist: true
})

4.在默认布局中引用,default.vue

<template>
  <div
    class="w-full h-full position-absolute left-0 top-0 overflow-hidden flex"
    :style="{ '--el-color-primary': setting?.theme }"
  >
    <!-- 左右布局 -->
    <!-- sidebar -->
    <div
      :style="{
        width: mixbarMenuWidth,
        backgroundColor: setting?.backgroundColor
      }"
      class="h-full transition-width shrink-0"
      v-if="setting?.mode !== 'top'"
    >
      <el-row class="h-full">
        <el-scrollbar
          v-if="setting?.mode !== 'mix'"
          :class="[setting?.mode !== 'mixbar' ? 'flex-1' : 'w-[64px] py-4']"
          :style="{
            backgroundColor:
              setting?.mode !== 'mixbar' ? 'auto' : darkenColor(setting?.backgroundColor, 10)
          }"
        >
          <!-- 左侧菜单和左侧菜单混合模式的布局-->
          <Menu
            :class="[{ mixbar: setting?.mode === 'mixbar' }]"
            v-if="setting?.mode === 'siderbar' || setting?.mode === 'mixbar'"
            mode="vertical"
            :data="mixbarMenus"
            :collapse="setting?.mode !== 'mixbar' && localSettings.collapse"
            text-color="#b8b8b8"
            :active-text-color="setting?.theme"
            :background-color="
              setting?.mode !== 'mixbar' ? setting?.backgroundColor : 'transparent'
            "
            @select="handleMenuSelect"
          ></Menu>
        </el-scrollbar>
        <el-scrollbar v-if="setting?.mode === 'mix' || setting?.mode === 'mixbar'" class="flex-1">
          <!-- 左侧菜单混合和顶部左侧菜单混合模式的二级menu -->
          <Menu
            mode="vertical"
            :data="getSubMenus(menus)"
            :collapse="localSettings.collapse"
            text-color="#b8b8b8"
            :active-text-color="setting?.theme"
            :background-color="setting?.backgroundColor"
            @select="handleMenuSelect"
          ></Menu
        ></el-scrollbar>
      </el-row>
    </div>
    <!-- content -->
    <div
      :class="['w-full h-full flex-1 overflow-hidden', setting?.fixedHead ? '' : 'overflow-y-auto']"
    >
      <!-- header -->
      <Header
        :locales="locales"
        :username="username"
        :src="avatar"
        :data="avatarMenu"
        :setting="setting"
        v-model:collapse="localSettings.collapse"
        @setting-change="handleSettingChange"
        @select="handleMenuSelect"
        :class="[setting?.fixedHead ? 'fixed top-0 left-0 right-0 z-10' : '']"
      >
        <template #menu>
          <!-- 顶部菜单和混合模式布局 -->
          <Menu
            v-if="setting?.mode === 'top' || setting?.mode === 'mix'"
            mode="horizontal"
            :data="setting?.mode === 'mix' ? getTopMenus(menus) : menus"
            :collapse="false"
            @select="handleMenuSelect"
            :active-text-color="setting?.theme"
          ></Menu>
        </template>
      </Header>
      <HeaderTabs
        v-model="tabsStore.current"
        :data="tabsStore.tabs"
        @tab-click="handleTabClick"
        @tab-remove="handleRemoveTab"
        @tab-actions="handleTabActions"
      ></HeaderTabs>
      <!-- main -->
      <div :class="[setting?.fixedHead ? 'overflow-y-auto h-full p-2 pb-25 bg-gray-100' : '']">
        <el-card>
          <router-view :key="routerKey"></router-view>
        </el-card>
      </div>
    </div>
    <!-- 移动端菜单抽屉 -->
    <el-drawer
      direction="ltr"
      class="w-full!"
      :style="{ backgroundColor: setting?.backgroundColor }"
      v-if="isMobile"
      :model-value="!localSettings.collapse"
      @close="localSettings.collapse = true"
    >
      <Menu
        text-color="#b8b8b8"
        :active-text-color="setting?.theme"
        :data="menus"
        :background-color="setting?.backgroundColor"
        @select="handleMenuSelect"
      ></Menu>
    </el-drawer>
  </div>
</template>

<script setup lang="ts">
import type { DropMenuItem } from '@/components/Avatar/types'
import HeaderTabs from '@/components/Layouts/HeaderTabs.vue'
import type { HeaderProps } from '@/components/Layouts/types'
import type { ThemeSettingProps } from '@/components/Themes/type'
import type { AppRouteMenuItem } from '@/components/menu/type'
import { useMenu } from '@/components/menu/useMenu'
import { useTabsStore } from '@/stores/tabs'
import { darkenColor } from '@/utils'
import type { RouteRecordRaw } from 'vue-router/auto'
import { routes } from 'vue-router/auto-routes'

interface ThemeSettingsOptions extends HeaderProps {
  username: string
  avatar: string
  avatarMenu: DropMenuItem[]
}
const router = useRouter()
const route = useRoute()
const tabsStore = useTabsStore()

// 设置配置默认数据
const localSettings = reactive<ThemeSettingsOptions>({
  username: 'admin',
  locales: [
    {
      name: 'zh-CN',
      text: '中文',
      icon: 'uil:letter-chinese-a'
    },
    {
      text: '英文',
      name: 'en',
      icon: 'ri:english-input'
    }
  ],
  avatarMenu: [
    {
      key: '1',
      value: '个人中心'
    },
    {
      key: '2',
      value: '修改密码'
    },
    {
      key: 'divider',
      value: ''
    },
    {
      key: '4',
      value: '退出登录'
    }
  ],
  avatar: '',
  collapse: false,
  setting: { menuWidth: 280 } as ThemeSettingProps
})
const { locales, avatarMenu, username, avatar } = toRefs(localSettings)

// 菜单和路由配置类型不相同,转换一下
const genrateMenuData = (routes: RouteRecordRaw[]): AppRouteMenuItem[] => {
  const menuData: AppRouteMenuItem[] = []
  routes.forEach((route) => {
    if (route.meta?.hideMenu) return
    let menuItem: AppRouteMenuItem = {
      name: route.name,
      path: route.path,
      meta: route.meta,
      alias: typeof route.redirect === 'string' ? route.redirect : undefined,
      component: route.component
    }
    // 判断是否有子路由,递归转换
    if (route.children && Array.isArray(route.children) && route.children.length > 0) {
      menuItem.children = genrateMenuData(route.children)
    }
    menuData.push(menuItem)
  })
  return menuData
}
// 路由类型数据转换为菜单类型数据
const menus = computed(() => genrateMenuData(routes))
const isMobile = ref(false)
// 设置主题
const handleSettingChange = (themeSettings: ThemeSettingProps) => {
  localSettings.setting = themeSettings
}
// 获取菜单宽度
const menuWidth = computed(() => (localSettings.setting ? localSettings.setting.menuWidth : 240))
// 获取设置菜单
const setting = computed(() => localSettings.setting)

// 获取mixbar和mix模式下的一二级菜单
const { getTopMenus, getSubMenus } = useMenu()

onMounted(() => {
  console.log(getTopMenus(menus.value))
  console.log(getSubMenus(menus.value))
})

// 混合mixbar模式下的菜单
const mixbarMenus = computed(() =>
  setting.value?.mode === 'mixbar' ? getTopMenus(menus.value) : menus.value
)
// 混合mixbar模式下的二级菜单是否都设置了icon,判断收起的显示情况
const isFullIcons = computed(() => {
  return getSubMenus(menus.value).every(
    (item) => typeof item.meta?.icon !== 'undefined' && item.meta?.icon
  )
})
// 混合mixbar模式下的菜单宽度
const mixbarMenuWidth = computed(() => {
  if (isMobile.value) return 0
  if (setting.value?.mode === 'mixbar' && isFullIcons.value) {
    return localSettings.collapse ? 'auto' : menuWidth.value + 'px'
  } else {
    return localSettings.collapse ? '64px' : menuWidth.value + 'px'
  }
})
// 选择menu事件
const handleMenuSelect = (menuItem: AppRouteMenuItem) => {
  if (menuItem && menuItem.name) {
    router.push(menuItem.name as string)
    if (isMobile.value) {
      localSettings.collapse = true
    }
  }
}

// 菜单抽屉展开折叠,屏幕宽度适配
const tmpWidth = ref(0)
const changeWidthFlag = ref(false)
useResizeObserver(document.body, (entries) => {
  // 获取浏览器宽度
  const { width } = entries[0].contentRect
  if (tmpWidth.value === 0) {
    // 记录初始宽度
    tmpWidth.value = width
  }
  if (width > tmpWidth.value) {
    // 扩大屏幕
    changeWidthFlag.value = width < 640
  } else {
    // 缩小屏幕
    changeWidthFlag.value = width > 1200
  }
  if (width < 640 && !changeWidthFlag.value) {
    localSettings.collapse = true
  }
  if (width > 1200 && !changeWidthFlag.value) {
    localSettings.collapse = false
  }
  // 是否是移动端屏幕宽度
  isMobile.value = width < 440
  tmpWidth.value = width
})
onBeforeMount(() => {
  // 是否是移动端屏幕
  if (
    navigator.userAgent.match(
      /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
    )
  ) {
    isMobile.value = true
    localSettings.collapse = true
  }
})
// 路由监听,tabsStore添加路由
watch(
  route,
  () => {
    tabsStore.addRoute(route)
    tabsStore.current = route.name
  },
  {
    immediate: true
  }
)
// 点击tab切换路由页面
const handleTabClick = (tab) => {
  const { index } = tab
  const route = tabsStore.tabs[index]
  router.push(route.name as string)
}
// 关闭tab,激活上一个tab
const handleRemoveTab = (tab) => {
  tabsStore.removeRoute(tab)
  if (tabsStore.current === tab) {
    if (tabsStore.tabs.length !== 0) {
      tabsStore.current = tabsStore.tabs[tabsStore.tabs.length - 1].name as string
    } else {
      // 删除最后一个tab,跳转到首页
      const tmpRoute = menus.value.filter((item) => item.path === '/')[0]
      tabsStore.addRoute(tmpRoute)
      tabsStore.current = tmpRoute.name as string
    }
    router.push(tabsStore.current as string)
  }
}

// 如果需要手动刷新,可以修改 key 的依赖,例如增加一个刷新计数器
const refreshKey = ref(0)
const routerKey = computed(() => route.fullPath + refreshKey.value)
// 页签操作
const handleTabActions = (tab, action) => {
  if (action === 'refresh') {
    router.push(tab.name as string)
    refreshKey.value++
  } else if(action === 'closeRight') {
    tabsStore.closeRight(tab.name as string)
  } else if(action === 'closeOther') {
    tabsStore.closeOther(tab.name as string)
  }
}
</script>

<style lang="scss" scoped>
.mixbar {
  :deep(.el-menu-item) {
    height: auto;
    line-height: unset !important;
    flex-direction: column;
    margin-bottom: 15px;
    padding: 4px 0 !important;
    svg {
      margin-right: 0;
      margin-bottom: 10px;
    }
  }
}
</style>

别再靠 Code Review 纠格式了!一套自动化前端工程化方案,让 Vue 项目提交即合规

作者 前端Hardy
2026年3月13日 16:50

上周五下午 5 点,同事提了个 PR,被 CI 卡了 7 次:

  • 缩进不对
  • 多了个 console.log
  • 提交信息写的是 “fix bug”
  • ESLint 报了 3 个 warning

他崩溃地问:“就不能在我本地就告诉我错了吗?”

我说:“能——但你们没配。”

今天,我就手把手教你搭建一套 Vue 3 项目开箱即用的自动化工程化流水线,包含:

  • 代码格式自动修复
  • 提交信息规范校验
  • Git Hooks 拦截脏提交
  • CI 零配置集成

全程只需 15 分钟,从此告别“格式战争”。


核心工具链(2026 年推荐组合)

功能 工具 优势
代码格式化 Prettier 统一风格,无配置争议
代码检查 ESLint + TypeScript 逻辑错误 + 类型安全
提交规范 Commitlint + Husky 强制 Angular 风格 commit
本地拦截 lint-staged 只检查 staged 文件,快!
构建集成 Vite + GitHub Actions CI 自动跑检查

关键理念:本地自动修,提交前拦截,CI 只做最终守门员


第一步:统一代码风格 —— Prettier + ESLint 联合治理

1. 安装依赖

npm install -D prettier eslint @typescript-eslint/eslint-plugin eslint-config-prettier

2. 配置 .prettierrc

{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 100
}

3. 配置 .eslintrc.js(关键:让 ESLint 不管格式)

module.exports = {
  extends: [
    'eslint:recommended',
    '@typescript-eslint/recommended',
    'prettier' // ← 关闭 ESLint 与 Prettier 冲突的规则
  ],
  plugins: ['@typescript-eslint'],
  parserOptions: { ecmaVersion: 2020, sourceType: 'module' }
};

效果:

  • ESLint 只管逻辑错误(如未使用变量)
  • Prettier 只管格式(如引号、缩进)
  • 两者不再打架!

第二步:提交前自动修复 —— lint-staged + Husky

1. 初始化 Git Hooks

npx husky-init && npm install

2. 配置 package.json 中的 lint-staged

{
  "lint-staged": {
    "*.{js,ts,vue}": [
      "eslint --fix",
      "prettier --write"
    ]
  }
}

3. 修改 .husky/pre-commit

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged

效果:
当你运行 git commit只有你改动的文件会被自动格式化 + 修复
如果有无法修复的错误(如类型错误),提交直接失败


第三步:规范提交信息 —— Commitlint

1. 安装

npm install -D @commitlint/cli @commitlint/config-conventional

2. 创建 commitlint.config.js

module.exports = {
  extends: ['@commitlint/config-conventional']
};

3. 添加 commit-msg Hook

npx husky add .husky/commit-msg 'npx --no-install commitlint --edit $1'

现在,提交信息必须符合格式:

feat(auth): add login button
fix(api): handle timeout error
docs(readme): update installation guide

否则:git commit -m "update"直接拒绝!


第四步:CI 自动守门(GitHub Actions 示例)

.github/workflows/ci.yml 中添加:

name: CI
on: [push]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 18 }
      - run: npm ci
      - run: npm run lint   # 检查 ESLint
      - run: npm run format:check  # 检查 Prettier

并在 package.json 中定义脚本:

{
  "scripts": {
    "lint": "eslint . --ext .js,.ts,.vue",
    "format:check": "prettier --check ."
  }
}

效果:即使有人绕过本地 Hooks(比如 --no-verify),CI 也会拦住他!


最终效果:开发者体验流程图

你写代码
  ↓
保存时 → VS Code 自动格式化(通过 EditorConfig + Prettier 插件)
  ↓
git add .
  ↓
git commit → Husky 触发
    ├─ lint-staged: 自动修复 staged 文件
    └─ commitlint: 校验提交信息格式
  ↓
推送 → GitHub Actions 运行完整检查
  ↓
合并!零格式争议,零低级错误

最后说两句

工程化不是“加流程”,而是减少人为摩擦

一套好的工具链,应该像空气——
你感觉不到它存在,但一旦没了,立刻窒息。

花 15 分钟配好它,
未来省下的是几百小时的 Code Review 和 debug 时间

有没有因为格式问题吵过架?欢迎留言区分享


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

用 uni-app x 重构我们的 App:一套代码跑通 iOS、Android、鸿蒙!人力成本直降 60%

作者 前端Hardy
2026年3月13日 16:48

曾经,我们维护三套代码:

  • iOS 用 Swift
  • Android 用 Kotlin
  • 鸿蒙 NEXT 用 ArkTS

现在?一套 Vue 3 + TypeScript 代码,同时上线三大平台
构建一次,全端分发——连华为应用市场都主动推荐我们。

如果你还在为“多端适配”焦头烂额,为鸿蒙生态焦虑,为人力成本飙升失眠——uni-app x 的正式成熟,可能是你今年最值得押注的技术决策


一、多端开发的“三座大山”

过去几年,移动开发团队面临前所未有的分裂:

  1. iOS + Android 双端维护:至少 2 个原生团队,沟通成本高;
  2. 鸿蒙 NEXT 强制独立生态:不再兼容 AOSP,旧 APK 无法上架;
  3. Web/小程序还要兼顾:产品需求要求“五端一体”。

结果?

  • 开发周期拉长 2–3 倍
  • Bug 修复需三端同步验证
  • 新人入职要学三种语言

我们曾试过 React Native、Flutter,但:

  • RN 在鸿蒙上支持弱,性能一般;
  • Flutter 虽跨端,但包体大(50MB+),且与原生交互复杂。

直到 uni-app x 出现——它用一个大胆的方案破局:编译时生成各平台原生代码


二、uni-app x 是什么?为什么它能“真·一套代码”?

不同于传统跨端框架(如 RN 的 JS Bridge、Flutter 的 Skia 渲染),uni-app x 采用“源码编译到原生”的架构

平台 输出产物 运行方式
iOS Swift + UIKit/SwiftUI 真·原生 App
Android Kotlin + Jetpack Compose 真·原生 App
鸿蒙 NEXT ArkTS + ArkUI 华为官方认证原生应用
Web / 小程序 保留原有 H5/小程序输出能力

关键优势:不依赖 WebView,不嵌入 JS 引擎,性能 ≈ 手写原生

这意味着:

  • 启动速度与原生一致
  • 内存占用低(实测比 Flutter 少 40%)
  • 完全调用平台最新 API(如鸿蒙的分布式能力)

而你写的,依然是熟悉的 Vue 3 语法 + TypeScript + Composition API


三、真实项目重构:从 3 人月 → 1 人月

公司一款电商导购 App(含商品列表、购物车、支付、消息推送)做迁移实验:

指标 原三端方案 uni-app x 重构后
开发人力 3 人(iOS/Android/鸿蒙) 1 人(前端)
首版交付周期 6 周 2 周
包体积(安装包) iOS: 48MB / Android: 52MB / 鸿蒙: 45MB 统一 ≈ 28MB
启动时间(冷启动) 1.8s / 2.1s / 1.9s 1.7s / 1.8s / 1.6s
华为应用市场上架 (旧 APK 被拒) 通过审核,获“鸿蒙原生”标签

四、uni-app x 的三大杀手锏

1. 鸿蒙 NEXT 原生支持,抢占生态红利

华为已明确:2026 年起,新上架应用必须为鸿蒙原生(.hap 格式)
uni-app x 可直接输出符合规范的 ArkTS 工程,无需重写。

<!-- 你的 Vue 组件 -->
<template>
  <view class="product-card">
    <image :src="item.image" />
    <text>{{ item.name }}</text>
    <!-- 自动映射为 ArkUI 的 Image + Text -->
  </view>
</template>

编译后,鸿蒙端得到的是标准 @Component 装饰的 ArkTS 文件——华为工具链完全识别


2. 性能接近手写原生,告别“跨端卡顿”标签

得益于 编译时优化 + 原生渲染,uni-app x 在关键指标上表现优异:

  • 列表滚动 FPS:58–60(Flutter:52–56,RN:45–50)
  • 内存峰值:120MB(同场景下 Flutter 为 210MB)
  • 启动耗时:低于 2 秒(满足华为“快应用”标准)

3. 生态无缝衔接,已有 uni-app 项目可平滑升级

如果你已有 uni-app 项目(H5/小程序),只需:

  1. 升级 DCloud HBuilderX 到最新版
  2. 修改 manifest.json 启用 uni-app x 模式
  3. 微调少量平台特有 API(如蓝牙、NFC)

90% 的业务代码无需改动


五、但它适合所有人吗?

uni-app x 当前最适合:

  • 中小型团队,希望降低多端维护成本
  • 需快速覆盖鸿蒙生态的 App
  • 以内容展示、表单交互为主的业务型应用(电商、工具、资讯)

不太适合:

  • 超重度图形应用(如 3D 游戏)
  • 需深度定制原生 UI 动画的场景(但可通过原生插件扩展)

但对 80% 的商业 App,它已是“性价比之王”。


六、5 分钟创建你的第一个 uni-app x 应用

  1. 下载最新 HBuilderX 4.20+(DCloud 官网)
  2. 新建项目 → 选择 “uni-app x” 模板
  3. 编写 Vue 3 组件(支持 <script setup>
  4. 点击“运行” → 可同时预览 iOS / Android / 鸿蒙模拟器
# 或使用 CLI(需 Node.js)
npm install -g @dcloudio/uni-cli-shared
uni create my-uniappx-app --template vue-ts
cd my-uniappx-app
uni dev

一次编码,三端真机调试——这才是多端开发该有的样子。


七、行业正在转向

  • 携程:部分工具类模块迁移到 uni-app x,鸿蒙版上线提速 3 倍
  • 美图秀秀:用 uni-app x 快速推出鸿蒙专属滤镜插件
  • 大量政务/银行 App:因合规要求,优先采用 uni-app x 构建鸿蒙原生版本

DCloud 官方数据显示:2026 年 Q1,uni-app x 项目数量环比增长 320%


结语:不是所有跨端,都叫“原生”

React Native 是“桥接”,Flutter 是“自绘”,
uni-app x,是“翻译”——把你的 Vue 代码,翻译成各平台的母语

在这个鸿蒙强制原生、人力成本飙升的时代,
用一套代码拿下 iOS、Android、鸿蒙三大阵地,不再是梦想,而是现实

官网:hx.dcloud.net.cn
鸿蒙迁移指南:ask.dcloud.net.cn/article/458…

今天,就用 uni-app x 重构你的 App——
也许下一个“鸿蒙原生标杆应用”,就是你的作品。

已尝试 uni-app x 的朋友,欢迎分享鸿蒙上架经验!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

❌
❌