TL;DR
Vue 3 Options API の data() で CodeMirror 6 の EditorView を保持すると、 Vue が reactive Proxy で wrap して内部の WeakMap が壊れ、 syntax highlight の <span> が一つも生成されなくなる。 文字は表示されるし syntaxTree も language facet も正常に返るのに、 色だけが静かに死ぬ。
// だめ: view が reactive Proxy で wrap される
data() { return { view: null } },
mounted() { this.view = new EditorView(...) }
// 正しい: instance プロパティ + markRaw で二重防御
created() { this.view = null },
mounted() { this.view = markRaw(new EditorView(...)) }
Three.js / Chart.js / Monaco / D3 など「生インスタンスを保持するライブラリ」 全般で同じ罠あり。
症状
@codemirror/lang-json + @codemirror/theme-one-dark で構成した JSON エディタを Vite + Vue 3 環境にマウントしたところ、 次のような状態になりました。
- 文字は普通に表示される
- 背景は oneDark の dark grey で塗られる
- だが key も string も number も全部同じ薄水色 1 色
- syntax highlight が完全に効かない
DevTools で覗くと:
document.querySelector('.cm-content').querySelectorAll('span').length
// → 0
token 単位の <span> が一つも生成されていない。 これが今回の真の症状でした。
ここで注意したいのは「部分的にしか壊れていない」 ことです。
-
view.state.docは読める (文字は表示される) -
view.state.facet(language)も返る (言語拡張は登録されている) -
syntaxTree(view.state)も正しい Tree を返す - でも
HighlightStyleが tag を引いて<span>を生成する段だけが落ちる
9 割動いているせいで、 manualChunks や optimizeDeps の delicate な仮説に時間を溶かしがちです。
最小再現で原因を特定する
仮説を立てるのをやめて、 動く構成と動かない構成の最小 diff を取る 方針に切り替えました。 vite dev server 上に独立 HTML を置いて、 playwright headless で <span> を数えます。
// tests/cm6-test/main.js — 動く版
import { EditorState } from '@codemirror/state'
import { EditorView, lineNumbers } from '@codemirror/view'
import { json } from '@codemirror/lang-json'
import { oneDark } from '@codemirror/theme-one-dark'
new EditorView({
state: EditorState.create({
doc: JSON.stringify({ name: 'test', age: 42 }, null, 2),
extensions: [lineNumbers(), json(), oneDark],
}),
parent: document.getElementById('editor'),
})
// tests/cm6-test/run-playwright.mjs
import { chromium } from 'playwright'
const browser = await chromium.launch({ headless: true })
const page = await (await browser.newContext()).newPage()
await page.goto('http://127.0.0.1:5173/tests/cm6-test/')
await page.waitForTimeout(1500)
const result = await page.evaluate(() => {
const spans = document.querySelector('.cm-content').querySelectorAll('span')
return {
count: spans.length,
firstColor: spans[0] ? getComputedStyle(spans[0]).color : null,
}
})
console.log(result)
await browser.close()
このまま 4 通り組んで diff を取ると、 境界が一気に見えます。
| ケース | span 数 |
|---|---|
| 独立 plain HTML, 最小 extensions | 25 |
| 独立 plain HTML, 本番と同じ全 extensions | 31 |
| Vue component, inline で EditorView 作成 | 11 |
| Vue component, 既存の JsonEditor.vue 経由 | 0 |
「Vue component + 既存 JsonEditor の構造」 だけが壊れています。 中身のコードは plain HTML 版と同じ。 差は Options API の data() に view: null を入れているかどうか だけでした。
真因
問題のコードはこれだけです。
data() {
return { view: null, readonlyCompartment: null, extensions: null }
},
mounted() {
this.view = new EditorView({ state, parent: this.$refs.hostRef })
}
Vue 3 では data() の戻り値のすべてのプロパティが reactive Proxy で wrap されます。 this.view = new EditorView(...) を代入すると view は Proxy の内側に入り、 以降の this.view 経由のアクセスはすべて Proxy 経由になります。
CodeMirror 6 の EditorView は内部で:
- WeakMap (各 facet 用)
- private symbol を使ったキー
-
view.docViewなどの相互参照 closure
を持ち、 「同じ identity の object であること」 に強く依存して動いています。 Proxy で wrap されると WeakMap の lookup が Map.get(originalView) で見つからず、 syntax highlight の token 生成だけが静かに skip されます。 例外は出ません。
修正
import { markRaw } from 'vue'
export default {
// data() に CodeMirror インスタンスを入れない
data() { return { /* reactive にしたい state だけ */ } },
created() {
// ここで instance プロパティとして初期化 (= reactive 対象外)
this.view = null
this.readonlyCompartment = null
},
mounted() {
// 念のため markRaw で double-protect
this.view = markRaw(new EditorView({ state, parent: this.$refs.hostRef }))
},
}
ポイントは 2 つです。
-
data()から外す — Vue Options API でthis.xxx = ...を直接書くと instance プロパティとして付与され、 reactive にならない。 これは Vue 3 で意外と知られていない動作です。 -
markRaw()でも保護 — 将来データバインディング経由で渡される可能性があるなら、markRawで「これは絶対に reactive 化しない」 を Vue に伝える。
Composition API (setup() + ref()) の場合は shallowRef() を使うか、 ref に入れずに let view で持つのが定石です。
なぜ Vue 3 の reactive がライブラリを壊すか
Vue 3 の reactive は ES Proxy ベースです。
const target = new EditorView(...)
const proxy = new Proxy(target, handler)
// view を data に入れた瞬間、 view は proxy になっている
CodeMirror 内部の擬似コード:
const facetCache = new WeakMap()
facetCache.set(view, computedValue) // ここで view は target
// 別の場所で
facetCache.get(view) // ここで渡る view は proxy
Proxy と target は WeakMap の上では別 key なので、 lookup が静かに undefined を返します。 例外は出ず、 ただ機能の一部が無効化される。 これが「syntax highlight だけが効かない」 という症状の正体です。
CodeMirror 6 は extensible / functional な設計で、 Compartment / Facet / Transaction がすべて identity ベースで動いています。 reactive 化と最も相性が悪い設計です。
教訓: 生インスタンスを持つライブラリは reactive 化しない
CodeMirror に限らず、 次のライブラリは同じ罠を踏みます。
- Three.js (
Scene,Renderer,Camera) - Chart.js (
Chartインスタンス) - Monaco Editor
- D3 selection
- Leaflet (
Map) - WebGL の context
ベストプラクティス:
- Options API なら
created()でインスタンスプロパティとして付与 - Composition API なら
shallowRef()、 もしくはrefに入れずletで持つ - 二重防御として
markRaw()を通す
vue-codemirror6 のようなコミュニティラッパーは、 だいたいこれを正しくやってくれています。 ラッパーを使わず自前で書くときだけ罠を踏む構図です。
まとめ
- Vue 3 で
data()に CodeMirror EditorView を入れると Proxy 化されて syntax highlight が静かに死ぬ - 直し方は
created()で instance プロパティ +markRawで 1 行 - Three.js / Chart.js / Monaco / D3 など生インスタンス系 library で同じ罠
- 不可解な症状に出会ったら「動く最小再現」 と「動かない最小再現」 を作って diff を取るのが結局いちばん早い
同じところでハマっている人の参考になれば幸いです。