慈溪市中国转运网

Vue createRenderer 自定义渲染器从入门到实战

2026-04-03 08:33:02 浏览次数:0
详细信息
Vue 自定义渲染器:从入门到实战

一、什么是自定义渲染器

Vue 3 的核心渲染逻辑被设计为与平台无关的。createRenderer API 允许你创建自定义的渲染器,将 Vue 的虚拟 DOM 渲染到不同的目标环境,而不仅仅是浏览器 DOM。

二、基础概念

核心 API

import { createRenderer } from 'vue'

const { render, createApp } = createRenderer({
  // 渲染器选项
  patchProp,
  insert,
  remove,
  createElement,
  // ...
})

渲染器选项

必须实现的几个核心函数:

函数 说明
createElement 创建元素
createText 创建文本节点
patchProp 更新属性/属性
insert 插入节点
remove 删除节点
setElementText 设置元素文本
createComment 创建注释

三、入门示例:控制台渲染器

让我们创建一个将 Vue 组件渲染到控制台的简单渲染器:

import { createRenderer, h } from 'vue'

// 定义节点类型
const NodeTypes = {
  ELEMENT: 'ELEMENT',
  TEXT: 'TEXT',
  COMMENT: 'COMMENT'
}

// 创建自定义渲染器
const { createApp } = createRenderer({
  // 创建元素
  createElement(type, isSVG, isCustomizedBuiltIn) {
    return {
      type: NodeTypes.ELEMENT,
      tag: type,
      children: [],
      props: {}
    }
  },

  // 创建文本节点
  createText(text) {
    return {
      type: NodeTypes.TEXT,
      content: text
    }
  },

  // 创建注释节点
  createComment(text) {
    return {
      type: NodeTypes.COMMENT,
      content: text
    }
  },

  // 插入节点
  insert(child, parent, anchor) {
    if (!parent.children) parent.children = []

    if (anchor) {
      const index = parent.children.indexOf(anchor)
      parent.children.splice(index, 0, child)
    } else {
      parent.children.push(child)
    }

    // 记录插入操作
    console.log(`插入 ${child.type} 到 ${parent.tag || '根节点'}`)
  },

  // 删除节点
  remove(child) {
    const parent = child.parent
    if (parent && parent.children) {
      const index = parent.children.indexOf(child)
      if (index > -1) {
        parent.children.splice(index, 1)
      }
    }
    console.log(`删除 ${child.type}`)
  },

  // 设置元素文本
  setElementText(el, text) {
    el.children = [{
      type: NodeTypes.TEXT,
      content: text
    }]
    console.log(`设置 ${el.tag} 的文本为: "${text}"`)
  },

  // 更新属性
  patchProp(el, key, prevValue, nextValue) {
    if (!el.props) el.props = {}

    if (nextValue === null || nextValue === undefined) {
      delete el.props[key]
      console.log(`删除属性 ${key}`)
    } else {
      el.props[key] = nextValue
      console.log(`设置属性 ${key} = ${JSON.stringify(nextValue)}`)
    }
  }
})

// 创建 Vue 组件
const App = {
  setup() {
    const count = ref(0)

    const increment = () => {
      count.value++
    }

    return () => h('div', { class: 'container' }, [
      h('h1', `计数: ${count.value}`),
      h('button', { onClick: increment }, '增加'),
      h('p', '这是一个控制台渲染的Vue应用')
    ])
  }
}

// 渲染到控制台
const app = createApp(App)
const container = { type: 'ROOT', children: [] }
app.mount(container)

console.log('渲染结果:', JSON.stringify(container, null, 2))

四、实战:Canvas 渲染器

让我们创建一个更实用的 Canvas 渲染器:

import { createRenderer, h, ref, onMounted } from 'vue'

class CanvasRenderer {
  constructor(canvas) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.elements = new Map()
  }

  drawRect(node) {
    const { x, y, width, height, fill, stroke } = node.props
    const ctx = this.ctx

    if (fill) {
      ctx.fillStyle = fill
      ctx.fillRect(x, y, width, height)
    }

    if (stroke) {
      ctx.strokeStyle = stroke.color || '#000'
      ctx.lineWidth = stroke.width || 1
      ctx.strokeRect(x, y, width, height)
    }
  }

  drawText(node) {
    const { x, y, text, fontSize, color } = node.props
    const ctx = this.ctx

    ctx.font = `${fontSize || 16}px Arial`
    ctx.fillStyle = color || '#000'
    ctx.fillText(text, x, y)
  }

  drawCircle(node) {
    const { x, y, radius, fill, stroke } = node.props
    const ctx = this.ctx

    ctx.beginPath()
    ctx.arc(x, y, radius, 0, Math.PI * 2)

    if (fill) {
      ctx.fillStyle = fill
      ctx.fill()
    }

    if (stroke) {
      ctx.strokeStyle = stroke.color || '#000'
      ctx.lineWidth = stroke.width || 1
      ctx.stroke()
    }
  }
}

const { createApp } = createRenderer({
  createElement(type) {
    return {
      type,
      children: [],
      props: {},
      canvasId: `canvas-${Date.now()}-${Math.random()}`
    }
  },

  createText(text) {
    return {
      type: 'text',
      props: { text }
    }
  },

  insert(child, parent, anchor) {
    if (!parent.children) parent.children = []

    if (anchor) {
      const index = parent.children.indexOf(anchor)
      parent.children.splice(index, 0, child)
    } else {
      parent.children.push(child)
    }

    child.parent = parent
  },

  remove(child) {
    const parent = child.parent
    if (parent && parent.children) {
      const index = parent.children.indexOf(child)
      if (index > -1) {
        parent.children.splice(index, 1)
      }
    }
  },

  setElementText(el, text) {
    el.props.text = text
  },

  patchProp(el, key, prevValue, nextValue) {
    if (nextValue === null || nextValue === undefined) {
      delete el.props[key]
    } else {
      el.props[key] = nextValue
    }

    // 标记需要重绘
    if (el.root && el.root.renderer) {
      el.root.renderer.requestRender()
    }
  },

  // 自定义的创建画布方法
  createCanvas(width, height) {
    const canvas = document.createElement('canvas')
    canvas.width = width
    canvas.height = height
    return canvas
  }
})

// 创建 Canvas 组件
const CanvasApp = {
  setup() {
    const count = ref(0)
    const canvasRef = ref(null)
    let renderer = null

    onMounted(() => {
      renderer = new CanvasRenderer(canvasRef.value)

      // 初始渲染
      render()
    })

    const increment = () => {
      count.value++
    }

    const render = () => {
      if (!renderer) return

      const ctx = renderer.ctx
      ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)

      // 绘制背景
      ctx.fillStyle = '#f0f0f0'
      ctx.fillRect(0, 0, canvasRef.value.width, canvasRef.value.height)

      // 绘制计数
      ctx.font = '24px Arial'
      ctx.fillStyle = '#333'
      ctx.fillText(`计数: ${count.value}`, 50, 50)

      // 绘制按钮
      ctx.fillStyle = '#4CAF50'
      ctx.fillRect(50, 80, 100, 40)
      ctx.fillStyle = 'white'
      ctx.font = '16px Arial'
      ctx.fillText('增加', 85, 105)

      // 绘制圆形
      ctx.beginPath()
      ctx.arc(150, 200, 30 + count.value * 2, 0, Math.PI * 2)
      ctx.fillStyle = '#2196F3'
      ctx.fill()
    }

    // 监听 count 变化
    watch(count, () => {
      if (renderer) render()
    })

    return () => h('div', [
      h('canvas', {
        ref: canvasRef,
        width: 400,
        height: 300,
        style: 'border: 1px solid #ccc'
      }),
      h('button', {
        onClick: increment,
        style: 'margin-top: 10px; padding: 10px 20px;'
      }, '增加计数')
    ])
  }
}

五、更高级的 Canvas 渲染器集成

创建一个完整的 Canvas 渲染系统:

// canvas-renderer.js
export function createCanvasRenderer(options = {}) {
  const {
    createCanvas = (w, h) => {
      const canvas = document.createElement('canvas')
      canvas.width = w
      canvas.height = h
      return canvas
    },
    getContext = '2d'
  } = options

  const nodeOps = {
    createElement(type) {
      return {
        type,
        tag: type,
        children: [],
        props: {},
        style: {},
        canvasNode: true
      }
    },

    createText(text) {
      return {
        type: 'TEXT',
        content: text,
        canvasNode: true
      }
    },

    createComment(text) {
      return {
        type: 'COMMENT',
        content: text,
        canvasNode: true
      }
    },

    insert(child, parent, anchor) {
      if (!parent.children) parent.children = []

      const index = anchor 
        ? parent.children.indexOf(anchor)
        : parent.children.length

      if (index >= 0) {
        parent.children.splice(index, 0, child)
      } else {
        parent.children.push(child)
      }

      child.parent = parent

      // 触发重绘
      if (parent.root && parent.root.ctx) {
        requestAnimationFrame(() => this.render(parent.root))
      }
    },

    remove(child) {
      const parent = child.parent
      if (parent && parent.children) {
        const index = parent.children.indexOf(child)
        if (index > -1) {
          parent.children.splice(index, 1)

          // 触发重绘
          if (parent.root && parent.root.ctx) {
            requestAnimationFrame(() => this.render(parent.root))
          }
        }
      }
    },

    setElementText(el, text) {
      el.textContent = text
      if (el.parent && el.parent.root && el.parent.root.ctx) {
        requestAnimationFrame(() => this.render(el.parent.root))
      }
    },

    patchProp(el, key, prevValue, nextValue) {
      if (key.startsWith('on')) {
        // 处理事件
        const eventName = key.slice(2).toLowerCase()
        if (prevValue) {
          el.removeEventListener(eventName, prevValue)
        }
        if (nextValue) {
          el.addEventListener(eventName, nextValue)
        }
      } else if (key === 'style') {
        Object.assign(el.style, nextValue)
      } else {
        el.props[key] = nextValue
      }

      // 触发重绘
      if (el.root && el.root.ctx) {
        requestAnimationFrame(() => this.render(el.root))
      }
    },

    // 渲染方法
    render(root) {
      const ctx = root.ctx
      const width = root.canvas.width
      const height = root.canvas.height

      // 清空画布
      ctx.clearRect(0, 0, width, height)

      // 绘制背景
      if (root.props.background) {
        ctx.fillStyle = root.props.background
        ctx.fillRect(0, 0, width, height)
      }

      // 递归绘制所有子节点
      this.drawNode(root, ctx)
    },

    drawNode(node, ctx) {
      if (!node) return

      // 保存上下文状态
      ctx.save()

      // 应用样式
      if (node.style) {
        Object.entries(node.style).forEach(([key, value]) => {
          if (key in ctx) {
            ctx[key] = value
          }
        })
      }

      // 根据类型绘制
      switch (node.type) {
        case 'rect':
          this.drawRect(node, ctx)
          break
        case 'circle':
          this.drawCircle(node, ctx)
          break
        case 'text':
          this.drawText(node, ctx)
          break
        case 'path':
          this.drawPath(node, ctx)
          break
      }

      // 绘制子节点
      if (node.children) {
        node.children.forEach(child => this.drawNode(child, ctx))
      }

      // 恢复上下文状态
      ctx.restore()
    },

    drawRect(node, ctx) {
      const { x = 0, y = 0, width = 100, height = 100 } = node.props
      const { fill, stroke } = node.props

      if (fill) {
        ctx.fillStyle = fill
        ctx.fillRect(x, y, width, height)
      }

      if (stroke) {
        ctx.strokeStyle = stroke.color || '#000'
        ctx.lineWidth = stroke.width || 1
        ctx.strokeRect(x, y, width, height)
      }
    },

    drawCircle(node, ctx) {
      const { x = 50, y = 50, radius = 30 } = node.props
      const { fill, stroke } = node.props

      ctx.beginPath()
      ctx.arc(x, y, radius, 0, Math.PI * 2)

      if (fill) {
        ctx.fillStyle = fill
        ctx.fill()
      }

      if (stroke) {
        ctx.strokeStyle = stroke.color || '#000'
        ctx.lineWidth = stroke.width || 1
        ctx.stroke()
      }
    },

    drawText(node, ctx) {
      const { x = 0, y = 0, text = '' } = node.props
      const { fontSize = 16, fontFamily = 'Arial', color = '#000' } = node.props

      ctx.font = `${fontSize}px ${fontFamily}`
      ctx.fillStyle = color
      ctx.fillText(text, x, y)
    }
  }

  const { createApp } = createRenderer(nodeOps)

  return {
    createApp,
    nodeOps
  }
}

// 使用示例
import { createCanvasRenderer } from './canvas-renderer'
import { h, defineComponent, ref } from 'vue'

const { createApp } = createCanvasRenderer()

const CanvasDemo = defineComponent({
  setup() {
    const x = ref(100)
    const y = ref(100)
    const colorIndex = ref(0)
    const colors = ['#FF6B6B', '#4ECDC4', '#FFE66D', '#1A535C']

    const moveCircle = (dx, dy) => {
      x.value += dx
      y.value += dy
    }

    const changeColor = () => {
      colorIndex.value = (colorIndex.value + 1) % colors.length
    }

    return () => h('canvas-root', {
      width: 800,
      height: 600,
      background: '#f8f9fa'
    }, [
      h('rect', {
        x: 50,
        y: 50,
        width: 200,
        height: 100,
        fill: '#E9ECEF',
        stroke: { color: '#495057', width: 2 }
      }),
      h('circle', {
        x: x.value,
        y: y.value,
        radius: 40,
        fill: colors[colorIndex.value],
        stroke: { color: '#343A40', width: 3 }
      }),
      h('text', {
        x: 300,
        y: 100,
        text: 'Vue Canvas 渲染器',
        fontSize: 24,
        color: '#212529'
      }),
      h('text', {
        x: 300,
        y: 140,
        text: `位置: (${x.value}, ${y.value})`,
        fontSize: 16,
        color: '#495057'
      })
    ])
  }
})

// 挂载到实际的 canvas 元素
const canvas = document.getElementById('app-canvas')
const app = createApp(CanvasDemo)

// 自定义挂载方法
app.mount = function(container) {
  container.width = 800
  container.height = 600
  container.root = {
    canvas: container,
    ctx: container.getContext('2d'),
    type: 'canvas-root',
    children: []
  }

  // 初始渲染
  requestAnimationFrame(() => {
    app._instance = this
    app._container = container
    nodeOps.render(container.root)
  })

  return app
}

app.mount(canvas)

六、实战:PDF 渲染器

import { createRenderer, h } from 'vue'
import PDFDocument from 'pdfkit'
import fs from 'fs'

function createPDFRenderer() {
  const nodeOps = {
    createElement(type) {
      return { type, children: [], props: {} }
    },

    createText(text) {
      return { type: 'text', content: text }
    },

    insert(child, parent) {
      if (!parent.children) parent.children = []
      parent.children.push(child)
    },

    patchProp(el, key, prevValue, nextValue) {
      el.props[key] = nextValue
    },

    setElementText(el, text) {
      el.content = text
    },

    createPDF() {
      const doc = new PDFDocument()
      const stream = fs.createWriteStream('output.pdf')
      doc.pipe(stream)

      return { doc, stream }
    },

    render(node, pdf) {
      this.renderNode(node, pdf.doc)
      pdf.doc.end()
    },

    renderNode(node, doc) {
      switch (node.type) {
        case 'document':
          node.children.forEach(child => this.renderNode(child, doc))
          break
        case 'page':
          doc.addPage()
          node.children.forEach(child => this.renderNode(child, doc))
          break
        case 'text':
          doc.text(node.content || '', node.props.x || 0, node.props.y || 0, {
            width: node.props.width,
            align: node.props.align
          })
          break
        case 'rect':
          doc.rect(node.props.x || 0, node.props.y || 0, 
                  node.props.width || 100, node.props.height || 100)
          if (node.props.fill) {
            doc.fill(node.props.fill)
          }
          if (node.props.stroke) {
            doc.stroke()
          }
          break
        case 'image':
          if (node.props.src) {
            doc.image(node.props.src, node.props.x || 0, node.props.y || 0, {
              width: node.props.width,
              height: node.props.height
            })
          }
          break
      }
    }
  }

  return createRenderer(nodeOps)
}

// 使用 PDF 渲染器
const { createApp } = createPDFRenderer()

const PDFApp = {
  render() {
    return h('document', [
      h('page', { size: 'A4' }, [
        h('text', {
          x: 50,
          y: 50,
          content: 'Vue PDF 渲染器示例',
          fontSize: 24
        }),
        h('text', {
          x: 50,
          y: 100,
          content: '这是使用 Vue 自定义渲染器生成的 PDF 文档',
          fontSize: 14
        }),
        h('rect', {
          x: 50,
          y: 150,
          width: 500,
          height: 300,
          fill: '#f0f0f0',
          stroke: '#333'
        })
      ])
    ])
  }
}

const app = createApp(PDFApp)
const pdf = app.mount()

// 生成 PDF 文件
fs.writeFileSync('output.pdf', pdf)

七、最佳实践和注意事项

1. 性能优化

// 使用 requestAnimationFrame 批量更新
let pendingRender = false
const queue = new Set()

function queueRender(node) {
  queue.add(node)
  if (!pendingRender) {
    pendingRender = true
    requestAnimationFrame(() => {
      queue.forEach(n => renderNode(n))
      queue.clear()
      pendingRender = false
    })
  }
}

2. 事件处理

// 在 Canvas 中处理事件
function setupCanvasEvents(canvas, rootNode) {
  canvas.addEventListener('click', (event) => {
    const rect = canvas.getBoundingClientRect()
    const x = event.clientX - rect.left
    const y = event.clientY - rect.top

    // 查找点击的元素
    const target = findNodeAt(rootNode, x, y)
    if (target && target.props.onClick) {
      target.props.onClick(event)
    }
  })
}

function findNodeAt(node, x, y) {
  if (!node) return null

  // 检查当前节点
  if (node.type === 'button' && 
      x >= node.props.x && 
      x <= node.props.x + node.props.width &&
      y >= node.props.y && 
      y <= node.props.y + node.props.height) {
    return node
  }

  // 检查子节点
  if (node.children) {
    for (const child of node.children) {
      const found = findNodeAt(child, x, y)
      if (found) return found
    }
  }

  return null
}

3. 服务端渲染支持

// 添加 SSR 支持
function createSSRCanvasRenderer() {
  const nodeOps = {
    // ... 基础的节点操作

    // SSR 特有的方法
    hydrate() {
      // 服务端渲染的水合逻辑
    },

    createStaticVNode() {
      // 创建静态节点
    }
  }

  return createRenderer({
    ...nodeOps,
    hydrate: nodeOps.hydrate,
    createStaticVNode: nodeOps.createStaticVNode
  })
}

八、总结

Vue 的自定义渲染器功能非常强大,它允许你将 Vue 的响应式系统和组件模型应用到各种渲染目标:

入门简单:只需要实现几个核心的节点操作方法 灵活强大:可以渲染到 Canvas、PDF、终端、Native 应用等 性能可控:可以根据目标平台优化渲染性能 复用 Vue 生态:可以使用 Vue 的组件、响应式系统、生命周期等所有特性

通过学习自定义渲染器,你可以更深入地理解 Vue 的内部工作原理,并创建出独特而强大的渲染解决方案。

相关推荐