BackIcon博客里的实时指针

2024年9月12日

Openai logomark

Hamster1963

介绍

你可以已经注意到,在你的指针下,跟随着一个属于你的指针指示器,并且如果当前页面正在有其他访客浏览的话,你也会看到他们的指针指示器,并且还可以按下/键与他们进行交流。

CleanShot 2024-09-10 at 16.32.20@2x.png

灵感来源

上周,在部署 Vercel 项目时,发现 Next.js Conf 2024 已经开始了各种宣传。

Next.js Conf 也是由 Vercel 进行举办的,每年 Next.js Conf 都会在宣传页上有些设计或者交互上的小心思。

今年的宣传页采用协作游戏的形式,大家通过鼠标移动下方的方块到指定的范围中,当全部的方块都摆放正确后,就会出现蓝色的 Next logo。

在这个过程中,可以看到其他人的指针以及可以与他人进行实时交流的方式让我眼前一亮。

实现背后的技术原理当然是 Websocket,在这个页面中,Vercel 采用了

Liveblocks | Build collaborative experiences faster

作为背后的中间件。

在 Liveblocks 中也有对应的Live Cursor 示例可供参考。

CleanShot 2024-08-30 at 15.50.54@2x.png

Live Cursors Chat | Liveblocks Example

虽然在 Next.js 中引入 Liveblocks 便可以很方便地构建实时指针,但为何不直接使用 Websocket 去实现呢? 因此便开始了自建同步服务的折腾过程。

自建同步服务

Websocket 双向通信的特性使得同步信息的难度大幅降低,因此只需要定义好消息类型以及消息结构便可以快速构建起同步服务。

alt: websocket 架构

websocket 架构

客户端(浏览器)

在浏览器中,对指针的移动(mousemove)进行监听,获取指针的位置传递给服务器。

在同步位置给服务器时,采用节流函数的方式,在一段时间内只同步一次位置给服务器,以降低网络的负载压力。

ray-so-export-2.png

同时,在接收到广播的其他指针信息后,与本地的指针进行合并进行渲染。

在客户端的处理逻辑是比较简单的,最核心的在于如何在整个应用程序中采用同一个 Websocket 链接, 在不同的组件中可以独立处理消息的接收解析与发送。

因此借助 Context Hook 用于在不同的组件中访问 Websocket 消息接收与发送。

alt: 构建 WebsocketProvider

构建 WebsocketProvider

在顶层导入后,就可以在组件中方便地使用。

const { message, sendMessage } = useWebSocketContext()

客户端的参考代码如下:

Live Cursor

指针动画

在谈及服务端之前,让我们先来聊聊如何使得实时指针看起来更灵动一些。

首先,搭配 framer-motion 的 AnimatePresence,指针的出现和消失,都带有轻微的消除动画(blur + opacity),使得不会有突兀的视觉表现。

在指针的位置移动上,使用

transition={{ type: 'spring', damping: 20 }}

来让移动带有一些弹性,不会太过于生硬。

最后,为了在不同页面尺寸上,指针都能有大致准确的位置显示,采用相对于屏幕比例的方式来显示其他的指针,这样便可以使得在不同的屏幕上都有一致的位置显示。

;(pointer.x / pointer.screenWidth) * window.innerWidth - window.scrollX

服务端

在服务端的实现上,核心是数据更新与广播。

在接收到客户端传来的数据后,通过 type 来区分不同的数据处理方式。

ray-so-export-2.png

在数据处理中,首先定义好不同的数据结构体,将房间内的指针都放到同一个哈希表中,后续都是针对这个房间内的 CursorList 进行操作与广播。

type RoomData struct {
	RoomId     string
	Clients    []*Client
	CursorList CursorList
	Mu         sync.Mutex
}

type Cursor struct {
	Id           string `json:"id"`
	X            int    `json:"x"`
	Y            int    `json:"y"`
	Name         string `json:"name"`
	Path         string `json:"path"`
	ScreenWidth  int    `json:"screenWidth"`
	ScreenHeight int    `json:"screenHeight"`
}

type CursorText struct {
	Id   string `json:"id"`
	Text string `json:"text"`
}

type Client struct {
	Ws   *ghttp.WebSocket
	Id   string
	Name string
}

type CursorList map[string]*Cursor

在接收到客户端传来的指针数据后,获取数据内的 id,修改指针哈希表中对应的数据,并将更新后的数据在房间内广播,即完成了指针数据在不同客户端之间的同步。

func (rm *RoomManager) UpdateCursorList(cursorData *Cursor, roomId string) {
	room, err := rm.GetRoom(roomId)
	if err != nil {
		return
	}

	room.Mu.Lock()
	room.CursorList[cursorData.Id] = cursorData
	room.Mu.Unlock()

	for _, c := range room.Clients {
		if c.Id == cursorData.Id {
			continue
		}
		rm.SyncCursorList(c, roomId)
	}
}

参考资料

在构建的过程中,翻阅了许多这方面的资料,不仅有 Liveblocks 的方案,也有来自 Supabase 的 Realtime 中展示的 Demo。

Supabase Realtime, with Multiplayer Features

还有在 Hacker News 上看到的类似的站点,

Every webpage deserves to be a place