0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vue 3 + CodeMirror 6 でシンタックスハイライトが効かない真因は data() だった

0
Posted at

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 つです。

  1. data() から外す — Vue Options API で this.xxx = ... を直接書くと instance プロパティとして付与され、 reactive にならない。 これは Vue 3 で意外と知られていない動作です。
  2. 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

ベストプラクティス:

  1. Options API なら created() でインスタンスプロパティとして付与
  2. Composition API なら shallowRef()、 もしくは ref に入れず let で持つ
  3. 二重防御として markRaw() を通す

vue-codemirror6 のようなコミュニティラッパーは、 だいたいこれを正しくやってくれています。 ラッパーを使わず自前で書くときだけ罠を踏む構図です。

まとめ

  • Vue 3 で data() に CodeMirror EditorView を入れると Proxy 化されて syntax highlight が静かに死ぬ
  • 直し方は created() で instance プロパティ + markRaw で 1 行
  • Three.js / Chart.js / Monaco / D3 など生インスタンス系 library で同じ罠
  • 不可解な症状に出会ったら「動く最小再現」 と「動かない最小再現」 を作って diff を取るのが結局いちばん早い

同じところでハマっている人の参考になれば幸いです。


参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?