Разработчик из консалтинговой компании в области разработки This Dot Labs рассказывает, как использовать canvas в Svelte и как превратить многословный API Canvas в краткий, более декларативный. Подробности — к старту нашего курса по фронтенду.


Элемент <canvas> и Canvas API позволяют рисовать на JavaScript, а с помощью Svelte его императивный API можно преобразовать в декларативный. Это потребует от вас знания Renderless-компонентов — компонентов, которые не отрисовываются.

Renderless

Все разделы файла .svelte, включая шаблон, необязательны. Поэтому можно создать компонент, который не отображается, но содержит логику в теге <script>.

Давайте создадим новый проект Svelte с помощью Vite:

npm init vite

 Project name: canvas-svelte
 Select a framework: › svelte
 Select a variant: › svelte-ts

cd canvas-svelte
npm i

И новый компонент — Renderless:

<!-- src/lib/Renderless.svelte -->
<script>
    console.log("No template");
</script>

После инициализации компонента выведем сообщение. Для этого перепишем точку входа App:

// src/main.ts
// import App from './App.svelte'
import Renderless from './lib/Renderless.svelte'

const app = new Renderless({
  target: document.getElementById('app')
})

export default app

Теперь запускаем сервер, открываем инструменты разработчика — и видим сообщение:

Работает.

Обратите внимание, что методы жизненного цикла компонента по-прежнему доступны, то есть компонент ведёт себя как обычный, даже когда у него нет шаблона.

Проверим это:

<!-- src/lib/Renderless.svelte -->
<script>
    import { onMount } from "svelte";
    console.log("No template");
    onMount(() => {
        console.log("Component mounted");
    });
</script>

После монтирования Renderless отображает второе сообщение, оба сообщения выводятся в ожидаемом порядке:

Это означает, что Renderless можно использовать как любой другой компонент Svelte. Вернём изменения main.ts и "отрисуем" компонент внутри App:

// src/main.ts
import App from './App.svelte'

const app = new App({
  target: document.getElementById('app')
})

export default app
<!-- src/App.svelte -->
<script lang="ts">
  import { onMount } from "svelte";

  import Renderless from "./lib/Renderless.svelte";
  console.log("App: initialized");
  onMount(() => {
    console.log("App: mounted");
  });
</script>

<main>
  <Renderless />
</main>

Перепишем Renderless, чтобы логировать важные сообщения:

<!-- src/lib/Renderless.svelte -->
<script>
    import { onMount } from "svelte";
    console.log("Renderless: initialized");
    onMount(() => {
        console.log("Renderless: mounted");
    });
</script>

При создании компонентов без отрисовки и Canvas важно обратить внимание на порядок инициализации и монтирования компонентов.

Ещё один способ монтировать компонент — передать его как дочерний элемент другого компонента. Такая передача называется проекцией контента. Эту проекцию сделаем с помощью slot.

Напишем компонент Container, который будет отрисовывать добавленные в слот элементы:

<!-- src/lib/Container.svelte -->
<script>
    import { onMount } from "svelte";
    console.log("Container: initialized");
    onMount(() => {
        console.log("Container: mounted");
    });
</script>

<h1>The container of things</h1>
<slot />
<p>invisible things</p>

С помощью prop добавим в компонент Renderless идентификатор:

<!-- src/lib/Renderless.svelte -->
<script lang="ts">
    import { onMount } from "svelte";
    export let id:string = "NoId"
    console.log(`Renderless ${id}: initialized`);
    onMount(() => {
        console.log(`Renderless ${id}: mounted`);
    });
</script>

Перепишем App для контейнера, затем передадим в App несколько экземпляров Renderless:

<!-- src/App.svelte -->
<script lang="ts">
  import { onMount } from "svelte";
  import Container from "./lib/Container.svelte";

  import Renderless from "./lib/Renderless.svelte";
  console.log("App: initialized");
  onMount(() => {
    console.log("App: mounted");
  });
</script>

<main>
  <Container>
    <Renderless id="Foo"/>
    <Renderless id="Bar"/>
    <Renderless id="Baz"/>
  </Container>
</main>

Ниже видно и Container, и компоненты без отрисовки, которые при инициализации и монтировании пишут в лог:

А теперь воспользуемся компонентами без отрисовки в сочетании с <canvas>.

HTML canvas и Canvas API

Элемент canvas не может содержать никаких дочерних элементов, кроме резервного элемента для отрисовки. Всё, что хочется показать в canvas, должно быть написано на императивном API.

Создадим новый компонент Canvas и отрисуем canvas:

<!-- src/lib/Canvas.svelte -->
<script>
    import { onMount } from "svelte";

    console.log("Canvas: initialized");
    onMount(() => {
        console.log("Canvas: mounted");
    });
</script>

<canvas />

Обновим App, чтобы воспользоваться Canvas:

<!-- src/App.svelte -->
<script lang="ts">
  import { onMount } from "svelte";
  import Canvas from "./lib/Canvas.svelte";

  console.log("App: initialized");
  onMount(() => {
    console.log("App: mounted");
  });
</script>

<main>
 <Canvas />
</main>

А теперь откроем инструменты разработчика:

Отрисовка элементов внутри canvas

Как уже говорилось, добавлять элементы прямо в canvas нельзя. Чтобы рисовать, нужно работать с API.

Ссылку на элемент получим через bind:this. Важно понимать, что для работы с API элемент должен быть доступен, то есть рисовать придётся после монтирования компонента:

<script lang="ts">
    import { onMount } from "svelte";
    let canvasElement: HTMLCanvasElement
    console.log("1", canvasElement) // undefined!!!
    console.log("Canvas: initialized");
    onMount(() => {
        console.log("2", canvasElement) // OK!!!
        console.log("Canvas: mounted");
    });
</script>

<canvas bind:this={canvasElement}/>

Нарисуем линию. Для наглядности я убрал всё логирование:

<script lang="ts">
    import { onMount } from "svelte";
    let canvasElement: HTMLCanvasElement
    onMount(() => {
        // get canvas context
        let ctx = canvasElement.getContext("2d")

        // draw line
        ctx.beginPath();
        ctx.moveTo(10, 20); // line will start here
        ctx.lineTo(150, 100); // line ends here
        ctx.stroke(); // draw it
    });
</script>

<canvas bind:this={canvasElement}/>

Чтобы рисовать, canvas нужен контекст, так что делать это можно только после монтирования компонента:

Если захочется добавить вторую строку, придётся дописать и новый блок кода:

<script lang="ts">
    import { onMount } from "svelte";
    let canvasElement: HTMLCanvasElement
    onMount(() => {
        // get canvas context
        let ctx = canvasElement.getContext("2d")

        // draw first line
        ctx.beginPath();
        ctx.moveTo(10, 20); // line will start here
        ctx.lineTo(150, 100); // line ends here
        ctx.stroke(); // draw it

       // draw second line
        ctx.beginPath();
        ctx.moveTo(10, 40); // line will start here
        ctx.lineTo(150, 120); // line ends here
        ctx.stroke(); // draw it
    });
</script>

Мы рисуем простые фигуры — а кода в компоненте всё больше и больше. Можно написать вспомогательные функции, сокращающие код линий:

<script lang="ts">
    import { onMount } from "svelte";
    let canvasElement: HTMLCanvasElement;
    onMount(() => {
        // get canvas context
        let ctx = canvasElement.getContext("2d");

        // draw first line
        drawLine(ctx, [10, 20], [150, 100]);

        // draw second line
        drawLine(ctx, [10, 40], [150, 120]);
    });

    type Point = [number, number];
    function drawLine(ctx: CanvasRenderingContext2D, start: Point, end: Point) {
        ctx.beginPath();
        ctx.moveTo(...start); // line will start here
        ctx.lineTo(...end); // line ends here
        ctx.stroke(); // draw it
    }
</script>

<canvas bind:this={canvasElement} />

Читать код легче, но вся ответственность по-прежнему делегируется Canvas, а это приводит к большой сложности компонента. Избежать большой сложности помогут компоненты без отрисовки и API Context.

И вот что нам уже известно:

  • Для рисования нам нужен Canvas.

  • Получить контекст можно после монтирования компонента.

  • Дочерние компоненты монтируются перед родительским компонентом.

  • Родительские компоненты инициализируются перед дочерними компонентами.

  • Дочерние компоненты можно использовать при монтировании.

Наш компонент нужно разделить на несколько компонентов. Здесь хочется, чтобы Line рисовал сам себя.

Canvas и Line связаны. Line нельзя отрисовать без Canvas, и ему нужен контекст canvas. Но контекст недоступен, когда монтируется дочерний компонент, ведь Line монтируется перед Canvas. Поэтому подход нужен другой.

Вместо передачи контекста для отрисовки самого себя сообщим родительскому компоненту, что рисовать нужно дочерний компонент. Canvas и Line соединим через Context.

Context — это способ взаимодействия двух и более компонентов. Его можно установить или получить только во время инициализации, а это нам и нужно: Canvas инициализируется перед Line.

Сначала давайте перенесём отрисовку линии в отдельный компонент, а некоторые типы — в их собственный файл, чтобы сделать их общими для компонентов:

// src/types.ts
export type Point = [number, number];
export type DrawFn = (ctx: CanvasRenderingContext2D) => void;
export type CanvasContext = {
  addDrawFn: (fn: DrawFn) => void;
  removeDrawFn: (fn: DrawFn) => void;
};
<!-- src/lib/Line.svelte -->
<script lang="ts">
    import type { Point } from "./types";

    export let start: Point;
    export let end: Point;

    function draw(ctx: CanvasRenderingContext2D) {
        ctx.beginPath();
        ctx.moveTo(...start);
        ctx.lineTo(...end);
        ctx.stroke();
    }
</script>

Это очень похоже на то, что было в Canvas, но абстрагировано до компонента. Теперь нужно организовать коммуникацию Canvas и Line.

Canvas будет работать как оркестратор всей отрисовки. Он инициализирует все дочерние компоненты, собирает функции отрисовки и выполняет отрисовку, когда это нужно:

<script lang="ts">
  import { onMount, setContext } from "svelte";
  import type { DrawFn } from "./types";

  let canvasElement: HTMLCanvasElement;
  let fnsToDraw = [] as DrawFn[];

  setContext("canvas", {
    addDrawFn: (fn: DrawFn) => {
      fnsToDraw.push(fn);
    },
    removeDrawFn: (fn: DrawFn) => {
        let index = fnsToDraw.indexOf(fn);
        if (index > -1){
        fnsToDraw.splice(index, 1);
        }
    },
  });

  onMount(() => {
    // get canvas context
    let ctx = canvasElement.getContext("2d");
    draw(ctx);
  });

  function draw(ctx){
    fnsToDraw.forEach(draw => draw(ctx));
  }
</script>

<canvas bind:this={canvasElement} />
<slot />

Первое, что нужно отметить, — шаблон изменился, рядом с canvas появился элемент <slot>. Он будет использоваться для монтирования любых дочерних элементов, которые передаются в canvas, — это компоненты Line. Эти Line не добавят никаких элементов HTML.

Массив let fnsToDraw = [] as DrawFn[] в <script> хранит все функции отрисовки.

Мы установили и новый контекст. Делать это нужно во время инициализации. Canvas инициализируется до Line, поэтому здесь устанавливаются два метода — для добавления и удаления функции из DrawFn[]. После этого любой их дочерний компонент будет иметь доступ к этому контексту и вызывать его методы. Именно это делается в Line:

<script lang="ts">
  import { getContext, onDestroy, onMount } from "svelte";
  import type { Point, CanvasContext } from "./types";

  export let start: Point;
  export let end: Point;

  let canvasContext = getContext("canvas") as CanvasContext;

  onMount(() => {
    canvasContext.addDrawFn(draw);
  });

  onDestroy(() => {
    canvasContext.removeDrawFn(draw);
  });

  function draw(ctx: CanvasRenderingContext2D) {
    ctx.beginPath();
    ctx.moveTo(...start);
    ctx.lineTo(...end);
    ctx.stroke();
  }
</script>

Функция регистрируется с помощью контекста, установленного Canvas, когда компонент монтируется. Выполнить регистрацию можно было и при инициализации, ведь контекст доступен в любом случае, но я предпочитаю делать это после монтирования компонента. Когда элемент уничтожается, он удаляет себя из списка функций отрисовки.

А теперь дополним App компонентами Canvas и Line:

<script lang="ts">
  import Canvas from "./lib/Canvas.svelte";
  import Line from "./lib/Line.svelte";
</script>

<main>
  <Canvas>
    <Line start={[10, 20]} end={[150, 100]} />
    <Line start={[10, 40]} end={[150, 120]} />
  </Canvas>
</main>

Компонент Canvas обновлён для декларативного программирования, но рисуем мы только один раз, когда он смонтирован.

А нам нужно, чтобы canvas отрисовывался часто и обновлялся при изменениях, если только вы не хотите обратного. Обратите внимание, что частую отрисовку пришлось бы делать с выбранным подходом или без него.

И вот распространённый способ обновления содержимого canvas:

<script lang="ts">
  // NOTE: some code removed for readability
  // ...
  let frameId: number

  // ...

  onMount(() => {
    // get canvas context
    let ctx = canvasElement.getContext("2d");
    frameId = requestAnimationFrame(() => draw(ctx));
  });

  onDestroy(() => {
    if (frameId){
        cancelAnimationFrame(frameId)
    }
  })

  function draw(ctx: CanvasRenderingContext2D) {
if clearFrames {
    ctx.clearRect(0,0,canvasElement.width, canvasElement.width)
}
    fnsToDraw.forEach((fn) => fn(ctx));
    frameId = requestAnimationFrame(() => draw(ctx))
  }
</script>

Это достигается повторной отрисовкой canvas через requestAnimationFrame. Переданная функция запускается до перерисовки браузером. Новая переменная для текущего frameId потребуется при отмене анимации. Затем, когда компонент монтируется, вызывается requestAnimationFrame, и возвращённый идентификатор присваивается нашей переменной.

Пока конечный результат такой же, как и раньше. Отличие — в функции отрисовки, которая запрашивает новый кадр анимации после каждой отрисовки. Canvas очищается, а иначе при анимации каждый кадр отрисовывается поверх другого. Этот эффект может быть желательным — тогда установите clearFrame в false. Наш Canvas будет обновлять каждый кадр до уничтожения компонента и погашения текущей анимации с помощью сохранённого идентификатора.

Больше функциональности

Базовая функциональность компонентов работает, но мы можем захотеть большего.

В этом примере представлены события onmousemove и onmouseleave. Чтобы они работали, измените canvas вот так:

<canvas on:mousemove on:mouseleave bind:this={canvasElement} />

Теперь эти события можно обрабатывать в App:

<script lang="ts">
  import Canvas from "./lib/Canvas.svelte";
  import Line from "./lib/Line.svelte";
  import type { Point } from "./lib/types";

  function followMouse(e) {
    let rect = e.target.getBoundingClientRect();
    end = [e.clientX - rect.left, e.clientY - rect.top];
  }
  let start = [0, 0] as Point;
  let end = [0, 0] as Point;
</script>

<main>
  <Canvas
    on:mousemove={(e) => followMouse(e)}
    on:mouseleave={() => {
      end = [0, 0];
    }}
  >
    <Line {start} {end} />
  </Canvas>
</main>

Svelte отвечает за обновление конечного положения линии. Но Canvas используется для обновления содержимого canvas через requestAnimationFrame:

Итоги

Надеюсь, это руководство поможет вам как введение в применение canvas в Svelte, а также поможет понять, как превратить библиотеку с императивным API в более декларативную.

Есть примеры сложнее, например svelte-cubed или svelte-leaflet. Из документации svelte-cubed:

Это:

import * as THREE from 'three';

function render(element) {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
45,
element.clientWidth / element.clientHeight,
0.1,
2000
);

const renderer = new THREE.WebGLRenderer();
renderer.setSize(element.clientWidth / element.clientHeight);
element.appendChild(renderer.domElement);

const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshNormalMaterial();
const box = new THREE.Mesh(geometry, material);
scene.add(box);

camera.position.x = 2;
camera.position.y = 2;
camera.position.z = 5;

camera.lookAt(new THREE.Vector3(0, 0, 0));

renderer.render(scene, camera);
}

Превращается в:

<script>
import * as THREE from 'three';
import * as SC from 'svelte-cubed';
</script>

<SC.Canvas>
<SC.Mesh geometry={new THREE.BoxGeometry()} />
<SC.PerspectiveCamera position={[1, 1, 3]} />
</SC.Canvas>

Canvas API можно расширить и даже создать библиотеку.

А мы поможем прокачать ваши навыки или с самого начала освоить профессию, актуальную в любое время:

Комментарии (0)