关于 n8n-expressions-official
面向 n8n 工作流作者的表达式编写指南,介绍 JavaScript 表达式、跨节点引用、日期处理和数据整形,并强调稳定引用与避免冗余节点。
主要功能
- 01按节点名称引用数据
- 02用 Luxon 处理日期
- 03以内联表达式减少节点
- 04用 JavaScript 方法处理数组
- 05编写多行逻辑并添加注释
适用场景
- 01从上游节点读取确定字段
- 02在字段中计算日期或格式
- 03过滤和汇总节点输出
- 04判断是否需要 Set 节点
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ n8n-io/ skills --skill n8n-expressions-official安装后在 Claude Code 中输入/n8n-expressions-official确认已加载。
详细介绍
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-official。 - 你需要通过设置
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-official 技能。
条件判断
{{ $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-official,更多示例和格式规则请参见 n8n-code-nodes-official 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) }}
请谨慎选择。在大模型提示词中美化 JSON 会浪费 token,并让模型上下文变得杂乱。在电子邮件中使用紧凑 JSON 则难以阅读。
JSON.stringify 和 JSON.parse:该在哪里使用
JSON.stringify 和 JSON.parse 常见于表达式中,使用它们都没有问题。关键原则:序列化和解析是存储层操作,而不是接口层操作。
- 当写入的存储列无法原生保存该类型时,才进行序列化。 典型情况:Data Tables 中以
_object结尾的字符串列,实际存放的是数组或对象。参见n8n-data-tables-official。 - 从该存储列读回数据时,再进行解析。 在拥有该存储的工作流内部进行。
- 不要让序列化后的形式跨越边界。 子工作流返回值、Webhook 响应、Agent 工具结果、下游使用方都应接收自然类型(数组仍为数组,对象仍为对象),而不是需要调用方记得执行
JSON.parse的序列化外壳。
常见失误:子工作流有一条“新鲜”路径(刚由大模型生成的数据,已经是数组),还有一条“缓存”路径(刚从 _object 列读取的数据,仍是字符串)。错误的做法是将新鲜路径序列化,“匹配”缓存路径。正确的做法是解析缓存路径,这样两个分支在输出时就会生成相同的自然类型。
存储表示应留在拥有该存储的工作流内部。在边界之外,应使用自然类型。n8n-subworkflows-official 的 SKILL.md“返回自然类型,而非存储类型”从子工作流的角度介绍了这一点,n8n-data-tables-official 则从存储的角度进行了介绍。
返回正确的类型:何时用 ={{ ... }} 包装
如果不告知 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-official。
如何选择:表达式、Edit Fields,还是 Code 节点?
根据 n8n-code-nodes-official 的决策树:
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。机密信息请使用凭据系统 |