Skip to content

Engine-Agnostic CodeEditor: CodeMirror Default, Monaco Opt-In

8/25/2025Backend DevelopmentKUIreact7 min read

Engine-Agnostic CodeEditor: CodeMirror Default, Monaco Opt-In

Aynı API, iki farklı engine. Monaco sadece talep edilince yükleniyor — kullanıcı bunu hiç fark etmiyor.

Bir kod editörü bileşeni yazmanız gerekiyor. İki popüler seçenek var: CodeMirror 6 (hafif, yaklaşık 100kB) ve Monaco Editor (VSCode motoru, yaklaşık 5MB). İkisi arasındaki karar çoğunlukla "hangisi daha iyi?" sorusu üzerinden yapılıyor. kui-react farklı bir soru soruyor: "İkisini aynı anda sunabilir miyiz?"

modules/ui/CodeEditor/index.tsx engine'i bir implementation detail olarak gizliyor. Kullanan kod sadece language ve value prop'larını biliyor.

API Yüzeyi

Dışarıya görünen interface şu:

export function CodeEditor({
  value,
  onChange,
  language = 'plaintext',
  theme = 'light',
  engine = 'codemirror',  // veya 'monaco'
  readonly = false,
  placeholder = '',
  label,
  hint,
  error,
  minHeight = 200,
  showLineNumbers = true,
  id,
  name,
  className,
}: CodeEditorProps) {

engine prop'u var ama default 'codemirror'. Çoğu kullanım bu prop'u yazmıyor. Sadece Monaco gereken yerlerde engine="monaco" geçiliyor.

Lazy Engine Loading

Engine seçimini useLazyEngine hook'u yönetiyor:

// modules/ui/CodeEditor/hooks/useLazyEngine.ts
export function useLazyEngine(engine: 'codemirror' | 'monaco') {
  const [Engine, setEngine] = useState<ComponentType<EngineProps> | null>(null);

  useEffect(() => {
    if (engine === 'monaco') {
      import('../engines/monaco').then((m) => setEngine(() => m.MonacoEngine));
    } else {
      import('../engines/codemirror').then((m) => setEngine(() => m.CodeMirrorEngine));
    }
  }, [engine]);

  return Engine;
}

Dynamic import — engine değişene kadar yüklenmiyor. CodeMirror sadece engine='codemirror' olan bileşenler render edildiğinde bundle'a giriyor. Monaco sadece engine='monaco' talep edilince.

Next.js'in static analysis'i bu dynamic import'ları ayrı chunk'lara ayırıyor. CodeMirror chunk'u ~100kB, Monaco chunk'u ~5MB. Kullanıcı Monaco gerektiren sayfaya gitmezse 5MB indirmediği oluyor.

İki Engine, Aynı Interface

engines/codemirror.tsx:

export function CodeMirrorEngine({
  value, onChange, language, theme,
  readonly, placeholder, showLineNumbers, minHeight,
}: EngineProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const viewRef = useRef<EditorView | null>(null);

  useEffect(() => {
    if (!containerRef.current) return;
    const view = new EditorView({
      state: EditorState.create({
        doc: value,
        extensions: [
          basicSetup,
          getLanguageExtension(language),
          theme === 'dark' ? oneDark : [],
          EditorView.updateListener.of((update) => {
            if (update.docChanged) {
              onChange?.(update.state.doc.toString());
            }
          }),
        ],
      }),
      parent: containerRef.current,
    });
    viewRef.current = view;
    return () => view.destroy();
  }, []);  // mount'ta bir kez

  // value sync, readonly, language değişimleri ayrı effect'lerde...

  return <div ref={containerRef} style={{ minHeight }} />;
}

engines/monaco.tsx aynı EngineProps'u alıyor ama monaco-editor API'sini kullanıyor:

export function MonacoEngine({
  value, onChange, language, theme,
  readonly, placeholder, showLineNumbers, minHeight,
}: EngineProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);

  useEffect(() => {
    if (!containerRef.current) return;
    const editor = monaco.editor.create(containerRef.current, {
      value,
      language: mapLanguage(language),
      theme: theme === 'dark' ? 'vs-dark' : 'vs',
      readOnly: readonly,
      minimap: { enabled: false },
      lineNumbers: showLineNumbers ? 'on' : 'off',
    });
    editor.onDidChangeModelContent(() => {
      onChange?.(editor.getValue());
    });
    editorRef.current = editor;
    return () => editor.dispose();
  }, []);

  return <div ref={containerRef} style={{ minHeight }} />;
}

Her iki engine de containerRef ile bir DOM div'e bağlanıyor. Her ikisi de onChange(newValue: string) callback'ini çağırıyor. Her ikisi de language, theme, readonly, minHeight prop'larını anlıyor. Dışarıdan bakıldığında fark yok.

Main Component'ta Birleşim

export function CodeEditor({ engine = 'codemirror', ...props }: CodeEditorProps) {
  const Engine = useLazyEngine(engine);
  const hiddenInputRef = useRef<HTMLInputElement | null>(null);

  useDiagnostics(props.markers);
  useAutocomplete(props.getSuggestions, props.getHover);

  if (!Engine) {
    return (
      <div
        style={{ minHeight: props.minHeight }}
        className="bg-surface-sunken animate-pulse rounded-md"
        aria-busy="true"
        aria-label={props.label ?? 'Code editor loading'}
      />
    );
  }

  return (
    <div className="space-y-1">
      {props.label && (
        <label className="block text-sm font-medium text-text-primary">
          {props.label}
        </label>
      )}
      <Engine {...props} />
      {props.name && (
        <input
          ref={hiddenInputRef}
          type="hidden"
          name={props.name}
          value={props.value}
        />
      )}
      {props.hint && (
        <p className="text-xs text-text-secondary">{props.hint}</p>
      )}
      {props.error && (
        <p className="text-xs text-error" role="alert">{props.error}</p>
      )}
    </div>
  );
}

Engine null iken — dynamic import henüz tamamlanmamış — skeleton loader gösteriliyor. aria-busy="true" ekran okuyucuya "yükleniyor" diyor.

name + hidden input: CodeEditor bir <form> içinde kullanılabilir. Form submit'te value hidden input üzerinden gönderiliyor — native form integration.

Language Mapping

Her engine farklı language identifier'lar kullanıyor. Ortak interface 'javascript' | 'typescript' | 'python' | 'css' | 'html' | 'markdown' | 'json' | 'plaintext' gibi canonical isimler alıyor:

// engines/codemirror.tsx
function getLanguageExtension(language: string) {
  const map: Record<string, () => Extension> = {
    javascript: () => javascript(),
    typescript: () => javascript({ typescript: true }),
    python: () => python(),
    css: () => css(),
    html: () => html(),
    json: () => json(),
  };
  return map[language]?.() ?? [];
}

// engines/monaco.tsx
function mapLanguage(language: string): string {
  const map: Record<string, string> = {
    javascript: 'javascript',
    typescript: 'typescript',
    python: 'python',
    markdown: 'markdown',
    json: 'json',
    html: 'html',
    css: 'css',
  };
  return map[language] ?? 'plaintext';
}

Her engine kendi mapping'ini yönetiyor. Dışarıdan language="typescript" yazıyorsunuz, engine ne yapacağını biliyor.

Trade-off

Engine agnostik yaklaşımın maliyeti: iki engine'i aynı feature seviyesinde tutmak zor. Monaco autocomplete, IntelliSense, multi-cursor destekliyor. CodeMirror bunların bir kısmını ek extension ile yapabiliyor ama default olarak daha minimal. getSuggestions, getHover, markers prop'ları şu an boş — M3 item. Bu prop'ları her iki engine'e eşit şekilde implement etmek ciddi iş.

engine prop'u bir bileşenin ömrü boyunca değiştirilmemeli. useEffect dependency [engine] var ama engine değişirse eski editor destroy edilip yenisi yaratılıyor — bu sırada içerik kaybı olabilir. Pratikte hiç kimse runtime'da engine değiştirmiyor ama belgelenmesi gereken bir kısıt.

Monaco'nun 5MB chunk boyutu bir seçim gerektiriyor: lazy-load veya pre-load. Lazy-load ilk render'da beyaz ekran riski taşıyor. Skeleton loader bu süreyi gizliyor ama her iki chunk için de loading state yönetimi gerekiyor.

İş Etkisi

Bir müşteri projesi "kod göster" feature'ı için CodeMirror ile başlıyor. Sonra "syntax check ve autocomplete de olsun" deniyor. Monaco geçişi için bileşeni değiştirmek zorunda değilsiniz — engine="monaco" ekleyip bitiriyorsunuz.

Showcasing uygulaması: kullanıcıların tarayıcıda canlı kod yazabildiği interaktif demo. CodeMirror yeterli. API test aracı veya in-browser IDE: Monaco tercih. Aynı codebase, iki farklı deneyim.

Deneyebileceğiniz Bir Şey

Kodunuzda bir kod editörü bileşeni varsa engine prop'u ekleyin ve iki ayrı dynamic import ile her iki implementasyona yönlendirin. Ortak bir EngineProps interface'i tanımlayın: value, onChange, language, readonly, minHeight. Her engine bu interface'i implement etsin.

Sonra bundle analyzer açın. İki chunk ayrı mı? CodeMirror yüklendiğinde Monaco bundle'a giriyor mu? Monaco sayfasına gitmeden Monaco yüklenmiyor mu? Bu soruların yanıtları implementasyonun doğru çalışıp çalışmadığını söylüyor.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox