跳到主要内容

n8n 使用入门

· 阅读需 9 分钟

在低代码自动化领域,n8n凭借其强大的灵活性和丰富的节点生态,成为越来越多开发者的首选。

前置准备

使用 Docker 方式安装n8n无需关注 Node.js 版本,环境隔离性更好,是新手最推荐的方式。

确保你的电脑已经安装了Docker环境。如果你还没有Docker,可以先通过以下方式快速安装:

  • Windows:直接下载Docker Desktop;
  • Mac:通过Homebrew或官网镜像安装;
  • Linux:使用对应的包管理工具安装;

安装步骤

步骤 1:检查 / 安装 Docker

执行以下命令检查 Docker 是否安装:

docker -v
docker compose version # 检查 Compose(Docker 20.10+ 已内置)

未安装的话:

  • Windows/macOS:下载Docker Desktop并安装(安装后启动 Docker Desktop,确保右下角 / 状态栏显示 Docker 运行中);
  • Linux:参考Docker 官方文档 安装。

步骤 2:创建持久化目录(关键)

为了避免容器重启后丢失工作流数据,先创建本地目录存储 n8n 数据:

# macOS/Linux
mkdir -p ~/n8n/data

# Windows(PowerShell)
mkdir $HOME\n8n\data

步骤 3:启动 n8n 容器

用Docker Compose启动,这样更易管理,比较推荐。

  1. 创建 docker-compose.yml 文件(可放在任意目录,如 ~/n8n/):
version: "3"
services:
n8n:
image: docker.n8n.io/n8nio/n8n
container_name: n8n
ports:
- "5678:5678"
volumes:
# n8n数据挂载(存储工作流、数据库)
- ${HOME}/n8n/data:/home/node/.n8n
environment:
- N8N_HOST=0.0.0.0
- N8N_PORT=5678
- N8N_LOG_LEVEL=info
restart: always # 开机自启/容器崩溃自动重启
  1. 进入 docker-compose.yml 所在目录,执行启动命令:
docker compose up -d  # -d 表示后台运行

步骤 4:访问与验证

  1. 浏览器访问 http://localhost:5678,首次登录创建账号即可使用;

  2. 查看容器状态(确认运行正常):

docker ps  # 查看运行中的容器,能看到 n8n 容器即正常
docker logs n8n # 查看 n8n 日志,排查启动问题

步骤 5:常用 Docker 操作

# 停止 n8n 容器
docker stop n8n

# 重启 n8n 容器
docker restart n8n

# 彻底删除 n8n 容器(数据卷已持久化,删除容器不丢数据)
docker rm -f n8n

# 更新 n8n 镜像(升级版本)
docker pull docker.n8n.io/n8nio/n8n
docker compose down && docker compose up -d

使用入门

我们以一个读取本地txt文件,并通过AI将文件内容二次优化后再输出到本地(创建新的txt文件)为例,做一个n8n的入门介绍。

浏览器访问 http://localhost:5678,第一次打开页面的时候,应该是这样的:

add first step

  1. 你可以点击加号,看到右侧会有很多节点以及分类。我们可以选择Trigger manually这个节点作为入口节点。

  2. 添加完Trigger节点后,接着我们添加读取本地文件的节点。点击Trigger节点右侧的加号,找到 Read/Write Files from Disk 这个节点,选择Read Files From Disk。要填写被读取文件的文件路径:

file selector

还记得我们之前配置的docker-compose.yml文件吗?那个文件我们配置了映射:

volumes:
# n8n数据挂载(存储工作流、数据库)
- ${HOME}/n8n/data:/home/node/.n8n

我们要读取文件需要安装映射的路径来读,而不是docker容器外的文件系统路径(电脑路径),这时候你可能会想到,把我们要读取的文件放到${HOME}\n8n\data目录下,然后在n8n的节点上配置/home/node/.n8n/xxx.txt这个路径即可。这种想法没问题,但测试运行n8n工作流时发现会报错,原因在于这个文件路径是n8n的数据挂载点,我们没有权限读取其中的文件内容。

一个解决的办法是再配置一个n8n允许访问的目录,修改docker-compose.yml文件增加一个volume

volumes:
# 原有n8n数据挂载(存储工作流、数据库)
- ${HOME}/n8n/data:/home/node/.n8n
# 新增:本地文件目录 → 容器白名单目录(n8n允许访问)
- ${HOME}/n8n/files:/home/node/.n8n-files

然后运行docker restart n8n重启一下。把我们要读取的文件放到${HOME}\n8n\files目录下(记得新建files目录)。

接着回到Read File那个节点,配置正确的路径:

/home/node/.n8n-files/6.txt

点击Execute step会发现读取成功(右侧有了文件信息):

Execute step

  1. Read File 节点需要与Extract from File节点配合使用,添加Extract from File节点,后点击Execute step就可以看到右侧的内容正是我们的文件的内容。

Extract from File

  1. 接着我们为Extract from File节点添加后续节点。有了文本后,我们可以调用大模型。

找到AI Agent节点,然后这个节点的Chat Model挂载DeepSeek Chat Model节点:

AI Agent

  1. 我们双击点开AI Agent节点,选择Define below,这时下方的Prompt输入框就会打开,我们给AI写一些提示词,并拖拽左侧的data字段到Prompt输入框中,n8n在执行工作流时就会将文件里读取的数据传给AI。

AI Agent settings

在运行AI Agent之前记得先到DeepSeekAPI平台注册申请API Key

  1. 运行AI Agent节点后就可以看到AI给过来的答复了。接着就是把AI的回答存到本地,用到的节点与前面读取文件时相似,但是反过来的,我们要先生成文件对象,然后将文件对象写入到本地。

AI Agent右侧连接Convert to File节点,配置如下:

Convert to File

然后连接Read/Write Files from Disk节点,配置如下(注意是前面配置的docker路径):

Write Files from Disk

运行后就可以看到文件夹中多了一个文件。

n8n连接本地部署到AI模型

除了使用云端的AI模型外,我们也可以使用本地部署模型作为n8n的节点。以Ollama 为例,我们实践一下如何调用本地模型。

Ollama是一种用于快速下载、部署、管理大模型的工具。Ollama官网(可能需要梯子才能访问):https://ollama.com

点击下载安装即可。下载安装完成后,打开命令行终端,输出ollama -v可以验证是否安装完成。

本地模型下载

来到Ollama官网,找到qwen3模型,根据自己电脑配置情况,选择下载对应的模型。

qwen3:1.7b模型为例,复制CLI命令ollama run qwen3:1.7b,来到命令行窗口,粘贴命令执行。

下载完成后直接就进入到了与qwen3:1.7b模型对话的界面。

这时候可以随便输入一些对话,模型就会做出回答,当输入/?可输出命令帮助信息,输入/bye则会退出对话。当第二次再去运行ollama run qwen3:1.7b时就会立即进入对话页面。

除此之外,打开Ollama客户端,可以看到刚才下载的qwen3:1.7b模型已经可以直接使用了:

在API测试工具中使用

除了使用命令行运行对话外,也可以在API测试工具中测试模型对话。先来到刚才复制下载qwen3:1.7b的那个页面,复制cURL命令:

打开postman,通过import粘贴复制的cURL命令,然后点击Import Into Collection

postman就会解析参数,需要注意的是,因postman默认以x-www-form-urlencoded格式解析,需要把body下面的参数复制下来,粘贴到raw中的JSON格式里。

然后点击Send,即可看到下面的输出:

也可以使用下面的命令直接粘贴,这样就不用重新更改传参格式了:

curl http://localhost:11434/api/chat \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3:1.7b",
"messages": [{"role": "user", "content": "Hello!"}]
}'

-d 传参里的messages中的content就是你向大模型发起的对话内容。可以更改成别的内容看大模型会返回哪些回复。

除此之外,国内的apifox也是一个不错的API测试功能,这里就不再赘述了,用法与postman基本相似。

apifox好的一点是可以查看数据合并后的内容(自动合并):

添加Ollama节点

回到n8n中,我们可以删掉AI Agent那个节点,添加Ollama节点(Message a model),配置如下:

Message a model

Model中可以选择你下载的本地模型,下方的Messages是与AI交互时填写的Prompt。

n8n 作为轻量且灵活的工作流自动化工具,它不仅能轻松实现数组的逐行 / 定时分批处理,更能无缝衔接本地服务(如 Ollama、数据库)与云端应用(如微信、钉钉、各类 API),把数据流转、定时执行、AI 调用、文件操作等重复工作全部串联成自动化流程。无论是个人轻量的日常效率提升,还是小团队的业务流程简化,本地部署的 n8n 都能以低代码的方式,让你摆脱机械操作,把精力聚焦在更有价值的工作上。不妨继续探索各类节点的组合与配置,让 n8n 成为你的「自动化生产力助手」,解锁更多高效的工作流玩法。

2024-12-22 小汇总

· 阅读需 3 分钟

级联选择

复选框树形选择器比较常见,比如antd里的TreeSelectCascader组件。当选择某一项时,有以下几种可能:

  1. 如果是选中,则它的下级全都会被选中,并且还需要判断他有没有父级,如果有父级,还需要判断父级是否也是全选中或者半选中状态;
  2. 如果是取消选中,它的下级也会全部被取消选中,并且也要判断父级(或者说是上级)的选中状态。

要实现一个updateSelectedStatus函数,它的前面如下:

enum SelectedStatus {
/** 未选中 */
Unselected,
/** 选中 */
Selected,
/** 半选中 */
Indeterminate,
}

interface Cascade {
id: string;
selectedStatus: SelectedStatus;
children?: Cascade[];
}

function updateSelectedStatus(
checked: boolean,
current: Cascade,
tree: Cascade[]
): Cascade[];

updateSelectedStatus的参数如下:

  • checked 当前项是不是选中状态;
  • current 当前项;
  • tree 整颗级联树;

返回值是路径数组,比如一个属性结构是:

1
|----1_1
|--------1_1_1
|--------1_1_2
|----1_2
|--------1_2_1
|--------1_2_2
2
|----2_1
|--------2_1_1
|--------2_1_2
|----2_2

如果一开始都是未选中状态,这时候我去选了1_2这一项。则:1_2和他的下级1_2_11_2_2都会被选中,并且1这一项将变成半选状态。返回值将会是:

[
{
id: '1_2',
selectedStatus: SelectedStatus.Selected,
children: [...]
},
{
id: '1',
selectedStatus: SelectedStatus.Indeterminate,
children: [...]
},
]

实现:

/** 更新父选择状态 */
function updateParentSelectedType (parent: Cascade) {
const children = parent.children;
/* 是不是半选 */
let hasIndeterminate = false;

if (children?.length) {
const checkedList = children.filter((item) => {
if (item.selectedType === SelectedType.Indeterminate) {
hasIndeterminate = true;
return false;
}
return item.selectedType === SelectedType.Selected;
}
);

if (hasIndeterminate) {
parent.selectedType = SelectedType.Indeterminate;
} else if (checkedList.length === children.length) {
parent.selectedType = SelectedType.Selected;
} else if (!checkedList.length) {
parent.selectedType = SelectedType.Unselected;
} else {
parent.selectedType = SelectedType.Indeterminate;
}
}
return parent;
};

function updateSelectedStatus(
checked: boolean,
current: Cascade,
tree: Cascade[],
) {
const _f = (
checked: boolean,
current: Cascade,
tree: Cascade[],
parent?: Cascade
): Cascade[] => {
// 存放更新的路径
const records: Cascade[] = [];

for (const child of tree) {
if (child.id === current.id) {
child.selectedType = checked
? SelectedType.Selected
: SelectedType.Unselected;

records.push(child);

/** 更新子项 */
if (child.children?.length) {
child.children.forEach((item) => {
_f(checked, item, child.children, child);
});
}

/** 更新父项 */
if (parent) {
records.push(updateParentSelectedType(parent));
}

// 找到后就可以返回了,不用再循环去找了
return records;
} else {
if (child.children?.length) {
const list = _f(checked, current, child.children, child);

// list 中有值说明在下级找到了 current,否则就是没找到
if (parent && list.length) {
return [...list, updateParentSelectedType(parent)];
}
return list;
}
}
}

return records;
};

return _f(checked, current, tree);
}

最大高度与纵向滚动

· 阅读需 11 分钟

模型上下文协议(Model Context Protocol,MCP)是一个协议。

一个训练好的AI模型的知识库可能是滞后的,或者它是通用性的模型,可能对某些领域并不是很了解,需要进一步学习。比如你想询问大模型今天你所在的城市天气怎么样,它可能会这样回答:

目前我无法实时获取北京的天气信息,建议您查看天气预报应用或网站(如中国天气网、Weather.com等)获取最新天气情况。如果您需要,我可以提供一些通用的天气建议,例如:

若有雨,建议携带雨具;
若有高温,注意防晒和补水;
若有寒流,注意保暖。
需要我帮您查找最新的天气信息吗?😊

这是因为大模型本身并不具备自动查询天气信息的能力,我们可以通过MCP协议自己制定一些查询天气的工具,当与大模型互动时,大模型会从我们制定的工具中选择与目前对话匹配的工具,然后调用这些工具获取天气的相关信息,并结合这些信息生成答复。

数据层

MCP协议分为客户端和服务器。MCP中的客户端与服务器端的通信方式有两种:

  1. stdio:标准输出输入输出;
  2. http:基于HTTP协议;

MCP使用JSON-RPC 2.0作为其传输格式。传输层负责将MCP协议消息转换为JSON-RPC格式以进行传输,并将接收到的JSON-RPC消息转换回MCP协议消息。

交互流程

  1. 监听客户端发来的消息;
  2. 客户端首先发起初始化(initialize)的消息,服务端接收到后返回响应;
  3. 客户端收到响应后,发出notifications/initialized消息,表明已经准备好;
  4. 当客户端发起tools/list消息时,服务端把它能支持的工具调用列表返回给客户端;
  5. 客户端根据tools列表决策使用什么工具。当选择使用某个工具时,客户端发起tools/call消息,服务端接收到后匹配tools,执行对应的函数,然后把结果返回给客户端。

大致交互流程如下:

画板

初始化

在客户端与服务器双方通信时,客户端要发送请求以建立连接并协商支持的功能。例如下面是一个初始化的请求消息:

{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {
"elicitation": {}
},
"clientInfo": {
"name": "example-client",
"version": "1.0.0"
}
}
}

其中:

  • jsonrpc表示JSON-RPC的版本;
  • id是一个消息标识,服务端响应的id需要与客户端发来的id保持一致;
  • method: "initialize"就表示是一个初始化的消息;
  • params.protocolVersion则表示MCP协议版本,确保客户端和服务器使用兼容的协议版本。这避免了不同版本尝试交互时可能发生的通信错误;
  • params.**capabilities**则表示客户端和服务器双方支持哪些功能,如工具(tools)、资源(resources)、提示(prompts)等;
    • **"elicitation": {}**- 客户端声明它可以处理用户交互请求(可以接收方法调用)
  • params.clientInfo以及params.serverInfo则提供身份识别和版本信息,用于调试和兼容性;

当请求发出后,服务器可能返回以下消息:

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": {
"listChanged": true
},
"resources": {}
},
"serverInfo": {
"name": "example-server",
"version": "1.0.0"
}
}
}

Tools

在MCP协议中,tools/list消息格式如下(客户端):

{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
}

服务端对应的tools/list消息:

{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "addition_arithmetic",
"title": "addition",
"description": "Add two numbers",
"inputSchema": {
"type": "object",
"properties": {
"a": { "type": "number", "description": "First number" },
"b": { "type": "number", "description": "Second number" },
},
"required": ["a", "b"]
}
},
{
"name": "weather_current",
"title": "Weather Information",
"description": "Get weather information",
"inputSchema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name"
},
},
"required": ["city"]
}
}
]
}
}

上面代码是将加法器与查询天气两个tools通过JSON Schema的方式声明了工具函数需要接收的参数以及参数类型、参数是否必填等信息,需要注意的是,description尽量描述的清晰一些,这更有利于AI理解该工具的使用场景,从而做出更好的决策。

客户端的tools/call消息格式如下:

{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "weather_current",
"arguments": {
"city": "San Francisco"
}
}
}

例子

我们以加法器和查询天气两个tools为例,基于Node.js编写一个简单的服务端MCP应用demo。

项目初始化

  1. npm init
  2. npx tsc --init
  3. npm install @types/node nodemon ts-node typescript --save-dev
  4. 更改package.json文件,添加"type": "module"配置
  5. 添加启动脚本:nodemon --exec node --loader ts-node/esm ./MCPServer.ts

编写服务端代码

下面是完整的服务端demo的例子:

// 工具函数对应的声明
const toolsMap = new Map([
[
"add",
{
name: "add",
description: "Add two numbers",
inputSchema: {
type: "object",
properties: {
a: { type: "number", description: "First number" },
b: { type: "number", description: "Second number" },
},
required: ["a", "b"],
},
},
],
[
"weather",
{
name: "weather",
description: "Get weather information",
inputSchema: {
type: "object",
properties: {
city: { type: "string", description: "City name" },
},
required: ["city"],
},
},
],
]);

// 工具函数集
const toolsFunctions = {
add(a: number, b: number): number {
return a + b;
},
weather(city: string): string {
return `The weather in ${city} is sunny.`;
},
};

// 监听客户端发送的数据
process.stdin.on("data", (data) => {
const req = JSON.parse(data.toString());
const { id } = req;
const resultData = {
id,
jsonrpc: "2.0",
result: null as any,
};
// 当是初始化的消息时
if (req.method === "initialize") {
resultData.result = {
protocolVersion: "2025-06-18",
capabilities: {
tools: {
listChanged: true,
},
},
serverInfo: {
name: "example-server",
version: "1.0.0",
},
};
} else if (req.method === "tools/list") {
const tools = Array.from(toolsMap.values());

resultData.result = { tools };
} else if (req.method === "tools/call") {
// 当客户端发起 tools/call 消息时,读取 arguments 和 tool name,
// 然后调用tool name对应的函数,将结果保存到 result 中
const { name, arguments: args } = req.params;
const func: any = toolsFunctions[name as keyof typeof toolsFunctions];
const funcArgs = Object.values(args) as any[];
const funcResult = func(...funcArgs);

resultData.result = resultData.result = {
content: [{ type: "text", text: funcResult }]
};
} else {
return;
}

// 发给客户端
process.stdout.write(JSON.stringify(resultData) + "\n");
});

测试

除了在自己实现客户端外,还可以借助MCP Inspector工具验证。在项目目录下运行以下命令:

npx @modelcontextprotocol/inspector

运行后会自动打开浏览器,进入调试页面:

点击Connect连接,右侧会加载出对应的页面:

可以看到左下角发送了一个initialize消息。点击顶部的Tools,然后点击List Tools可以看到所有的工具列表。右侧是对应的参数列表,这些表单是根据tools/list消息生成的。点击Run Tool后客户端会发送tools/call消息,获取到服务端响应的数据。

使用@modelcontextprotocol/sdk

@modelcontextprotocol/sdk 是对MCP协议的封装,用它来实现MCP服务端很容易,代码如下:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const NWS_API_BASE = "https://uapis.cn/api/v1/misc/weather";

const server = new McpServer({
name: "weather",
version: "1.0.0",
});

// 注册 tools
server.registerTool("weather-tool", {
description: "Get the current weather for a specified city.",
inputSchema: { // 参数定义
city: z.string().describe("The name of the city to get the weather for."),
}
}, async ({ city }) => { // 请求数据
const response = await fetch(`${NWS_API_BASE}?city=${encodeURIComponent(city)}`, {
method: 'GET'
});

if (!response.ok) {
return {
content: [
{
type: 'text',
text: `Unable to fetch weather data for ${city}.`,
}
]
}
}

const content = await response.text();

return { // 返回数据
content: [
{
type: 'text',
text: content,
}
]
}
});

async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Weather MCP Server running on stdio");
}

main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});

在使用McpServer这个API时,我们直接注册Tools即可,不需要手动进行初始化操作。

在平台中添加MCP Server

接下来我们就可以验证一下MCP Server是否可以成功运行,要运行MCP Server需要MCP Client,它们两个是一一对应的关系,流程如下:

  1. MCP Client接收MCP Server的启动命令参数,然后内部会启动MCP Server,获取Tools列表;
  2. 当我们与大模型对话时,会经过MCP Client,MCP Client将用户提出的问题和Tools发给大模型;
  3. 如果用户的对话内容包含MCP Tools,大模型就会返给MCP Client命中的Tools;
  4. MCP Client调用对应的Tools函数,然后把结果返回给大模型,大模型根据拿到的信息再进一步思考,生成内容经MCP Client,最终把大模型的答复返回给用户。

有很多应用内部已经集成了MCP Client,例如VScode、Cherry Stdio、Cursor等,下面我们以在VScode和Cherry Stdio中配置MCP Client为例,说一下怎么在这两个应用中使用MCP服务。

VScode配置MCP Server

VScode 客户端支持配置MCP Server,我们可以在vscode项目中新建.vscode文件夹,然后在文件夹中添加mcp.json,配置如下:

{
"servers": {
"my-mcp-server": {
"type": "stdio",
"command": "node",
"args": [
"--loader",
"ts-node/esm",
"D:\\projects\\ai-study\\index.ts"
]
}
},
"inputs": []
}

配置完成后就会有下面这样的提示:

点击启动后,MCP 就运行起来了。在更多里有一个配置模型访问的菜单,点击后就可以选择与哪些大模型对话时启用该MCP Server。也可以在插件栏看到已安装的MCP Server列表:

打开聊天命令窗口,就可以输入内容测试了:

上面的配置方式仅限于特定项目中的使用场景,在vscode中也可以全局配置,步骤如下:

  1. ctrl + p 打开命令输入框,输入> MCP: Open User Configuration回车就会打开用户配置中的 mcp.json 文件,然后配置包括即可。

需要注意的是,如果你使用的是TS文件作为MCP Server启动脚本,最好是使用bun去运行(需要先全局安装),用node可能会因配置问题导致运行出错。

Cherry Stdio中配置

Cherry 是一个多元化的AI设计工具。从官网上下载后,我们可以点击右上角的设置按钮,找到MCP服务器,进入后点击添加-> 从JSON导入,按照提示导入,然后打开即可。

然后回到首页,添加一个助手,下方聊天框可以看到MCP服务:

选择对应的MCP服务器即可。

除了使用第三方平台提供的添加MCP Server外,你也可以自己实现一个。大致思路是:顶层有一个Agent类,这个类初始化时可以传入MCP Client数组,MCP Client内部会初始化MCP Server以及提供查询和执行Tools的方法。在Agent类初始化后可以拿到MCPClient类中所有的Tools,当与大模型对话时,用户的prompt会先进入Agent类,然后Agent类与大模型交互,将Tools和Prompt传给大模型,大模型得出结果后返回给Agent,Agent再将结果传给下游。

浏览器渲染原理

· 阅读需 5 分钟

当浏览器的网络线程收到 HTML 文档后,会产生一个渲染任务,并将其传给渲染主线程的消息队列。在时间循环机制的作用下,渲染主线程取出消息队列中的渲染任务,开启渲染流程。

整个渲染流程分为多个阶段:HTML解析(Parse)、样式计算(Style)、布局(Layout)、分层(Layer)、绘制(Paint)、分块(Tiling)、光栅化(Raster)、画(Draw)。

渲染进程中包含:渲染主线程和合成线程。

1. HTML 解析(Parse)

解析 HTML 字符串产生两棵树(对象结构):DOM Tree 和 CSSOM Tree。

解析过程中遇到CSS代码怎么办?

为了提高解析效率,浏览器会启动一个预解析线程先下载和解析CSS。

例如:当遇到 link 标签引用的CSS文件时,预解析线程会把CSS资源链接交给网络线程下载文件,下载完成后把CSS内容交给预解析线程解析CSS,解析完成后再交给渲染主线程最后生成CSSOM Tree。

因下载和解析CSS的工作是在预解析线程中进行的,因此CSS不会阻塞HTML解析。

解析过程中遇到JS代码怎么办?

预解析线程可以分担一点下载JS的任务,解析HTML时发现JS文件在下载中,这时会暂停解析HTML,等待JS下载完成,下载完成后执行JS代码,然后继续解析HTML。

JS阻塞HTML解析过程原因是:JS代码执行过程中有可能更改已经解析过的HTML。

如果是带有 defer 或 async 属性的 script 标签则规则略有不同,带有async属性的脚本在下载JS时不会阻塞解析HTML,一旦下载完成,渲染线程会暂停解析HTML,转而执行JS代码;而带有defer属性的脚本在下载时也不会阻塞解析HTML,在文档被解析后,但在触发 DOMContentLoaded 事件之前(会阻塞该事件的触发)执行该脚本。

2. 样式计算(Style)

CSS 属性值的计算过程发生在这一步。 比如层叠、继承。许多预设值会变成绝对值,比如red变成rgb(255,0,0);相对单位变成绝对单位,比如em变成px。

遍历DOM Tree和CSSOM Tree,计算出每个节点的最终样式(Computed Style)。

3. 布局(Layout)

根据上一步计算好的样式算出每个节点的尺寸和位置,生成Layout Tree。比如margin的合并、BFC规则、浮动规则等。计算出的位置和尺寸的值相对于 包含块

DOM树和Layout树不一定是一一对应的,比如:

  • DOM 树中存在display:none;的节点,而Layout Tree中会去除这样的节点;
  • DOM 树中不存在伪元素(如::before),而在Layout Tre中会被解析为一个节点;
  • 内容必须在行盒中,行盒和块盒不能相邻;

4. 分层(Layer)

对布局树进行分层的好处在于,将来某一层改变后,仅会对该层进行后续处理,从而提示效率。

滚动条、堆叠上下文、transform、opacity等样式都会或多或少影响分层效果,也可以通过will-change属性更大程度的影响分层结果。

5. 绘制(Paint)

主线程会为每个层单独绘制指令集,用于描述这一层的内容如何画出来。

6. 分块(Tiling)

完成绘制后,主线程将每个图层的绘制信息提交给合成线程,剩余工作将由合成线程(是在渲染主进程中)完成。

合成线程首先对每个图层进行分块,将其划分为更多的小区域。会从线程池中拿出多个线程来完成分块工作。

7. 光栅化(Raster)

合成线程会将分块信息交给GPU进程,以极高的速度完成光栅化。

GPU进程会开启多个线程来完成光栅化,并且优先处理靠近视口区域的块。

光栅化的结果就是一块快的位图。

8. 画(Draw)

合成线程拿到每个层、每个块的位图后,生成一个个的quad信息,然后把这些信息交给GPU进程,由GPU进程产生系统调用,交给GPU硬件处理,完成最终的屏幕成像。

quad信息会标识出每个位图应该画到屏幕哪个位置,以及会考虑旋转、缩放等变形。

变形发生在合成线程,与渲染主线程无关,这就是transform属性效率高的本质原因。

回流(Reflow)

比如更改了CSS尺寸或者位置,这时会触发第二步即样式计算,然后触发重新布局、分层等后续操作。

为了避免连续多次操作导致布局树反复计算,浏览器会合并这些操作。当JS代码全部完成后再进行统一计算。

但在JS中获取几何信息的属性(如clientWidth)时会立即reflow。

重绘(Repaint)

比如改动了背景颜色、字体颜色与几何信息无关的样式,repaint的本质是重新根据分层信息计算了绘制指令。

Layout 甚至 Layer步骤会被跳过。

Reflow 一定会导致 Repaint

为什么 transform 的效率高

因为 transform 既不影响布局也不影响绘制指令,它影响的只是渲染流程的最后一个 Draw 阶段。

由于 Draw 阶段在合成线程中,所以 transform 的变化几乎不会影响渲染主线程。反之,渲染主线程无论如何忙碌,也不会影响 transform 的变化。

2024-11-16 小汇总

· 阅读需 7 分钟

1. navigator.onLine 可能不准确

最近有用户反馈页面数据加载异常,UI加载出来了,但是页面中的表格数据是空的,但是周围同学一个也复现不出来,看用户演示,切到那个页面时,页面并没有出现loading,看着像是没有调接口。打开用户控制台,发现确实没有调接口。

接着看接口调用逻辑,很直白的调用方式,怎么会不调接口!?排查陷入迷茫状态。晚上下班想了想觉得逻辑写的是没毛病的,难道是接口调用用的React-Query这个库有问题?带着疑惑,来到github上react-query的官方仓库。翻了翻issue,发现有一条可疑的:

https://github.com/TanStack/query/issues/5679

问题描述跟我遇到的情况很相似,看了下面的回复,似乎是Mac电脑的chrome存在的一个bug:

navigator.online

来到chromium的bug页面:navigator.onLine returns false even though there is internet connectivity,下面回复有用react-query时出现的问题。

react-query v4版本内部使用了navigator.onLine这个浏览器API,用于判断当前网络环境,默认情况下如果当前网络是断开的,则不会调用接口。

packages/query-core/src/queryClient.ts

那是不是这个问题引起的呢?目前没法排查出来,只能等有人再次反馈时把他的控制台打开,看看navigator.online的值是不是有问题。不过我可以先把这个问题修了,React-Query中有一个networkMode配置(默认值是online,有网络连接才会触发查询/突变),我将它配置成always,这样会忽略在线/离线状态,当网络异常时也会调接口。

幸运的是,在改动代码上线之前,组里有个同学也出了相同的问题,最后确认是navigator.online不对引起的。

2. svgr 更改图标大小问题

svgr是一个很好用的插件,它可以把SVG文件转成组件,在项目中直接引入使用。比如在React项目中,把svg文件直接引入作为icon图标使用:

import { ReactComponent as FolderOutlined } from '@/icons/folder-outlined.svg';

const App = () => {
return (
<div>
<FolderOutlined />
</div>
);
}

当我想给图标更改大小时,发现设置后的样式有问题。

<FolderOutlined width={24} height={24} />

检查元素看了对应的HTML,发现svg的viewBox没了。本来文件里的viewBox好好的,经过svgr后就没了。

当svg中的viewBox没有指定时,他会默认与viewport相同,viewport就是我们设置的widthheightviewport24*24的大小,viewBox将是0 0 24 24viewBox属性定义的是画布上可以显示的区域:从 (0,0) 点开始,24宽*24高的区域。也就是两者刚好1:1。svg元素里面的子元素pathrect等它们的坐标也会跟着缩放,1:1意味着坐标没发生变化,因此UI上绘制区域(原始的viewBox)如果很大就会展示不全,如果绘制区域(原始的viewBox)比viewport小,就会出现图标大小没变,svg整体区域(viewport区域)变大了。

如果原始的viewBox没被移除,就不会有这个问题,比如原始的viewBox0 0 12 12,viewport是24x24,这个12*12的区域,会放到24*24的画布上显示,形成放大两倍的效果。

后来也是看了svgr的官方仓库找到了解决办法,相关bug链接:Preserving viewBox #18

svgr

配置完成后,看到viewBox没有丢失,问题也就解决了。我项目中用的svgr是比较老的版本了,新版本应该没有这个问题了。

3. 其他一些小问题

  1. React 中是不支持给内联样式添加!important标记的,下面的写法将不生效。
<div style={{ fontSize: '20px !important' }}>
Hello
</div>

相关bug链接:Support !important for styles? #1881

  1. 在使用@testing-library/react这个库写一些UI测试用例时,如果我们的测试用例涉及到scrollHeight/clientHeight等DOM相关的API时,需要怎么测试?

因为jsdom不支持布局,像这样的测量值将始终返回0。答案是自己模拟数据。

相关bug:scrollHeight/clientHeight for an element is always zero #353

相关文档:Testing element dimensions without the browser

  1. 依赖报错问题

最近用了一个三方包A,A包里依赖了很多其他三方包,而这些其他三方包好多都依赖一个B包,有些三方包规定B包的版本需要是0.x,但问题是B包连正式版都还没有,只有beta版本。因为找不到B包的正式版本,下载依赖时总是会报错。但我们在项目中需要使用A这个包,他比较复杂且重要,目前卡在了安装环节。

我们是知道B这个包用最新的beta版本就可以的,那些第三方包是版本依赖书写的有问题。需求紧急,只好使用overrides这个配置了,它是将依赖项树中的包替换为其他版本或完全替换成其他包的方法。

如果你需要对依赖项的依赖项进行特定更改,例如将依赖项的版本替换为已知的安全版本,或者确保在任何地方都使用相同版本的包,则可以添加这个配置。

最后我们决定在package.json中增加这个配置,问题得以解决。

{
"overrides": {
"B": "0.0.1-beta.144"
},
}
  1. 快速找到组件对应代码

项目比较大,对一个没新接触的模块或者很久没参与过的模块改需求或者修复bug,先去找到这个模块在哪可能就比较麻烦。有没有什么办法可以快速定位到代码在那个文件夹里?

最原始的办法可能就是利用页面上的文案全局进行搜索,如果文案比较特别那还是很容易定位到的,如果文案都差不多,或者有些组件写的很像,一搜可能出现好多个结果,这就有点麻烦了,可能需要改点UI先试一下,看页面上有没有反应。

另一种办法是使用插件,chrome插件商店有一个LocatorJS插件,在开发环境下,可以快速定位到代码对应的文件。

LocatorJS

React项目为例,使用这个插件之前你最好先安装一下React-Dev-Tools这个浏览器插件,不然可能定位不出来。

安装完后,启动本地开发环境,Mac上使用Option+鼠标点击快捷键就可以定位组件了,Window则使用Alt+鼠标点击快捷键。

使用 ts-morph 库编辑TS文件

· 阅读需 6 分钟

时间如梭,停更快三个月了,时间过得太快了!

今天分享一个库:ts-morph。使用它可以读取和操作TS文件,并实时保存文件内容。这个库包装了 TypeScript 编译器 API,可以让我们方便的操作TS AST。

起因

接触到这个库的起因是这样的:不久前我下载了stable diffusion webui,玩了一段一时间的AI文生图。然后想把这些图片分享出来,比如创建一个网站。但是图片比较大,原版的png格式文件基本都在5MB以上,转成jpeg格式基本也要1MB以上。

如果网页上图片很多,而且还都是尺寸很大的图片,不仅加载慢,而且占内存。大部分网站都会把原始的图片压缩或者剪切生成空间小、尺寸小的图片,当用户要预览的时候再动态加载原始图片。

只是做一个很简单的分享网站,我采取的方案是使用JamStack相关框架,比如:DocusaurusVitePressAstro等。然后在public或者static文件夹下存放图片资源。

使用 sharp 这样的库读取、压缩图片。然后我需要生成一个配置文件,比如json文件,他内部存放的都是图片的信息,比如:hashwidthheightpathkeywordscategory等信息。

然后就是需要一个脚本,两种思路:

  1. 我需要读取这个json文件,然后看哪些图片还没处理,就去读取图片,生成完整的图片信息;
  2. 我去先读取文件,对比json文件中还没有的图片,然后给这些新的图片增加信息;

最后我采用了第一种办法,第一种办法更可控一些,但是无需要先把要使用的图片的基础信息编辑到配置文件里,比如它的文件路径、它的关键词、它的分类等等,不过这些还好(后续获取可以利用AI工具自动帮忙生成关键词)。

But,我没有使用json文件,现在想想获取使用json文件最省事🤔,我最终选用了TS文件,使用TS文件,我可以在前端直接引入(当然,用json也不是不行~)。

TS文件可不是想json一样,他不算是数据文件。在脚本里,json文件直接引入即可,处理完数据直接从新写入新的数据即可。

TS文件你怎么读取里面的数据变量?并且只是改这个数据变量的值?好像只有AST了(想想工作量就上来了😔),先读取文件,然后语法解析,更改值、生成新的语法树、转换成原始code,最后保存文件...

有没有可以简化的方案?当然有的,比如ts-morph。他使用起来就简单多了。只需要读取文件,然后操作AST、调用save,文件内容直接就变了(😊)。前提是我们需要了解一点AST的知识,比如这个网站:https://ts-ast-viewer.com/

Demo

举个例子,summary.ts我们待处理的文件,他的内容如下:

interface Person {
name: string,
age: number,
}

const members = [
{ name: 'John', age: 18 },
{ name: 'Mary', age: 20 }
];

我们想修改John的年龄改成24,需要怎么做?代码如下:

import { Project, SyntaxKind } from 'ts-morph';

const project = new Project();
const fileSource = project.addSourceFileAtPath('./summary.ts');
const variableDeclaration = fileSource.getVariableDeclarationOrThrow('members');
const children = variableDeclaration.getChildren();
const arrayLiteralExpressionNode = children.filter(child => child.getKind() === SyntaxKind.ArrayLiteralExpression);

for (let node of arrayLiteralExpressionNode) {
const arrayLiteralExpression = node.asKindOrThrow(SyntaxKind.ArrayLiteralExpression);

const expressionList = arrayLiteralExpression.getElements();

for (let expression of expressionList) {
const objectLiteralExpression = expression.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
const nameProperty = objectLiteralExpression.getPropertyOrThrow("name");
const ageProperty = objectLiteralExpression.getPropertyOrThrow("age");
const nameLiteral = nameProperty.getFirstChildByKind(SyntaxKind.StringLiteral);
const ageLiteral = ageProperty.getFirstChildByKind(SyntaxKind.NumericLiteral);

if (nameLiteral?.getLiteralValue() === 'John') {
if (ageLiteral) ageLiteral.setLiteralValue(24);
else objectLiteralExpression.addPropertyAssignment({
name: 'age',
initializer: `${24}`,
});

project.saveSync(); // 同步保存修改内容
}
}
}

我们把summary.ts的代码粘贴进去,就可以得到语法树了。

TS AST Viewer

解释一下上面代码的意思,首先使用ts-morph时,第一步要先创建一个项目(new Project),然后让项目里添加文件,就是addSourceFileAtPath,他会返回一个当前文件的SourceFile对象,这个对象提供了很多用于操作AST的API,其中getVariableDeclarationOrThrow就可以获取到文件中的某个变量声明,这个方法后带有OrThrow,表明如果获取不到就会停止执行代码,直接抛错。当然如果不叫OrThrow,则会返回VariableDeclaration | undefined类型,代码会接着往下走。

variableDeclaration 对应到语法树,就是这个位置:

接下来的认为就是沿着树结构往下找,一直找到StringLiteralNumericLiteral两个节点。

代码第五行getChildren,可以获取VariableDeclaration节点的子节点们,即:IdentifierArrayLiteralExpression,显然我们想要ArrayLiteralExpression。因此做一下过滤,把节点类型是ArrayLiteralExpression的给过滤出来。或者我们使用下面的方法也可以得到arrayLiteralExpression,这样就不用再做遍历了。

const arrayLiteralExpression = variableDeclaration.getFirstChildByKindOrThrow(SyntaxKind.ArrayLiteralExpression);

接着就是获取数组的元素列表,使用getElements()就可以拿到了,然后遍历列表,把元素类型定义为ObjectLiteralExpression,即是对象类型。

得到对象类型后,就是获取对象中的属性,使用getPropertyOrThrow拿到nameage属性结点:

然后还是使用getFirstChildByKind获取属性值节点即可,这个方法会返回各种类型的Literal对象。

使用getLiteralValue可以获取值,使用setLiteralValue设置值。当然如果没有Literal这个节点,或者没有Property这个节点,我们也可以使用addPropertyAssignment,添加一个新的属性。需要注意的是initializer参数需要是一个字符串,如果属性值是Number类型,我们需要传"4"即可,而如果是String类型,则需要多包一层字符串:"'Tom'"

了解了ts-morph的原理和使用,接下来就好办了,无非就是增删改查。再比如,我想往数组里添加新的一项,该怎么做?代码如下:

arrayLiteralExpression.addElement("{ name: 'Tom', age: 16 }");

直接调用ArrayLiteralExpression对象的addElement方法即可,而且是传字符串即可,它会自己解析。

项目配置

因为都是使用ts,用node跑程序,我需要下面几个包辅助:

  • ts-node、@types/node、typescript

然后还需要配置一下tsconfig.json

{
"compilerOptions": {
"target": "es2016",

/* Modules */
"module": "ESNext",
"moduleResolution": "node10",
"forceConsistentCasingInFileNames": true,

/* Type Checking */
"strict": true,
"skipLibCheck": true
}
}

然后就是package.json的配置,需要添加"type": "module",因为我们用的是ES Module模块。然后还需要配置脚本:

{
"type": "module",
"scripts": {
"parse": "node --loader ts-node/esm --no-warnings=ExperimentalWarning ./scripts.ts"
}
}

2024-03-24 小汇总

· 阅读需 8 分钟

算法:合并公共范围

假如有一个数字类型的二维数组,数组中的每一项都是固定长度为 2 的子数组,代表范围,比如 [2, 5] 表示数字范围在 2~5 之间,并且包含 2 和 5。要求你设计一个函数,它接受这样格式的二维数组,合并有交集的范围(Range),最终返回新的二维数组,这个新二维数组的数字范围与原数组一样,且总体是按照从小到大排列的,只是有些重复的范围被合并了。

比如下面的一个二维数组,经过函数处理后最终得到新的二维数组为:[[1,5], [7,9]]

[ [3, 4], [1, 3], [7, 9], [2, 5] ]

已知条件:子数组的第一个数字比第二个要小(从小到大排列)。

2024-03-16 小汇总

· 阅读需 8 分钟

1. 文本自适应超出省略

单行文本超出省略时,通常需要指定一个固定的宽度,或者使用百分比宽度。比如下面的 div 元素,虽然没有指定宽度,但是他会继承父元素的宽度,超出出现省略符。

<div>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Et, ducimus eaque laborum nostrum sed cumque optio quisquam nihil soluta aliquid! Iste officiis delectus distinctio veniam? Recusandae consectetur sit pariatur saepe!
</div>

<style>
div {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

但有时候我们可能会遇到下面这种布局,父容器的宽度不是固定的,中间的文本内容会自动超出省略,而它两侧的内容可以正常展现。

中间文本超出省略

琐碎点汇总

· 阅读需 26 分钟
多云转晴
前端开发

在 Node 环境中使用 ESM

两种方式:

  1. 把模块文件的后缀改成 .mjs
  2. 给最近的上级 package.json 文件添加名为 type 的字段,并将字段值改成 module

渲染原理

· 阅读需 8 分钟

事件循环

事件循环

  1. 在最开始的时候,渲染主线程会进入一个无限循环。
  2. 每一次循环会检查消息队列中是否还有任务存在。如果有,就取出第一个任务执行,执行完一个后进入下一次循环;如果没有,则进入休眠状态。
  3. 其他所有的线程(包括其他进程的线程)可以随时向消息队列中添加任务。新任务会加到消息队列的末尾,在添加新任务是,如果主线程是休眠状态,则会将其唤醒以继续循环拿取任务,这样一来,就可以让每个任务有条不紊、持续的进行下去。