关于 n8n-expressions
n8n-expressions 是面向 n8n 工作流构建者的技能指南,介绍表达式语法与数据引用方式。重点涵盖跨节点引用、JavaScript 变换、日期处理及避免不必要节点的实践。
主要功能
- 01使用节点名称引用数据
- 02用 JavaScript 表达式转换数据
- 03使用 Luxon 处理日期
- 04编写多行表达式与注释
- 05判断何时使用 Set 节点
适用场景
- 01在节点字段中直接计算或格式化值
- 02从上游节点稳定读取数据
- 03处理日期、数组与条件逻辑
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ n8n-io/ skills --skill n8n-expressions安装后在 Claude Code 中输入/n8n-expressions确认已加载。
详细介绍
n8n 表达式
n8n 的表达式语言是嵌入在 {{...}} 代码块中的 JavaScript。表达式会同步运行,每次处理一个条目($json),可以访问上游节点($('Name'))、用于处理日期的 Luxon,以及大部分原生 JS 功能。
不容妥协
按节点名称引用数据,而不是使用 $json。 使用 $('Node Name').item.json.field(或 .first().json.field)。$json 虽然可用,但只要有节点清除了条目上下文(Aggregate、使用 Run for All 的 Code、分支合并),或重构时加入了中间节点,就会失效。故障不会显式报错,下游会静默地拿到错误数据。按节点名称引用更稳定。
强烈建议
- 不要添加仅用于向单个下游字段提供数据的 Set 节点。 在使用值的节点中直接写表达式。只有在 2 个或更多节点会读取同一个派生值、派生过程并非简单操作,或 Set 节点用于塑造子工作流的最终返回值时,才值得添加。参见下文“Set 节点反模式”。
- 使用 Luxon 处理日期,而不是 DateTime 节点。 日期计算、格式化和解析都可以在表达式中完成:
{{ DateTime.now().minus({ days: 7 }).toISO() }}。对于刚开始接触 n8n 的用户来说,DateTime 节点在画布上更直观,但除非用户特别要求,否则应避免使用。 - 通常优先使用表达式,而不是额外添加节点。 在邮件节点的正文栏中编写邮件正文,在 HTTP Request 的 URL 栏中计算 URL。只有在需要重复使用转换结果,或该部分的主要目的就是执行转换时,才使用额外节点。
- 多行表达式应缩进并添加注释。 表达式超过一行时,应像编写真正的代码那样设置格式。大多数 n8n 用户都不是程序员,因此请用简洁的行内注释解释代码。
为什么要按节点名称引用($('Name').item.json.x),而不是使用 $json.x
$json 表示“流入此节点的当前条目”。如果节点直接连接到一个数据源,且没有节点清除条目上下文(某些节点会清除,例如 Aggregate、使用 Run for All 的 Code、分支合并),那么使用 $json 没问题。
以下情况会导致问题:
- 在数据源和使用数据的节点之间插入节点(使用数据的节点原本通过
$json.x读取 3 步之前某个节点的数据,现在$json指向新插入的中间节点)。 - 某个节点清除了上下文(Aggregate、某些合并操作,以及不会保留数据结构的 Code 节点)。
- 分支通过 Merge 汇合。
$json指向最后触发的分支,无法保证结果一致。
$('Get User').item.json.id 指向明确。始终引用指定节点的第一个条目的 JSON,无论中间经过哪些节点。
例外情况:
当分支汇合后需要一个稳定的引用点时,在汇合处插入 NoOp 节点。为节点起一个描述性名称(例如 Combine Inputs)。下游节点按名称引用它。
Branch A ──┐
├─→ [NoOp: Combine Inputs] ──→ Downstream nodes use $('Combine Inputs').item.json.x
Branch B ──┘
重构时 NoOp 也能保持稳定:在 Combine Inputs 和使用数据的节点之间插入转换节点,不会影响 $('Combine Inputs') 引用。
如果下游节点需要使用上下文会被中间操作清除的节点中的数据,就必须采用这种模式。
如果各分支产生的数据结构不同,请使用 Set 节点,而不是 NoOp。 NoOp 会原样传递收到的数据结构,因此下游仍需判断哪个分支触发了。Set 节点会将两个分支的数据统一为一种结构,下游只需读取一组字段:
// Set node: "Normalize Inputs"
name: `={{ $('Lookup by Email').item.json.name || $('Lookup by ID').item.json.full_name }}`
email: `={{ $('Lookup by Email').item.json.email || $('Lookup by ID').item.json.contact_email }}`
无论数据由哪个分支产生,下游节点都按 $('Normalize Inputs').item.json.name 引用。
Set 节点反模式
AI Agent 经常生成的模式:
Webhook → Set: { customer_id: $json.body.customer_id, amount: $json.body.amount }
→ Postgres: WHERE id = {{ $json.customer_id }}
→ Email: Total is {{ $json.amount }}
Set 节点没有任何实际用途。每个下游节点都可以直接从 Webhook 读取:
Webhook → Postgres: WHERE id = {{ $('Webhook').item.json.body.customer_id }}
→ Email: Total is {{ $('Webhook').item.json.body.amount }}
只有在以下情况下,Set 节点才有存在的价值:
- 同一个派生值会被多个下游使用者使用(派生过程并不简单)。
- 派生逻辑较复杂,给它命名有助于提高可读性。
- 多个分支需要相同的数据结构,使用共享的上游引用更清晰。
- **它是子工作流的最后一个节点,用于塑造返回契约。**明确例外:“单个使用者”指的是每个调用方,因此 Set 是 API 边界。对子工作流而言,这种做法可选但值得推荐;有时如果前一个节点包含噪声字段,则是必需的。参见
n8n-subworkflows。 - **你需要通过设置
Include Other Fields: false从条目中移除字段。**Set 是将输出结构限定为白名单的最简洁方式。这也是上面“塑造子工作流返回值”条目背后的机制(防止内部临时字段泄露给调用方),但凡是需要下游使用干净数据结构的地方都适用。 - **你需要重命名字段。**使用 Set 可以将重命名逻辑集中在一处,而不必散布到每个使用者的表达式中。
对于“从请求体中提取一个字段并使用一次”,不要使用 Set 节点。应将表达式放在使用该字段的地方。
对于“提取一次,供多个下游使用”,Set 节点确实合理。如果只有一个使用者使用它,Set 就会成为技术债(上面的返回值塑造情形除外)。
快速判断是否需要 Set 节点
有多少个下游节点会引用每个字段?
- 0 或 1 个 → 删除节点,将表达式内联。
- 2 个或更多 → 可能有保留的价值,尤其是派生过程并不简单时。
多个连续的 Set 节点几乎可以肯定属于过度提取。将它们合并。
表达式可以做什么
单字段转换
{{ $json.name.toUpperCase() }}
{{ $json.email.toLowerCase().trim() }}
{{ $json.items.length }}
{{ $json.user.first_name + ' ' + $json.user.last_name }}
{{ `(${$json.user.phone.slice(0, 3)}) ${$json.user.phone.slice(3, 6)}-${$json.user.phone.slice(6, 10)}` }}
方法链:.map()、.filter()、.find()、.reduce()
数组方法是最实用的表达式工具之一。它们可以替代数十个节点。
{{ $json.tags.filter(tag => tag.active).map(tag => tag.name).join(', ') }}
{{ Object.values($json.scores).reduce((sum, score) => sum + score, 0) }}
// Find one matching item from another node's output
{{ $('Get Models').all().find(model => model.json.id === $json.modelId).json.modelName }}
// Filter array, then check shape
{{
$('Get User\'s Entries').all()
.map(item => item.json)
.filter(entry => entry.prize_eligible === 'eligible')
.length > 0
}}
多步方法链始终缩进,并添加注释
如果方法链包含 2 个或更多方法调用,或筛选逻辑不明显,请分行格式化并添加注释。读者不一定是作者,因此注释也能让非技术读者理解代码意图。
{{
// Find all entries that are still processing AFTER 1 hour
// (used to allow re-submission since something likely went wrong)
$('Get User\'s Entries').all()
.map(item => item.json)
.filter(entry =>
entry.prize_eligible === 'processing' &&
$now.diffTo(entry.created_at, 'minutes') > 60
)
.length > 0
}}
这类逻辑常见于路由节点(Switch、IF)中。不加注释时,大多数用户都难以读懂。
.all().map() 会引发“执行一次”的问题
当你使用 $('Source Node').all().map(...)(或 .filter()、.reduce())处理整个数据集时,表达式本身会进行迭代。如果节点使用默认的逐条目执行模式,它会对每个输入条目运行一次,但每次运行都会执行完整的 .all() 聚合:这会造成资源浪费,也可能导致结果错误。
在以下情况下,将节点设置为仅执行一次:
- 表达式使用了
.all().map()/.all().filter()/.all().reduce()。 - 输出应为单个聚合结果,而不是逐条目输出。
这对应节点上的 executeOnce: true。大多数节点都支持此设置。
const aggregateNode = node({
type: 'n8n-nodes-base.set',
config: {
executeOnce: true, // important when using .all() in expressions
parameters: {
assignments: {
assignments: [
{
name: 'totalEligible',
value: `={{
$('Get Entries').all()
.map(item => item.json)
.filter(entry => entry.eligible)
.length
}}`,
type: 'number',
},
],
},
},
},
})
忘记设置 executeOnce 通常仍能正常运行,但 N 个条目会执行 N 次工作。更糟的是,如果下游预期只有一个条目,你会得到 N 个。
**反例:将 .all() 用作逐条目查找,而非聚合。**如果 .all() 读取的是另一个节点,并根据当前条目的标识进行筛选,那么就应该逐条目执行。每次迭代都会产生不同的结果,因此这是实际所需的工作,并非资源浪费。
// Workflow: Get Tags (200 items) → Search Posts (10 items) → this Set Fields node.
// Each post carries a `tag_ids` array. Set Fields runs per-item (10 times)
// and resolves each post's tag_ids into the full tag objects.
tags: ={{
$('Get Tags').all()
.filter(tag => $('Search Posts').item.json.tag_ids.includes(tag.json.id))
}}
在这里设置 executeOnce: true 会将 10 个输出合并为 1 个。
区分这两种情况的关键:
$source.all()单独使用(对整个数据集进行聚合)→executeOnce: true。$source.all().filter(... matches $other.item.json.x)(根据当前条目进行查找)→ 保持executeOnce关闭。
**快速判断:**表达式是否使用了 .all(),但没有将它与另一个节点的 .item 结合?如果是,节点很可能应该设置为 executeOnce: true。
如需进一步了解迭代和显式循环,请参见 n8n-loops 技能。
条件表达式
{{ $json.status === 'active' ? 'Active' : 'Inactive' }}
{{ $json.amount >= 100 ? 'Large' : ($json.amount >= 10 ? 'Medium' : 'Small') }}
日期运算(Luxon)
{{ DateTime.now().toISO() }}
{{ DateTime.fromISO($json.created_at).toFormat('yyyy-MM-dd') }}
{{ DateTime.now().minus({ days: 7 }).startOf('day').toISO() }}
{{ DateTime.fromISO($json.due).diffNow('days').days }} // days from now (negative if past)
跨节点引用(优先于 $json)
{{ $('Webhook Trigger').item.json.body.customer_id }}
{{ $('Lookup customer').item.json.email }}
{{ $('Combine Inputs').item.json.coupon_code }} // NoOp convergence point
对于只处理单个项目的节点,.item 和 .first() 基本等效,任选其一即可。.first() 更明确,.item 更简短。
使用 IIFE 箭头函数编写多行逻辑
如果逻辑太复杂,无法写成一行,但只处理单个项目,可以将其包装在一个立即调用的箭头函数中:
{{ (() => {
// Compute total including tax
const items = $json.line_items
const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0)
const tax = subtotal * 0.08
return (subtotal + tax).toFixed(2)
})() }}
在函数内部,你可以使用完整的表达式作用域($json、$('Node Name')、$now、Luxon),以及在任何函数中都能编写的 JS:const/let、if/switch、try/catch、正则表达式。
参数不起作用。 表达式没有调用方来传递参数,因此 (text) => text.replace(...) 没有任何东西可以调用它。请直接引用外部作用域中的值。函数仍需使用 IIFE 包装((...)())才能实际执行。
{{ (() => $json.text.replace(/\b(?:foo|bar)\b/gi, 'baz'))() }}
外层的 ( 和末尾的 )() 都是必需的:第一对括号将函数表达式括起来,末尾的 () 则调用它。省略任意一部分,n8n 都会报错并拒绝运行工作流。
为什么选择这种方式而不是 Code 节点? Code 节点在沙箱 VM 中运行:最坏情况下耗时约 500-1000ms。表达式 IIFE 则在与外围表达式相同的上下文中运行:耗时稳定在 1-10ms。对于纯粹的单项目处理,这两者相差 100 倍,功能却没有区别。这是资深用户常用的方法。
对于多项目聚合($input.all())、外部库或异步任务,Code 节点仍然有用。决策树请参阅 n8n-code-nodes,更多示例和格式规则请参阅 n8n-code-nodes 中的 ARROW_FUNCTIONS_IN_EDIT_FIELDS.md。
可用的原生 JS
String、Array、Number、Object、Map、Set、JSON.parse、JSON.stringify、Math、正则表达式、Date(但只能使用 Luxon)。
实用惯用写法
字段可能缺失时使用默认值
{{ $json.id || "fallback-id-here" }}
也可以使用可选链:
{{ $json.user?.profile?.id ?? "anonymous" }}
这对用于查询的筛选值尤其有用:传入一个无法匹配任何行的默认值,而不是让查询因 undefined 失败。
在文本字段中嵌入 JSON:选择哪种序列化方式
两种序列化方式,适用于两种场景:
.toJsonString()用于不在意格式的紧凑 JSON。典型场景:AI 提示词。体积更小、更节省 token,也更容易在提示词模板中浏览。{{ $('Get Data').item.json.toJsonString() }}JSON.stringify(value, null, 2)用于在意格式的美化 JSON。典型场景:电子邮件正文、Slack 消息、调试输出,以及其他需要让人阅读结果的场景。{{ JSON.stringify($('Source Node').item.json, null, 2) }}
请谨慎选择。在 LLM 提示词中美化输出会浪费 token,并让模型的上下文变得杂乱。在电子邮件中使用紧凑 JSON 则难以阅读。
JSON.stringify 和 JSON.parse:应该用在哪里
JSON.stringify 和 JSON.parse 常用于表达式中,两者都没问题。关键原则:序列化和解析属于存储层操作,而非接口层操作。
- 当你要写入的存储列无法原生保存该类型时,进行序列化。 最典型的情况:Data Tables 中后缀为
_object的字符串列,实际存储的是数组或对象。请参阅n8n-data-tables。 - 从该存储列读回数据时,进行解析。 在拥有该存储的工作流内进行。
- 不要跨越边界传递序列化后的结构。 子工作流返回值、Webhook 响应、Agent 工具结果、下游使用方,都应接收自然结构(数组就是数组,对象就是对象),而不是需要调用方记得用
JSON.parse的序列化外壳。
一个常见的错误:子工作流有一条“新鲜”路径(数据刚由大模型生成,已经是数组)和一条“缓存”路径(数据刚从 _object 列读出,仍是字符串)。错误的做法是把新鲜路径序列化,“以匹配”缓存路径。正确的做法是解析缓存路径,让两个分支在输出时都采用相同的自然结构。
存储表示形式应留在拥有该存储的工作流内。越过这个边界后,应使用自然结构进行交互。n8n-subworkflows SKILL.md 中的“返回自然结构,而非存储结构”从子工作流的角度阐述了这一点,而 n8n-data-tables 则从存储的角度进行了说明。
返回正确的类型:何时使用 ={{ ... }} 包裹
如果不告诉 n8n 将值作为表达式求值,一些节点字段会把值视为字符串字面量。使用 ={{ ... }} 包裹(= 前缀会将字段切换为表达式模式),即可返回内部代码生成的实际类型:
// String literal (default behavior)
foo: 'plain string'
// Number
foo: '={{ 100 }}'
// Boolean
foo: '={{ true }}'
// Object (the `={{ ... }}` is what makes the receiver see an object, not a string)
foo: '={{ { "valid": true, "items": [] } }}'
// Array
foo: '={{ ["a", "b", "c"] }}'
// Reference to another node's value (preserves whatever type that value already is)
foo: '={{ $("Source Node").item.json.payload }}'
需要关注类型的场景:Set / Edit Fields 上的对象/数组字段(将列的 Type 设为 Object 或 Array 时)、HTTP Request 的 JSON 正文参数、子工作流的类型化 workflowInputs.values[type] 输入、Agent 工具参数,以及任何接收节点会校验类型的场景。如果不使用 ={{ ... }} 包裹,传递的就是字符串,接收方要么强制转换,要么报错。
请按节点名称引用,而不是使用 $json,遵循上文不可妥协的第 1 条:
// WRONG
foo: '={{ $json.payload }}'
// RIGHT
foo: '={{ $("Source Node").item.json.payload }}'
例外情况:如果 $json 确实是正确选择(没有中间转换,也没有汇合),并且该字段是某个节点上的逐项字段,而该节点直接位于单一来源之后。即便如此,使用命名引用也更能适应重构。
带说明性注释的多行表达式
{{
// Default to avoid query errors when user_id is missing.
// The fallback UUID is a known-empty row.
$json.id || "305f7106-6988-4651-b26a-18979641b7b5"
}}
逻辑不明显时,建议添加注释。 这样后续阅读代码的人就能看到注释。
表达式无法完成的操作
- 使用外部库(不能使用
require)。 - 使用 Async / await。
$json 本身仅表示当前项,但表达式可以通过 $input.all()、$input.all()[3]、$('Source Node').all() 等方式访问其他项。请参阅上文的“方法链”。
相关内容请参阅 n8n-code-nodes。
如何选择:表达式、Edit Fields 还是 Code 节点?
根据 n8n-code-nodes 的决策树:
1. Single-field transform → expression in the field
2. Multi-step pure logic on one item → arrow function in Edit Fields
3. Multi-source aggregation, libraries, or stateful → Code node
默认使用表达式。只有在输入或作用域有要求时,才考虑其他方式。
“额外节点”异味
常见的额外节点用法其实可以保留在表达式中:
| 添加此节点 | 更好的做法 |
|---|---|
| 使用 DateTime 节点格式化日期 | 在使用方的表达式中使用 DateTime.fromISO(...).toFormat(...) |
| 使用 Set 节点构建电子邮件正文 | 在电子邮件节点的正文栏位中直接内联表达式 |
| 使用 Set 节点计算只使用一次的派生字段 | 在使用方内联计算 |
| 使用两个节点(Set + IF)先计算再测试 | 使用一个 IF 节点,在其条件表达式中进行计算 |
使用 Code 节点调用 .toUpperCase() | 直接使用表达式 |
为转换添加节点会造成更多视觉杂乱、拖慢工作流,并增加阅读难度。
适合添加额外节点的情况:
- 该转换会被多个下游使用方重复使用。
- 该转换开销较大(适合使用 Code 节点)。
- 该转换是某个部分的主要目的(明确的“计算 X”步骤)。
反模式
| 反模式 | 问题所在 | 修正方法 |
|---|---|---|
| 添加 Set 节点,只为从 webhook 正文中提取一个字段,供一个下游使用方使用 | 本应内联却多加了节点,重构时也容易出错 | 删除 Set 节点,直接在使用方中引用 $('Webhook').item.json.body.x |
| 连续添加多个 Set 节点,每个节点只定义一个字段 | 工作流臃肿 | 合并。大多数节点都没有必要;确实需要的字段则集中到一个 Set 节点中 |
在包含多个分支和中间转换的工作流深处使用 $json.x | 添加中间节点或清除上下文时引用会失效 | 使用 $('Source Node').item.json.x。如果分支合并,请添加一个 NoOp 汇合点。 |
| 添加 DateTime 节点来格式化时间戳 | 为一行 Luxon 表达式多加了一个节点 | {{ DateTime.fromISO($('Source').item.json.x).toFormat('yyyy-MM-dd') }} |
| 使用 Set 节点构建电子邮件 HTML,然后在 Email 节点中读取 | 本可使用一个表达式,却用了两个节点 | 直接在电子邮件节点的正文栏位中构建 HTML |
使用 new Date($json.created_at),而不是 Luxon | 无法使用格式化和操作功能 | DateTime.fromISO($('Source').item.json.created_at) |
| 实际有 200 个字符的单行表达式 | 难以阅读 | 改为多行,使用箭头函数、缩进和注释 |
使用 $json.foo.bar.baz,却未检查 $json.foo 是否存在 | 缺少中间字段时会崩溃 | 使用 ?. 链:$('Source').item.json.foo?.bar?.baz |
| 在表达式中硬编码本应作为配置的值 | 出现魔法字符串 | 使用 $vars.X(n8n Variables,付费方案)或 Data Table |
分支合并后在下游使用 $json 引用 | 最后触发的分支会胜出,结果不确定 | 在合并处插入一个 NoOp(“Combine Inputs”),并按名称引用 |
在任何表达式中使用 $env.X | 无法工作;运行时会报错 | 配置请使用 $vars.X(付费方案)或 Data Table。机密信息请使用凭据系统 |