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的开发体验已经相当成熟,对于中小型桌面应用是一个很好的选择。