去年做一个内容社区项目,主页面是个卡片瀑布流。设计稿上有个效果:卡片随滚动缩放,滚到屏幕中央的那张最大,越往上下越小,视觉上像一排卡片在轨道上滑动。看着挺好看,实现起来也不复杂——监听 scroll-view 的 scroll 事件,每个卡片根据自己的位置算一个 scale 值。
结果上线测试的时候问题来了。低端 Android 机上一划就掉帧,帧率大概在 35 到 45 之间 fluctuates,能感受到明显的卡。iOS 和中高端 Android 倒还好。
排查了半天,最后定位到两点。第一,scroll 事件通过 setData 传到视图层,每次都要做一次跨线程通信。一秒钟十几帧,一秒钟十几次 setData,视图层忙不过来。第二,每个 card 的 scale 值都在 JS 线程里算,算完再一条条 setData 下去,JS 线程一旦忙着处理其他逻辑,动画也跟着卡。
当时想过很多方案。用 CSS 的 transform 加 scroll-timeline?小程序不支持。改成 CSS 动画?做不到滚动联动。用 canvas 自己画?工作量太大,交互也做不了。
后来同事提了一句:试试 Skyline 吧,worklet 就是为这种场景做的。折腾了两天,效果出乎意料。同样的代码结构,低端机上跑到了 55 帧以上,稳定不掉。
这篇文章把两天的踩坑记录整理一遍。内容集中在三块:怎么开启 Skyline、worklet 和 sharedValue 到底是什么、以及三个实际用到的案例。
一、Skyline 和 webview 到底差在哪
微信小程序默认的渲染方式是 webview。页面里的 DOM 结构会被翻译成 webview 里的 HTML,由浏览器的渲染引擎负责绘制。逻辑层(JS 线程)和渲染层(webview 线程)是分开的,两边通过 setData 通信。
这套架构已经用了很多年,稳定、兼容性好,缺点也很明确:所有和数据相关的动画都要走一遍跨线程通信。滚动事件从渲染线程报到 JS 线程,JS 算完结果再发回渲染线程,一来一回至少两帧的延迟。做静态页面没问题,做高频联动动画跟不上。
Skyline 的思路是把渲染引擎换掉。它不用 webview 了,自己实现了一套渲染管线,组件布局和绘制都在渲染线程完成。同时它引入了 worklet——一段可以直接在渲染线程执行的函数。滚动事件的响应、动画的插值、样式的更新,全都在渲染线程内部完成,不经过 JS 线程。
这就是性能差异的来源。不是说 Skyline 的渲染更快,而是它把”动画”这件事从跨线程通信变成了线程内计算,少了通信环节的延迟。
二、开启 Skyline 的步骤
开启分成两个层面:全局配置和页面级配置。
全局开关
在 manifest.json 的 mp-weixin 节点下加上这几项:
{
"mp-weixin": {
"appid": "你的appid",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true
},
"renderer": "skyline",
"componentFramework": "glass-easel",
"lazyCodeLoading": "requiredComponents",
"skylineRenderEnable": true
}
}
四个配置项的作用分别是:
renderer 指定渲染引擎,可选 webview 或者 skyline。componentFramework 必须配合改成 glass-easel,这是微信新的组件框架,旧框架和 Skyline 不兼容。lazyCodeLoading 开启按需注入,Skyline 下对这个配置有依赖。skylineRenderEnable 是 uni-app 特有的开关,会让编译器识别 worklet 相关的语法。
要注意 componentFramework 一旦改成 glass-easel,整个小程序的组件实现都换了一套。有一些第三方组件库在老框架上行为正常,换到新框架之后会出现样式偏差或者事件不响应,这个后面细讲。
页面级开关
全局打开之后,也可以让部分老页面继续用 webview 渲染。在 pages.json 里:
{
"pages": [
{
"path": "pages/index/index",
"style": {
"renderer": "skyline",
"componentFramework": "glass-easel",
"navigationStyle": "custom",
"disableScroll": true
}
},
{
"path": "pages/settings/index",
"style": {
"renderer": "webview"
}
}
]
}
开关是按页面生效的,加了 renderer: "skyline" 的页面走新引擎,没加的走老的。这个设计很实用——迁移不用一次性完成,可以从一两个页面开始试。
另外提一句,用 Skyline 的页面一般都建议加 disableScroll: true,把页面级的滚动关掉,由页面里的 scroll-view 自己处理。因为在 Skyline 下页面滚动和 viewport 的行为跟 webview 不太一样,交给 scroll-view 更可控。
三、worklet 和 sharedValue 是什么
这两个概念是 Skyline 的核心,绕不开。
worklet 是一段能在渲染线程跑的 JS
写法和普通函数一样,只是在函数体第一行加了一个指令:
function double(x) {
'worklet'
return x * 2
}
加了这行标记之后,编译器会把整个函数序列化成一段字节码,随页面一起发给渲染线程。等到需要执行的时候,渲染线程直接在本地跑它,不需要再和 JS 线程通信。
有个关键限制:worklet 函数里只能访问被显式标记为可在渲染线程访问的值。闭包捕获的外部变量会被”快照”一次——也就是说,你在 worklet 创建的时候看它是什么值,之后它在 JS 线程里怎么变,worklet 里还是拿旧值。
let counter = 0
const double = () => {
'worklet'
return counter * 2 // counter 是快照,永远是 0
}
counter = 10
// double() 在渲染线程执行时,读到的 counter 还是 0
要跨线程共享可变状态,得用 sharedValue。
sharedValue 是一个跨线程可读写的容器
// #ifdef MP-WEIXIN
const { shared } = wx.worklet
const progress = shared(0)
// #endif
通过 shared() 创建的值,在 JS 线程里用 progress.value 访问和修改。在 worklet 里也是 .value,但读到的永远是最新值。
修改 sharedValue 不会触发 setData,也不会引起页面重渲染。它只是一个共享的内存槽,谁读谁拿到当前值。真正驱动 UI 更新的是另一套机制——useAnimatedStyle。
useAnimatedStyle 把 sharedValue 映射到样式
// #ifdef MP-WEIXIN
const { shared, useAnimatedStyle, withSpring } = wx.worklet
const scale = shared(1)
const cardStyle = useAnimatedStyle(() => {
return {
transform: `scale(${scale.value})`,
}
})
// #endif
模板里把 cardStyle 绑到元素的 :style 上。之后只要 scale.value 变化,样式就会在渲染线程内自动应用,不经过 setData,也不走 JS 线程。
<view class="card" :style="cardStyle">
<text>卡片内容</text>
</view>
这三个东西连起来就是 Skyline 动画的最小闭环:用 sharedValue 存状态,用 worklet 算值,用 useAnimatedStyle 把值映射到样式。全过程都在渲染线程完成,没有一处 setData。
四、案例一:滚动缩放的卡片列表
把开头那个需求重写一遍。目标是滚动时每张卡片根据自己离屏幕中心的距离算一个缩放比例。
先写滚动容器:
<template>
<scroll-view
class="card-scroll"
scroll-y
:style="{ height: viewportHeight + 'px' }"
@scroll="onScroll"
>
<view
v-for="(item, index) in cards"
:key="item.id"
class="card-slot"
>
<ScaleCard
:title="item.title"
:index="index"
:scroll-y="scrollY"
:card-height="CARD_HEIGHT"
/>
</view>
</scroll-view>
</template>
<script setup>
import ScaleCard from '@/components/ScaleCard.vue'
const CARD_HEIGHT = 220
const cards = Array.from({ length: 30 }, (_, i) => ({
id: i,
title: `卡片 ${i + 1}`,
}))
// #ifdef MP-WEIXIN
const { shared } = wx.worklet
const scrollY = shared(0)
const onScroll = (e) => {
'worklet'
scrollY.value = e.detail.scrollTop
}
// #endif
</script>
注意 onScroll 是一个 worklet 函数。在 Skyline 下,@scroll 事件的回调可以直接标记成 worklet,事件数据在渲染线程处理,不经过 JS 线程。这是整个方案里最关键的优化点——原方案里 setData 的通信开销全在这里省掉了。
子组件负责把自己那部分缩放逻辑写清楚:
<!-- components/ScaleCard.vue -->
<template>
<view class="card" :style="cardStyle">
<text class="card-title">{{ title }}</text>
</view>
</template>
<script setup>
const props = defineProps({
title: String,
index: { type: Number, required: true },
scrollY: { type: Object, required: true },
cardHeight: { type: Number, required: true },
})
// #ifdef MP-WEIXIN
const { useAnimatedStyle, interpolate } = wx.worklet
const cardStyle = useAnimatedStyle(() => {
const slotTop = props.index * props.cardHeight
const center = props.scrollY.value + 375
const distance = Math.abs(center - (slotTop + props.cardHeight / 2))
const progress = Math.min(distance / (props.cardHeight * 2), 1)
return {
transform: `scale(${interpolate(progress, [0, 1], [1, 0.86])})`,
opacity: interpolate(progress, [0, 1], [1, 0.55]),
}
})
// #endif
</script>
这里假设了视口高度是 750rpx 对应的 375px,实际项目里应该用一个响应式的值。为了代码简单起见,直接写了常量。
interpolate 是 worklet 环境内置的插值函数,语义和常见的 easing 库一样:给一个输入值和一个值域映射表,返回对应的输出。它的执行是纯函数式的,不会引入额外开销。
可以在 worklet 里用 setTimeout 吗
不能。渲染线程里没有 timer、没有 requestAnimationFrame、也没有 fetch。你能做的只有纯计算和读写 sharedValue。
需要延迟动画的时候用 withTiming 或者 withSpring 包装一下值:
import { shared, withSpring } from 'wx.worklet' // 伪代码示意
const scale = shared(1)
scale.value = withSpring(1.2, { damping: 18, stiffness: 180 })
这些包装函数会返回一个”动画描述对象”,赋值给 sharedValue 的时候会自动开始动画。这也是 Skyline 里唯一的做动画方式,没有 keyframe 也没有 transition,全靠这个。
五、案例二:可拖拽排序的列表
手势系统是另一块常用能力。拿一个常见的场景练手:任务清单里上下拖动重新排序。
<template>
<view class="list" :style="{ height: totalHeight + 'px' }">
<view
v-for="(item, index) in items"
:key="item.id"
class="row"
:style="rowStyles[index]"
@touchstart="startDrag(index, $event)"
@touchmove="onDrag($event)"
@touchend="endDrag"
>
<text>{{ item.label }}</text>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
const ROW_HEIGHT = 64
const items = ref([
{ id: 1, label: '设计首页' },
{ id: 2, label: '对接接口' },
{ id: 3, label: '写单测' },
{ id: 4, label: '部署上线' },
])
const totalHeight = computed(() => items.value.length * ROW_HEIGHT)
// #ifdef MP-WEIXIN
const { shared, useAnimatedStyle, withSpring } = wx.worklet
const draggingIndex = shared(-1)
const draggingOffset = shared(0)
const rowStyles = items.value.map((_, i) => {
return useAnimatedStyle(() => {
const isDragging = draggingIndex.value === i
const shift = isDragging ? draggingOffset.value : 0
return {
transform: `translateY(${i * ROW_HEIGHT + shift}px)`,
zIndex: isDragging ? 10 : 1,
boxShadow: isDragging
? '0 8px 24px rgba(0,0,0,.12)'
: 'none',
}
})
})
let startY = 0
const startDrag = (index, e) => {
startY = e.touches[0].clientY
draggingIndex.value = index
draggingOffset.value = 0
}
const onDrag = (e) => {
'worklet'
if (draggingIndex.value === -1) return
draggingOffset.value = e.touches[0].clientY - startY
}
const endDrag = (e) => {
// 在 JS 线程处理真正的数组重排,这里要同步一次
if (draggingIndex.value === -1) return
const targetIndex = Math.round(
(draggingIndex.value * ROW_HEIGHT + draggingOffset.value) / ROW_HEIGHT
)
const clamped = Math.max(0, Math.min(items.value.length - 1, targetIndex))
if (clamped !== draggingIndex.value) {
const arr = [...items.value]
const [moved] = arr.splice(draggingIndex.value, 1)
arr.splice(clamped, 0, moved)
items.value = arr
}
draggingIndex.value = -1
draggingOffset.value = 0
}
// #endif
</script>
这段代码里有几个细节值得说。
第一,拖拽的视觉位移完全由 worklet 计算,但数组的真实重排在 JS 线程。因为 items 是一个响应式数组,改它需要走 JS 线程和 setData。这个双轨结构是 Skyline 里比较常见的写法——轻量的位置动画走 worklet,重量的数据操作还是走 JS。
第二,真实的排序发生在松手的瞬间,而不是拖动过程中。拖动过程中视觉上只移动被拖拽的那一项,其他项不动。要做到”其他项让位”的效果,还需要额外的逻辑:根据被拖拽项的位置,动态调整其他项的 translateY。这部分不展开了,思路是把每个 item 的当前位置和目标占位都存进 sharedValue,useAnimatedStyle 里算一下让位偏移就行。
第三,动画收尾可以用 withSpring 让它有个小弹跳:
draggingOffset.value = withSpring(0, { damping: 20, stiffness: 220 })
这样就避免了直接归零的突兀感。
手势和 scroll-view 的冲突
上面这个例子我没用 Gesture.Pan(),用的是原生 touchstart、touchmove、touchend。原因很简单:如果列表本身在一个 scroll-view 里,用手势系统会跟滚动冲突,需要一个额外的激活阈值才能分开。
真实项目如果要用手势系统,大概是这样:
// #ifdef MP-WEIXIN
const pan = wx.worklet.Gesture.Pan()
.activeOffsetY([-12, 12])
.onStart(() => {
'worklet'
draggingIndex.value = currentIndex
})
.onUpdate((e) => {
'worklet'
draggingOffset.value = e.translationY
})
.onEnd(() => {
'worklet'
// 结算
})
// #endif
activeOffsetY([-12, 12]) 的意思是:手指垂直方向位移超过 12 像素之后,才把手势识别成 Pan 而不是滑动。这个阈值是用来区分”用户想滚动页面”和”用户想拖动元素”的。阈值设太小,滚动容易误触发拖拽;设太大,拖拽要划很久才启动。
12 是个经验值,实际项目里根据行高和滚动惯性调整。行高越大的场景,阈值可以设得越宽松。
六、案例三:吸顶分组
通讯录那种 A、B、C 分组的效果,Skyline 有一个专门的组件 sticky-section 来做。相比 webview 里靠 position: sticky 硬撑,Skyline 的实现更稳定——sticky 的层级、切换时机、和滚动容器的关系都由引擎处理,不用自己算偏移。
<template>
<scroll-view class="contacts" scroll-y>
<sticky-section
v-for="group in groupedContacts"
:key="group.letter"
>
<sticky-header>
<view class="section-header">{{ group.letter }}</view>
</sticky-header>
<view
v-for="contact in group.items"
:key="contact.id"
class="contact-row"
>
<text>{{ contact.name }}</text>
</view>
</sticky-section>
</scroll-view>
</template>
<script setup>
import { computed } from 'vue'
const contacts = [
{ id: 1, name: '安琪', letter: 'A' },
{ id: 2, name: '白川', letter: 'B' },
{ id: 3, name: '陈默', letter: 'C' },
// ...
]
const groupedContacts = computed(() => {
const map = new Map()
for (const c of contacts) {
if (!map.has(c.letter)) map.set(c.letter, [])
map.get(c.letter).push(c)
}
return Array.from(map, ([letter, items]) => ({ letter, items }))
})
</script>
sticky-section 和 sticky-header 是成对的组件。前者是分组容器,后者是吸顶部分。当 group 滚出可视区时,它的 header 会自动被”推”出去,下一个 header 顶上来。整个过程的视觉连贯度是由引擎负责的,不像 webview 里需要小心处理 z-index 和 transform 的组合。
要强调一下,这两个组件是 Skyline 独有的。在 webview 渲染的页面里用它们会报”组件不存在”。所以跨页面复用代码的时候,得用条件编译包一下。
七、多端差异与降级策略
写到这里你可能已经意识到一个问题:Skyline 是微信小程序独有的能力,项目里如果有 App 端、H5 端、支付宝小程序端,不可能只在微信上写一套、其他端写另一套。
解决办法是条件编译。uni-app 提供的语法可以按平台切分代码:
// #ifdef MP-WEIXIN
// 微信端专用代码
// #endif
// #ifndef MP-WEIXIN
// 非微信端的降级代码
// #endif
把 worklet 部分抽成 hook
实际项目里更合理的做法是把 worklet 相关的逻辑抽成 hook,各端返回不同实现,业务层统一调用:
// composables/useScrollScale.js
export function useScrollScale(cardHeight) {
// #ifdef MP-WEIXIN
const { shared, useAnimatedStyle } = wx.worklet
const scrollY = shared(0)
const bindScroll = (e) => {
'worklet'
scrollY.value = e.detail.scrollTop
}
const makeStyle = (index) => useAnimatedStyle(() => {
const slotTop = index * cardHeight
const center = scrollY.value + 375
const distance = Math.abs(center - (slotTop + cardHeight / 2))
const progress = Math.min(distance / (cardHeight * 2), 1)
return {
transform: `scale(${1 - progress * 0.14})`,
opacity: 1 - progress * 0.45,
}
})
return { bindScroll, makeStyle }
// #endif
// #ifndef MP-WEIXIN
// 其他端用普通 scroll 事件加 CSS transition 兜底
const scrollY = ref(0)
const bindScroll = (e) => {
scrollY.value = e.detail.scrollTop
// 用 requestAnimationFrame 节流
if (rafId) return
rafId = requestAnimationFrame(() => {
rafId = 0
})
}
const makeStyle = (index) => {
return computed(() => {
const slotTop = index * cardHeight
const center = scrollY.value + 375
const distance = Math.abs(center - (slotTop + cardHeight / 2))
const progress = Math.min(distance / (cardHeight * 2), 1)
return {
transform: `scale(${1 - progress * 0.14})`,
opacity: 1 - progress * 0.45,
transition: 'transform .1s linear, opacity .1s linear',
}
})
}
return { bindScroll, makeStyle }
// #endif
}
业务组件里只 import 这个 hook,看不到任何平台差异。这样的结构在维护上最省事,降级策略也容易在某个平台出问题时单独调整。
判断当前是否处于 Skyline
有些场景下需要在运行时判断,比如做一些性能埋点或者动态加载策略:
// #ifdef MP-WEIXIN
const isSkyline = (() => {
try {
return typeof wx.worklet !== 'undefined'
&& !!wx.getSkylineInfoSync
&& wx.getSkylineInfoSync()?.isSupported
} catch (e) {
return false
}
})()
// #endif
wx.getSkylineInfoSync() 会返回当前页面是否用 Skyline 渲染,以及基础库版本是否满足要求。在启动时用它来判断,比硬性检查全局对象更稳。
八、六个踩过的坑
1. worklet 里访问外部变量是快照语义
前面提过,但这里再强调一次,因为它是最容易出问题的地方。
let baseScale = 1
const style = useAnimatedStyle(() => {
// baseScale 是创建 style 那一刻的值,永远不变
return { transform: `scale(${baseScale})` }
})
baseScale = 1.5 // 改了也没用
解决方案:要么用 sharedValue(跨线程可变),要么重建 useAnimatedStyle。前者的写法是:
const baseScale = shared(1)
// JS 线程里改:
baseScale.value = 1.5
// worklet 里读:
const style = useAnimatedStyle(() => ({
transform: `scale(${baseScale.value})`,
}))
2. style 对象里的属性名要用 camelCase
// 错误
return { 'font-size': '14px' }
// 正确
return { fontSize: '14px' }
和常规 Vue inline style 对象一样。但如果是从 CSS 里复制粘贴过来的,很容易带上连字符,然后样式就静默不生效,排查起来挺费劲。
3. position: fixed 在 Skyline 下不生效
Skyline 的布局模型参考了更严格的流式布局,不支持和 webview 一样的 fixed 定位语义。要用吸顶效果,应该用 sticky-header;要做悬浮按钮,应该把它放在页面根节点上,用绝对定位加一个固定的父容器。
<!-- 在 webview 里能用,在 Skyline 里不行 -->
<view class="back-to-top">...</view>
<!-- 换成 -->
<view class="page-root">
<scroll-view>...</scroll-view>
<view class="floating-btn">...</view>
</view>
把 fixed 换成绝对定位,并且保证父容器覆盖整个视口。这个改动不大,但如果不小心在某个地方留了 fixed,可能会出现元素完全消失的情况——不报错,也不显示。
4. 开发者工具和真机行为差异很大
微信开发者工具的 Skyline 模拟一直不太准。有些 worklet 相关的问题在工具里看不出来,在真机上才会暴露;反过来也有——工具里报的错在真机上不一定复现。
结论是:所有关于 Skyline 的调试都要在真机上看。开发者工具用来快速验证逻辑,真机用来确认表现。项目里应该准备至少一台低端安卓和一台 iPhone,两边都要过一遍。
5. 组件库的兼容性
把 componentFramework 切到 glass-easel 之后,很多老的第三方组件会出问题。常见的有:
自定义组件内部用了 this.setData 的写法,在一些边缘场景下不触发更新。组件的样式在 skyline 下布局不一致,因为 skyline 的盒模型更严格,有些 overflow 和 position 组合的行为跟 webview 不同。有些组件用到了 wx.createSelectorQuery,skyline 下这个 API 的可用性有限。
做法是:迁移前先列一遍项目里用到的第三方组件,逐个验证。遇到不兼容的要么升级版本、要么替换、要么把用到它的页面暂时保留在 webview 渲染。
6. 基础库版本要求
不同 Skyline 能力的可用版本不完全一样。sticky-section 是比较晚才稳定的,shared 更早。写兼容代码的时候要注意判断基础库版本。
const version = wx.getAppBaseInfo().SDKVersion
const [major, minor, patch] = version.split('.').map(Number)
if (major > 3 || (major === 3 && minor >= 3)) {
// 可以用比较新的 API
}
实际项目里更省事的做法是:在 manifest.json 里声明一个最低基础库版本,让不满足的用户走 webview 分支。这样代码里就不用到处判断版本了。
九、什么时候该上 Skyline
用了一年多,我的判断是可以分成三类。
第一类是明显该用的场景。列表滚动动画、拖拽排序、手势驱动的手势系统、复杂的滚动联动效果。这些场景在 webview 下要么做起来很难,要么性能上不去。Skyline 在这些地方的优势非常直观。
第二类是可以用但不着急的。普通的内容展示页面、表单页面、设置页面。这些页面本身没什么动画,迁移到 Skyline 的收益只是”更现代的渲染引擎”这种模糊的描述,实际用户感知不到。留在 webview 反而能避免组件库兼容问题。
第三类是不该用的。大量依赖 web-view 组件的页面、依赖复杂表单控件和编辑器组件的页面、大量使用老版本第三方组件的页面。这些页面迁移成本高,收益低。
分页面对待,是这两年以来我做 Skyline 迁移最大的心得。不用全上,也不用全不上。一个项目里 3 到 5 个高频交互页面用 Skyline,其余页面留给 webview,混合运行完全没问题。这样既能拿到性能收益,又能把迁移风险和兼容性问题控制在一个可以接受的范围内。
十、写在最后
回到开头那个列表。
最后落地的时候,我们没有把整个 App 迁移到 Skyline,只把主内容浏览页和搜索页改了。剩下二十多个页面继续用 webview。改动两周完成,把主页面从 40 帧拉到了 58 帧左右,用户可在客服那边没有收到任何关于”卡”的反馈了。
这两年小程序生态的演进速度是挺快的。很多以前需要各种奇技淫巧才能做到的效果,慢慢都有了原生的实现方式。Skyline 就是其中一个比较典型的例子——它没有引入新的编程模型,也没有让整个生态推倒重来,只是把渲染引擎换掉了,然后让 worklet 和 sharedValue 这两个东西去处理那些最需要性能的部分。
如果你的项目里也有那种”做得出效果,但性能上不去”的页面,值得试一下。不一定全站迁移,从一个页面开始就好。

