第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