我见过太多把 OpenLayers 的view.animate()当成“跳转函数”用的代码:从一个 demo 里复制下来,换两个坐标,动画确实也能跑,但总觉得手感不对——要么“嗖”一下冲太快,要么慢吞吞像网络卡了。问题基本不出在时长上,十有八九在easing参数被忽略了。这篇内容面向 Vue3 + OpenLayers 项目,我把 easing 动画函数彻底讲透:它到底是什么、OpenLayers 内置了哪几个、怎么在View.animate()里正确使用,以及在 Vue3 组件里怎么封装成真正能落地、可取消、可等待的动画能力。无论你是第一次接触地图动画,还是已经写了很多flyTo,这几节内容都值得过一遍。
1. 为什么一条 0 到 1 的进度曲线,决定了地图动画的“手感”
1.1 先看一个没有 easing 的动画:为什么它显得“愣”
很多初学者对动画的理解停留在“从 A 点移动到 B 点,这段时间里匀速插值”。对应到 OpenLayers 就是:
view.animate({ center: [12000000, 3000000], zoom: 13, duration: 1000 });如果你不传easing,OpenLayers 默认会使用 easeOut,所以上面这段其实不会匀速。但如果强行传了linear,效果就是整个 1000 毫秒里地图以恒定速度平移缩放,然后在最后一帧突然停住。
这种动画为什么“愣”?生活里几乎没有东西是匀速移动然后瞬间刹停的。汽车到站要减速,电梯启动和停止都要缓冲,物理世界里的运动天然有加速度。地图动画如果完全线性,人眼的视觉预期会被瞬间打破,于是你会觉得“这不自然”。反而是在动画开头快一点、接近目标时慢下来,最后轻轻贴上目标点,眼睛看起来很舒服。
1.2 OpenLayers 的默认值就用 easeOut,很多人不知道
大多数人在写view.animate()时根本不传 easing,然后发现效果“好像还行”,但不知道为什么还行。其实 OpenLayers 源码里把默认 easing 设置成了 easeOut,也就是“先快后慢”的缓动:
view.animate({ center: target, zoom: 15, duration: 1200, // easing 缺省时,内部按 easeOut 处理 });这个默认值选得很讲究。地图动画最常见的场景是“飞到某个点”:用户希望迅速起手,让地图尽快动起来,给即时反馈;同时不希望到目标后撞得戛然而止,所以结尾需要减速缓冲。easeOut 完全符合这个体验曲线。
理解了这一点,你再看别人写的flyTo函数,会发现很多实现其实依赖了默认值——代码里没写easing,但行为已经是缓动的了。
1.3 “性格开关”再往深说一步
如果把动画拆开看,两个变量决定了最终观感:
- duration:控制总时长,也就是“这个动画持续多久”。
- easing:控制速度分布,也就是“每一帧动画跑到什么程度”。
duration 是等分时间,easing 是重排速度。同一个duration: 1000,配上不同的 easing,用户感知到的是完全不同的动画性格:
| easing | 用户感知 |
|---|---|
| linear | 匀速巡航,机械感,适合不需要强调落点的场景 |
| easeIn | 慢起快冲,像起步迟疑后突然加速,地图较少用 |
| easeOut | 快起慢收,直觉上的“飞过去”,最常用 |
| easeInOut | 慢起快跑慢停,两端都柔和,适合旋转和转场 |
所以不要把 easing 当成一个可有可无的参数,它直接决定你项目的动效质感。下面几节,我们来抠View.animate()的具体行为。
2. View.animate() 的参数拆解:一份可以直接照抄的 options 清单
2.1 核心参数速查表
View.animate()接受一个或多个“动画选项”对象。每个选项对象里可以组合以下字段:
| 参数 | 类型 | 含义与注意点 |
|---|---|---|
| center | Coordinate | 目标中心点坐标,如[lng, lat],动画期间逐分量线性插值 |
| resolution | number | 目标分辨率,内部实际按 resolution 做插值 |
| zoom | number | 目标缩放级别,内部会先转成 resolution 再插值 |
| rotation | number | 目标旋转弧度,内部会自动按最短角度方向旋转 |
| duration | number | 动画时长(毫秒),缺省时走内部默认时长 |
| easing | Function | 缓动函数,输入 0 到 1 的时间进度,输出插值系数 |
| callback | Function | 动画结束或取消时回调,参数finished为 true 表示正常播完 |
一个容易踩的坑是 zoom 和 resolution 的关系。View.animate()内部真正用来插值的量是 resolution,zoom 只是方便你写的语法糖。如果你在同一个 options 里同时传了zoom和resolution,源码会以 resolution 为准,zoom 被忽略。平时写业务时尽量二选一。
2.2 单相位与多相位:同一个 animate 里的两种玩法
先理解“单相位”:一个 options 对象里的多个属性是并行动画的,共享同一个 duration 和 easing。
// 中心和缩放同时变化,总共 1200ms view.animate({ center: [12000000, 4000000], zoom: 12, duration: 1200, easing: easeOut });这三个属性的动画起点是当前视图状态,终点是 options 里的目标值。动画过程中,每一帧都会根据同一个 easing 时间进度,分别更新 center 和 resolution。
再看“多相位”:如果你给animate()传了多个 options 对象,这些相位会依次执行,前一个播完才开始下一个。
view.animate( { zoom: 8, duration: 600, easing: easeIn }, { center: [12000000, 4000000], zoom: 12, duration: 1000, easing: easeOut }, { rotation: 0, duration: 400, easing: easeInOut } );这段代码的播放顺序是:先放大到 8,再一边移动中心一边放大到 12,最后归正旋转角。相位之间是明确的前后关系,非常适合做“先出画面、再飞过去、最后稳定视角”的转场。
2.3 callback 参数:动画结束的可靠信号
很多开发者不知道animate()的 options 里还能带 callback,于是用setTimeout猜动画时长——这是非常容易出问题的做法,尤其是多相位动画和用户打断动画的情况下。
view.animate({ center: target, zoom: 14, duration: 1000, easing: easeOut, callback: (finished) => { if (finished) { // 动画正常播完 console.log('动画完成'); } else { // 动画被取消 console.log('动画被打断'); } } });callback的好处是它能告诉你动画是否真的走到了终点。动画被中途取消时,finished是 false。这对很多业务逻辑很重要:比如飞行动画被打断后,不该继续弹出信息框。
3. easing 的数学本质与 OpenLayers 内置函数逐个验算
3.1 一行公式想清动画插值
不管用什么 easing,动画插值本质都是下面这个公式:
// progress 是时间进度,0 到 1 const progress = (now - startTime) / duration; // eased 是经过缓动函数映射后的插值系数 const eased = easing(progress); // 当前值 = 起点 + (终点 - 起点) * 插值系数 const currentValue = start + (target - start) * eased;easing 本质就是一个函数:把“时间进度”映射成“属性插值进度”。如果输入 0.5,输出 0.5,说明动画进行到一半时属性也正好走到一半,这就是 linear;如果输入 0.5,输出 0.8,说明时间过半时属性已经走了八成,视觉上就是“前半段特别快”,这是 easeOut 的特征。
3.2 内置四个基础函数:采样表与适用场景
OpenLayers 的ol/easing.js模块提供了几个现成缓动函数,使用方式:
import { easeIn, easeOut, easeInOut, linear } from 'ol/easing.js';它们的实现公式和“性格”如下:
- linear:
f(p) = p,匀速。 - easeIn:
f(p) = p^3,越来越快,前半段像在蓄力。 - easeOut:
f(p) = 1 - (1 - p)^3,先快后慢,最后趋于平稳。 - easeInOut:
0.5 前加速,0.5 后减速,两端都柔和。
下面这张表可以直观看出同一时间进度下,各函数输出值的差异:
| 时间进度 p | linear | easeIn | easeOut | easeInOut |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 0.2 | 0.2 | 0.008 | 0.488 | 0.104 |
| 0.5 | 0.5 | 0.125 | 0.875 | 0.5 |
| 0.8 | 0.8 | 0.512 | 0.992 | 0.896 |
| 1 | 1 | 1 | 1 | 1 |
看第 0.2 这一行:easeOut 已经跑到 0.488,接近一半,而 easeIn 才 0.008,几乎没动。这就是为什么 easeOut 适合“飞过去”——起手快,落点稳;而 easeIn 适合配合其他动画做“蓄力起跳”的感觉。
3.3 容易被忽略的 uplift:飞行效果的节奏秘密
除了上面四个,ol/easing.js里还有一个经常被忽略的uplift函数。它的公式:
export function uplift(p) { return p + (1 - p) * 0.5 * Math.sin(2 * Math.PI * p); }这个函数很有意思:它在动画前半段会让属性快速冲过中间值,然后略有回落,最后到达 1。用在 center 动画上,地图会沿着直线“冲过头一点再回来”;用在 zoom 动画上,会先快速拉近、中间稍远、最后稳定到目标级别,模拟一种“镜头抬起再落下”的飞行节奏。
不过要注意,uplift 的输出不是单调递增的,这意味着同一属性可能出现回退。如果你对某个 zoom 值做了 min/max 约束,回退部分可能被 clamp 掉,视觉上就只剩前段的冲劲。想验证效果,可以在本地做个简单 demo:同一个中心点、同一个 duration,分别用 easeOut 和 uplift 各跑一遍,差异非常明显。
4. 自定义 easing:手写一套属于项目的动效节奏
4.1 自定义函数必须满足的三个条件
OpenLayers 的easing参数不强制要求你从ol/easing.js里选,任何函数都能传进去,满足以下三点就很安全:
- 入参是 0 到 1 的时间进度。
- 返回值建议也落在 0 到 1 之间;如果超出范围,会产生“过冲”或“反弹”效果,但属性受 min/max 约束时会被截断。
easing(0)应该等于 0,easing(1)应该等于 1,否则动画起点或终点会突然跳变。
另外,easing 函数会被 OpenLayers 每帧调用,必须是一个纯函数,不能依赖外部可变状态。
4.2 两个可直接用的自定义函数
如果你希望地图到达目标时带一点“轻微过冲再回弹”的感觉,可以用 easeOutBack:
const easeOutBack = (p) => { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2); }; view.animate({ center: target, zoom: 14, duration: 1200, easing: easeOutBack });这个公式会让插值系数在接近终点时短暂超过 1。对 center 来说,中心点会“冲过目标一小截再弹回来”,视觉上很活泼。但对 zoom 来说,如果目标 zoom 已经接近你设置的最大缩放级数,超出的部分会被约束掉,你只会看到正常的快慢变化,看不出过冲。
如果你想要弹跳式的落地效果,可以用 easeOutElastic:
const easeOutElastic = (p) => { if (p === 0 || p === 1) return p; const c4 = (2 * Math.PI) / 3; return Math.pow(2, -10 * p) * Math.sin((p * 10 - 0.75) * c4) + 1; };这类函数输出在 0 到 1 之间反复震荡,适合强调“到达”的庆祝感场景。但生产环境慎用,动效太花哨反而容易让用户觉得不稳定。
4.3 进阶组合:把多个缓动拼接成阶段
有时候你想在同一个相位里让 zoom 用 easeOut、center 用 easeInOut——但单相位内多个属性共享同一个 easing,做不到。替代思路有两个:
- 把属性拆到不同相位,用链式动画实现:
view.animate( { zoom: 12, duration: 800, easing: easeOut }, { center: target, duration: 800, easing: easeInOut } );- 自己在业务层用 requestAnimationFrame 分别控制不同属性的 easing 函数。这种方案自由度高,但要处理 View 状态同步、动画取消和组件卸载,工作量大,一般只在特殊需求里才值得做。
5. Vue3 落地:把 View.animate 封装成可取消、可等待的动画能力
5.1 最大的 Vue3 陷阱:View 实例千万别被深度响应式代理
在 Vue3 里集成 OpenLayers,最容易踩的一个性能坑是把Map、View这类实例直接放进ref或reactive里。
Vue3 的ref会对对象做深度响应式代理,而 OpenLayers 的 View 在动画过程中每帧都在修改 center、resolution、rotation 等内部属性。这些属性如果被 Proxy 包裹,会触发大量的响应式更新计算,轻则白屏卡顿,重则报“Maximum recursive updates exceeded”。
正确做法是shallowRef加markRaw:
import { shallowRef, markRaw } from 'vue'; import View from 'ol/View.js'; const view = shallowRef(null); function createView() { view.value = markRaw(new View({ center: [0, 0], zoom: 2 })); }shallowRef只对.value这一层做响应式,markRaw明确告诉 Vue 不要代理这个对象,两层保护下来,动画性能不会受响应式系统拖累。
5.2 封装一个 useViewAnimation composable
我习惯把地图动画封装成一个 composable,统一处理“动画中状态、取消、防竞态”几个问题:
// composables/useViewAnimation.js import { ref, readonly, onBeforeUnmount } from 'vue'; export function useViewAnimation(viewRef) { const animating = ref(false); let token = 0; function animate(options) { const view = viewRef.value; if (!view) return; token += 1; const current = token; animating.value = true; view.animate({ ...options, callback: (finished) => { if (current === token) { animating.value = false; } } }); } function cancel() { const view = viewRef.value; if (!view) return; token += 1; animating.value = false; view.cancelAnimations(); } onBeforeUnmount(() => { cancel(); }); return { animating: readonly(animating), animate, cancel }; }这个 composable 解决了两件事:
- 连续多次调用
animate时,通过token让旧动画的 callback 不再影响状态,避免竞态。 - 组件卸载时自动调用
cancel(),把 OpenLayers 的动画循环停掉,防止内存泄漏。
在组件里使用:
import { shallowRef, markRaw, onMounted } from 'vue'; import Map from 'ol/Map.js'; import View from 'ol/View.js'; import TileLayer from 'ol/layer/Tile.js'; import XYZ from 'ol/source/XYZ.js'; import { easeOut } from 'ol/easing.js'; import { useViewAnimation } from '../composables/useViewAnimation'; const view = shallowRef(null); const { animating, animate, cancel } = useViewAnimation(view); onMounted(() => { view.value = markRaw(new View({ center: [0, 0], zoom: 2 })); new Map({ target: 'map-container', layers: [ new TileLayer({ source: new XYZ({ url: 'https://your-tile-server.example.com/{z}/{x}/{y}.png' }) }) ], view: view.value }); }); function flyTo(coord) { animate({ center: coord, zoom: 13, duration: 1200, easing: easeOut }); }地图源地址这里用了一个示例占位域名,实际项目替换成自己接入的瓦片服务地址即可。
5.3 把动画 Promise 化,用 async/await 编排业务
很多业务逻辑是“先飞到 A,然后弹窗;再飞到 B,然后请求数据”。这种场景最好把动画包成 Promise:
function animateTo(view, options) { return new Promise((resolve) => { view.animate({ ...options, callback: (finished) => resolve(finished) }); }); }配合 async/await 使用,业务代码非常顺:
async function flightFlow() { const finished = await animateTo(view.value, { center: targetA, zoom: 14, duration: 1200, easing: easeOut }); if (!finished) { // 动画被打断,不再继续业务 return; } // 动画正常结束,继续后续动作 window.openPanel(); }要注意:二次封装时如果依赖view.animate()的 callback 判断是否被打断,必须先接受一个事实——不同 OpenLayers 版本对cancelAnimations()的回调处理不完全一致。所以我在 composable 里用token单独管理状态,而不是只依赖 callback。
5.4 同步 View 状态到 Vue 响应式:动画结束后的坐标显示
动画跑完后,View 内部确实到了目标坐标,但这不会自动触发 Vue 组件的 UI 更新。如果你需要在界面上展示“当前中心点坐标”或“当前 zoom”,不能直接读view.getCenter()到模板里,因为那不是一个响应式 getter。
正确做法是监听 OpenLayers 自己的变化事件,同步到一个 ref:
const center = shallowRef(null); const zoom = shallowRef(null); let viewRef; function bindViewState(v) { viewRef = v; const sync = () => { center.value = v.getCenter().slice(); zoom.value = v.getZoom(); }; v.on('change:center', sync); v.on('change:resolution', sync); // 初始化同步一次 sync(); }有了这个同步,动画播放过程中坐标数字会实时变化,UI 可以做成一个类似“经纬度指示器”的组件。注意这里也用shallowRef存数组,避免深层代理。
6. 实战中容易翻车的四个场景及应对
6.1 用户拖地图打断动画
地图动画播放到一半,用户手动拖拽或滚轮缩放,这是最常见的打断场景。OpenLayers 不会默认中止动画,你可能会看到地图一边被用户拖走,一边还在被动画拉回目标点,观感非常糟糕。
解决办法是自己监听交互事件,主动取消动画:
const cleanup = () => cancel(); map.on('pointerdown', cleanup); map.on('wheel', cleanup);项目里我通常会把这几个取消事件和动画组件生命周期绑定在一起,避免散落各处。
6.2 连续触发相同目标动画
用户连续点击多个目标点时,如果不对旧动画做处理,新的animate()和旧的动画会叠加冲突。稳妥做法是每次开始新动画前先cancel(),再接新动画:
function flyTo(coord) { cancel(); animate({ center: coord, zoom: 13, duration: 1000, easing: easeOut }); }不要试图靠 OpenLayers 内部的合并逻辑,不同的相位组合、不同的属性会带来大量边界情况,自己控制时序才是最可靠的。
6.3 easing 过冲碰到 zoom 约束被 clamp
自定义 easing 输出大于 1 或小于 0 时,center 可以自由越界再回弹,视觉上是有趣的过冲效果;但 zoom 和 resolution 受 View 的 min/max 约束,越界部分会被强行截断,你期望的“回弹”就看不到了。
如果确实需要缩放有过冲感,可以把动画拆两步:先用 easeOutBack 飞到比目标高一点的 zoom,再用短时长 easeOut 落回目标。整体效果可控,还不受 clamp 影响。
6.4 zoom 和 resolution 同时传
前面提到过,View.animate()内部以 resolution 为插值基准。如果你同时传了zoom和resolution,可能会出现“缩放没按预期走”的怪异现象。写代码时保持清晰:要么用 zoom,要么用 resolution,同一个 options 里不要交叉。
6.5 组件卸载后动画还在跑
组件销毁后,如果动画循环还在跑,OpenLayers 会继续尝试更新 View,轻则白屏报错,重则内存泄漏。Vue3 组合式 API 的解决方案已经在 composable 里体现了:
onBeforeUnmount(() => { cancel(); });组件一旦卸载,立即调用view.cancelAnimations()停掉所有动画循环。这个习惯要养成,不是只有长时间动画才需要。
最后说一个我自己的习惯,也算给刚做地图动效的读者一个参考:项目里把所有动画节奏抽成一个常量配置,全局统一管理。
export const ANIMATION_PRESETS = { quick: { duration: 300, easing: easeOut }, fly: { duration: 1200, easing: easeOut }, rotate: { duration: 600, easing: easeInOut } };业务代码里调用时直接引用预设,不会出现“这个动画怎么 2 秒、那个怎么 300 毫秒”的玄学差异。easing 这东西说到底就是一条从 0 到 1 的曲线,你把它的输出值画出来、把它的性格想清楚,地图动画的质感立刻会上一个台阶。