mcpField
N
n8n-expressions-official

n8n-expressions-official

github.com/n8n-io开发5582.0k 次安装2 周前更新
代码仓库

关于 n8n-expressions-official

面向 n8n 工作流作者的表达式编写指南,介绍 JavaScript 表达式、跨节点引用、日期处理和数据整形,并强调稳定引用与避免冗余节点。

主要功能

  1. 01按节点名称引用数据
  2. 02用 Luxon 处理日期
  3. 03以内联表达式减少节点
  4. 04用 JavaScript 方法处理数组
  5. 05编写多行逻辑并添加注释

适用场景

  1. 01从上游节点读取确定字段
  2. 02在字段中计算日期或格式
  3. 03过滤和汇总节点输出
  4. 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。机密信息请使用凭据系统

评论

支持 Markdown