← Курс/Custom Renderer: рендеринг вне браузера#249 из 257+40 XP

Custom Renderer: рендеринг вне браузера

Что такое Custom Renderer

Vue 3 разделяет рантайм на две части:

  • **@vue/runtime-core** — ядро (реактивность, компоненты, жизненный цикл)
  • **@vue/runtime-dom** — DOM-рендерер (работает с браузерным DOM)
  • Вы можете создать свой рендерер, заменив операции с DOM на операции с любой другой "платформой": Canvas, WebGL, Terminal, iOS/Android (через NativeScript/Capacitor).

    createRenderer() API

    import { createRenderer } from '@vue/runtime-core'
    
    const { render, createApp } = createRenderer({
      // nodeOps — объект с операциями над "узлами"
    
      // Создать элемент
      createElement(type, isSVG, isCustom) {
        return createMyNode(type)
      },
    
      // Создать текстовый узел
      createText(text) {
        return createMyTextNode(text)
      },
    
      // Вставить узел в родителя
      insert(el, parent, anchor) {
        parent.insertBefore(el, anchor)
      },
    
      // Удалить узел
      remove(el) {
        el.parent?.removeChild(el)
      },
    
      // Установить свойство/атрибут
      patchProp(el, key, prevValue, nextValue) {
        el[key] = nextValue
      },
    
      // Установить текст
      setElementText(el, text) {
        el.textContent = text
      },
    
      // Создать комментарий (для v-if пустых веток)
      createComment(text) {
        return { type: 'comment', text }
      },
    
      // ... ещё несколько методов
    })

    Реальные примеры использования

    Canvas Renderer

    // Рендерим Vue компоненты на HTML5 Canvas
    const canvasRenderer = createRenderer({
      createElement(type) {
        return new CanvasElement(type)  // прямоугольник, круг и т.д.
      },
      patchProp(el, key, prev, next) {
        if (key === 'fill') el.style.fill = next
        if (key === 'x') el.x = next
      },
      insert(child, parent) {
        parent.addChild(child)
        canvas.requestRender()
      },
      // ...
    })

    Terminal Renderer (как ink в React)

    // Рендеринг в терминал через blessed/ink-подобные библиотеки
    const termRenderer = createRenderer({
      createElement(type) {
        if (type === 'box')  return blessed.box()
        if (type === 'text') return blessed.text()
      },
      patchProp(el, key, prev, next) {
        if (key === 'content') el.setContent(next)
        if (key === 'style')   el.style = next
      },
      // ...
    })

    Как работает DOM-рендерер Vue

    DOM-рендерер Vue — это именно createRenderer с nodeOps для браузера:

    // Упрощённо — что делает @vue/runtime-dom:
    const { render } = createRenderer({
      createElement: (tag) => document.createElement(tag),
      createText: (text) => document.createTextNode(text),
      insert: (el, parent, anchor) => parent.insertBefore(el, anchor || null),
      remove: (el) => el.parentNode?.removeChild(el),
      patchProp: (el, key, prev, next) => {
        if (key === 'class') el.className = next
        else if (key.startsWith('on')) el.addEventListener(key.slice(2).toLowerCase(), next)
        else el.setAttribute(key, next)
      },
      setElementText: (el, text) => { el.textContent = text },
      createComment: (text) => document.createComment(text),
      querySelector: (sel) => document.querySelector(sel),
      parentNode: (el) => el.parentNode,
      nextSibling: (el) => el.nextSibling,
    })

    Библиотеки на основе Custom Renderer

  • **TresJS** — Vue + Three.js (3D графика)
  • **@vue/vue3-pixi** — Vue + PixiJS (2D Canvas)
  • **Revili** — Vue для iOS/Android
  • **Vue Terminal UI** — Terminal renderer
  • Примеры

    Реализация Custom Renderer — рендеринг Vue-подобных компонентов в строковое "дерево" вместо DOM

    // Реализуем кастомный рендерер: вместо DOM используем
    // дерево JavaScript-объектов. Это демонстрирует,
    // как createRenderer() работает под капотом.
    
    // =====================================================
    // Node операции (nodeOps) — платформо-специфичная часть
    // =====================================================
    
    const StringNodeOps = {
      // Создать "элемент"
      createElement(type) {
        return {
          type,
          props: {},
          children: [],
          parent: null,
          _text: null,
        }
      },
    
      // Создать текстовый узел
      createText(text) {
        return { type: '#text', text, parent: null }
      },
    
      // Создать комментарий (для v-if пустых блоков)
      createComment(text) {
        return { type: '#comment', text, parent: null }
      },
    
      // Вставить дочерний элемент в родителя
      insert(el, parent, anchor = null) {
        if (el.parent) {
          const idx = el.parent.children.indexOf(el)
          if (idx !== -1) el.parent.children.splice(idx, 1)
        }
        el.parent = parent
    
        if (anchor) {
          const anchorIdx = parent.children.indexOf(anchor)
          parent.children.splice(anchorIdx, 0, el)
        } else {
          parent.children.push(el)
        }
      },
    
      // Удалить элемент
      remove(el) {
        if (el.parent) {
          const idx = el.parent.children.indexOf(el)
          if (idx !== -1) el.parent.children.splice(idx, 1)
          el.parent = null
        }
      },
    
      // Применить prop/атрибут
      patchProp(el, key, prevVal, nextVal) {
        if (nextVal === null || nextVal === undefined) {
          delete el.props[key]
        } else {
          el.props[key] = nextVal
        }
      },
    
      // Установить текстовое содержимое
      setElementText(el, text) {
        el.children = []
        el._text = text
      },
    
      // Получить текст
      getText(el) {
        return el._text
      },
    
      // Родительский узел
      parentNode(el) {
        return el.parent
      },
    
      // Следующий сиблинг
      nextSibling(el) {
        if (!el.parent) return null
        const idx = el.parent.children.indexOf(el)
        return el.parent.children[idx + 1] || null
      },
    }
    
    // =====================================================
    // Движок рендеринга (аналог @vue/runtime-core)
    // =====================================================
    
    function createCustomRenderer(nodeOps) {
      function render(vnode, container) {
        if (vnode == null) {
          // Unmount
          if (container._vnode) unmount(container._vnode)
        } else {
          patch(container._vnode, vnode, container)
        }
        container._vnode = vnode
      }
    
      function patch(n1, n2, container, anchor = null) {
        if (n2 == null) return
        if (typeof n2 === 'string' || typeof n2 === 'number') {
          const textNode = nodeOps.createText(String(n2))
          nodeOps.insert(textNode, container, anchor)
          return
        }
    
        if (typeof n2.type === 'function') {
          // Компонент
          patchComponent(n1, n2, container, anchor)
          return
        }
    
        if (n1 == null) {
          mountElement(n2, container, anchor)
        } else {
          updateElement(n1, n2, container)
        }
      }
    
      function mountElement(vnode, container, anchor) {
        const el = nodeOps.createElement(vnode.type)
        vnode.el = el
    
        // Применяем props
        for (const [key, val] of Object.entries(vnode.props || {})) {
          if (key !== 'children') nodeOps.patchProp(el, key, null, val)
        }
    
        // Монтируем детей
        const children = vnode.children || []
        if (typeof children === 'string') {
          nodeOps.setElementText(el, children)
        } else {
          children.forEach(child => patch(null, child, el))
        }
    
        nodeOps.insert(el, container, anchor)
      }
    
      function updateElement(n1, n2, container) {
        const el = n2.el = n1.el
    
        // Patch props
        const oldProps = n1.props || {}
        const newProps = n2.props || {}
        for (const [k, v] of Object.entries(newProps)) {
          if (oldProps[k] !== v) nodeOps.patchProp(el, k, oldProps[k], v)
        }
        for (const k of Object.keys(oldProps)) {
          if (!(k in newProps)) nodeOps.patchProp(el, k, oldProps[k], null)
        }
      }
    
      function patchComponent(n1, n2, container, anchor) {
        const rendered = n2.type(n2.props || {})
        patch(null, rendered, container, anchor)
      }
    
      function unmount(vnode) {
        if (vnode.el) nodeOps.remove(vnode.el)
      }
    
      return { render }
    }
    
    // =====================================================
    // Сериализатор — рендер в строку
    // =====================================================
    
    function serialize(node, indent = 0) {
      if (!node) return ''
      const pad = '  '.repeat(indent)
    
      if (node.type === '#text') return pad + node.text
      if (node.type === '#comment') return pad + `<!-- ${node.text} -->`
    
      const attrs = Object.entries(node.props)
        .map(([k, v]) => `${k}="${v}"`)
        .join(' ')
      const tag = node.type + (attrs ? ' ' + attrs : '')
    
      if (node._text) return `${pad}<${tag}>${node._text}</${node.type}>`
    
      if (node.children.length === 0) return `${pad}<${tag} />`
    
      const inner = node.children.map(c => serialize(c, indent + 1)).join('\n')
      return `${pad}<${tag}>\n${inner}\n${pad}</${node.type}>`
    }
    
    // =====================================================
    // Использование Custom Renderer
    // =====================================================
    
    const renderer = createCustomRenderer(StringNodeOps)
    
    // Создаём "корневой контейнер"
    const root = StringNodeOps.createElement('root')
    
    // VNode-дерево
    function App(props) {
      return {
        type: 'div',
        props: { id: 'app', class: 'container' },
        children: [
          {
            type: 'h1',
            props: { class: 'title' },
            children: 'Привет, Custom Renderer!'
          },
          {
            type: 'ul',
            props: { class: 'list' },
            children: (props.items || []).map((item, i) => ({
              type: 'li',
              props: { class: i % 2 === 0 ? 'even' : 'odd' },
              children: item,
            }))
          },
          {
            type: 'footer',
            props: {},
            children: '© 2024'
          }
        ]
      }
    }
    
    // Первый рендер
    renderer.render(
      { type: App, props: { items: ['Vue', 'React', 'Angular', 'Svelte'] } },
      root
    )
    
    console.log('=== Custom Renderer Output ===')
    console.log(serialize(root))
    
    console.log('\n=== Статистика дерева ===')
    function countNodes(node) {
      if (!node || !node.children) return 1
      return 1 + node.children.reduce((sum, c) => sum + countNodes(c), 0)
    }
    console.log('Всего узлов в дереве:', countNodes(root))
    console.log('Потомков корня:', root.children.length)