React Leaflet + React Pixi:双倍的快乐,我全都要

一篇实用性的文章,记录一下最近在自娱自乐使用 Leaflet 和 PixiJS 的过程中整的一个有意思的活,帮助我们使用 React 声明式的语法在 Leaflet 的图层上使用 PixiJS 绘图。

如果你对这些库和它们的用途都已有所了解,只想直接看代码的话,可以直接访问 react-leaflet-react-pixi(然后点个 star 再走,谢谢👀)。

项目动机

项目整体框架使用了 React。由于需要大地图展示的功能,一番调研以后决定采用 Leaflet 来实现。Leaflet 是一个用于快速构建可交互地图的 JS 库,开箱即用地为我们提供了地图中各种常见的交互能力,被广泛地用在 WebGIS 领域当中。

不过作为一个纯 JavaScript 的库,leaflet 依然采用的是命令式的 API,在 React 中使用起来显得也是格格不入,颇为蛋疼。所幸的是,已经有了 React Leaflet 库能帮助我们在 React 中通过声明式的语法来使用 Leaflet。

// pure leaflet 语法
var map = L.map('map').setView([51.505, -0.09], 13);

L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
    attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);

L.marker([51.5, -0.09]).addTo(map).bindPopup('A pretty CSS3 popup.<br> Easily customizable.');
    
// react-leaflet 语法
<MapContainer center={[51.505, -0.09]} zoom={13}>
  <TileLayer
    attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
    url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
  />
  <Marker position={[51.5, -0.09]}>
    <Popup>A pretty CSS3 popup. <br /> Easily customizable.</Popup>
  </Marker>
</MapContainer>

我们做一个地图类的应用,往往需要在地图上绘制各种标记、图形等。Leaflet 本身已经提供了这些功能,你可以直接使用 marker 在地图上显示一个标记(就像上面的代码那样),也可以使用 Path、Polygon 等功能画一些简单的图形。

但这些绘图功能都比较简单,往往满足不了我们的需要。更重要的问题是,绘图功能的默认原理是向网页中插入新的 DOM、SVG 等元素,并在地图发生拖动、缩放时,动态改变它们的 CSS 样式。当这些元素变得越来越多时,网页容易变得卡顿。

因此,我们需要使用更有效率的方式来绘图。不难想到可以使用 Canvas 实现。当然市面上也早已有了类似的解决方案,例如 Leaflet.Canvas-MarkersLeaflet.CanvasLayer 等等。

不过提到 2D Canvas 绘图,显然有个更好的方案就是 PixiJS 。它不仅为我们封装了繁琐的 Canvas API,更能通过 WebGL 的能力进一步提升性能,还自带了交互和动画的能力。

当然,和 Leaflet 一样,PixiJS 采用的也是命令式 API,在 React 中显得不甚优雅。不过也同样,我们可以通过现成的 React Pixi 库来帮助我们使用声明式语法操作 PixiJS 。

// pure pixijs 语法
const app = new PIXI.Application({ width: 640, height: 360 });
document.body.appendChild(app.view);

const basicText = new PIXI.Text('Basic text in pixi');
basicText.x = 50;
basicText.y = 100;
app.stage.addChild(basicText);

// react-pixi 语法
<Stage width={640} height={360}>
  <Text x={50} y={100} text='Basic text in pixi' />
</Stage>

最终,我们需要将它们结合在一起,让 Leaflet 能使用 PixiJS 来渲染地图上的各种图形。又一次我们有了现成的轮子:Leaflet.PixiOverlay,它搭建起了 Leaflet 到 PixiJS 之间的桥梁。

但这时问题就出现了。Leaflet.PixiOverlay 以命令式的语法附加到 Leaflet 本尊之上,也要求我们以命令式的语法在它的 callback 中调用 PixiJS 执行绘图逻辑。在纯 JavaScript 的世界中这无可厚非,但对于早已在采用了声明式语法的现代前端框架中浸淫了多时的我们来说,简直不能忍。

明明已经有了 React Leaflet,又有了 React Pixi,两个现成的轮子重合在一起,得到的,本该是无限丝滑的开发体验。但是,为什么会变成这样呢?(被拖走

不管了,我全都要!

cover


问题的核心就在于,我们如何能将 Leaflet.PixiOverlay 与 React Leaflet 和 React Pixi 结合起来,在开发时对这些丑陋的胶水代码无感知。我们理想的代码长这个样子:

{/* React Leaflet 封装的地图容器 */}
<MapContainer ...>
  {/* 地图的图层 */}
  <TileLayer ... />
  {/* Pixi 的图层,内部的东西都使用 PixiJS 渲染 */}
  <PixiRoot>
    {/* Pixi 图层的内部,可以用任何 React Pixi 的组件编写 */}
    <Text text="marker" />
    <Sprite ... />
  </PixiRoot>
</MapContainer>

下面就让我们一步一步来实现它。

结合 React Leaflet

Leaflet.PixiOverlay 与 React Leaflet 的结合相对来说比较简单,就像 React 结合任何 JavaScript 库一样。我们可以在 React 组件的生命周期/Hook 中调用这些库的相应 API 。

首先搭建 React Leaflet 环境,本文使用的 dependencies 是:

"leaflet": "^1.7.1",
"react-leaflet": "^3.2.2",
// 文章简洁起见,下文中用到的依赖也在这里一并列出
"pixi.js": "^6.2.0",
"leaflet-pixi-overlay": "^1.8.2",
"@inlet/react-pixi": "^6.6.5",

随后写一段最简单的 Demo 代码:

import { MapContainer, TileLayer, Marker } from 'react-leaflet'
import L from 'leaflet'

const position = [51.505, -0.09]

function App() {
  return (
    <MapContainer center={position} zoom={13} style={{ width: '100vw', height: '100vh' }}>
      <TileLayer
        attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
      />
      <Marker position={position} icon={L.divIcon({ html: '我是 div icon' })}/>
    </MapContainer>
  )
}

配置过程中的注意点:

  1. 不要忘记引入 Leaflet 的 css:@import '~leaflet/dist/leaflet.css'
  2. React Leaflet 源码中使用到了 ?? 语法,需要额外为 babel 配置 @babel/plugin-proposal-nullish-coalescing-operator 插件

base

可以看到地图和标记已经成功显示了出来(我为标记增加了一些 CSS 样式,让它变得更明显)。通过开发者工具可以看到,标记就是一个普通的 div 元素。

接下来,我们引入 Leaflet.PixiOverlay 。它的文档中展示了命令式的用法:

var map = L.map(...);
var pixiOverlay = L.pixiOverlay(function(utils) {
    // your drawing code here
}, new PIXI.Container());

pixiOverlay.addTo(map);

不难看出 PixiOverlay 与 Leaflet 通过 map 对象连接在一起。而 React Leaflet 提供了 useMap() hook 供我们获取 map 对象。因此一切就顺理成章了:

function PixiRoot() {
  const map = useMap() // 获取 map 实例,来自 react-leaflet 的 MapContainer

  useEffect(() => {
    pixiOverlay.addTo(map)
  }, [map])

  return null // 不需要渲染任何东西,把它放在 MapContainer 组件内部即可
}

我们可以在 PixiOverlay 初始化的回调函数中调用 PixiJS 的 API 画图。当地图移动和缩放时,PixiOverlay 会调用此回调函数重新绘图。此外我们也可以调用 redraw() 方法手动触发重绘。

import 'leaflet-pixi-overlay' // Must be called before the 'leaflet' import
import L from 'leaflet'
import * as PIXI from 'pixi.js'

// 构建 Pixi 对象树的层级
const container = new PIXI.Container()
const text = new PIXI.Text('我是 Pixi Text', { fill: 0xff1010 })
container.addChild(text)

const pixiOverlay = L.pixiOverlay((utils) => {
  const container = utils.getContainer()
  const renderer = utils.getRenderer()
  const project = utils.latLngToLayerPoint
  const scale = utils.getScale()

  const coords = project([51.505, -0.08]) // 需要把经纬度转换为 canvas 上的坐标
  text.x = coords.x
  text.y = coords.y
  text.text = '我是 Pixi Text ' + Math.floor(Math.random() * 100)
  text.scale.set(1 / scale)

  renderer.render(container)
}, container)

在上面的例子中,我们绘制了一个 PIXI.Text,并在每次触发重绘时随机改变它的内容。我们可以通过开发者工具看到它确实是通过 PixiJS 使用 WebGL 渲染到 Canvas 上的。

pixi

PixiJS 也提供了 Devtools 用于查看 PixiJS 对象树。但有个坑点是,它是通过检测 window.PIXI 对象来判断页面是否使用 PixiJS 的,因此在现代前端项目中,你需要在代码中手工定义 window.PIXI = PIXI

在这个例子中可以看到,我们刚开始就把 PixiJS 的对象树给建立好了,而每次重绘只是修改了 Text 的属性。但在真实项目中,对象树的层级、以及涉及子元素的增删改会复杂许多。如果每次的变化都需要我们手动来管理,还是很麻烦的。这其实和我们手动操作 DOM 树遇到的问题是一样的,也就是为什么我们需要用声明式的语法来写 PixiJS 。

那么下面就来继续探索如何结合 React Pixi 。

结合 React Pixi

结合 React Pixi 就没有前面那么容易了。或许我们会想能不能直接把 React Pixi 的内容渲染到 MapContainer 内部:

  <MapContainer ...>
    <TileLayer ... />
    {/* Stage 是 React Pixi 的根节点 */}
    <Stage width={300} height={300}>
      <Sprite
        image="https://s3-us-west-2.amazonaws.com/s.cdpn.io/693612/coin.png"
        scale={{ x: 0.5, y: 0.5 }}
        anchor={0.5}
        x={150}
        y={150}
      />
    </Stage>
  </MapContainer>

但运行后会发现 React Pixi 的内容被 Leaflet 地图遮挡,且并不会随着地图的平移和缩放来改变自己。这是因为,Leaflet 的图层体系帮助我们做了这些事情,而这并不是我们简单把东西放在 Leaflet 的层级内部就能享受到的。

通过审查元素,可以发现用这种方式创建的 Canvas,完全游离在 Leaflet 的体系之外:

leaflet-container
├─ leaflet-map-pane
│  ├─ leaflet-tile-pane         <-- 地图图块所在层级
│  ├─ ... 省略其他层级
│  └─ leaflet-overlay-pane
│     └─ leaflet-pixi-overlay   <-- Leaflet.PixiOverlay 所在层级
│        └─ canvas              <-- Leaflet.PixiOverlay 创建的 Canvas
├─ leaflet-control-container
└─ canvas                       <-- React Pixi 创建的 Canvas

实践中还要注意的一个类似问题:React Pixi <Stage> 之下的组件是渲染在另一个 Root 下面的。因此假如配合全局 Provider 使用(例如使用 Redux、MobX 等全局状态管理工具),需要在 <Stage> 内部再套一层 <Provider>

既然如此,我们不难想到两种解决途径。第一种方法是尽量融入 Leaflet 的体系,想办法把 React Pixi 创建的内容渲染到 Leaflet 内部的 Canvas 中。第二种方法则是维持 React Pixi 的用法不变,但把 Leaflet 层级交互的相关逻辑抽取出来,手动控制 Canvas 位置和缩放与 Leaflet 地图同步。

理论上两种方法都是可行的,那就得看哪种方法做起来更方便。通过一番查找文档后,我看到 React Pixi 提供了一个独立的 render 方法,可以绕过整个体系,直接把 JSX 格式的代码渲染到一个 PIXI.Container 中。这使得实现上述第一种方法的路径一下子清晰了起来。

render(<Text text="Hello World" x={200} y={200} />, app.stage)

那么这个 render 方法做了什么呢?一开始我以为它的作用是渲染当前 JSX 对应的画面,但把它放到代码中,调用之后却一点反应都没有。是我的姿势不对吗?文档里已经没有更多信息了,只好打开源码一看究竟。

render 方法的源码十分简单,核心逻辑就是一行代码:

export function render(element, container, callback = () => {}) {
  ...
  PixiFiber.updateContainer(element, root, undefined, callback)
  ...
}

继续查看 PixiFiber 的实现,这次则更为简单:

export const PixiFiber = Reconciler(hostconfig)

虽然似乎什么有用的信息都没有,但当看到 Reconciler 这个词时想必心里已经明白了七七八八。对 React 架构稍有了解的人都会知道 Reconciler 是 React 的一个重要部分,负责 Fiber 树的生成和更新。因此我们可以合理推测 React Pixi 的 render 方法做的也是类似的事情,即将 JSX 转换为 PixiJS 的对象树(官方称为 Scene Graph)。

既然已经生成了 Scene Graph,接下来直接调用真正的渲染器将其渲染为画面就完事了。

const container = new PIXI.Container()

const pixiOverlay = L.pixiOverlay((utils) => {
  const container = utils.getContainer()
  const renderer = utils.getRenderer()
  const project = utils.latLngToLayerPoint
  const scale = utils.getScale()

  const coords = project([51.505, -0.08])
  // 将 JSX 渲染为以 container 为根节点的 PixiJS 对象树
  render(<Text x={coords.x} y={coords.y} text='我是 Pixi Text' scale={1 / scale} />, container)
  // 将 PixiJS 对象树真正渲染为画面
  renderer.render(container)
}, container)

成功了!

(图和上面一样,就不放了)


不过能渲染 JSX 只是成功的第一步。对比于理想情况,我们还希望彻底把 pixiOverlay 封装起来,不被外界感知;以及让 React Pixi 的 JSX 和正常的 React 组件一样,自由地使用各种各样的 Hooks。

我们依然把复杂度封装在上节中已经创建的 PixiRoot 组件中:

const container = new PIXI.Container()

function PixiRoot({ children }) {
  ...
  
  useEffect(() => {
    render(children, container)
  }, [children])

  ...
}

现在这个组件感知它内部的 children 即 React Pixi 的 JSX。当 children 发生改变时,我们通过副作用将其转变成对应的 PixiJS 对象树。

回到 React Pixi 的源码,我们可以看到,在生成新的对象树的 Diff 算法中,如果检测到更新,就会抛出名为 REACT_PIXI_REQUEST_RENDER 的自定义事件,明显是为了请求触发重新渲染:

appendInitialChild(...args) {
  const res = appendChild.apply(null, args)
  window.dispatchEvent(new CustomEvent(`__REACT_PIXI_REQUEST_RENDER__`, { detail: 'appendInitialChild' }))
  return res
},

因此我们可以监听这个事件来触发画面的重新渲染。之所以不在上一步调用 render 方法之后直接调用重新渲染,是因为 children 内部子元素的变化并不会触发父元素的更新,但自定义事件是始终会触发的。

const requestUpdate = useCallback(() => {
  pixiOverlay.redraw(container)
}, [])

useEffect(() => {
  window.addEventListener('__REACT_PIXI_REQUEST_RENDER__', requestUpdate)
  return () => {
    window.removeEventListener('__REACT_PIXI_REQUEST_RENDER__', requestUpdate)
  }
}, [requestUpdate])

上面的代码在监听到自定义事件后立即调用了 redraw 方法,进而触发 pixiOverlay 的回调函数。在回调函数中我们只需保留 renderer.render(container) 这一句,将其真正渲染为画面就可以了。

这样做可行,但存在着重复渲染的问题。如果 Diff 过程中 REACT_PIXI_REQUEST_RENDER 事件被多次触发,那么就会调用多次 redraw,造成资源浪费。一般在这种重绘图和动画的应用中,我们会使用 requestAnimationFrame 来实现渲染逻辑,以使得渲染逻辑与屏幕刷新保持步调一致。

想用好 requestAnimationFrame 并不是一件简单的事。无论新手还是老手,我都推荐阅读我之前翻译的一篇文章,它非常详细地讲解了你可能会遇到的问题和解决方案。

PixiJS 把原生的 requestAnimationFrame 封装为了 Ticker 模块。我们可以维护一个是否需要重绘的 flag,在每次 tick 时通过判断 flag 再真正触发重绘。

// 判断是否需要重绘的 flag
const [needsRenderUpdate, setNeedsRenderUpdate] = useState(false)

// 当触发 __REACT_PIXI_REQUEST_RENDER__ 事件时,不立即重绘,而是点亮 flag
const requestUpdate = useCallback(() => {
  setNeedsRenderUpdate(true)
}, [])

// 在每次 tick 时判断 flag 来决定是否真正触发重绘
const renderStage = useCallback(() => {
  if (needsRenderUpdate) {
    setNeedsRenderUpdate(false)
    pixiOverlay.redraw(container)
  }
}, [needsRenderUpdate])

useEffect(() => {
  const ticker = PIXI.Ticker.shared
  ticker.autoStart = true
  ticker.add(renderStage)
  window.addEventListener('__REACT_PIXI_REQUEST_RENDER__', requestUpdate)

  return () => {
    ticker.remove(renderStage)
    window.removeEventListener('__REACT_PIXI_REQUEST_RENDER__', requestUpdate)
  }
}, [renderStage, requestUpdate])

这样一个相对完善的渲染逻辑就完成了。让我们写个简单的 Demo 来验证下效果是否与我们的预期一致:

function MyContent() {
  const [flag, setFlag] = useState(true)
  return (
    <Container x={65506} y={43586} scale={1/16} interactive={true} click={() => setFlag(val => !val)}>
      <Text text={flag ? 'Pixi Text' : 'Text Changed'} />
      {flag && <Sprite y={50} image="https://s3-us-west-2.amazonaws.com/s.cdpn.io/693612/IaUrttj.png"/>}
    </Container>
  )
}

function App() {
  return (
    <MapContainer ...>
      <TileLayer ... />
      <PixiRoot>
        <MyContent />
      </PixiRoot>
    </MapContainer>
  )
}

Demo

我们终于把复杂度隐藏了起来,使得 React Leaflet 与 React Pixi 的结合就像写原生 React 那样自然,并且可以自由地使用 Hooks 等能力。我们成功了!但还有最后一点遗憾,在上例中为了演示方便,组件的坐标和缩放的值都是写死的奇怪的值。这是因为,在 pixiOverlay 被封装起来之后,我们没有办法调用它的投影函数动态地把经纬度转换为 Canvas 坐标了。这在真实项目中可不能接受。下一节我们就来解决这个问题。

注入上下文

我们需要向子组件中注入一些诸如坐标转换、缩放等必要的信息与工具函数。不难想到可以用 React 的 Context 机制配合自定义 Hook 来完成。下面就分别来实现它们。

坐标转换

对于地图上的物体,我们主要关心它的经纬度,但 PixiJS 需要的是它们在 Canvas 上的坐标。Leaflet.PixiOverlay 提供了 utils.latLngToLayerPoint 函数帮助我们完成这一坐标转换。另外要注意的是,PixiJS 元素的坐标是相对于它们的父元素的,这就要求我们自顶向下地逐级计算每个元素的坐标,再减去父元素的坐标。

基于以上原则,可以设计出坐标转换的 Hook:

const Context = createContext(...)

export function useProject(latlng, parentPosition = [0, 0]) {
  // project 就是 utils.latLngToLayerPoint
  const { project } = useContext(Context)
  const myPosition = project(latlng)
  // 返回值就是应该被填入 PixiJS 元素的 xy 值
  return [myPosition.x - parentPosition[0], myPosition.y - parentPosition[1]]
}

缩放感知

默认情况下,Leaflet.PixiOverlay 已经为我们做好了缩放的逻辑,当地图缩放时,上面绘制的物体也会随之缩放。但有时我们希望地图上的标记在地图缩放时保持大小不变,这就需要能感知当前的缩放并作反向的响应。

我们可以在组件中维护当前的缩放值。当地图发生缩放时,必定会触发 pixiOverlay 的回调,那么正好在回调中更新最新的缩放值。为了做到这一点,我们把 pixiOverlay 也放到 PixiRoot 组件中:

// in PixiRoot Component
const [scale, setScale] = useState(1)

const [pixiOverlay] = useState(L.PIXIOverlay((utils) => {
  const container = utils.getContainer()
  const renderer = utils.getRenderer()
  // 更新当前的 scale
  setScale(utils.getScale())
  // 本职工作:渲染画面
  renderer.render(container)
}, container))

然后提供一个 Hook 获取最新缩放值:

export function useScale() {
  const { scale } = useContext(Context)
  return scale
}

useTick

我们往往需要做一些动画效果。上文已经提到,可以使用 PixiJS 的 Ticker 模块。在 React 中,为了更加方便地使用,可以将 Ticker 的命令式 API 封装为 useTick Hook。

React Pixi 中已经提供了这个 Hook,但是它使用的 ticker 来源于它内部自己管理的 PIXI.Application 实例。我们并没有采用 React Pixi 的全套体系,也就没有这个 Application 实例,也就不能拿它的 Hook 直接用。

但不用担心,这个所谓的 Application 并没有什么魔法,只是对 PixiJS 的 Loader、Ticker 和 Render 模块的简易封装。我们依旧可以单独使用它的 Ticker 模块:

export function useTick(callback) {
  useEffect(() => {
    const ticker = PIXI.Ticker.shared
    ticker.add(callback)
    
    return () => {
      ticker.remove(callback)
    }
  }, [callback])
}

不过,在实际使用 useTick 时,我们往往会传入内联函数,即 useTick(() => {...}),进而导致 useEffect 被频繁触发。第一种解决方案是通过 useRef 维护对最新 callback 的引用。这是一种写在 React 官方文档上,但并不被推荐的做法,不过可以减轻调用者的心智负担。React Pixi 源码中采用的就是这种做法。

export function useTick(callback) {
  const savedRef = useRef(null)

  useEffect(() => {
    savedRef.current = callback
  }, [callback])
  
  useEffect(() => {
    const ticker = PIXI.Ticker.shared
    const tick = delta => savedRef.current?.apply(ticker, [delta, ticker])
    ticker.add(tick)

    return () => {
      ticker.remove(tick)
    }
  }, [])
}

第二种方案则是保持我们先前的实现不变,但调用者必须小心地采用 useCallbackuseReducer(当 useCallback 的依赖经常发生变化时)保持传入 useTick 的 callback 不频繁改变。

这是一个 Hooks 使用中的经典问题了。社区中已经有不少文章(如这篇这篇)探讨这一话题,本文不作展开。为了保持与 React Pixi 行为的一致性,目前采取了第一种方案。

把它们放在一起

由于坐标转换和缩放的 Hook 都用到了 Context,我们需要在调用 React Pixi 的 render 方法时为 children 套上一层 Provider:

useEffect(() => {
  const project = pixiOverlay.utils.latLngToLayerPoint
  const provider = <PixiOverlayProvider value={{project, scale}}>{children}</PixiOverlayProvider>
  render(provider, container)
}, [children, pixiOverlay, scale])

最后用一个综合性的例子收尾,利用各个 Hook 创建一个不断平移的小图标,并在缩放中保持图标大小不变:

function MyContent() {
  const [containerX, containerY] = useProject([51.5, -0.09])
  const [markerOffset, setMarkerOffset] = useState(0)
  const [markerX, markerY] = useProject([51.5, -0.09 + markerOffset], [containerX, containerY])
  const scale = useScale()

  const [direction, setDirection] = useState(1)
  useTick(delta => {
    setMarkerOffset(val => val + delta * direction / 3000)
    if (markerOffset > 0.05) {
      setDirection(-1)
    } else if (markerOffset < -0.05) {
      setDirection(1)
    }
  })

  return (
    <Container x={containerX} y={containerY}>
      <Sprite x={markerX} y={markerY} anchor={0.5} scale={1/scale} image="https://s3-us-west-2.amazonaws.com/s.cdpn.io/693612/IaUrttj.png"/>
    </Container>
  )
}

Demo

结语

虽然看上去我们沿着很顺畅的思路完成了这一切,最后的代码量也不多,回头看去也不难,但当中经历的方向不明时的探索和纠结,也只有自己才知道。在这过程中也对一些 React 的机制有了更深的理解。正所谓纸上得来终觉浅,绝知此事要躬行。

Leaflet 这块在整个大前端中应该算是比较小众的领域,如果正好有做这方面开发的朋友们,希望这篇文章能为你们起到帮助。

TAGS:  ReactLeafletPixiJS
正在加载,请稍候……