Tauri实战:打造一个Markdown编辑器

Tauri是Electron的轻量替代品,用Rust做后端,系统WebView做渲染,打包体积通常只有几MB。这篇文章记录用Tauri + Vue3做一个Markdown编辑器的过程。

项目架构

md-editor/
├── src-tauri/          # Rust后端
│   ├── src/
│   │   └── main.rs     # Tauri入口 + 命令定义
│   ├── Cargo.toml
│   └── tauri.conf.json # Tauri配置
├── src/                # Vue3前端
│   ├── App.vue
│   ├── components/
│   │   ├── Editor.vue      # Monaco编辑器
│   │   ├── Preview.vue     # Markdown预览
│   │   └── TitleBar.vue    # 自定义标题栏
│   └── main.ts
├── package.json
└── vite.config.ts

技术栈选择:

  • Tauri 1.x:桌面端框架
  • Vue 3 + TypeScript:前端UI
  • Monaco Editor:代码编辑器(VS Code同款)
  • marked.js:Markdown渲染
  • highlight.js:代码高亮

初始化项目

# 创建Tauri + Vue3项目
npm create tauri-app@latest md-editor -- --template vue-ts

cd md-editor
npm install

# 安装额外依赖
npm install monaco-editor marked highlight.js
npm install -D @types/marked

编辑器组件

用Monaco Editor做编辑区域,体验和VS Code一致:

<!-- src/components/Editor.vue -->
<template>
  <div ref="editorContainer" class="editor-container"></div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import * as monaco from 'monaco-editor'

const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

const editorContainer = ref<HTMLDivElement>()
let editor: monaco.editor.IStandaloneCodeEditor | null = null

onMounted(() => {
  if (!editorContainer.value) return

  editor = monaco.editor.create(editorContainer.value, {
    value: props.modelValue,
    language: 'markdown',
    theme: 'vs-dark',
    minimap: { enabled: false },
    wordWrap: 'on',
    lineNumbers: 'on',
    fontSize: 15,
    fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
    padding: { top: 16 },
    scrollBeyondLastLine: false,
    automaticLayout: true,
  })

  // 内容变化时同步
  editor.onDidChangeModelContent(() => {
    const value = editor?.getValue() || ''
    emit('update:modelValue', value)
  })
})

// 外部更新时同步到编辑器(如打开文件)
watch(() => props.modelValue, (newVal) => {
  if (editor && editor.getValue() !== newVal) {
    editor.setValue(newVal)
  }
})

onBeforeUnmount(() => {
  editor?.dispose()
})
</script>

<style scoped>
.editor-container {
  width: 100%;
  height: 100%;
}
</style>

Markdown预览组件

使用marked.js渲染,highlight.js做代码高亮:

<!-- src/components/Preview.vue -->
<template>
  <div class="preview-container" v-html="renderedHTML"></div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'

const props = defineProps<{
  content: string
}>()

// 配置marked
marked.setOptions({
  highlight(code: string, lang: string) {
    if (lang && hljs.getLanguage(lang)) {
      return hljs.highlight(code, { language: lang }).value
    }
    return hljs.highlightAuto(code).value
  },
  breaks: true,
  gfm: true,
})

const renderedHTML = computed(() => {
  try {
    return marked(props.content)
  } catch {
    return '<p style="color:red;">渲染出错</p>'
  }
})
</script>

<style scoped>
.preview-container {
  padding: 16px 24px;
  overflow-y: auto;
  height: 100%;
  background: #1e1e1e;
  color: #d4d4d4;
}
</style>

Rust后端:文件读写

Tauri的核心优势是Rust后端。文件操作在Rust侧实现,前端通过invoke调用:

// src-tauri/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use std::fs;
use tauri::api::dialog::FileDialogBuilder;

#[tauri::command]
fn read_file(path: String) -> Result<String, String> {
    fs::read_to_string(&path).map_err(|e| format!("读取文件失败: {}", e))
}

#[tauri::command]
fn write_file(path: String, content: String) -> Result<(), String> {
    fs::write(&path, &content).map_err(|e| format!("写入文件失败: {}", e))
}

#[tauri::command]
fn get_filename(path: String) -> String {
    std::path::Path::new(&path)
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_else(|| "untitled".to_string())
}

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            read_file,
            write_file,
            get_filename,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

前端调用Rust命令

// src/composables/useFile.ts
import { invoke } from '@tauri-apps/api/tauri'
import { open, save } from '@tauri-apps/api/dialog'
import { ref } from 'vue'

export function useFile() {
  const currentPath = ref<string | null>(null)
  const isDirty = ref(false)

  async function openFile(): Promise<string | null> {
    const selected = await open({
      filters: [
        { name: 'Markdown', extensions: ['md', 'markdown'] },
        { name: '所有文件', extensions: ['*'] },
      ],
    })

    if (typeof selected !== 'string') return null

    const content = await invoke<string>('read_file', { path: selected })
    currentPath.value = selected
    isDirty.value = false
    return content
  }

  async function saveFile(content: string): Promise<boolean> {
    let path = currentPath.value

    if (!path) {
      const selected = await save({
        filters: [{ name: 'Markdown', extensions: ['md'] }],
        defaultPath: 'untitled.md',
      })
      if (!selected) return false
      path = selected
      currentPath.value = path
    }

    await invoke('write_file', { path, content })
    isDirty.value = false
    return true
  }

  return { currentPath, isDirty, openFile, saveFile }
}

主界面布局

左右分栏,左边编辑,右边实时预览:

<!-- src/App.vue -->
<template>
  <div class="app">
    <TitleBar
      :filename="filename"
      :dirty="file.isDirty.value"
      @open="handleOpen"
      @save="handleSave"
    />
    <div class="main-content">
      <div class="pane editor-pane">
        <Editor v-model="content" />
      </div>
      <div class="divider"></div>
      <div class="pane preview-pane">
        <Preview :content="content" />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import Editor from './components/Editor.vue'
import Preview from './components/Preview.vue'
import TitleBar from './components/TitleBar.vue'
import { useFile } from './composables/useFile'

const content = ref('# Hello Markdown\n\nStart typing...')
const file = useFile()

const filename = computed(() => {
  if (!file.currentPath.value) return 'untitled.md'
  return file.currentPath.value.split(/[/\\]/).pop() || 'untitled.md'
})

async function handleOpen() {
  const text = await file.openFile()
  if (text !== null) {
    content.value = text
  }
}

async function handleSave() {
  await file.saveFile(content.value)
}
</script>

<style>
* { margin: 0; padding: 0; box-sizing: border-box; }

.app {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background: #1e1e1e;
}

.main-content {
  display: flex;
  flex: 1;
  overflow: hidden;
}

.pane {
  flex: 1;
  overflow: hidden;
}

.divider {
  width: 2px;
  background: #333;
  cursor: col-resize;
}
</style>

构建与打包

# 开发模式
npm run tauri dev

# 生产构建
npm run tauri build

构建产物在src-tauri/target/nelease/bundle/目录下。Windows下生成.msi安装包,macOS下生成.dmg,体积通常在3-8MB。相比Electron动辄100MB以上,Tauri的优势非常明显。

可以继续扩展的功能

这个基础版可以进一步添加:

  • 快捷键支持(Ctrl+S保存、Ctrl+O打开)
  • 文件树侧栏(打开整个目录)
  • 同步滚动(编辑器和预览联动滚动位置)
  • 导出PDF(通过Rust侧调用wkhtmltopdf或类似工具)
  • 主题切换(亮色/暗色)

Tauri的开发体验已经相当成熟,对于中小型桌面应用是一个很好的选择。