df —— 取数流(.df)
读者:做工具的用户,和替他干活的 AI。两者读同一份。
它是什么
.df 是一个组件或一个页面取数据的那一步:打网络、抽字段、算派生量,最后把要给渲染层的键透出去。它只做取数与加工,没有 UI、没有导航、没有用户参与——因为它跑在渲染链路上,可能发生在用户看不见的时候(桌面小组件定时刷新、仪表盘首帧)。
需要用户手指参与的动作(弹确认、打开页面、写回参数、震一下)属于另一种文件 .af。两者是同一套引擎的两个能力档:
.df 取数流 |
.af 交互流 |
|
|---|---|---|
| 用在哪 | .xwidget 的 canvas.depends、XPage 节点的 depends、横幅 flow |
events.*(点组件、点节点、表单提交) |
| 能用什么 | 白名单子集(见下) | 全部,含 ui.* / nav.* / widget.* |
| 触发者 | 平台(定时、首帧、下拉) | 用户 |
写错文件类型的症状很具体:H5 里用 runDataFlow 调一个 .af,App 里回 -6 flow not found。.af 的写法与 action 全表见 numable docs af。
最小可用示例
一条真的会打网络、会失败、会透出的流:
{
"version": 1,
"actions": [
{
"id": "resp",
"action": "request",
"params": {
"url": "https://hn.algolia.com/api/v1/search?tags=front_page",
"method": "GET",
"formatType": "json",
"timeout": "8000"
}
},
{
"op": "set",
"props": { "key": "_b", "value": "1" },
"_note": "屏障:request/concurrent 之后紧邻的节点读不到结果,隔一个空 set 再用"
},
{
"op": "if",
"props": { "val": "$[if::(gt::(length::(${resp.hits}),0),0,1)]" },
"items": [ { "action": "error", "params": { "errorMsg": "取不到榜单" } } ],
"_note": "主干字段取不到 → 让流失败,平台回落上一次成功的数据;绝不渲一份空数据上去"
},
{ "op": "set", "props": { "key": "t0", "value": "${resp.hits[0].title}" } },
{ "op": "set", "props": { "key": "p0", "value": "${resp.hits[0].points}" } },
{ "op": "set", "props": { "key": "id0", "value": "${resp.hits[0].objectID}" } },
{ "op": "set", "props": { "key": "at", "value": "$[formatDate::(${@time.nowMs},HH:mm)]" } },
{ "op": "set", "props": { "key": "hasT0", "value": "$[if::(gt::(length::(${t0}),0),1,0)]" } },
{ "action": "resultFilter", "params": { "keys": ["t0", "p0", "id0", "at", "hasT0"] } }
]
}
跑它:
numable run <包> --flow top1 # 名字 = 组件名(.xwidget 文件名),页面流用文件名(如 rates)或 page/rates
怎么写
文件结构
顶层两个必填键:{ "version": 1, "actions": [ … ] }。可选 i18n(本文件自己的文案表,与 .af 同形,正文用 ${@i18n.key} 取,见下「按语言取数与出文案」)。任何位置都可以加 _note 字符串作注释,下划线开头的键运行时一律忽略。
步骤的三种形状
| 形状 | 写法 | 说明 |
|---|---|---|
| action 步 | { "id": "resp", "action": "request", "params": {…} } |
干活的一步。id = 它的结果键 |
| operation 步 | { "op": "set", "props": { "key": "x", "value": … } } |
控制流与赋值,走 op 键,子节点放 items |
| 复合步 | { "action": "concurrent", "items": [ … ] } |
写在 action 位,不是 op 位 |
concurrent / sequential 写成 {"op": "concurrent"} 会被整块静默跳过 —— 现象是那一段 3 毫秒就「成功」了、字段全空。
并发怎么写
两条互不依赖的请求并排跑,总耗时按最慢的那条算:
{ "action": "concurrent", "items": [
{ "id": "a", "action": "request", "params": { "url": "https://api.example.com/a", "formatType": "json" } },
{ "id": "b", "action": "request", "params": { "url": "https://api.example.com/b", "formatType": "json" } } ] }
紧接着放一个屏障,再开始用 ${a.…} / ${b.…}:
{ "op": "set", "props": { "key": "_b", "value": "1" } }
- 并发分支的
params在排队那一刻就用当时的数据算完了。所以分支之间互相看不见,同一批里排在前面的op:set写的键也看不见——这就是屏障那条规矩的由来(串行则是每步现算)。 - 任一分支失败会中止其余兄弟分支。允许挂掉的支路别放进来,单独串行跑、判空后走兜底。
- 复合步带
id时,各分支结果整包塞进那个键(${组名.a.字段});不带id才平铺进公共数据域(${a.字段})。两种写法下游取值路径不同,别混。 policy(wait/skip/detach)只对 action 步与复合步有效,写在op:*步上不生效。
id 就是结果键
带 id 的一步跑完,结果以 id 为键写进数据域,后面用 ${id.字段} 取。两条规矩:
- 没写
id= 结果丢弃。 - 结果是 null 也丢弃 —— 那个键根本不会出现在数据域里,下游
${k}恒空且不报错。所以data.get一定要写default(哪怕是{}),否则缓存没命中时整条下游全空。
能用的 action(白名单,超出即失败)
| 类 | action |
|---|---|
| 网络与解析 | request · clearCookie · htmlParse · xmlParse |
| 本包存储 | data.get · data.set · data.remove · data.has · data.keys · data.getAll · data.merge · data.clear |
| 流程 | cancel · error · finish · sleep · log · resultFilter |
| 复合 | sequential · concurrent(写在 action 位) |
operation(走 op 键) |
if · for · forEach · set · remove · include |
明确禁止:toast / showLoading / hideLoading,以及全部 App 注入 action(ui.* / nav.* / xpage.* / widget.* / installBundle)。这些只能出现在 .af 里,numable check 会拦(E)。
clearCookie的pattern是拿去匹配 domain 的正则(不是 cookie 名),大小写不敏感:{"action":"clearCookie","params":{"pattern":"\\.example\\.com$"}}。op:include别用:它在取数流与交互流里都恒展开成零个节点,那一段什么都不做,流照报成功。要复用公共步骤,只有两条路——把那几步复制过去,或者拆成独立.df、由多条depends分别绑定。
表达式里的方法($[calc::(…)] 这类)是另一张表,全表见 numable docs methods。写方法名前先查那张表:未注册的方法一律静默求空,流照样报成功,组件上那个符号后面的数字就没了。
request 的全部参数
| 参数 | 类型 | 说明 |
|---|---|---|
url |
string | 必填。可以插 ${}。host 必须在 manifest.network 里 |
method |
string | GET(缺省)/ POST / PUT / PATCH / DELETE |
queryParams |
object | 问号后面的参数,写成 {"page":"1"},不用自己拼串和转义。每个值都写成字符串,数字也加引号(check G52) |
header |
object | 自定义请求头,值一律字符串(check G52)。别把密钥写这儿,走 credential |
formData |
array | multipart 表单上传,只在 method 是 POST 时生效。填了它就别再填 body |
body |
string | 请求体,只吃字符串。要发 JSON 就在这里写 JSON 串 |
formatType |
string | string(缺省,拿原文)/ json / xml / base64(拿字节的 base64 串)/ tsv / csv(表格转对象数组)。写了这六个以外的值不会报错,而是「先试 JSON、再试 XML、都不成给原文」—— 大小写不敏感("JSON" 照样命中),但拼成 "text" 这类不认识的值就走那条猜测链 —— 多半也能跑出结果,代价是你失去了「这个接口到底给的是不是 JSON」这条判据,见下 |
columns |
array | 只对 tsv / csv 生效:只保留这几列(按表头名),顺序照你写的;表头里没有的列输出空串。也接受逗号分隔的字符串。不写、写空数组或空串 = 保留全部列 |
credential |
string | 凭证声明 id,字面量,见下 |
timeout |
string | 毫秒,写成字符串 "8000",常用 5000–10000。不写就一直等,慢接口会拖住整个组件的首帧。写成数字在 iOS 与网页上照跑、在鸿蒙上整条请求不发(check G52) |
GET:参数走 queryParams
{ "id": "resp", "action": "request", "params": {
"url": "https://api.example.com/v1/quote",
"method": "GET",
"queryParams": { "symbol": "${sym}", "range": "1d" },
"formatType": "json",
"timeout": "8000" } }
POST:body 与 formData 二选一
{ "id": "resp", "action": "request", "params": {
"url": "https://api.example.com/graphql",
"method": "POST",
"header": { "Content-Type": "application/json" },
"body": "{\"query\":\"{ viewer { login } }\"}",
"formatType": "json",
"timeout": "8000" } }
formData只有method是POST时才会被拼进请求。PUT/PATCH带着它,那一段被静默丢掉、请求按body发(没写body就是空请求体),对方回 400 而流本身一切正常。- 写了
body又没在header里给Content-Type时,按application/json; charset=utf-8发。键名大小写敏感:写成content-type不算数,仍按 JSON 发。 - 重定向会自动跟随,但每一跳的 host 都要在
manifest.network里 —— 跳出白名单的那一跳直接被拒(凭证也不会跟过去)。GitHub 的仓库改名跳转这类同域重定向不用额外声明。numable run/render同样逐跳校验:跳出去时该请求按network_blocked:redirect_escaped:<host>失败,日志写明「X 重定向到 Y,Y 不在 network 里」。最省事的修法是直接请求跳转后的地址。 - 请求体里要拼变量,先用
op:set把值落地成顶层键再拼串。GraphQL 尤其如此:查询串里的$变量与表达式的${}混在一行极易看走眼,把值直接拼进查询串最省事。
formatType 的六个值
| 值 | 拿到什么 | 什么时候用 |
|---|---|---|
string(缺省) |
原文字符串 | 接着交给 htmlParse / xmlParse,或自己 split:: 切 |
json |
解析后的对象/数组,${resp.a.b[0].c} 直接取 |
接口返回 JSON |
xml |
{ 根标签: {…} }:属性挂成 @属性名 键、节点文本挂成 value 键、同名兄弟自动变数组 |
结构浅的 XML |
base64 |
对原始字节做 base64 得到的字符串 | 取二进制(图片字节)用,不是给文本用 |
tsv / csv |
对象数组:首行是表头,每行一个对象,值一律是字符串 | 接口返回制表符 / 逗号分隔的表格(报表下载这类) |
formatType: "json"解析失败返回 null——不是原文、也不报错,于是${resp.…}全空。分不清是接口坏了还是路径写错了,先改成string看原文。- 响应体为空时,
string/json/xml/base64的结果都是 null;而 null 结果不写进数据域,resp这个键根本不存在。tsv/csv例外:空响应体和只有表头都给[]。 - 写了这六个以外的值,会走「先按 JSON、不行再按 XML、再不行给原文」的兜底链,形状不稳定,别依赖。
- RSS 这类嵌套深的 XML,比
formatType: "xml"更稳的是string+htmlParse或split:::严格 XML 解析遇到一处不闭合就整份失败,而 HTML 解析容错。 - 响应是 gzip 压缩的(字节以
1F 8B开头)会先自动解压,再按formatType解析,六个值都一样。解压后超过 4MB,或压缩数据损坏,这次请求算失败,错误信息里带body_too_large/decode_failed—— 不会给你一个空数组冒充「今天是 0」。 tsv/csv的细节:按 UTF-8 解码、去掉开头的 BOM;空行跳过;表头每格去掉首尾空白,数据格原样;某行比表头短的补空串、长的丢掉多余的。csv认双引号包裹(引号里可以有逗号和换行、""表示一个引号),tsv不处理引号。数字要算就交给groupSum/parseNumber这些方法,见numable docs methods。- 报表列多、你只用几列时一定写
columns:几十列只留几列,数据直接小到几分之一,桌面小组件里尤其要紧。 - 用了
tsv/csv(或mapField/groupSum/convertSum)的包,manifest.minEngine要写"3.0.0",老版本 App 不认它们、也不报错(G51)。
凭证引用
{ "id": "me", "action": "request", "params": {
"url": "https://api.github.com/user", "formatType": "json", "credential": "gh" } }
credential 的值必须是 manifest.credentials 里声明过的 id 字面量,不能是 ${} 表达式 —— 动态值在运行时会被静默透传成匿名请求,而你看到的是 401 或空数据。声明格式见 numable docs layout,本机测试的密钥放 .numable/params/_credentials.json。
流里永远不出现密钥本身:注入发生在 App 的网络边界。也不要把凭证或它的派生物放进 resultFilter —— 透出去就进了渲染缓存和分享截图;要让 RCN 知道「绑没绑」,透一个 hasToken 旗标。
抓网页:htmlParse / xmlParse
接口拿不到就抓页面。先用 formatType: "string" 取原文,再交给 htmlParse:
{ "id": "page", "action": "htmlParse", "params": {
"content": "${resp}",
"rules": [
{ "key": "title", "selector": "h1.site-title", "defaultValue": "" },
{ "key": "posts", "selector": "ul.list > li", "isArray": "true",
"items": [
{ "key": "name", "selector": "a.t" },
{ "key": "url", "selector": "a.t", "extractFrom": "href", "prefix": "https://example.com" },
{ "key": "hot", "selector": "span.n", "regex": "(\\d+)", "defaultValue": "0" }
] } ] } }
rules 是数组,不是对象——写成 {"title": "h1"} 这样的对象,一个 key 都取不出来,而且不报错。每一项就是一个字段:
| 字段 | 必填 | 说明 |
|---|---|---|
key |
是 | 结果里的字段名。没有 key 的规则整条被跳过 |
selector |
是 | 以 /、./、( 开头当 XPath,其余一律当 CSS 选择器 |
isArray |
"true" = 命中的节点全要,结果是数组;缺省只取第一个 |
|
extractFrom |
text(缺省,取文本并去空白)/ html(取内部 HTML)/ 其它任意值都当属性名(href、src、data-id) |
|
regex |
对取到的串再筛一次,要第 1 个捕获组(没写捕获组就是整个匹配);匹配不上 = 这个值算空 | |
prefix / suffix |
拼在取到的值前后,补相对链接的域名用 | |
defaultValue |
没命中节点、或值为空时用它 | |
items |
子规则数组,把每个命中节点再拆成对象。写了 items,同一条规则上的 extractFrom / regex / prefix / suffix 全部不生效 |
三个坑:
- 不带
isArray的规则取不到值时,那个 key 根本不写进结果(不是空串)。下游${page.title}恒空且不报错——每条这样的规则都给defaultValue,或者按上面第 3 条立hasX旗标。 - 选择器跑在原始 HTML 上。页面靠脚本渲出来的内容抓不到,现象是选择器看着完全对、结果恒空;此时去看那个页面的接口。
xmlParse的rules与htmlParse同形,另有一个encoding(只有解析出乱码时才填)。
结构化 op:set
{ "op": "set", "props": { "key": "rows", "value": ["${v1}", "${v2}", "${v3}"] } }
value 可以是数组或对象字面量,结构保留、叶子逐个求值。要交给 sum:: / max:: 的数组必须这样造 —— 把 [...] 写进方法参数串里不成立,那个统计会静默消失。
op:if 与两种循环
{ "op": "if", "props": { "val": "$[eq::(${fresh},0)]" }, "items": [ … ] }
op:if 的条件键是 props.val。写成 cond 不会报错:条件恒假、分支体静默不执行、流照样报成功。
op:if 只用来分派动作,不要用它算值:分支里 op:set 出来的键出了分支就取不到。算值用表达式里的嵌套 if::。
固定次数用 op:for:
{ "op": "for", "props": { "count": "5", "index": "i" },
"items": [ { "op": "set", "props": { "key": "y_${i}", "value": "$[calc::(48+${i}*48)]" } } ] }
遍历集合用 op:forEach:
{ "op": "forEach", "props": { "items": "${resp.list}", "key": "it", "index": "i" },
"items": [ { "op": "set", "props": { "key": "n_${i}", "value": "${it.name}" } } ] }
op:for |
op:forEach |
|
|---|---|---|
| 跑几轮 | count 轮(取整;≤0 一轮都不跑) |
items 的元素个数 |
| 循环变量 | 只有序号 | key = 当前元素,index = 序号 |
| 序号变量名 | index,不写时叫 __index |
同左 |
forEach的items给数组按顺序走;给对象按 key 字典序取值;给单个字符串/数字会当成只有一个元素;给空(null / 没这个键)一轮都不跑,并且不报错。循环变量出了循环就没了:
${i}/${it}只在items里有效,循环后面读到的是空,不是最后一轮的值。循环体里
op:set写的是流的公共数据域,不是每轮各一份:同一个key每轮覆盖,循环完只剩最后一轮。要留下每一轮就把序号写进键名(上面示例的y_${i})。键名里的.会被当成路径分层,别在键里随便用点。⚠️ 但每轮的第一个节点晚一拍 —— 它读到的是「进入循环那一刻」的快照,不是上一轮写完的值。 从第二个节点起才读得到实时值。这跟
request/concurrent/data.get后面要补屏障是同一件事,只是这里的触发条件是循环本身。什么时候会咬你:循环体里读一个「跨轮累积」的键时。最典型的就是「只挑第一个满足条件的元素」——立一个
pickHas旗标,在条件里用eq::(${pickHas},0)守卫:{ "op": "forEach", "props": { "items": "${resp.list}", "key": "it", "index": "i" }, "items": [ { "op": "set", "props": { "key": "_lb", "value": "1" } }, { "op": "set", "props": { "key": "sel", "value": "$[if::(and::(eq::(${pickHas},0),…),1,0)]" } }, { "op": "set", "props": { "key": "pick", "value": "$[if::(eq::(${sel},1),${it.date},${pick})]" } }, { "op": "set", "props": { "key": "pickHas", "value": "$[if::(eq::(${sel},1),1,${pickHas})]" } } ] }第一行那个
_lb就是循环屏障:值写什么都行,它的作用只是把「读快照」的那一拍消耗掉,让后面的节点读到实时的pickHas。少了它,pickHas在每轮的第一个节点眼里恒为进循环前的 0,守卫完全失效 —— 挑出来的是最后一个满足条件的,不是第一个。为什么必须记住这条:它不报错。流报
success,键数与resultFilter完全对得上,check0 error,run全绿,组件上是一个看着完全正常的错误数字。三层验证一层都抓不到,只有你恰好知道正确答案时才会发现。(2026-09-10 实测复现:同一条流,摘掉屏障 → 从「中秋节·15 天」变成「国庆节·21 天」,两次都报✓ 1 通过 / 0 失败。)
resultFilter 决定渲染层看得见什么
{ "action": "resultFilter", "params": { "keys": ["t0", "p0", "id0", "at", "hasT0"] } }
只有 keys 里的键会交给 RCN / XPage。漏一个,组件上那格就空、且不报错(闸 G28 会拿 RCN 里的 ${} 跟这份 keys 对一遍)。两条附带纪律:
- 给组件用的派生量一律提成顶层标量。
${resp.hits[0].objectID}这种路径在onClick的导航串里插不进去,先在流里落成id0。页面用的流不受这条限制:H5 页拿到的是整个结果对象,XPage 用forEach直接遍历数组,keys里透出一个对象数组(如items)就行。 - 业务键避开
lang/theme这两个名字:渲染层在流没透出它们时会补上当前语言与明暗值,于是那一格显示的是en-US/dark而不是空,查起来像「数据串台了」。
data.*:本包持久化
读写本包私有的沙盒(用户的自选列表、上次选中项、首屏缓存),跨启动保留。八个 action 各一行:
{ "id": "saved", "action": "data.get", "params": { "key": "cities", "default": [ { "name": "上海" } ] } },
{ "id": "hasCity", "action": "data.has", "params": { "key": "cities" } },
{ "id": "allKeys", "action": "data.keys", "params": {} },
{ "id": "all", "action": "data.getAll", "params": {} },
{ "action": "data.set", "params": { "key": "cityIdx", "value": "2" } },
{ "action": "data.merge", "params": { "key": "prefs", "value": { "unit": "c", "sort": "hot" } } },
{ "action": "data.remove", "params": { "key": "c.wx.31_121" } },
{ "action": "data.clear", "params": {} }
| action | 参数 | 结果 |
|---|---|---|
data.get |
key · default |
存的值;没存过时给 default |
data.set |
key · value |
回显刚写进去的值 |
data.has |
key |
true / false |
data.keys |
— | 本包全部键名的数组 |
data.getAll |
— | 本包全部键值的对象 |
data.merge |
key · value(对象) |
无结果 |
data.remove |
key |
无结果 |
data.clear |
— | 无结果 |
要点:
- 没有
scope参数,命名空间恒为本包;写了scope会被静默忽略。 data.get一定要给default(见「id 就是结果键」)。data.merge是浅合并:value的顶层键覆盖同名旧键,更深的层整块换掉。value不是对象(写成数组、字符串、数字)时按空对象处理——等于什么都没干,而且不报错。那个键原来存的不是对象时,直接被这个对象顶掉。- 无结果的那三个(
merge/remove/clear)带了id也拿不到键,别拿它当「成功了没有」的判据;要确认写没写进去,下一步data.get回读。 key会被强转成字符串;不写key= 空串键(合法,但没人读得到)。data.clear清空本包全部数据、不可撤销,别拿它清缓存——清缓存用data.remove点名那个键。- 本机测试用
.numable/params/_datastore.json预置初始内容。
首屏缓存(页面用)与 ${_cache} 开关
页面消费的 .df 应该做三态,否则每次进页面都白等一轮网络:
- 有缓存且新鲜 → 直接用,连接都不建立;
- 有缓存但过期(stale)→ 也先渲出来,同时透一个
_stale旗标让页面后台校新; - 没缓存 / 太旧 → 老实取数。
{ "op": "set", "props": { "key": "cacheIn", "value": "${_cache}" } },
{ "op": "set", "props": { "key": "useCache", "value": "$[parseNumber::(${cacheIn},0)]" } },
{ "op": "set", "props": { "key": "ckey", "value": "$[connect::(c.wx.,${lat},_,${lon})]" } },
{ "id": "hit", "action": "data.get", "params": { "key": "${ckey}", "default": {} } }
三条硬要求:
- 缓存必须由入参
${_cache}开关控制、默认关。页面的 depends 传"_cache": 1,组件什么都不传 → 0 → 不缓存。组件的刷新意图就是「跳过缓存直取」,数据层再压一层缓存等于整条刷新链空转。同一份.df页面和组件共用,不要为此复制一份。 - 缓存键不能含下标。下标随增删漂移,漂移后会把 A 对象的缓存渲给 B,而且不报错。用对象的稳定标识(坐标、代码、id),键加个
c.前缀便于清理。 - 带缓存的页面必须能刷得动:XPage 的 root 要有
events.onRefresh,里面先data.remove掉缓存键再xpage.reloadPage。少这一步,下拉只会又命中缓存,「刷了等于没刷」且日志干净。
必守的六条
- 屏障:
request/concurrent/data.get之后紧邻的那一步读不到结果(晚一拍),循环体的第一个节点同样如此(它读的是进循环那一刻的快照)。中间插一个{"op":"set","props":{"key":"_b","value":"1"}}再用。也不要在op:if的嵌套域里读并发分支的 id —— 求值域对不上,取到的是空。 - 取数失败要让流失败。主干字段取不到就走
error,平台会回落上一次成功的数据;报成功再渲一份空的,会把好数据覆盖掉。判据钉在「少了它这个组件就没意义」的那个字段上;「集合为空」是成功,可选支路挂了不抛。 - 判空一律立显式旗标:
hasX = $[if::(gt::(length::(${原始串}),0),1,0)],渲染层只看hasX。不要用eq::判空 —— 它两边能转数字就按数字比,eq::("00","")和eq::(空,0)都为真。length::只钉在原始字符串字段上,对自己算出来的数字它恒为 0。 - 方法参数里的
${}看不见 flow 入参,只看得见流里已落地的键(带id的 action 结果、op:set的键)。所以入参进流的第一件事是落地:{"op":"set","props":{"key":"k","value":"$[findNotEmpty::(${pair},btcusd)]"}}。 op:set引用的键必须排在它前面。引擎按顺序跑、没有依赖图,顺序错了就是分支恒落 else,而代码看着全对。- 时间锚要防空。实时数字得让人知道是什么时候的数;
formatDate::喂空串会渲成 1970 年。先兜哨兵:$[if::(eq::(findNotEmpty::(${ts},__none__),__none__),,formatDate::(${ts},HH:mm))]。
按语言取数与出文案
.df 里可以直接读 ${@app.language}(也可读 @device.language / @time.locale),按语言取数是合法且期望的写法(新闻标题、城市名、商店描述)。切换语言时,仪表盘会对每个包做一次 widget.refresh(清取数计时 + 真取数),按语言取的数据会跟着换。
.df 与 .af 完全同构:.df 也可以有自己的顶层 i18n 表({ locale: { key: 文案 } } 扁平表),正文写 ${@i18n.key},取数流入参里注入的是宿主页表 ⊕ 本文件表(加 @app)。所以「和昨天差不多」「今日在高低区间的 59%」这类成品文案可以直接在数据层出:
{
"version": 1,
"actions": [
{ "id": "resp", "action": "request", "params": { "url": "https://api.example.com/today?lang=${@app.language}", "formatType": "json" } },
{ "op": "set", "props": { "key": "_b", "value": "1" } },
{ "op": "set", "props": { "key": "delta", "value": "${resp.delta}" } },
{ "op": "set", "props": { "key": "summary", "value": "$[if::(gt::(${delta},0),${@i18n.up},${@i18n.flat})]" } },
{ "action": "resultFilter", "params": { "keys": ["delta", "summary"] } }
],
"i18n": {
"zh-CN": { "up": "比昨天高", "flat": "和昨天差不多" },
"en-US": { "up": "Higher than yesterday", "flat": "About the same as yesterday" }
}
}
一句话记住:任何 flow 文件都可以有顶层 i18n 表,文案写 ${@i18n.key},语言分支读 ${@app.language}。「今天」这类纯文案放取数层还是渲染层由你自决 —— 渲染层拼则同一份数据在两门语言下可复用,取数层出则渲染层少一次拼接。numable check 对 .df 的表做与 .af 相同的检查(G8:引用的 key 必须在必需门里、各门 key 集一致、无未使用 key)。
数据缓存的键含语言:切完语言,每个组件在新语言下没有落盘数据 —— 先出骨架,取到再显示;离线切语言就没有该语言的数据可显示(走空态 / 错误态)。这是有意取舍。主题(明暗)不同:它是纯渲染参数,切主题只重渲、不取数,数据缓存也不分明暗。文案表的完整规则见 numable docs i18n。
规则(违反 = 返工)
| 规则 | 检查方式 | 违反时的现象 | 修法 |
|---|---|---|---|
组件的 .df 里有 request 或 concurrent 就必须有 error 出口 |
check G26 |
取数失败时流报成功,空数据覆盖掉好数据 | 主干字段判空 → error |
concurrent 之后紧邻的节点不得引用并发分支的 id |
check G4b |
那些字段全空,流仍成功 | 插一个 op:set _b 屏障 |
op:if 的条件键必须是 props.val |
check G30 |
条件恒假、分支静默不执行、流仍成功 | 改成 props.val |
parseDate 的 pattern 去掉 token 后不能剩字母 |
check G4c |
手机上整条解析失败返 null,只有 render 出的图正常 |
先 subString:: 切掉 T/Z 再解析 |
request.credential 必须是已声明的字面量 declId |
check G18b |
运行时静默降级成匿名请求,拿到 401 或空数据 | 写 manifest.credentials 里的 id |
请求的 host 与 manifest.network 恰好相等 |
check G3 |
真机被网络守卫静默拦掉,或安装面板列出用不到的域名 | 不多不少 |
表达式里不得双单位后缀(14.0ptpt)、$[…] 不得再嵌 $[ |
check G28 |
整个组件渲不出来且不报错 | 内层写裸方法名 if::(…) |
RCN 用到的 ${x} 必须在某条 .df 的 resultFilter keys 里 |
check G28 |
那一格渲空或恒走兜底 | 补进 keys |
页面消费的 .df 有 request 就该有 data.get 缓存 |
check --profile publish G23 |
每次进页面白等一轮网络 | 做三态;刻意不做就写 _lintCache: "exempt" 加 _note 说明理由 |
组件消费的 .df,缓存写回必须受 ${_cache} 控制且默认关 |
check --profile publish G23 |
组件的刷新链整条空转,数据永远停在上次 | 页面传 _cache:1,.xwidget 不传 |
| 缓存写回的值不得含凭证;缓存键不得含下标 | check --profile publish G23 |
密钥进缓存;A 的数据渲给 B | 只透旗标;键用稳定标识 |
.df 里引用的 ${@i18n.key} 必须在本文件(或宿主页)表的必需门里存在 |
check G8 |
那一句文案缺一段,不报错 | 补 key;刻意为空写 i18nEmptyOk |
| 未注册的方法名一个都不用 | run 层(那个键静默求空) | 组件上「+%」这类符号还在、数字没了 | 先查 numable docs methods |
concurrent / sequential 写在 action 位 |
run 层(那段 3ms 就成功、字段全空) | 整块静默跳过 | 改成 "action": "concurrent" |
sleep 的参数名是 timestamp,值写成字符串 "1500" |
run 层(根本不等)· check G52(写成数字) |
节流/退避没生效;写成数字时鸿蒙上这一步不执行 | 改名;数字加引号 |
htmlParse / xmlParse 的 rules 写成对象 |
run 层(结果对象是空的) | 一个字段都取不出来,流仍成功 | 改成数组 [{"key":…,"selector":…}] |
formData 只在 POST 下写 |
run 层(对方 400) | 表单没发出去、流没有异常 | 换 POST,或改用 body |
不用 op:include |
人审 | 那一段一个节点都不跑,流仍成功 | 复制那几步,或拆成独立 .df |
| 并发分支里不放「允许失败」的支路 | run 层(其它分支一起没结果) | 一条挂掉,整组都没数据 | 把它挪出 concurrent 串行跑 |
出错怎么办
| 现象 | 最可能的原因 | 先做什么 |
|---|---|---|
run 报成功但所有字段空 |
取值路径写错一层 | numable run <包> --full 看真实响应形状 |
紧跟 request 的那个键是 null |
结果晚一拍 | 插屏障 op:set |
| 循环「只挑第一个」挑成了最后一个 | 每轮第一个节点读进循环前的快照,守卫旗标恒为初值 | 循环体首行插屏障 op:set _lb |
| 某个统计键凭空消失 | 方法名不存在,或把 [...] 写进了方法参数串 |
查方法表;数组用结构化 set 造 |
| 分支怎么都不执行,流却成功 | op:if 写了 cond |
改 props.val |
组件上出现假的 0、0 条、1970-01-01 |
空值被聚合/格式化成了合法值 | 先判有无再算;时间锚套哨兵 |
只有某个平台整组件 -- |
xmlParse 各平台引擎不同;或 parseDate 的 pattern 带字面量 |
改 formatType:"string" + split::;pattern 只留纯 token |
| 页面下拉刷新数字纹丝不动 | 又命中了缓存 | onRefresh 里先 data.remove 缓存键再 reloadPage;键要与流里写回的逐字一致 |
| 组件刷新了但数据是旧的 | 组件的 .df 里缓存没受 ${_cache} 控制 |
加开关,默认关 |
| 请求 401 / 拿到匿名数据 | credential 没声明或写成了表达式 |
对照 manifest.credentials |
htmlParse 结果是空对象 |
rules 写成了对象,或选择器只在脚本渲染后才成立 |
改成数组;看那个页面的接口 |
formatType: "json" 之后整个 resp 不存在 |
对方返回的不是合法 JSON,解析失败给了 null | 先改 string 打印原文 |
相关
numable docs methods · numable docs builtins · numable docs af