В последнее время я увлекся кроссплатформенным рендерингом пользовательского интерфейса и подходом к проблеме с нижнего уровня (обычно это какой-либо графический API, в идеале — GPU). Я открыл для себя CanvasKit от Skia, который представляет собой WASM-модуль для использования их Skia API в Интернете.
Если вы не слышали о Skia, он используется в Chrome и Android для рендеринга графики (например, SVG). Это библиотека C++, которую можно использовать для «рисования» фигур, текста и других элементов на «холсте» (например, <canvas>
в DOM или даже виртуальный холст для рендеринга на стороне сервера в Node).
Я заинтересовался Skia, когда Shopify создал для него интеграцию React Native — React Native Skia. Было здорово видеть, что можно взять такую библиотеку на C++ и создать привязки для разных платформ, таких как Android или iOS, на основе их графических движков — например, OpenGL или Vulkan.
Это заставило меня задуматься — а что если создать библиотеку, которая использует Skia под капотом для рендеринга непосредственно на холст? В зависимости от цели рендеринга/платформы, можно использовать различные «мосты» к Skia API. На нативной платформе вы бы использовали React Native Skia (или аналогичную реализацию). А на веб-платформе — модуль WASM CanvasKit от Skia.
Моей целью было создать библиотеку, которую разработчик может написать в синтаксисе JSX, и вместо рендеринга в DOM (как в React для web), мы будем рендерить в элемент <canvas>
.
Хотите посмотреть код или попробовать демо-версию? Посмотрите репозиторий Github для финального кода здесь: solid-canvaskit-renderer.
Удар по нему
После небольшого исследования я обнаружил, что кто-то уже опередил меня в этом вопросе для React — react-canvaskit. Эта реализация использовала react-reconciler для создания пользовательского рендерера для React. Это тот же процесс, который используется **react-three-fiber** (aka R3F) для приведения элементов ThreeJS в React VDOM/lifecycle (например, когда вы используете <mesh>
).
По сути, react-reconciler позволяет создавать пользовательские элементы с нижним регистром (например, <ambientLight>
или <skParagraph>
). Когда эти компоненты рендерятся, они вызывают функцию или класс, которые вы указываете. В react-canvaskit они, по сути, вызывают API CanvasKit.
Автор react-canvaskit проделал большую работу по разрушению большей части архитектуры ядра (и даже основных компонентов). Мне показалось, что там не так много вклада. Но я все еще хотел испачкать руки и почерпнуть что-то из копания, поэтому я немного изменил направление своего исследования.
Универсальный рендерер SolidJS
Пару недель назад у меня было немного времени, чтобы погрузиться в SolidJS, и он кажется отличной и даже гораздо более быстрой альтернативой React. По сути, это то же самое, что и React — он использует JSX и хуки, с парой незначительных отличий.
Что действительно привлекло меня в SolidJS, так это универсальный рендерер. Это как возможность создать свою собственную версию React DOM.
По умолчанию SolidJS ориентирован на веб и использует DOM API для рендеринга вашего приложения. Поэтому, когда вы пишете <div>
— он знает, что на самом деле нужно сказать document.createElement('div')
. Это похоже на React, если вы когда-нибудь смотрели на скомпилированную версию приложения React.
Вместо того чтобы использовать DOM, с универсальным рендерером вы можете нацелиться на что угодно! Вы можете создать «виртуальный» DOM, который можно разобрать с помощью фреймворка для тестирования или рендеринга на стороне сервера. Или, в данном случае, вы можете нацелиться непосредственно на <canvas>
.
Технически мы все еще используем DOM, поскольку мы пишем HTML и JavaScript (и в конечном итоге отображаем этот код в браузере). Но мы избавились от накладных расходов на DOM и времени, которое требуется браузеру для создания новых элементов DOM (а также для их изменения по мере обновления реквизитов компонента Solid).
Когда я впервые искал универсальный рендерер, я нашел **solid-three,** который является универсальным рендерером для ThreeJS. Он фактически основан на react-three-fiber и имеет схожую архитектуру и API (использование Zustand для хранения контекста сцены, или даже useFrame
). Это могло бы стать отличным ресурсом для создания моего собственного рендерера, который отказался бы от DOM.
Первые эксперименты
Я начал с того, что сделал несколько начальных прототипов, которые проверяли, что определенные процессы работают. Это были просто быстрые проверки на вменяемость, такие как изучение руководства по началу работы с CanvasKit и попытка самостоятельного использования библиотеки.
Для этого я запустил быстрое приложение NextJS, установил модуль CanvasKit WASM из NPM и попробовал использовать API внутри некоторых компонентов. Вот коммит с этой первой попыткой. Все работало отлично — после того, как я разобрался в некоторых проблемах с NextJS и модулями WASM (в какой-то момент я практически отключился и просто использовал CDN-версию модуля WASM).
Когда все заработало, я абстрагировал процесс инициализации до провайдера React Context и хука (useCanvasKit
). Это позволило мне захватить контекст в любом компоненте и «рисовать» на нем.
Полный код этого начального прототипа вы можете найти здесь.
Реализация Solid
Ранее я уже занимался исследованием SolidJS и универсального рендеринга, где я пытался создать версию ThreeJS (до того, как я понял, что такая версия существует). Тот проект представлял собой монорепо с пакетом для универсального рендера и «демо» приложением Vite для тестирования рендера. По сути, я просто создал новое приложение Vite, используя их шаблон SolidJS. А для монорепо — я просто скопировал из своей горы предыдущего кода монорепо.
Имея эту основу, я начал создавать рендерер. Честно говоря, это был очень простой процесс с помощью API, который они предоставляют:
// example custom dom renderer
import { createRenderer } from "solid-js/universal";
const PROPERTIES = new Set(["className", "textContent"]);
export const {
render,
effect,
memo,
createComponent,
createElement,
createTextNode,
insertNode,
insert,
spread,
setProp,
mergeProps
} = createRenderer({
createElement(string) {
return document.createElement(string);
},
createTextNode(value) {
return document.createTextNode(value);
},
replaceText(textNode, value) {
textNode.data = value;
},
setProperty(node, name, value) {
if (name === "style") Object.assign(node.style, value);
else if (name.startsWith("on")) node[name.toLowerCase()] = value;
else if (PROPERTIES.has(name)) node[name] = value;
else node.setAttribute(name, value);
},
insertNode(parent, node, anchor) {
parent.insertBefore(node, anchor);
},
isTextNode(node) {
return node.type === 3;
},
removeNode(parent, node) {
parent.removeChild(node);
},
getParentNode(node) {
return node.parentNode;
},
getFirstChild(node) {
return node.firstChild;
},
getNextSibling(node) {
return node.nextSibling;
}
});
Первым делом я взял каждый метод и console.log
вывел параметры. Это дало бы мне представление о том, когда все работает, и что когда нужно.
createElement(string) {
console.log(string);
},
Я быстро обнаружил, что мой monorepo не был настроен должным образом, и мне нужно было определить пользовательский конфиг Babel, который указывал Solid на мой пользовательский рендерер. Спасибо сообществу SolidJS Discord за подсказку:
import { defineConfig } from "vite"
import solidPlugin from "vite-plugin-solid"
export default defineConfig({
plugins: [
solidPlugin({
solid: {
generate: "universal",
renderers: [
{
name: "universal",
moduleName: "solid-canvaskit-renderer",
elements: []
}
]
}
})
]
})
Без этого конфига SolidJS продолжал использовать DOM, поэтому когда я создавал <testelement>
— DOM действительно создавал его (вместо того, чтобы вызвать мой метод createElement
в моем пользовательском рендере).
Как только это было решено, я быстро смог подключить API CanvasKit к процессу createElement
. Я создал компонент <skCanvas>
и <skGradient>
. Класс Canvas прошел через процесс инициализации CanvasKit (ака запуск init()
на CanvasKit, подключение его к элементу <canvas>
, выбор бэкенда, например OpenGL, и т.д.). Класс Gradient визуализирует градиент из примеров документации.
Я создал компонент, который использует эти новые пользовательские элементы:
function App() {
return (
<skCanvas>
<skGradient />
</skCanvas>
);
}
Этот процесс не сработал… но вроде как сработал? При первоначальном рендеринге приложения ничего не происходит (никаких ошибок, только чистый холст). Но когда вы обновляете компонент, приложение правильно отображает компоненты на холсте.
Изучив логи, я обнаружил, что SolidJS создает элементы (createElement
), затем вставляет их в ваше «дерево» (insertBefore
). На этапе createElement
происходит инициализация холста и рендеринг каждого компонента. Я переместил рендеринг компонента в insertBefore
, чтобы дать холсту время на инициализацию — но и это не сработало.
Проблема кроется в методе CanvasKit init()
. Это Promise
— то есть его нужно await
-ed для того, чтобы приложение выполнялось последовательно.
Я пытался вынести инициализацию в отдельный метод, чтобы пользователь мог вызвать его перед рендерингом — но это казалось неправильным? Не кажется ли мне хорошей практикой задерживать рендеринг до инициализации? В идеале дерево должно формироваться и ждать рендеринга, пока холст не будет готов (так вещи не «приостанавливаются» и делают несколько вещей одновременно — например, инициализируют свои элементы).
Чтобы решить эту проблему, мне пришлось на некоторое время вынуть мозг из рендерера и вернуться в страну Solid. Я все пытался найти способ заставить рендерер признать инициализацию холста — но не было ни одного метода, который бы упростил эту задачу. Вместо этого я создал компонент SolidJS <Canvas>
, который обрабатывает инициализацию (вместо пользовательского элемента типа <skCanvas>
). Таким образом, я мог использовать свойство Solid children
и приостановить рендеринг до завершения инициализации.
export const Canvas = ({children}: Props) => {
const [initialized, setInitialized] = createSignal(false);
console.log('[CANVAS] children', children);
/**
* If there's only 1 child, make it an array so we can loop over it
* @param childrenCheck
* @returns
*/
const checkChildren = (childrenCheck: ResolvedChildren) => {
if(!Array.isArray(childrenCheck)) return [childrenCheck];
return childrenCheck;
}
/**
* Initialize CanvasKit and connect `<canvas>` element
*/
createEffect(async () => {
if(!initialized()) {
// This is the "await" I mentioned earlier
await initializeCanvas()
setInitialized(true);
}
})
/**
* Run the render method on all children (aka "drawing" all child elements)
*/
const memo = solidChildren(() => children);
createEffect(() => {
// We should only be rendering when the canvas is initialized!
// Otherwise nothing renders unless props change
if(initialized()) {
console.log('[CANVAS-C] RENDERING CHILDREN!');
const realChildren = memo();
// @ts-ignore Not sure where to wire this up...but this does return SkNode, not JSXElement
let childrenMap = checkChildren(realChildren) as SkNode[];
childrenMap?.forEach((c: SkNode) => c.render())
}
})
return (
<></>
)
}
export default Canvas;
Это решение отлично сработало и позволило приложению инициализироваться должным образом.
Подключение реквизитов
Единственное, чего я не понимал в рендере Solid — как реквизиты передаются элементам? Я заметил, что рендерер имеет метод setProperty
, который использует API DOM setAttribute
для присвоения свойства элементу DOM (очень похоже на Vue или Web Component). Я предполагал, что элемент DOM будет иметь доступ к своим собственным атрибутам (aka «props»).
Я не упомянул ранее, как работает мой пользовательский рендерер, он использует специальный класс «Node», который я создал. Он выступает в качестве заменителя API Element
DOM. Но в моем случае мне нужно только инициализировать и рендерить экземпляры классов CanvasKit, которые я создал (например, SkText
). Поэтому мой базовый класс SkNode
имеет только initialize()
и render()
.
Чтобы отслеживать реквизиты, я создал свойство props
в классе SkNode
:
export class SkNode implements SkBase {
initialize() {};
render() {};
// Component props (aka React/Solid props)
props: Map<string, any> = new Map();
setProp(name: string, value: any) {
this.props.set(name, value);
};
getProp(name: string) {
this.props.get(name);
};
}
Очень простые методы getter/setter, использующие Map
для хранения реквизитов. И с помощью этого я смог установить свойства с помощью метода рендерера setProperty
, а затем получить к ним доступ в классе компонента с помощью this.props.get(propName)
:
import store from "../store";
import { SkNode } from "./SkNode";
export default class SkGradient extends SkNode {
render() {
// ...code cut out...
const colors = this.props.get('color') ?? ['RED', 'GREEN', 'BLUE'];
// ...code cut out...
}
}
И волшебство, которое помогло этому сработать — я также вызываю метод рендеринга компонента после изменения свойства. Таким образом, в идеале перерисовывается только измененный компонент.
setProperty(node: SkNode, name: string, value: any) {
log('Setting prop', node, name, value);
node.setProp(name, value);
// Re-render component
node.render();
},
Что дальше?
Это был POC для изучения возможности такого рода «стека». Я бы хотел провести несколько тестов на производительность, чтобы понять, действительно ли это более производительно, чем классический DOM. Также я хотел бы попробовать написать целое приложение с использованием этой системы, чтобы понять, какая архитектура приложений и компонентов потребуется для организации всего этого.
Советы и хитрости
- Вам не нужно реализовывать все методы рендеринга! Большинство из них необязательны, особенно если у вас нет таких вещей, как родительские/детские отношения между компонентами или реквизиты компонентов.
- Аналогично, вам не обязательно нужно (виртуальное или нет) дерево DOM/узлов. Если ваш графический API представляет собой плоский граф сцены, вам не нужно беспокоиться обо всех методах «родитель»/»ребенок». Если вам нужен виртуальный DOM, в моем шаблоне есть класс
VNode
, который имеет большинство методов, которые вам понадобятся. - SolidJS проходит через дерево компонентов как гигантский стек вызовов функций, сначала начиная с
createElement
, затемinsertBefore
и т.д. - По-видимому, в браузерах отсутствуют некоторые функции, которые заставляют CanvasKit использовать некоторые API на основе JS (вместо кода WASM) — что неизбежно может способствовать возникновению узкого места.
Все универсально!
Хотите сделать свой собственный универсальный рендерер на SolidJS? Я создал шаблон на основе ранней итерации моего рендера CanvasKit (в основном на начальном этапе console.log
). Это должен быть чистый*-почти* лист, чтобы вы могли начать подключать любой API, какой захотите! Клонируйте и пробуйте код здесь, на Github.
Kanpai,
Ryo
Ссылки
- solid-canvaskit-renderer
- solid-universal-renderer-template
- CanvasKit — Skia + WebAssembly
- Пример CanvasKit на JSFiddle
- canvaskit-wasm на NPM
- Примеры Skia C++
- SolidJS
- Webpack 5 ломает динамический импорт wasm для SSR