- Vue.js
Vue h関数とrender関数の使い方|テンプレート対応表とJSX
Vueのh関数とは、画面の部品を表すオブジェクト(vnode、仮想ノード)を作る関数で、render関数(レンダー関数)とは、そのvnodeを返す関数のことです。 テンプレートはビルド時にレンダー関数へ変換されるので、h関数で書くことは「テンプレートが変換された後の形を自分で書く」ことにあたります。JSXは、そのh関数の呼び出しをタグに近い見た目で書ける記法です。
この記事では、同じ画面をテンプレート・h関数・JSXの3通りで作り、出力されるHTMLと操作後の結果が一致することをブラウザで確かめたうえで、書き方を3列の対応表にまとめました。 ViteでJSXを動かす設定、script setupの中でh関数を使う方法、関数型コンポーネントの型付けと実際の型エラー、同じvnodeを使い回したときに表示が更新されなくなる例まで、貼って動くコードと出力で確認できます。
確認環境:Ubuntu 24.04、Node.js 22.22.2、Vue 3.5.43、Vite 8.3.2、@vitejs/plugin-vue 6.0.9、@vitejs/plugin-vue-jsx 5.1.6、TypeScript 7.0.2。ブラウザはPlaywright 1.56.0から起動したChromium 141です。
テンプレート・h関数・JSXの対応早見表
すべて、後半に載せたファイル(BoardTemplate.vue、BoardH.js、BoardJsx.jsxなど)で動かした書き方です。
| やりたいこと | テンプレート | h関数 | JSX |
|---|---|---|---|
| 要素と属性 | <div class="board"> |
h('div', { class: 'board' }, [...]) |
<div class="board"> |
| 文字の埋め込み | {{ memo }} |
`メモ: ${memo.value}` |
{memo.value} |
| v-if / v-else | <p v-if="tasks.length === 0"> と <p v-else> |
条件 ? h('p', ...) : h('p', ...) |
{条件 ? <p>…</p> : <p>…</p>} |
| v-for | <li v-for="t in tasks" :key="t.id"> |
tasks.value.map((t) => h('li', { key: t.id }, t.title)) |
{tasks.value.map((t) => <li key={t.id}>{t.title}</li>)} |
| v-on | @click="fn" |
{ onClick: fn } |
onClick={fn} |
| イベント修飾子 | @submit.prevent="add" |
{ onSubmit: withModifiers(add, ['prevent']) } |
onSubmit={withModifiers(add, ['prevent'])} |
| キー修飾子 | @keyup.esc="fn" |
{ onKeyup: withKeys(fn, ['esc']) } |
onKeyup={withKeys(fn, ['esc'])} |
| v-model(input) | v-model="text" |
{ value: text.value, onInput: (e) => { text.value = e.target.value } } |
v-model={text.value} |
| v-model(コンポーネント) | <TextField v-model="memo" /> |
h(TextField, { modelValue: memo.value, 'onUpdate:modelValue': (v) => { memo.value = v } }) |
<TextField v-model={memo.value} /> |
| v-show | v-show="showHelp" |
withDirectives(h('p', ...), [[vShow, showHelp.value]]) |
v-show={showHelp.value} |
| カスタムディレクティブ | v-focus |
withDirectives(h('input', ...), [[vFocus]]) |
v-focus(directivesオプションに登録) |
| テンプレート参照 | ref="inputEl" と useTemplateRef('inputEl') |
{ ref: 'inputEl' } と useTemplateRef('inputEl') |
ref="inputEl" と useTemplateRef('inputEl') |
| 名前付きスロットを渡す | <template #header>タスク一覧</template> |
h(Card, props, { header: () => 'タスク一覧' }) |
<Card>{{ header: () => 'タスク一覧' }}</Card> |
| スコープ付きスロットを渡す | <template #item="{ task }"> |
{ item: ({ task }) => task.title } |
{{ item: ({ task }) => task.title }} |
| デフォルトスロットを渡す | タグの中に書く | { default: () => h('small', info.value) } |
{{ default: () => <small>{info.value}</small> }} |
h関数とJSXでは、refの中身は .value を付けて読みます。 テンプレートのような自動の取り出しは行われません。
どれで書くかの判断表
| 場面 | 向いている書き方 | 理由 |
|---|---|---|
| ふつうの画面・フォーム | テンプレート | HTMLに近い形で書け、読みやすい |
| タグ名や階層をデータから組み立てる(見出しレベル、ツリー、表のセル) | h関数 | JavaScriptの式でそのまま組み立てられる |
| 受け取ったスロットを加工して並べ直す | h関数 | スロットの戻り値(vnodeの配列)を直接扱える |
| 条件分岐やループが多い小さな部品を、型つきで書きたい | JSX / TSX | propsの型チェックがタグの位置で効く |
| ライブラリ用の薄いラッパー | h関数または関数型コンポーネント | ビルド設定を足さずに書ける |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vue h関数とrender関数の使い方|テンプレート対応表とJSX」
h関数の基本:引数の形と出力
h関数は h(タグまたはコンポーネント, props, 子) の形で呼びます。propsと子は省略できます。Node.jsでサーバー用のレンダラーを使い、いくつかの形を文字列にして確かめました。
// hforms.mjs(node hforms.mjs で実行)
import { h, createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
const show = async (label, vnode) =>
console.log(label.padEnd(26), await renderToString(createSSRApp({ render: () => vnode })))
await show('タグだけ', h('div'))
await show('props', h('div', { id: 'box' }))
await show('子が文字列', h('p', 'こんにちは'))
await show('props と子', h('p', { class: 'lead' }, 'こんにちは'))
await show('子が配列', h('ul', [h('li', 'A'), h('li', 'B')]))
await show('class に配列とオブジェクト', h('p', { class: ['a', { b: true, c: false }] }, 'x'))
await show('style にオブジェクト', h('p', { style: { color: 'red', fontSize: '14px' } }, 'x'))
await show('innerHTML', h('p', { innerHTML: '<b>太字</b>' }))
await show('属性を強制(^)', h('input', { '^value': 'abc' }))
await show('v-for にあたる map', h('ol', [1, 2, 3].map((n) => h('li', { key: n }, `${n}番`))))
タグだけ <div></div>
props <div id="box"></div>
子が文字列 <p>こんにちは</p>
props と子 <p class="lead">こんにちは</p>
子が配列 <ul><li>A</li><li>B</li></ul>
class に配列とオブジェクト <p class="a b">x</p>
style にオブジェクト <p style="color:red;font-size:14px;">x</p>
innerHTML <p><b>太字</b></p>
属性を強制(^) <input value="abc">
v-for にあたる map <ol><li>1番</li><li>2番</li><li>3番</li></ol>
| 引数 | 書けるもの | 例 |
|---|---|---|
| 第1引数 | タグ名の文字列、またはコンポーネント | 'div'、Card |
| 第2引数(省略可) | 属性・プロパティ・イベントをまとめたオブジェクト | { class: 'lead', onClick: fn } |
| 第3引数(省略可) | 文字列、vnodeの配列、スロットのオブジェクト | 'こんにちは'、[h('li', 'A')]、{ default: () => ... } |
- classとstyleは、テンプレートの
:class、:styleと同じく配列やオブジェクトを渡せます。 - イベントは
onで始まるキャメルケースのキーで書きます(onClick、onUpdate:modelValue)。 - コンポーネントはimportした変数をそのまま第1引数に渡します。
render関数を置く場所
レンダー関数の置き方は2通りあります。どちらも今回の確認で動かした形です。
| 置き方 | 書き方 | 向いている場面 |
|---|---|---|
| setupから関数を返す | setup() { return () => h('div', ...) } |
Composition APIの状態(ref)をそのまま使う |
| renderオプション | { render: () => h('div') } |
状態を持たない単純な出力 |
同じ画面を3通りで書く(そのまま動く一式)
タスクを追加できる小さな画面を、テンプレート・h関数・JSXで1つずつ作ります。使う機能は、v-if、v-for、v-on(修飾子つき)、v-model(inputとコンポーネント)、スロット(名前付き・スコープ付き・デフォルト)、ディレクティブ(v-showとカスタム)、テンプレート参照です。
共通の部品
<!-- src/Card.vue:スロットを3種類持つ部品 -->
<script setup>
defineProps({ tasks: Array })
</script>
<template>
<section class="card">
<header><slot name="header" /></header>
<ul v-if="tasks.length">
<li v-for="(task, i) in tasks" :key="task.id"><slot name="item" :task="task" :index="i" /></li>
</ul>
<slot />
</section>
</template>
<!-- src/TextField.vue:v-model を受け取る部品 -->
<script setup>
const model = defineModel()
</script>
<template>
<input class="field" :value="model" @input="model = $event.target.value" />
</template>
// src/focus.js
// 表示されたら data-focused を付けてフォーカスするディレクティブ
export const vFocus = {
mounted(el) {
el.dataset.focused = 'yes'
el.focus()
},
}
テンプレート版
<!-- src/BoardTemplate.vue -->
<script setup>
import { ref, useTemplateRef, onMounted } from 'vue'
import Card from './Card.vue'
import TextField from './TextField.vue'
import { vFocus } from './focus.js'
const tasks = ref([{ id: 1, title: '見積もり' }])
const text = ref('')
const memo = ref('')
const showHelp = ref(false)
const inputEl = useTemplateRef('inputEl')
const info = ref('')
function add() {
if (!text.value) return
tasks.value.push({ id: tasks.value.length + 1, title: text.value })
text.value = ''
}
onMounted(() => { info.value = `ref: ${inputEl.value.tagName}` })
</script>
<template>
<div class="board">
<form @submit.prevent="add">
<input ref="inputEl" v-model="text" v-focus class="new" @keyup.esc="text = ''" />
<button type="submit">追加</button>
</form>
<TextField v-model="memo" />
<p class="memo">メモ: {{ memo }}</p>
<button class="help-btn" @click="showHelp = !showHelp">ヘルプ</button>
<p v-show="showHelp" class="help">Enterで追加できます</p>
<p v-if="tasks.length === 0" class="empty">タスクはありません</p>
<p v-else class="count">{{ tasks.length }}件</p>
<Card :tasks="tasks">
<template #header>タスク一覧</template>
<template #item="{ task, index }">{{ index + 1 }}. {{ task.title }}</template>
<small>{{ info }}</small>
</Card>
</div>
</template>
h関数版
// src/BoardH.js
import { h, ref, useTemplateRef, onMounted, withModifiers, withKeys, withDirectives, vShow } from 'vue'
import Card from './Card.vue'
import TextField from './TextField.vue'
import { vFocus } from './focus.js'
export default {
setup() {
const tasks = ref([{ id: 1, title: '見積もり' }])
const text = ref('')
const memo = ref('')
const showHelp = ref(false)
const inputEl = useTemplateRef('inputEl')
const info = ref('')
function add() {
if (!text.value) return
tasks.value.push({ id: tasks.value.length + 1, title: text.value })
text.value = ''
}
onMounted(() => { info.value = `ref: ${inputEl.value.tagName}` })
// setup から関数を返すと、それがレンダー関数になる
return () =>
h('div', { class: 'board' }, [
// @submit.prevent → withModifiers
h('form', { onSubmit: withModifiers(add, ['prevent']) }, [
// v-focus → withDirectives、v-model → value と onInput、ref → ref 属性
withDirectives(
h('input', {
ref: 'inputEl',
class: 'new',
value: text.value,
onInput: (e) => { text.value = e.target.value },
onKeyup: withKeys(() => { text.value = '' }, ['esc']),
}),
[[vFocus]],
),
h('button', { type: 'submit' }, '追加'),
]),
// コンポーネントの v-model → modelValue と onUpdate:modelValue
h(TextField, { modelValue: memo.value, 'onUpdate:modelValue': (v) => { memo.value = v } }),
h('p', { class: 'memo' }, `メモ: ${memo.value}`),
h('button', { class: 'help-btn', onClick: () => { showHelp.value = !showHelp.value } }, 'ヘルプ'),
// v-show → withDirectives と vShow
withDirectives(h('p', { class: 'help' }, 'Enterで追加できます'), [[vShow, showHelp.value]]),
// v-if / v-else → 三項演算子
tasks.value.length === 0
? h('p', { class: 'empty' }, 'タスクはありません')
: h('p', { class: 'count' }, `${tasks.value.length}件`),
// スロット → 関数を持つオブジェクト
h(Card, { tasks: tasks.value }, {
header: () => 'タスク一覧',
item: ({ task, index }) => `${index + 1}. ${task.title}`,
default: () => h('small', info.value),
}),
])
},
}
JSX版
// src/BoardJsx.jsx
import { defineComponent, ref, useTemplateRef, onMounted, withModifiers, withKeys } from 'vue'
import Card from './Card.vue'
import TextField from './TextField.vue'
import { vFocus } from './focus.js'
export default defineComponent({
directives: { focus: vFocus }, // JSX の v-focus はここから解決される
setup() {
const tasks = ref([{ id: 1, title: '見積もり' }])
const text = ref('')
const memo = ref('')
const showHelp = ref(false)
const inputEl = useTemplateRef('inputEl')
const info = ref('')
function add() {
if (!text.value) return
tasks.value.push({ id: tasks.value.length + 1, title: text.value })
text.value = ''
}
onMounted(() => { info.value = `ref: ${inputEl.value.tagName}` })
return () => (
<div class="board">
<form onSubmit={withModifiers(add, ['prevent'])}>
<input
ref="inputEl"
v-model={text.value}
v-focus
class="new"
onKeyup={withKeys(() => { text.value = '' }, ['esc'])}
/>
<button type="submit">追加</button>
</form>
<TextField v-model={memo.value} />
<p class="memo">メモ: {memo.value}</p>
<button class="help-btn" onClick={() => { showHelp.value = !showHelp.value }}>ヘルプ</button>
<p v-show={showHelp.value} class="help">Enterで追加できます</p>
{tasks.value.length === 0
? <p class="empty">タスクはありません</p>
: <p class="count">{tasks.value.length}件</p>}
<Card tasks={tasks.value}>
{{
header: () => 'タスク一覧',
item: ({ task, index }) => `${index + 1}. ${task.title}`,
default: () => <small>{info.value}</small>,
}}
</Card>
</div>
)
},
})
3つを並べてマウントする
// src/main.js
import { createApp } from 'vue'
import BoardTemplate from './BoardTemplate.vue'
import BoardH from './BoardH.js'
import BoardJsx from './BoardJsx.jsx'
createApp(BoardTemplate).mount('#t')
createApp(BoardH).mount('#h')
createApp(BoardJsx).mount('#j')
index.htmlのbodyには、idがt・h・jの3つのdivと、/src/main.js を読み込むtype="module"のscriptタグを置きます。
実行結果:HTMLと操作後の状態を比べる
3つの領域のinnerHTMLを取り出して比べました。その後、それぞれで「発注」と入力してEnter、別の文字を入力してEsc、メモ欄に入力、ヘルプボタンを押す、という同じ操作をしています。
[初期表示] template===h: false template===jsx: true
T: <div class="board"><form><input class="new" data-focused="yes"><button type="submit">追加</button></form>…
H: <div class="board"><form><input class="new" value="" data-focused="yes"><button type="submit">追加</button></form>…
[操作後] template===h: false template===jsx: true
T: <div class="board"><form><input class="new" data-focused="yes"><button type="submit">追加</button></form><input class="field" value="午後に確認"><p class="memo">メモ: 午後に確認</p><button class="help-btn">ヘルプ</button><p class="help" style="">Enterで追加できます</p><p class="count">2件</p><section class="card"><header>タスク一覧</header><ul><li>1. 見積もり</li><li>2. 発注</li></ul><small>ref: INPUT</small></section></div>
new の値: "","",""
- テンプレート版とJSX版は、HTMLが完全に一致しました。
- h関数版との違いは、inputの
value=""属性だけです。 h関数版はvalueをpropsとして渡しているため、要素を作るときに属性としても出力されます。入力・追加・Escでのクリア・v-showの切り替え・スロットの表示・テンプレート参照(ref: INPUT)は、3つとも同じ結果でした。 - Enterでフォームを送信してもURLは変わらず、withModifiersのpreventが効いていることも確かめられました。
対応表の補足:つまずきやすい4点
v-modelは「値を渡す」と「更新を受け取る」の2つに分かれる
テンプレートのv-modelは、h関数では2つのpropsになります。
| 対象 | 値を渡す | 更新を受け取る |
|---|---|---|
| input要素 | value: text.value |
onInput: (e) => { text.value = e.target.value } |
| コンポーネント | modelValue: memo.value |
'onUpdate:modelValue': (v) => { memo.value = v } |
input要素で修飾子(trimなど)も含めてテンプレートと同じ動きにしたい場合は、Vueが内部で使っているvModelTextディレクティブをwithDirectivesで付ける方法もあります。
// src/VModelText.js
import { h, ref, withDirectives, vModelText } from 'vue'
export default {
setup() {
const text = ref('')
return () =>
h('div', [
withDirectives(
h('input', { class: 'vm', 'onUpdate:modelValue': (v) => { text.value = v } }),
[[vModelText, text.value, '', { trim: true }]],
),
h('p', `入力: [${text.value}]`),
])
},
}
前後に空白を付けて「 資料 」と入力した結果です。trimが効き、value属性も出力されていません。
<div><input class="vm"><p>入力: [資料]</p></div>
JSXでは v-model={text.value} と書けます。.value を付けたrefを渡す点がテンプレートとの違いです。
スロットは「関数を持つオブジェクト」で渡す
h関数の第3引数にオブジェクトを渡すと、キーがスロット名、値がスロットの中身を返す関数になります。スコープ付きスロットの引数は、その関数の引数として受け取ります。
h(Card, { tasks: tasks.value }, {
header: () => 'タスク一覧',
item: ({ task, index }) => `${index + 1}. ${task.title}`,
default: () => h('small', info.value),
})
反対に、スロットを受け取る側は、setupの第2引数の slots から関数として呼び出します。関数型コンポーネントの例で後述します。
ディレクティブはwithDirectivesで包む
withDirectives(vnode, [[ディレクティブ, 値, 引数, 修飾子]]) の形で、vnodeにディレクティブを付けます。v-showはvShowをimportして使います。JSXでは v-show={...} と書け、カスタムディレクティブはコンポーネントの directives オプションに登録しておくと v-focus の形で使えました。
テンプレート参照はrefに同じ名前を渡す
Vue 3.5のuseTemplateRefは、h関数でもJSXでも使えます。useTemplateRef('inputEl') と、要素側の ref: 'inputEl'(JSXでは ref="inputEl")の名前をそろえます。3つの版とも、onMountedで要素のtagNameを読み、「ref: INPUT」と表示できました。
ViteでJSXを動かす設定手順
1. プラグインを入れる
npm install vue
npm install -D vite @vitejs/plugin-vue @vitejs/plugin-vue-jsx
2. vite.config.jsに追加する
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
export default defineConfig({
plugins: [vue(), vueJsx()],
})
.vue ファイルも使う場合は、このように2つのプラグインを並べます。これで .jsx と .tsx のファイルがVueのJSXとして変換されます(後述のusage.tsxも、この設定のままブラウザで表示とクリックを確かめました)。npx vite build の出力は次のとおりです。
✓ 15 modules transformed.
dist/index.html 0.27 kB │ gzip: 0.19 kB
dist/assets/index-DoXjNQ-5.js 70.90 kB │ gzip: 27.70 kB
3. TypeScriptを使う場合はtsconfig.jsonを設定する
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"jsxImportSource": "vue",
"noEmit": true,
"skipLibCheck": true
},
"include": ["ts/*.tsx"]
}
公式ドキュメントには、tsconfig.jsonで "jsx": "preserve" を指定すること、Vue 3.4以降はグローバルのJSX名前空間を暗黙に登録しなくなったため "jsxImportSource": "vue" を指定することが書かれています。jsxImportSource の行を外して tsc を実行すると、次のエラーになりました。
ts/TaskItem.tsx(8,3): error TS7026: JSX element implicitly has type 'any' because no interface 'JSX.IntrinsicElements' exists.
ts/usage.tsx(8,7): error TS7026: JSX element implicitly has type 'any' because no interface 'JSX.IntrinsicElements' exists.
TS7026が出たら、まずjsxImportSourceを確かめます。 行を戻すと、エラーなしで通りました。
script setupの中でh関数を使う
script setupは、テンプレートと組み合わせて使う書き方です。トップレベルでレンダー関数をreturnしようとすると、コンパイルの段階でエラーになります。
<script setup>
import { h } from 'vue'
return () => h('p', 'hello')
</script>
[vue/compiler-sfc] 'return' outside of function. (3:0)
script setupの中でh関数を使いたいときは、次の2つの形が使えます。
<!-- src/SetupH.vue -->
<script setup>
import { h, ref } from 'vue'
const count = ref(0)
// 1) 関数型コンポーネントを変数として定義し、テンプレートでタグとして使う
const Badge = (props) => h('span', { class: 'badge' }, `${props.label}: ${count.value}`)
Badge.props = ['label']
// 2) vnode を返す関数を component の is に渡す
const renderList = () => h('ul', ['A', 'B'].map((x) => h('li', { key: x }, `${x}-${count.value}`)))
</script>
<template>
<button class="inc" @click="count++">+1</button>
<Badge label="件数" />
<component :is="renderList" />
</template>
[初期] <button class="inc">+1</button><span class="badge">件数: 0</span><ul><li>A-0</li><li>B-0</li></ul>
[+1後] <button class="inc">+1</button><span class="badge">件数: 1</span><ul><li>A-1</li><li>B-1</li></ul>
どちらも、script setupのrefを読んでいて、値が変わると再描画されました。画面全体をh関数で書きたい場合は、script setupではなく通常のscriptブロックを使い、テンプレートを書かずにsetupから関数を返します。
<!-- src/SetupReturn.vue -->
<script>
import { h, ref } from 'vue'
export default {
setup() {
const count = ref(0)
// template を書かず、setup からレンダー関数を返す
return () => h('button', { class: 'inc', onClick: () => count.value++ }, `押した回数: ${count.value}`)
},
}
</script>
[初期] <button class="inc">押した回数: 0</button>
[クリック] <button class="inc">押した回数: 1</button>
| やりたいこと | 書き方 |
|---|---|
| テンプレートの一部だけh関数で作る | script setupで関数型コンポーネントを変数にし、タグとして使う |
| vnodeを返す関数をその場に差し込む | <component :is="関数" /> |
| 画面全体をh関数で書く | 通常のscriptブロックでsetupから関数を返す、または .js / .jsx ファイルにする |
関数型コンポーネントの書き方と型付け
関数型コンポーネントは、状態を持たず、propsを受け取ってvnodeを返すだけの関数です。第1引数がprops、第2引数が { slots, emit, attrs } です。
// src/TaskItem.js
import { h } from 'vue'
// 関数型コンポーネント:状態を持たず、props から vnode を返すだけの関数
const TaskItem = (props, { slots, emit }) =>
h(
'li',
{ class: { done: props.done }, onClick: () => emit('toggle', props.id) },
slots.default ? slots.default() : props.title,
)
TaskItem.props = { id: Number, title: String, done: Boolean }
TaskItem.emits = ['toggle']
export default TaskItem
// src/FcDemo.js
import { h, ref } from 'vue'
import TaskItem from './TaskItem.js'
export default {
setup() {
const tasks = ref([
{ id: 1, title: '見積もり', done: false },
{ id: 2, title: '発注', done: true },
])
const toggle = (id) => {
const t = tasks.value.find((x) => x.id === id)
t.done = !t.done
}
return () =>
h('ul', tasks.value.map((t) => h(TaskItem, { key: t.id, ...t, onToggle: toggle })))
},
}
[初期] <ul><li class="">見積もり</li><li class="done">発注</li></ul>
[1件目をクリック] <ul><li class="done">見積もり</li><li class="done">発注</li></ul>
TaskItem.props と TaskItem.emits を関数のプロパティとして宣言している点がポイントです。propsを宣言しておくと、id、title、done がpropsとして渡り、onToggle はemitのリスナーとして扱われます。
TypeScriptで型を付ける
FunctionalComponent 型の第1引数にprops、第2引数にイベントの型を渡します。
// ts/TaskItem.tsx
import type { FunctionalComponent } from 'vue'
type Props = { id: number; title: string; done?: boolean }
type Events = { toggle(id: number): void }
// 第1引数が props、第2引数が emits の型
const TaskItem: FunctionalComponent<Props, Events> = (props, { emit, slots }) => (
<li class={{ done: props.done }} onClick={() => emit('toggle', props.id)}>
{slots.default ? slots.default() : props.title}
</li>
)
TaskItem.props = ['id', 'title', 'done']
TaskItem.emits = ['toggle']
export default TaskItem
// ts/usage.tsx
import { defineComponent, ref } from 'vue'
import TaskItem from './TaskItem'
export default defineComponent({
setup() {
const done = ref(false)
return () => (
<ul>
<TaskItem id={1} title="見積もり" done={done.value} onToggle={(id) => { done.value = !done.value; console.log(id.toFixed(0)) }} />
</ul>
)
},
})
この2ファイルは tsc --noEmit をエラーなしで通りました。onToggle の引数 id はnumberと推論されています。次に、型に合わない使い方を書いて確かめます。
// ts/bad.tsx
import TaskItem from './TaskItem'
export const a = <TaskItem id="1" title="見積もり" />
export const b = <TaskItem id={2} />
export const c = <TaskItem id={3} title="発注" onToggle={(id: string) => console.log(id)} />
ts/bad.tsx(3,28): error TS2322: Type 'string' is not assignable to type 'number'.
ts/bad.tsx(4,19): error TS2322: Type '{ id: number; }' is not assignable to type 'IntrinsicAttributes & Props & { onToggle?: ((id: number) => any) | undefined; }'.
Property 'title' is missing in type '{ id: number; }' but required in type 'Props'.
ts/bad.tsx(5,46): error TS2322: Type '(id: string) => void' is not assignable to type '(id: number) => any'.
Types of parameters 'id' and 'id' are incompatible.
Type 'number' is not assignable to type 'string'.
| 行 | 書いた内容 | 検出されたこと |
|---|---|---|
| a | idに文字列を渡した | numberに代入できない |
| b | titleを省いた | 必須のtitleがない |
| c | onToggleの引数をstringにした | イベントの引数の型が合わない |
emitの側も同様に確かめられます。宣言していないイベント名を書くと、次のエラーになりました。
// ts/emit.tsx
import type { FunctionalComponent } from 'vue'
type Props = { id: number }
type Events = { toggle(id: number): void }
export const Bad: FunctionalComponent<Props, Events> = (props, { emit }) => (
<li onClick={() => emit('remove', props.id)}>{props.id}</li>
)
ts/emit.tsx(7,27): error TS2345: Argument of type '"remove"' is not assignable to parameter of type '"toggle"'.
イベントの型(第2引数)から、使う側の onToggle の型まで自動で決まるので、TSXと関数型コンポーネントの組み合わせは、小さな表示用の部品を型つきで量産するのに向いています。
同じvnodeを使い回すと表示が更新されなくなる
公式ドキュメントには、コンポーネントツリーの中のvnodeはすべて一意でなければならないと書かれています。1つのvnodeを複数の場所に置くとどうなるかを確かめました。
// src/Reuse.js
import { h, ref } from 'vue'
// 同じ vnode を3回並べた例
export const Reuse = {
setup() {
const n = ref(0)
return () => {
const item = h('li', [h('b', '値 '), h('span', String(n.value))])
return h('ul', { onClick: () => n.value++ }, [item, item, item])
}
},
}
// 並べる数だけ vnode を作る例
export const Fixed = {
setup() {
const n = ref(0)
return () => {
const item = () => h('li', [h('b', '値 '), h('span', String(n.value))])
return h('ul', { onClick: () => n.value++ }, [item(), item(), item()])
}
},
}
// 子が文字列だけの vnode を3回並べた例
export const ReuseText = {
setup() {
const n = ref(0)
return () => {
const item = h('li', `値 ${n.value}`)
return h('ul', { onClick: () => n.value++ }, [item, item, item])
}
},
}
ulをクリックするたびにnが1増えます。3つの例の結果です。
[Reuse] 初期: <ul><li><b>値 </b><span>0</span></li><li><b>値 </b><span>0</span></li><li><b>値 </b><span>0</span></li></ul>
[Reuse] 1回クリック: <ul><li><b>値 </b><span>0</span></li><li><b>値 </b><span>0</span></li><li><b>値 </b><span>1</span></li></ul>
[Reuse] 2回クリック: <ul><li><b>値 </b><span>0</span></li><li><b>値 </b><span>0</span></li><li><b>値 </b><span>2</span></li></ul>
[Fixed] 初期: <ul><li><b>値 </b><span>0</span></li><li><b>値 </b><span>0</span></li><li><b>値 </b><span>0</span></li></ul>
[Fixed] 1回クリック: <ul><li><b>値 </b><span>1</span></li><li><b>値 </b><span>1</span></li><li><b>値 </b><span>1</span></li></ul>
[Fixed] 2回クリック: <ul><li><b>値 </b><span>2</span></li><li><b>値 </b><span>2</span></li><li><b>値 </b><span>2</span></li></ul>
[ReuseText] 初期: <ul><li>値 0</li><li>値 0</li><li>値 0</li></ul>
[ReuseText] 1回クリック: <ul><li>値 1</li><li>値 1</li><li>値 1</li></ul>
[ReuseText] 2回クリック: <ul><li>値 2</li><li>値 2</li><li>値 2</li></ul>
| 例 | 最初の表示 | 更新後 |
|---|---|---|
| Reuse(子を持つvnodeを3回並べた) | 3行とも表示される | 最後の1行だけが更新され、前の2行は0のまま |
| Fixed(関数で毎回作る) | 3行とも表示される | 3行とも更新される |
| ReuseText(子が文字列だけのvnodeを3回並べた) | 3行とも表示される | 3行とも更新された |
最初の表示は正しく見え、コンソールに警告も出ませんでした。そのため、値が変わったときに初めて気づく種類の不具合になります。子が文字列だけの単純な形では更新されましたが、子に要素を持たせた時点で表示がずれています。どの形なら動くかを覚えるより、並べる数だけvnodeを作ると決めておくのが確実です。
JSXでも同じ結果でした。JSXの式を変数itemに入れて3回並べると、クリック後に更新されたのは最後の1行だけです。
const item = <li><b>値 </b><span>{n.value}</span></li>
return <ul onClick={() => n.value++}>{item}{item}{item}</ul>
[クリック後] <ul><li><b>値 </b><span>0</span></li><li><b>値 </b><span>0</span></li><li><b>値 </b><span>1</span></li></ul>
FixedのようにJSXでも関数にして、並べる数だけ呼び出します。
よくあるつまずきと直し方
| 症状 | 原因 | 直し方 |
|---|---|---|
画面に [object Object] と出る |
.value を付けていない |
h関数・JSXではrefを .value で読む |
| 値を変えても再描画されない | setupの本体でvnodeを作り、それを返している | return () => h(...) のように関数を返し、その中でrefを読む |
| 同じ部品を並べたら一部だけ更新されない | 同じvnodeを使い回している | 関数にして毎回作る |
'return' outside of function |
script setupのトップレベルでreturnしている | 通常のscriptブロックでsetupから返す |
| TS7026(JSX.IntrinsicElementsがない) | jsxImportSourceが未設定 | tsconfig.jsonに "jsxImportSource": "vue" |
| 「Non-function value encountered for default slot」の警告 | コンポーネントの子にvnodeを直接渡している | { default: () => ... } のように関数で渡す |
h関数版だけinputに value="" が付く |
valueをpropsで渡している | 動作は同じ。そろえたい場合はvModelTextを使う |
1行目と2行目は、次のコードで確かめました。どちらもpをクリックするとcountが1増えます。
// .value を付けていない例
export const NoValue = {
setup() {
const count = ref(0)
return () => h('p', { onClick: () => count.value++ }, `件数: ${count}`)
},
}
// setup の本体で vnode を作った例
export const StaticVnode = {
setup() {
const count = ref(0)
const vnode = h('p', { onClick: () => count.value++ }, `件数: ${count.value}`)
return () => vnode
},
}
[NoValue] 初期: <p>件数: [object Object]</p>
[StaticVnode] 初期: <p>件数: 0</p>
[StaticVnode] クリック後: <p>件数: 0</p>
setupは1回しか実行されないため、そこで作ったvnodeは作り直されません。返した関数の中でh関数を呼び、その中でrefを読む形にすると、値の変更に合わせて再描画されます。今回のBoardH.jsやSetupReturn.vueは、すべてこの形で書いています。
よくある質問
Q. h関数とrender関数は何が違いますか? A. h関数はvnodeを1つ作る関数、render関数(レンダー関数)は「そのコンポーネントが何を表示するか」をvnodeで返す関数です。レンダー関数の中でh関数を何度も呼び、画面を組み立てます。setupから関数を返すと、それがレンダー関数として使われます。
Q. テンプレートとh関数は、どちらを使うのがよいですか? A. 通常の画面はテンプレートが向いています。タグ名や構造をデータから組み立てたいとき、スロットの中身を加工したいとき、型つきの小さな部品をTSXで書きたいときに、h関数やJSXを選びます。今回の3つの版は、同じ操作に対して同じ結果になりました。
Q. VueのJSXはReactのJSXと同じですか?
A. 見た目は近いですが、変換先がVueのvnodeです。今回動かしたコードでは、属性名は class のまま書き、v-model や v-show をJSXの属性として使い、スロットは関数を持つオブジェクトを子として渡しています。tsconfig.jsonでは "jsxImportSource": "vue" を指定します。
Q. script setupでレンダー関数は使えますか?
A. トップレベルでreturnすることはできませんが、関数型コンポーネントを変数として定義してテンプレートでタグとして使う方法と、vnodeを返す関数を component の is に渡す方法が使えました。画面全体をh関数で書くなら、通常のscriptブロックでsetupから関数を返します。
まとめ
- h関数はvnodeを作る関数、render関数はvnodeを返す関数。
h(タグ, props, 子)の形で呼ぶ - v-ifは三項演算子、v-forはmap、v-onは
onで始まるprops、v-modelは値と更新イベントの2つに置き換える - スロットは関数を持つオブジェクト、ディレクティブはwithDirectives、修飾子はwithModifiersとwithKeysで書く
- テンプレート・h関数・JSXの3つの版は、同じ操作で同じ結果になった。 違いはh関数版のinputに付く
value=""属性だけ - JSXは@vitejs/plugin-vue-jsxを足し、TypeScriptでは
jsx: preserveとjsxImportSource: vueを設定する - script setupでは、関数型コンポーネントを変数にするか、
componentのisに関数を渡す - 関数型コンポーネントは
FunctionalComponent型でpropsとイベントに型が付く - 同じvnodeは使い回さず、関数にして並べる数だけ作る
参考・出典
- Vue.js 公式ドキュメント「レンダー関数と JSX」(https://ja.vuejs.org/guide/extras/render-function)
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vue h関数とrender関数の使い方|テンプレート対応表とJSX」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。