第一次用n8n搭一个数据清洗工作流时,我差点在表达式这一关直接弃坑:明明Webhook已经把一整包JSON丢进来了,结果到下一节点想取某个字段,不是undefined,就是Cannot read properties of null。后来我才明白,问题不在数据,而在我自己对n8n内置方法和变量作用域的认知几乎等于零。这篇就把我踩出来的这些东西系统整理一遍。适合两类人:一是正在用n8n写工作流、但每次碰到数据处理都在表达式和代码节点之间反复横跳的;二是刚入坑、想弄清楚$json、$node、$env到底怎么用、为什么别人写的一行表达式能顶我好几个节点的。
n8n教程:掌握内置方法与变量,轻松驾驭数据处理
1. 数据在n8n里到底怎么流动:先理解节点、item与数据类型
1.1 每个节点其实都是一台小型数据处理器
很多人把n8n的工作流想成"一条线串好几个工具",但更准确的理解是:每个节点都是一台独立的数据处理器。你从Webhook节点收到一段JSON,它会把这段JSON变成输出;下一个节点拿到这份输出,做一次变换,再变成新的JSON传给下下个节点。每次"传递"发生时,数据都会被完整地复制一份出来,而不是像指针一样原地修改。这一点非常重要,因为你在A节点改了某个字段,完全不影响B节点收到的原始数据——除非你显式地把修改后的字段写回输出。
n8n里的数据基本单位是item(条目)。默认情况下,你的工作流可能会一次处理多条数据。比如你用HTTP Request节点拉了一个列表接口,返回100条记录,那这一节点的输出就是100个item;后面连接的节点默认会依次处理这100个item,每一条都走一遍整个流程。数据处理的核心,就是搞清楚当前节点到底同时面对多少个item,以及你希望操作的是单条还是批量。
1.2$json与$node的区别:一个管当前,一个管全局
这是n8n新手最容易混淆的两个内置变量。
$json表示当前正在处理的这条item的完整JSON对象。在表达式里写{{ $json.email }},取到的就是当前这条数据的email字段。它的特点是"跟着当前数据走":如果工作流在一个循环里跑,每轮循环中$json的内容都会变,表达式会随着当前item重新计算。
$node则提供的是"图上任意节点的输出访问"。写法是{{ $node["某个节点名"].json.字段名 }},或者简写为$node.某个节点名.json.字段名。它等同于你去查看那个节点最后一次执行后留下来的数据。这个访问方式在你做跨节点数据合并时极其有用,因为上游节点的数据可能不会自动往后传,全靠你手动用$node把它捞回来。
还有一个关键区别:$json只在它所在的表达式求值的那一瞬间有效;而$node即使在链路末尾也能回看起点Webhook接收到的原始报文。所以我经常跟人开玩笑说,$json是"眼前的数据",$node是"备忘录里的数据"。
1.3 数据类型藏着的大坑:字符串和数字从来不会自动替你转换
数据处理翻车的第二大头,是类型不匹配。n8n表达式底层跑的是JavaScript,所以你必须对JavaScript的类型转换心里有数。JSON里的数字是数字,字符串是字符串。如果你用{{ $json.age + 1 }},而age被Webhook解析成了字符串"30",你得到的不是31,而是字符串"301"。反过来,如果你在一个拼字符串的场景里忘了把数字转成字符串,可能输出又是一堆莫名其妙的结果。
我建议你在做关键字段的清洗前,先花10秒钟确认类型。想快速看的话,在表达式编辑器里试一下{{ typeof $json.age }},返回值能直接告诉你它是string还是number。数据类型决定了你后续所有内置方法能不能正常调用——"hello".trim()没问题,但42.trim()会直接报错。
2. 内置方法不是越多越好:按数据处理场景挑工具
2.1 把内置方法当成"表达式里的瑞士军刀"
n8n的表达式远比很多人以为的强。它不只是取字段、拼字符串,它支持绝大多数JavaScript原生方法,比如字符串的trim()、toLowerCase()、split()、replace(),数组的map()、filter()、find(),JSON.parse(),Math.round()等等。再加上n8n自己封装好的内置方法和变量,比如日期时间的$now、$today,查询用的$jmespath(),你几乎能在表达式里完成70%的常见数据变换。
但"能"和"该"是两回事。我的经验是:表达式适合做字段级的小变换,比如把用户输入的邮箱统一转小写、给手机号做格式校验、在多个字段之间拼接出一个完整名称。一旦你开始写超过一行的嵌套逻辑,表达式就会变得难读、难调试,这时候应该考虑用可视化节点或代码节点。
2.2 常用内置方法和节点选型对照表
我按数据处理中最高频的几个场景整理了一张表,帮你快速决定某个任务该用表达式、可视化节点还是代码节点:
| 处理场景 | 推荐方式 | 示例/说明 |
|---|---|---|
| 字符串清洗(去除空格、统一大小写) | 表达式字符串方法 | {{ $json.name.trim().toLowerCase() }} |
| 字段重命名/新增字段 | Edit Fields(新版)或Set节点 | 可视化配置,最直观 |
| 按条件过滤数据 | Filter节点或IF节点 | 可视化配置条件即可,不必写代码 |
| 数组拆成多条item | Split Out节点 | 把一个数组字段拆成多个独立item |
| 多条item合并成数组 | Aggregate节点 | 适用于把分散数据聚合成一个列表 |
| 日期时间格式化 | DateTime节点 | 可以指定时区和目标格式 |
| 复杂查询/深层过滤 | $jmespath()表达式 | 适合在JSON里做条件查询 |
| 多步逻辑、循环处理、异常捕获 | Code节点 | 写JavaScript,拥有完整编程能力 |
这张表的核心思路是:能可视化配置的就别硬写表达式,能写短表达式的就别开代码节点。这不是说谁更厉害,而是为了让自己三个月后还能一眼看懂工作流在干什么。
2.3 为什么复杂处理要果断上Code节点
我见过不少人在表达式里硬堆三四个嵌套方法,结果运行报错后根本分不清是哪一层出了问题。比如你想把一个数组字段里的每个元素都取出来做个聚合,表达式写法很容易绕晕,而换一个Code节点,逻辑会清晰得多。Code节点的输入输出结构是:接收items数组,返回新的items数组。一个非常典型的JavaScript写法是:
return items.map((item) => { const d = item.json; return { json: { fullName: `${d.firstName} ${d.lastName}`.trim(), ageNumber: Number(d.age), emailLower: (d.email || '').toLowerCase(), }, }; });这样做的优势不只是可读性,还在于你可以自由地加console.log做调试、写try...catch做异常处理,这是表达式很难替代的。判断标准很简单:如果你发现自己在表达式里写了超过两次.map()或.filter(),或者开始怀疑"这段逻辑我下周还能看懂吗",那就转Code节点。
3. 变量的三种级别与重跑陷阱:搞清作用域才能不丢数据
3.1 节点级、工作流级、环境级:变量的分层管理
n8n里的"变量"其实分散在三层作用域里,很多人只用过第一层,导致数据流经常断。
节点级变量就是$json以及节点输出中你能访问到的字段。它的生命周期只存在于当前工作流的执行过程中。当前item被处理完,后续节点再往回看$node的时候,看到的也仅仅是那个节点最后输出的快照,不是实时变更。
工作流级变量是我自己习惯的叫法,指的是你通过Set节点、Edit Fields节点或Code节点主动保存的在节点间传递的中间数据。比如你可以在工作流起点放一个Code节点,把一些全局配置项(比如默认时区、默认货币符号)塞进输出,后面所有节点用$node["配置节点"].json来引用。这样数据就能"背着走",不需要在每个节点里重复写死。
环境级变量对应的是$env。你可以在n8n的配置文件或环境变量里定义好API密钥、数据库连接串、业务开关等,然后在任意节点用{{ $env.DATABASE_URL }}引用。好处很明显:工作流本身是干净的,可复用的,换环境你只要改配置,不需要改每个表达式的硬编码值。
这三个级别各有各的适用场景。我见过有人把所有密码直接写进表达式里,虽然能跑通,但工作流一旦导出给团队其他人用,就是个巨大的安全隐患。数据处理的"数据"可不仅仅指业务数据,还包括你工作流里的敏感配置。
3.2 变量赋值的正确姿势:Set节点与Code节点怎么选
如果你想新增、修改或者重命名字段,n8n老版本里常用Set节点,新版本里更推荐Edit Fields节点。它们的本质是对当前item的JSON做一层"可变映射",你可以选择字段是静态值还是从表达式动态计算。
实操中我强烈建议把"赋值"和"计算"分开看待。单字段、低逻辑的赋值,用Edit Fields节点;一旦涉及循环、条件判断、临时变量,请直接放到Code节点里。因为Code节点的变量是真正的JavaScript变量,你可以写const tempVar = ...,可以让它在代码块内部自由流转,不会污染后续节点。这样从职责上划分,工作流更清晰:可视化节点负责"看得见的字段调整",Code节点负责"看不见的逻辑计算"。
3.3 重跑工作流时,变量会怎样影响结果
数据处理场景经常会碰到工作流重跑,比如你调接口失败后手动点了"Execute node"或者重跑了整个工作流。这里有个关键认知:$node引用的是那个节点最近一次执行留下的输出。如果你在重跑时跳过了某个节点,或者某个节点执行出错没产出数据,后面引用它字段的表达式就会变成undefined或者直接报错。
我在实际调试中遇到过非常典型的场景:工作流第一步用Webhook接收数据,第二步我用Code节点把数据清洗后存到一个临时变量,第三步引用了$node["清洗节点"].json.xxx。后来因为测试方便,我单独重跑了第二步,结果第三步引用的数据就变成了新执行的输出,整个结果和线上完全对不上。所以我的建议是:在生产工作流里尽量保持整体执行,不要频繁单独重跑中间节点;如果确实需要调试,记得最终以完整执行为准。
另外一个值得注意的点是,n8n的执行数据面板里会记录每次执行时每个节点的状态。你在看"为什么这个变量是这个值"时,不要只盯着表达式本身,要去点开上游节点,看它实际输出里的字段名和结构。很多时候不是表达式写错了,而是上游节点输出结构的字段命名跟你预期不一样。
4. 实战演练:用一个Webhook清洗工作流把前面知识串起来
4.1 场景设定:杂乱无章的报名数据
假设我们做活动报名系统,前端表单通过Webhook把报名记录发到n8n。原始数据长这样:
[ { "name": " 张三 ", "contact_email": "ZHANGSAN@EXAMPLE.COM", "age": "28", "note": "", "source": " 官网-北京 " }, { "name": "李四", "contact_email": null, "age": 42, "note": "老朋友介绍", "source": "老用户 转介绍" } ]问题很明显:姓名和来源字段有多余空格,邮箱大小写不统一,年龄有的是字符串有的是数字并有人超范围,note字段可能为空,source里混进了无意义的"-"分隔符。我们最终希望输出一份干净的数据写入数据库:姓名去除首尾空格、邮箱统一小写、年龄转为数字且限定在18-60之间、note为空时填默认值、source按规则拆成渠道和地区两个字段。
4.2 第一步:用Edit Fields节点做字段级标准化
我先放一个Edit Fields节点,把明显能通过表达式一行搞定的字段处理掉。以邮箱为例,表达式是:
{{ ($json.contact_email || '').toString().trim().toLowerCase() }}这里有一个关键细节:contact_email可能为null,所以先用|| ''把它兜底成空字符串,再调用字符串方法,这样不会报错。年龄字段,我直接用{{ Number($json.age) }}把它转成数字。
姓名和note字段类似,姓名用{{ $json.name.trim() }},note用{{ ($json.note || '无备注').trim() }}。这一步做完,工作流里已经有一部分字段被清洗干净了,但它还只是"字段级处理",解决不了过滤和拆分的问题。
4.3 第二步:用Code节点做逻辑拆分与过滤
审核年龄范围和拆分source字段属于逻辑判断,我宁可放在Code节点里一次讲清楚。我把上一节点的输出接进Code节点,核心代码大致是这样:
return items.map((item) => { const d = item.json; const age = Number(d.age); if (!d.emailLower || !d.emailLower.includes('@')) { return null; // 邮箱为空或格式不对,直接丢弃 } if (age < 18 || age > 60) { return null; // 年龄不在范围内,丢弃 } const [channel, region] = (d.source || '') .split('-') .map(s => s.trim()); return { json: { name: d.name, email: d.emailLower, age: age, note: d.note, channel: channel || '未知渠道', region: region || '未知地区', sourceRaw: d.source, }, }; }).filter(item => item !== null);这段代码把"判断并返回新对象"集中在一个地方,比在表达式里拆成五六个节点要直观得多。而且我把sourceRaw原始字段也保留下来,方便后续审计或排查问题时看到底是哪条原始数据产生了问题。这里我的经验是:清洗工作流里尽量保留一份原始快照,哪怕之后写入数据库时不用它,排查问题时它就是你的救命稻草。
4.4 第三步:与数据库节点对接的变量映射
清洗完成后的数据,后面接一个PostgreSQL或Google Sheets节点。以PostgreSQL为例,你需要把字段映射到数据库表的列。这个映射就能直接用内置变量$json了,每一条传入数据库节点的item都对应一个$json里的字段。
如果你希望把多条数据批量写入,注意数据库节点一般有两种操作模式:逐条插入和批量插入。逐条插入适合数据量小、每条都可能独立失败的场景;批量插入性能更好,但一条失败可能影响整批。数据处理场景里我一般选择逐条插入,配合下文的错误处理更稳。映射时记得核对好类型,数据库里的整数列千万别传字符串进去。
4.5 第四步:加上错误处理链路
清洗数据不是只有"成功"这条路,实际跑起来基本每天都能遇到奇怪的数据。我会在Code节点后面接一个If节点或分支判断,用表达式检查清洗结果是否为空数组。如果为空,直接把一条"今日无有效数据"的通知发到群聊;如果非空,才继续进入数据库写入。同时,所有节点统一接一个Error Trigger(错误触发)分支,遇到异常时把错误信息连同原始数据打包发到Telegram或企业微信群,方便第一时间处理。
这样整条链路的最终意义不是"展示一个能跑通的小demo",而是告诉你:数据处理流程一定包含校验、清洗、过滤、容错、审计这五个部分,缺任何一个,线上都会还给你一个不定时炸弹。
5. 表达式返回undefined时,我是怎么一步步定位的
5.1 先看"执行数据"面板,别靠猜
遇到表达式返回undefined,第一反应不要是"再改改看",而是打开对应节点下方的"执行输出"面板,把上游节点实际产出的JSON结构完整看一遍。我会重点关注三件事:字段名跟表达式里写的一不一样、字段值是不是null、字段类型是不是我预期的。80%的"undefined"都死于这三件事。
比如我在真实项目里遇到过,Webhook返回的字段叫contact.email,但我表达式里写的是$json.contact.email,看着没问题吧?结果Webhook实际返回的结构是{ "contact": { "email": ... } },这里没问题,但如果你写$json["contact.email"],那取到的就是空——因为字段名包含点号,你必须用中括号加引号的方式访问。这就是"看结构"和"想当然"之间的差距。
5.2 善用表达式编辑器的实时预览
n8n的表达式输入框通常自带一个实时预览区。你把表达式写下去,下方会立刻显示在当前item上计算出的值。学会利用这个小窗口,能省掉大量执行整个工作流的时间。我在测试新字段时,会很自然地在预览区打一行{{ JSON.stringify($json) }},先把整个对象打印出来,看清楚全貌再逐步取字段。
这里有个小技巧:如果你在表达式中使用JSON.stringify,要注意输出的可能是一个字符串,预览区里会带引号包裹。这是正常的,不代表字段值是字符串。同理,如果你要对数组做长度判断,可以试试{{ $json.arr.length }},但要确保arr真的是数组,而不是对象或字符串。
5.3 常见类型转换失败与兜底写法
我列几个出现频率最高的类型问题,以及我的应对写法:
- 字段可能为null:用
($json.field || 默认值)做兜底,但注意0和false也会被||忽略,所以如果明确要保留0和false,就得用($json.field ?? 默认值)。 - 需要数字判断:先
Number($json.age)再比较,别直接拿字符串和数字比。 - 需要字符串拼接:用模板字符串
${a}${b},JS会自动把变量转成字符串,比+更直观。 - 需要数组查找:先确认字段是数组并用
Array.isArray()校验,再调用.find()或.includes()。 - 需要日期比较:统一用DateTime节点先格式化,再比较字符串或时间戳,别在表达式里直接做时间戳加减。
5.4 我的调试铁律:先打印、后处理、再联调
最后分享我Debug数据工作流时自己反复用的一条流程。第一,在每个清洗节点后面临时放一个Code节点,里面只写一行console.log(JSON.stringify(items));,把输出打印到日志里。第二,先单独执行这个节点,确认输入输出都对,再往后接后续节点。第三,全部节点联调时,从执行数据的"时间线"视角看每个节点的耗时和输出大小,这一步有时能意外发现某些节点因为数据量太大被拖慢的问题。
等你确认某段表达式逻辑完全稳定,再把这些临时调试节点删掉,替换成正式的字段映射。这个习惯帮我少踩了至少一半的无谓的坑。数据处理的本质是"减少不确定性",而调试方法论的核心,恰恰也是先消除变量、逐步缩小范围,把问题锁死在最小闭环里。