主线程脚本

主线程脚本是一种可以在主线程上执行的 JS 脚本。最常见的使用场景是流畅的动画和手势处理。它解决了 Lynx 多线程架构中固有的响应延迟问题,旨在实现接近原生的交互体验。

Lynx 中的事件响应延迟

下面是一个简单的动画示例:一个跟随 scroll-view 同步移动的小方块。在组件中,我们监听滚动事件,从事件参数中获取当前滚动位置,并立即更新方块的位置:

你可以尝试滚动示例左侧的 scroll-view。右侧的蓝色方块会跟随 scroll-view 的移动。但你可能会注意到,它的移动有一个不可预测的延迟,尤其是在性能较低的设备上。随着页面复杂度的增加,这种延迟也会加大。

这是因为在 Lynx 的架构中,事件在主线程上触发,而常规的 JS 事件处理函数(Vue 的 @scroll 等)只能在后台线程上执行。因此,使用常规事件来触发动画会引入多次线程切换,导致响应不及时,动画落后于手势。

  Without Main Thread Script:

  ┌────────────────┐      ┌────────────────────┐      ┌────────────────┐
  │  Main Thread   │      │ Background Thread   │      │  Main Thread   │
  │  (event fires) │ ──▶  │ (Vue handler runs)  │ ──▶  │  (render)      │
  └────────────────┘      └────────────────────┘      └────────────────┘
          ▲                                                    │
          └─────────────── 2 thread crossings ─────────────────┘

主线程脚本提供了在主线程上同步处理事件的能力,确保事件响应的同步性。

  With Main Thread Script:

  ┌────────────────────────────────────────────────────────────┐
  │                       Main Thread                          │
  │   event fires  ──▶  handler runs  ──▶  render              │
  └────────────────────────────────────────────────────────────┘
                      0 thread crossings

使用主线程函数消除事件响应延迟

使用主线程脚本实现动画

使用主线程脚本同步事件非常简单。这里我们尝试修改前面的示例。

首先,我们通过使用 main-thread-bindscroll 属性代替 @scroll,告诉框架我们希望在主线程上处理此事件:

<scroll-view :main-thread-bindscroll="onScroll" />

由于 onScroll 现在是一个主线程事件处理函数,我们还需要将其声明为主线程函数。方法是在函数体的第一行添加 'main thread' 指令:

const onScroll = (event) => {
  'main thread'
  // ...
}

将其声明为主线程函数后,我们就不能再从后台线程调用它了。

最后,由于我们现在可以直接在主线程上操作元素的属性,因此不需要使用响应式 ref 来改变位置。当使用主线程函数作为事件处理函数时,我们可以通过 useMainThreadRef() 获取目标元素的引用,并在主线程函数内通过 .current 访问它。这个对象允许你同步获取和设置节点属性,例如在示例中使用 setStyleProperty()

<script setup lang="ts">
import { useMainThreadRef } from 'vue-lynx'

const mtDraggableRef = useMainThreadRef(null)

const onScroll = (event: { detail?: { scrollTop?: number } }) => {
  'main thread'
  const scrollTop = event.detail?.scrollTop ?? 0
  const el = (mtDraggableRef as unknown as {
    current?: { setStyleProperty?(k: string, v: string): void }
  }).current
  if (el?.setStyleProperty) {
    el.setStyleProperty('transform', `translate(0px, ${500 - scrollTop}px)`)
  }
}
</script>

<template>
  <scroll-view :main-thread-bindscroll="onScroll">
    <!-- ... -->
  </scroll-view>
  <view :main-thread-ref="mtDraggableRef">
    <!-- ... -->
  </view>
</template>

以上就是所需的全部修改。下面的示例将修改前后的组件并排放置以供比较。你可能会注意到动画延迟已经消失了!

从后台线程获取数据

你可能已经注意到,将一个函数指定为主线程函数会将其与周围的上下文隔离开来,使其感觉像一个"孤岛"。它的运行时环境与其他函数不同,这意味着它不能自由地与后台线程通信。

然而,在主线程函数中从后台线程获取数据非常简单:直接使用即可,就像普通函数一样。

<script setup lang="ts">
const red = 'red'

const addBackgroundColor = (event: MainThread.ITouchEvent) => {
  'main thread'
  event.currentTarget.setStyleProperty('background-color', red)
}
</script>

<template>
  <view :main-thread-bindtap="addBackgroundColor">
    <text>Hello World!</text>
  </view>
</template>

当主线程函数被定义时,它会自动捕获来自后台线程的外部变量,例如上面示例中的 red 变量。但是,你不能直接修改后台线程中的值。

主线程函数捕获的值不会实时更新。相反,它们仅在包含主线程函数的组件重新渲染后,才会从后台线程同步到主线程。此外,同步要求捕获的值必须可以使用 JSON.stringify() 进行序列化。

总结注意事项:

  • 主线程函数只能在主线程上运行。主线程函数之间可以互相调用。
  • 捕获的变量需要通过 JSON.stringify() 在线程间传递,因此必须可序列化为 JSON。
  • 主线程函数不支持嵌套定义。
  • 不能在主线程函数中修改从外部作用域捕获的变量。

使用 main-thread-ref 获取节点对象

在上面的示例中,点击 view 会改变其背景颜色。如果我们只想改变某个特定子元素的颜色,仅靠 event.targetevent.currentTarget 不容易实现。在这种情况下,你可以使用 main-thread-ref 来获取可在主线程上使用的节点对象。

使用 useMainThreadRef() 组合式函数创建一个 MainThreadRef,然后将其赋值给目标节点的 main-thread-ref 属性:

<script setup lang="ts">
import { useMainThreadRef } from 'vue-lynx'

const red = 'red'
const textRef = useMainThreadRef(null)

const addBackgroundColor = (event: MainThread.ITouchEvent) => {
  'main thread'
  const el = (textRef as unknown as {
    current?: { setStyleProperty?(k: string, v: string): void }
  }).current
  el?.setStyleProperty?.('background-color', red)
}
</script>

<template>
  <view :main-thread-bindtap="addBackgroundColor">
    <text :main-thread-ref="textRef">Hello World!</text>
    <text>Hello World!</text>
  </view>
</template>

请注意,MainThreadRefcurrent 属性只能在主线程函数中访问。

在主线程函数中维护状态

主线程函数不能修改捕获的变量。因此,如果你需要在主线程函数之间维护状态,应该使用 MainThreadRef

<script setup lang="ts">
import { useMainThreadRef } from 'vue-lynx'

const countRef = useMainThreadRef(0)

const handleTap = (event: MainThread.ITouchEvent) => {
  'main thread'
  event.currentTarget.setStyleProperty(
    'background-color',
    ++countRef.current % 2 ? 'blue' : 'green'
  )
}
</script>

<template>
  <view :main-thread-bindtap="handleTap">
    <text>Tap me!</text>
  </view>
</template>

跨线程函数调用

到目前为止,示例中使用 'main thread' 函数作为事件处理函数。但如果你需要从后台线程调用主线程函数,或者反过来呢?Vue Lynx 提供了 runOnMainThread()runOnBackground() 来实现双向异步通信。

在这个示例中,点击方块会触发一次往返:

  1. 主线程 onTap 触发,调用 runOnBackground(incrementCount)() 更新响应式状态
  2. 后台线程 watch(count) 触发,调用 runOnMainThread(applyColor)(nextColor) 改变方块颜色

从后台线程异步调用主线程函数

在后台线程中使用 runOnMainThread(),可以异步地在主线程上执行主线程函数:

<script setup lang="ts">
import { useMainThreadRef, runOnMainThread } from 'vue-lynx'

const countRef = useMainThreadRef(0)

const addCount = (value: number) => {
  'main thread'
  countRef.current += value
  return countRef.current
}

async function increaseMainThreadCount() {
  const result = await runOnMainThread(addCount)(1)
  console.log(result)
}
</script>

从主线程异步调用非主线程函数

在主线程中使用 runOnBackground(),可以异步地在后台线程上执行普通函数:

<script setup lang="ts">
import { ref } from 'vue'
import { runOnBackground } from 'vue-lynx'

const current = ref(0)

const syncIndexToBackground = runOnBackground((nextIndex: number) => {
  current.value = nextIndex
  return `Current index updated to ${nextIndex}`
})

const onScrollMainThread = async (event: { detail: { scrollTop: number } }) => {
  'main thread'
  const index = Math.round(event.detail.scrollTop / 300)
  const result = await syncIndexToBackground(index)
  console.log(result)
}
</script>

<template>
  <view>
    <scroll-view :main-thread-bindscroll="onScrollMainThread" />
    <text>Current index: {{ current }}</text>
  </view>
</template>

跨线程共享模块

默认情况下,主线程函数不能直接调用没有 'main thread' 指令的普通函数。这使得代码复用变得困难。 为了解决这一限制,Vue Lynx 支持 shared-module 机制,允许你显式声明某些模块可在主线程和后台线程之间共享。

import 语句后添加 with { runtime: 'shared' },该模块中导出的变量(包括函数、类、对象等)就可以在主线程函数中直接调用:

import { func } from './utils' with { runtime: 'shared' }

const onTap = () => {
  'main thread'
  func() // ✅ Allows calling plain functions in a main thread function
}
NOTE

通过 with { runtime: 'shared' } 导入的模块中的函数可以被主线程函数调用,但它们不会被自动视为主线程函数。如果它们使用了主线程特有的功能(如 MainThreadRef),仍然需要手动添加 'main thread' 标记。

共享模块是一个普通的 TypeScript/JavaScript 文件——它需要 'main thread' 指令。它只能包含纯函数和常量(不能有 Vue 响应式、不能有 DOM 访问、不能有依赖于特定线程的副作用)。

// color-utils.ts — shared between both threads
const COLORS = ['#4FC3F7', '#81C784', '#FFB74D', '#E57373', '#BA68C8']

export function getNextColor(index: number): string {
  return COLORS[index % COLORS.length]!
}

通过 { runtime: 'shared' } 导入后,导出的函数既可以在 'main thread' 函数内部调用,也可以在普通的后台线程代码中调用:

<script setup lang="ts">
import { ref, computed } from 'vue'
import { useMainThreadRef } from 'vue-lynx'
import { getNextColor } from './color-utils' with { runtime: 'shared' }

// Background Thread: use shared function in computed/watch
const count = ref(0)
const currentColor = computed(() => getNextColor(count.value))

// Main Thread: use the same shared function in a tap handler
const boxRef = useMainThreadRef(null)
const onTap = () => {
  'main thread'
  const color = getNextColor(1)
  const el = (boxRef as unknown as {
    current?: { setStyleProperty?(k: string, v: string): void }
  }).current
  el?.setStyleProperty?.('background-color', color)
}
</script>

引用第三方库

通常,第三方库(如 motion-dom)包含纯 JavaScript 函数。如果在主线程函数中直接调用它们,由于缺少 'main thread' 指令会报错。

我们可以使用 with { runtime: 'shared' } 将其作为共享模块导入:

// Import and use directly
import { animate } from 'motion-dom' with { runtime: 'shared' }

const onScroll = () => {
  'main thread'
  // Can be called directly in a main thread function
  animate(element, { opacity: 0 })
}

为了方便复用或避免下面的限制(例如赋值后丢失共享特性),我们建议将导入封装在主线程函数中:

// src/utils/motion.ts
// 1. Import the original function as shared
import { animate as _animate } from 'motion-dom' with { runtime: 'shared' }

// 2. Export a new main thread function for encapsulation
export function animate(...args) {
  'main thread'
  return _animate(...args)
}

这样,animate 就变成了一个标准的主线程函数,可以在任何主线程函数中自由使用,不再受静态分析的限制:

<script setup lang="ts">
import { animate } from './utils/motion'

const onTap = () => {
  'main thread'
  // Standard main thread function — can be assigned or passed freely
  const myAnimate = animate
  myAnimate(element, { x: 100 })
}
</script>

限制

只有通过 import 直接导入的标识符才会被识别为"共享"的。赋值给新变量会导致其丢失共享特性:

import { func } from './utils' with { runtime: 'shared' }
const anotherFunc = func // [!code error]

const onTap = () => {
  'main thread'
  anotherFunc() // ❌ Cannot be analyzed at compile time, call fails
}

状态隔离

共享模块中的变量和状态在两个线程之间完全隔离,各自拥有独立的实例。

这意味着,如果你在主线程上修改了共享模块中的变量,后台线程无法感知到,反之亦然。共享模块解决的是"代码共享"的问题,而不是"状态共享"的问题。