第08章 - 表达式与数据驱动样式
8.1 表达式是什么
样式里的属性值有两种写法:常量("#ff0000"、4)和表达式(形如 ["get", "name"] 的数组)。表达式是 JSON 数组,第一个元素是运算符名,后面是参数。它能读取当前 zoom、要素属性、要素状态,据此算出最终值。
规范里一共定义了 88 个表达式运算符,按用途分 15 组。这份清单是写样式时的”词汇表”,值得整体过一遍:
| 分组 | 运算符 |
|---|---|
| 变量绑定 | let、var |
| 类型 | literal、semiliteral、array、typeof、string、number、boolean、object、collator、format、image、number-format、to-string、to-number、to-boolean、to-color |
| 取值 | at、in、index-of、slice、global-state、get、has、length |
| 条件 | case、match、coalesce、==、!=、>、<、>=、<=、all、any、!、within |
| 插值/阶梯 | step、interpolate、interpolate-hcl、interpolate-lab |
| 数学 | +、-、*、/、%、^、sqrt、log10、ln、log2、sin、cos、tan、asin、acos、atan、min、max、round、abs、ceil、floor、distance、ln2、pi、e |
| 颜色 | to-rgba、rgb、rgba |
| 要素数据 | properties、feature-state、geometry-type、id、line-progress、accumulated |
| 缩放 | zoom |
| 热力 | heatmap-density |
| 高程 | elevation |
| 字符串 | is-supported-script、upcase、downcase、concat、resolved-locale、split、join |
表达式不是到处都能用。规范给每个属性标了 expression.parameters,写明允许的输入:
| 属性 | 允许的参数 | 含义 |
|---|---|---|
line-color |
["zoom", "feature", "feature-state"] |
相机、要素属性、要素状态都能读 |
text-size |
["zoom", "feature"] |
只能读相机与要素属性,不能读 feature-state |
background-color |
常量 | 完全不支持表达式 |
filter |
["zoom", "feature"] |
不支持 feature-state,zoom 只在整数级求值 |
写之前查这张表,比写完之后看控制台快。
8.2 三类求值时机
同一段表达式,可能在不同的时机求值,这决定了它的性能与可用输入:
常量表达式 在样式解析时算一次 → 直接变成 uniform
(literal / 纯常量)
相机表达式 每帧按当前 zoom/pitch 算 → 也是 uniform,但每帧更新
(含 ["zoom"])
源/复合表达式 在 worker 解析瓦片时逐要素写入顶点属性 → 随要素数增长
(含 ["get"] / ["feature-state"])
这个分类对应源码里 properties.ts 的 PropertyValue 与 PossiblyEvaluatedPropertyValue:kind 为 constant 或 camera 时可以在主线程算完直接设 uniform,为 source 或 composite 时才需要落到顶点数据。第 3 章说过”常量走 uniform、数据驱动走顶点属性”,这里补上判据:表达式里有没有 get/feature-state。
// 相机表达式:每帧算一次,成本可忽略
'line-width': ['interpolate', ['linear'], ['zoom'], 5, 1, 12, 4, 18, 12]
// 源表达式:为每条线的每个顶点写宽度,成本与要素数线性相关
'line-width': ['interpolate', ['linear'], ['get', 'lanes'], 1, 1, 8, 8]
8.3 缩放插值:interpolate 与 step
interpolate 在两个 stop 之间线性(或指数)插值,是最常用的相机表达式:
'line-width': [
'interpolate',
['linear'], // 插值方式:linear / exponential / cubic-bezier
['zoom'],
8, 1, // zoom=8 时 1px
12, 3, // zoom=12 时 3px
16, 8 // zoom=16 时 8px
]
step 是分段常量,在区间内不变、跨过 stop 直接跳变:
'circle-radius': [
'step',
['get', 'value'],
4, // 默认值(小于第一个 stop)
10, 6, // value >= 10 → 6
50, 10, // value >= 50 → 10
100, 16 // value >= 100 → 16
]
两者的选择标准很直接:需要在缩放过程中保持视觉连续用 interpolate;分类显示(道路等级、数量档位)用 step,它在语义上更准确,也更容易被读者理解。
interpolate-hcl 与 interpolate-lab 是色彩空间感知的插值,用于色带。默认的 RGB 线性插值在蓝到黄之间会经过灰,interpolate-hcl 不会:
'fill-color': [
'interpolate-hcl', ['linear'], ['get', 'density'],
0, '#f7fbff',
5000, '#6baed6',
20000, '#08306b'
]
指数插值要显式给 base:['exponential', 1.5] 表示 1.5 次方;['linear'] 等价于 base=1。
8.4 条件分支:case、match、coalesce
case 是 if/else 链,match 是 switch,coalesce 取第一个非空值。三者覆盖了绝大多数分类需求。
// case:按属性条件给颜色
'fill-color': [
'case',
['==', ['get', 'type'], 'residential'], '#f0f0f0',
['==', ['get', 'type'], 'commercial'], '#fdd49e',
['boolean', ['get', 'isPark'], false], '#c7e9c0',
'#d9d9d9' // 默认
]
// match:等值分派,比 case 更短
'line-color': [
'match', ['get', 'class'],
'motorway', '#e4572e',
'primary', '#f3a712',
'secondary', '#f0c808',
'#9e9e9e'
]
// coalesce:第一个非 null 的值
'text-field': ['coalesce', ['get', 'name:zh'], ['get', 'name'], '']
case 的分支是顺序求值的,命中的第一个立即返回,所以把高频条件放前面能省一点计算。match 的最后一个参数是默认值,不写会返回 null,可能导致属性值非法。
8.5 要素数据:get、has、geometry-type、id
// get:读属性,第二个参数是默认值
['get', 'height', 0]
// has:判断属性是否存在(比 get 之后再判空更省)
['has', 'name']
// geometry-type:point / line / polygon
['==', ['geometry-type'], 'Polygon']
// id:要素 id,需要 source 配 promoteId 或 generateId
['==', ['id'], 42]
// properties:整个属性对象,配合 in / length 用
['in', 'name', ['properties']]
geometry-type 只返回 Point、LineString、Polygon 三种基础类型,Multi 几何会被归并到基础类型。历史上有过把它改成返回 MultiPolygon 的尝试,因为破坏大量样式被回滚,现在不要指望它区分单/多几何。
id 依赖稳定 id。矢量瓦片要写 promoteId,GeoJSON 用 generateId: true 或 promoteId。没有 id 时 ["id"] 返回 null。
8.6 变量绑定:let / var
复杂表达式里同一个值会重复出现,用 let 绑定可以少算几遍,也让样式更可读:
'circle-radius': [
'let',
'density', ['get', 'density'],
'radius', [
'interpolate', ['linear'], ['var', 'density'],
0, 3,
1000, 8,
5000, 16
],
'radius'
]
let 的最后一个参数是结果表达式,前面是成对的”变量名、值”。变量只在当前表达式内可见,不能跨属性共享。
8.7 字符串与 format
字符串相关运算符里,最有用的是 concat、upcase/downcase、split/join,以及 format。
format 用于富文本标注:同一段文字里可以有不同字号、颜色、字体。它返回 formatted 类型,专门给 text-field 用:
'text-field': [
'format',
['get', 'name'],
{'font-scale': 0.9},
'\n',
{},
['get', 'name_en'],
{'font-scale': 0.7, 'text-color': '#666'}
]
format 的参数是”内容、样式选项”交替出现。样式选项支持 font-scale、text-font、text-color、vertical-align 等。它不能和 to-string 之类的转换混用在同一层级。
number-format 用于数字格式化,例如给统计值加千分位:
'text-field': [
'number-format', ['get', 'population'],
{'locale': 'zh-Hans', 'style': 'decimal', 'maximumFractionDigits': 0}
]
8.8 feature-state 与 global-state
这两个运算符都读”运行时状态”,不是要素属性:
// feature-state:单要素状态,需先 setFeatureState
'line-color': [
'case',
['boolean', ['feature-state', 'hover'], false], '#ff9f1c',
'#4a4a4a'
]
map.setFeatureState({source: 'roads', id: 12}, {hover: true});
global-state 读的是样式级状态,5.6 引入,浏览器与原生端都支持:
// 样式里声明默认值
const style = {
version: 8,
state: {
showLabels: {default: true},
theme: {default: 'light'}
},
sources: {},
layers: [
{
id: 'labels',
type: 'symbol',
source: 'poi',
layout: {
'text-field': ['get', 'name'],
// 用 global-state 控制是否显示
'visibility': ['case', ['global-state', 'showLabels'], 'visible', 'none']
}
}
]
};
// 运行时改状态,不用重建图层
map.setGlobalStateProperty('showLabels', false);
global-state 的好处是把”主题、开关”这类状态从图层重建里解放出来。6.14 起还可以在创建 Map 时通过 globalState 选项覆盖样式里的默认值。
注意 feature-state 只能在允许它的属性里用,而且需要 setFeatureState 先写入。它不走缓存重建,但每次变更会触发受影响瓦片的重绘,高频变更(如逐帧改)要谨慎。
8.9 常见错误与排查
Expected color but found number 一类的类型错误。 表达式返回类型与属性要求不一致。用 to-color、to-number 显式转换,或检查 get 的字段是不是字符串。
表达式整段不生效,属性回落到默认值。 常见于 match 少写默认值返回 null、case 全部不命中、get 的属性名拼错。先在浏览器里把样式打印出来,用 console.log(JSON.stringify(style.layers[i].paint)) 确认表达式没被序列化破坏。
控制台出现 legacy expression 警告。 style-spec 25(对应 v6)起,遇到旧式函数写法会产生 warning 级别的校验错误而不是静默忽略。清掉这些警告,否则未来大版本可能直接报错。
filter 里用了 feature-state。 规范明确不支持。改用图层属性 + setFilter,或拆出高亮图层用 paint 里的 feature-state。
数据驱动属性让内存暴涨。 每个要素顶点都要存一份属性值。要素超过十万时,把连续取值改成分档 step,或者把计算前移到数据生产端。
数字被当成字符串。 GeoJSON 属性里的数字如果是字符串,interpolate 会静默失败或得出错误结果。用 ['to-number', ['get', 'value']] 兜底。
// 排查:把一个图层的 paint 表达式打印出来,确认结构没被破坏
const layer = map.getStyle().layers.find((l) => l.id === 'district-fill');
console.log(JSON.stringify(layer.paint, null, 2));
// 排查:用 queryRenderedFeatures 看属性值的真实类型
map.queryRenderedFeatures({layers: ['district-fill']}).slice(0, 3).forEach((f) => {
console.log(f.properties.density, typeof f.properties.density);
});
8.10 生产建议
表达式写进配置而不是散落在代码里。 把常用的插值、分档封装成函数返回表达式数组,样式构建时可读性和一致性都会好很多。
优先 step 而不是多层 case。 step 是专门为分档设计的,渲染器能识别它的结构做优化;case 链是逐条求值。
色带用 hcl 插值。 连续色带(人口密度、温度)用 RGB 线性插值会出现灰带,interpolate-hcl 的成本差别可以忽略。
少在表达式里做重计算。 distance、within 这类几何运算在数据驱动场景下会逐要素执行,量大了明显吃 CPU。能预计算就预计算。
给所有 match/case 写默认分支。 漏写默认值返回 null,属性非法时错误信息往往指向别处,很难查。
升级时盯 style-spec 版本。 maplibre-gl 的依赖是 @maplibre/maplibre-gl-style-spec ^26.x,大版本变化会带来校验行为变化(例如 legacy expression 从静默变为告警)。
8.11 本章小结
| 主题 | 结论 |
|---|---|
| 语法 | JSON 数组,首元素是运算符;共 88 个运算符,分 15 组 |
| 求值时机 | 常量、相机(每帧)、源/复合(逐要素写顶点)三类,判据是有没有 get/feature-state |
| 插值 | interpolate 连续,step 分档,色带用 interpolate-hcl |
| 条件 | case 顺序命中、match 等值分派、coalesce 取首个非空 |
| 要素数据 | get/has/geometry-type/id;geometry-type 不区分 Multi |
| 运行时状态 | feature-state 需稳定 id 且属性允许;global-state 读样式级状态(5.6+) |
| 富文本 | format 生成 formatted 类型,专供 text-field |
| 约束 | filter 不支持 feature-state 且 zoom 只在整数级;属性可用参数见 expression.parameters |