Konva 和 Vue-Konva 在 SSR 模式下的使用及其他问题

Konva 是一个 2d canvas 绘图库,并且和 ts 结合良好,所以我选用了 konva,而不是 fabricjs。但在使用过程中也碰到了不少问题。这里简要记录一下。

SSR 服务端缺少 canvas

这个问题很常见,SSR 模式基本安装完 Konva 开始使用就会碰到,在还没有使用 Vue-Konva 的时候,我的解决方法是运行时动态引入。比如:

// 动态加载 Konva,因为 Konva 依赖于 canvas,不能服务端渲染(实际上是 canvas 安装失败)
const Konva = (await import('konva')).default
const stage = new Konva.Stage({
  container: ctx.stageContainer,
  width: ctx.stageContainer.clientWidth,
  height: ctx.stageContainer.clientHeight,
})

这里 ctx 是我传入的一个对象,忽略即可。

如注释所言,其实我也尝试过安装 node-canvas,但这个库……被墙了,本地还好,但服务器上不好弄,所以这个方案我无法实施。所以,在使用到 Konva 的地方,我使用了动态引入,并且要在浏览器端执行。我用的 vue3,所以上述代码的调用是在 onMounted 钩子里面。

Vue-Konva 的局部注册

为了方便使用,我安装了 Vue-Konva,用在网站的某个页面,如果好用,其他需要的页面也准备使用 Vue-Konva,但事与愿违。

因为我只有部分页面用到,所以,我需要局部注册 Vue-Konva 组件。但是,官方文档只有全局注册的方式。

import { createApp } from 'vue';
import App from './App.vue';
import VueKonva from 'vue-konva';

const app = createApp(App);
app.use(VueKonva);
app.mount('#app');

// 页面代码
<template>
  <v-stage :config="configKonva">
    <v-layer>
      <v-circle :config="configCircle"></v-circle>
      <v-rect :config="configRect"></v-rect>
    </v-layer>
  </v-stage>
</template>

我用的 Quasar,全局注册 Vue-Konva 的话需要写在 boot 文件里。

import { boot } from 'quasar/wrappers'

export default boot(async ({ app }) => {
  // konva 只能在 client 使用
  if (process.env.CLIENT) {
    const VueKonva = (await import('vue-konva')).default
    app.use(VueKonva)
  }
})

同样,为了编码服务端 canvas 问题,注册代码是在客户端执行。但这里也只是在我本地可以,服务端打包后就不清楚了,因为我最终的代码不是这样的,boot 文件里相关代码我注释掉了。

虽然官方文档没有局部注册的例子,但不是没有办法。相关 issue 里也是有提及的。我这里贴一下我的代码:

<script lang="ts">
import { Component } from 'vue'
import VueKonva from 'vue-konva'

export default {
  name: 'content-stage',
  components: {
    ...(() => {
      const map: Record<any, Component> = {}
      VueKonva.install({
        component: (key: string, com: Component) => {
          map[key] = com
        },
      })
      return map
    })(),
  },
  setup() {
    // ……
    return {
    }
  },
}
</script>

以上是解决方法之一,但我的代码一般直接写在 setup 里。代码如下:

// ContentStage.vue
import VueKonva from 'vue-konva'

const instance = getCurrentInstance()

// 这里有风险,因为 components 不是实例公开的属性
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
instance!.components = (() => {
  const map: Record<any, Component> = {}
  VueKonva.install(
    {
      component: (key: string, com: Component) => {
        map[key] = com
      },
    }
  )
  return map
})()

为了避免服务端 canvas 问题,相关组件也需要动态引入该组件:

<script setup lang="ts">
import type ContentStage from './components/ContentStage.vue'

onMounted(() => {
  init()
})

async function init() {
  try {
    // 动态加载组件,因为 ssr 时 konva 无法引入 canvas
    ContentStageCom.value = (
      await import('./components/ContentStage.vue')
    ).default
  } catch (e) {
    console.log(e)
  }
}
</script>

<template>
  <q-no-ssr>
    <component
      :is="ContentStageCom"
      :service="service"
      :konva="konva"
    />
  </q-no-ssr>
</template>

为了保险一点,相关组件还用 q-no-ssr 包裹了一下。

这样就可以了吗?事实上我错了,还是不可以,本地没问题,但服务端打包之后启动服务还是报错:没有 canvas。┭┮﹏┭┮

Vue-Konva 这个包有几个文件都是在顶层引入的 Konva,只要是这种方式引入的,都会报错。没办法,我只能把 Vue-Konva 拷贝到页面目录下进行魔改,好在 Vue-Konva 代码不多。以下示例:

// vue-konva/components/Stage.vue
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { /* …… */ } from 'vue'
import type Konva from 'konva'
import { applyNodeProps, checkOrder } from '../utils'

export default defineComponent({
  // eslint-disable-next-line vue/multi-word-component-names
  name: 'Stage',
  props: {
    config: {
      type: Object as PropType<Konva.StageConfig>,
      default: function () {
        return {}
      },
    },
    __useStrictMode: {
      type: Boolean,
    },
    konva: {
      type: Object,
    },
  },

  inheritAttrs: false,

  setup(props, { attrs, slots, expose }) {
    const instance = getCurrentInstance()
    if (!instance) return
    const oldProps = reactive({})

    const container = ref<HTMLDivElement | null>(null)
    const konva = props.konva as typeof Konva
    const __konvaNode = new konva.Stage({
      width: props.config.width,
      height: props.config.height,
      container: document.createElement('div'), // Fake container. Will be replaced
    })
  },
})

顶层的 Konva 引入改成只引入类型,组件的 props 里加一个 konva 属性,使用 Konva 的地方改成从 props 获取。

其他文件类似的改法,然后使用 Vue-Konva 的地方改成引用本地目录的魔改版。这样一来,服务端终于不报错了。

为了用这个包真的废了不少脑细胞,但这个包的问题还不止这个。

Vue-Konva 其他问题

如文章开头所说,用 Konva 的一个原因就是和 ts 的良好结合,但 Vue-Konva 注册的组件……并没有正确的属性提示,如果自己编写相关 d.ts 文件,那我为什么不直接用原版 Konva,并且基于原版 Konva 针对业务需求编写特定的功能库也是正常的做法。

然后还要性能问题。这里的性能问题主要是由 vue-devtools 引起的,因为图元可能有几百个,每一个都是 vue 组件实例的话,vue-devtools 就容易引起卡顿。所以这个问题主要体现在开发过程中。

还有灵活性问题。用 Vue-Konva 结构层次很清晰,和 DOM 结构差不多,但是灵活性不如原版,比如怎么知道图元是否绘制结束呢?虽然原版也没有相关事件,但原版可以调用 layer.draw 方法。

还有 v-model 双向绑定问题。众所周知,props 是不允许修改的,但 Vue-Konva 源码里我也没看到处理 props 同步的代码。事实上,也确实没有 v-model 的参数配置,props 里传递的 config,文档里也只是说在 v-stage 设置了 __useStrictMode 的情况下不管成不成功,都会尝试同步 config,so……基本认为无法双向同步。

还有 clone 之后还原的问题。


虽然存在上述问题,但 Vue-Konva 的思想值得借鉴,以后二次封装 Konva 的时候可以考虑顶层内置一个 reactive 对象,同步 konva 内部数据和绑定的数据。事实上,这也确实是我另一个页面改版需要做的事情,否则同步用户输入的图元属性会比较麻烦。

阻止右键默认右键行为

这个比较好解决,以 Vue-Konva 为例,直接在 v-stage 上监听 contextmenu 事件,然后 preventDefault 就行。如果需要区分左键右键,直接 click 事件回调里的 evt.button 进行区分就可以,0、1、2 分别是左、中、右键。

<v-stage
    v-if="!!service.state.level"
    ref="stage"
    :config="{
      width: layoutInfo.width * scale.x,
      height: layoutInfo.height * scale.y,
      scale,
      preventDefault: false,
    }"
    @contextmenu="preventContextmenu"
  ></v-stage>

function preventContextmenu(e: any) {
  e?.evt?.preventDefault?.()
}

区分 tap 和 dbltap 事件

这个问题……从本质上来说也是比较常见的,dom 也有 click 和 dblclick 区分的问题,所以一般不建议同时监听单击和双击事件,业务上要避免这种情况,但这次我不好避免。pc 端还可以通过鼠标左键和右键区分,移动端除非用默认的长按作为右键,但这种体验也不好,并且更重要的是,Konva 没有提供长按事件,除非自己根据 touchstart 和 touchend 事件处理。

所以为了避免麻烦,我用了 tap 和 dbltap,但其实这样也不是很顺利,因为 dbltap 也会触发 tap。所以我只能在 tap 事件回调里加了一个定时器延迟执行,如果是 dbltap 就取消定时器,否则指定时间之后执行 tap 事件。那么这个指定时间是多少呢?答案是 400ms,因为这是 Konva 内部默认的双击时间窗口。

默认 DOM 滚动行为

之前我绘图是用原生 canvas 绘制的,因为功能比较简单,所以没有引入三方库,但这次更新增加了一些功能,逻辑变复杂了一些,所以用了 Konva,但 Konva 默认阻止了 DOM 的一些默认行为,比如滚动。如果在 Konva 绘制的 canvas 元素上触摸移动,页面不会滚动,毕竟 canvas 上的图元是需要拖动的,默认行为有必要禁止。但我这个页面的图元不需要拖动,反而需要默认的滚动行为。根据 issue 里的回答,只需要设置图元的 preventDefault 属性为 false 就可以维持浏览器默认行为。

但在我将所有元素设置为允许默认行为之后,虽然可以滚动了,但 tap 事件和 click 事件变成了同时触发。本来 Konva 的 click 事件和 tap 事件分别在 pc 和移动端触发的,但因为 preventDefault 变成了 false,移动端点击先后触发 tap 和 click。所以,原来的 tap 事件就需要手动阻止默认行为。

function cellTap(e: any) {
  // 因为图形上为了滚动支持,设置了 preventDefault 为 false,这里为了防止触发 click,手动调用
  e.evt.preventDefault()
  // ……
}