第一次尝试用 vue 开发一个小应用,记录一下。
代码地址:https://github.com/Naloam/vue-markdown
1. 项目结构
- 技术栈:Vue3 + Vite + TypeScript
- 目录结构:
src/
:Vue3 源码,所有核心组件、样式、工具函数components/
:编辑器、工具栏、侧边栏、字数统计等 UI 组件electron/
:Electron 主进程代码(本来想用 electron 打包成 exe,但是好像页面配置没有写好,就暂时搁置了)public/
:静态资源index.html
:入口页面
2. 组件 + 功能
2.1 MarkdownEditor.vue
- 编辑区+预览区
<textarea v-model="markdownText" class="editor" ... ></textarea> <div class="preview" v-html="renderedMarkdown"></div>
- 菜单栏/工具栏
<div class="menubar"> <div class="menu-item" v-for="menu in menus" :key="menu.label"> ... </div> </div> <FormatToolbar textareaSelector=".editor" />
- 主题切换
const currentTheme = ref('light'); // 切换主题 currentTheme.value = 'dark';
- 快捷键支持
onMounted(() => { document.addEventListener('keydown', handleKeyDown); }); function handleKeyDown(e: KeyboardEvent) { if (e.ctrlKey && e.key.toLowerCase() === 'b') { e.preventDefault(); handleToolbarFormat('bold'); } }
- 文件操作
const handleSaveFile = () => { const blob = new Blob([markdownText.value], { type: 'text/markdown' }); ... };
- 字数统计
<WordCount :content="markdownText" :is-visible="showWordCount" />
- 侧边栏
<Sidebar :is-visible="showSidebar" :markdown-content="markdownText" />
- Vue3 语法点
ref
/reactive
const markdownText = ref('');
computed
const renderedMarkdown = useMarkdownRender(markdownText);
v-model
<textarea v-model="markdownText" ... />
v-for
/v-if
<div v-for="menu in menus" v-if="menu.children" ... >...</div>
onMounted
/onUnmounted
onMounted(() => { ... }); onUnmounted(() => { ... });
defineProps
/defineEmits
const props = defineProps<{ isVisible: boolean }>(); const emit = defineEmits(['close']);
watchEffect
/watch
watchEffect(() => setStyle(currentHighlightStyle.value));
script setup
+ TypeScript<script setup lang="ts"> ... </script>
scoped style
+ CSS 变量.dark .editor { background: #1a1a1a; color: #e6edf3; }
- 事件修饰符
<textarea @keydown.prevent ... />
2.2 FormatToolbar.vue
- 一键插入格式
<button v-for="item in formatList" :key="item.type" @click="() => handleFormat(item.type)"> <span v-html="item.icon"></span> </button>
- 插入逻辑
function handleFormat(type: string) { const textarea = document.querySelector(props.textareaSelector || '.editor') as HTMLTextAreaElement; ... if (type === 'bold') { insert = selected ? `**${selected}**` : '**加粗**'; ... } ... }
- 表格风格
case 'table-adv': let table = `\n<!--table-style:${style}-->\n|`; ...
- Vue3 语法点
defineProps
const props = defineProps<{ textareaSelector?: string }>();
defineExpose
defineExpose({ handleFormat });
v-for
渲染按钮- 事件绑定 @click
2.3 useMarkdownRender.ts
- marked 配置
marked.setOptions({ highlight: (code, lang) => hljs.highlight(code, { language: lang }).value, langPrefix: 'hljs language-', ... });
- highlight.js 注册多语言
hljs.registerLanguage('javascript', javascript); ...
- katex 数学公式
import katex from 'katex'; // 渲染 $$公式$$
- Vue3 语法点
computed
return computed(() => marked.parse(markdownText.value));
2.4 useHighlightStyle.ts & MarkdownStyleSwitcher.vue
- 代码高亮主题切换
export function useHighlightStyle(defaultStyle = 'github') { const currentStyle = ref(defaultStyle); watchEffect(() => { import(`highlight.js/styles/${currentStyle.value}.css`); }); ... }
- Vue3 语法点
ref
管理当前主题watchEffect
动态切换样式defineEmits
组件事件
2.5 Sidebar.vue & 子组件
- 大纲
const headings = computed(() => { const lines = props.markdownContent.split('\n'); ... });
- 文档列表/文件树
const documents = ref([{ id: 1, name: '文档1.md' }, ...]); const fileTree = ref([{ id: 1, name: '项目文档', files: [...] }, ...]);
- Vue3 语法点
defineProps
/defineEmits
ref
/computed
v-for
/v-if
- 事件绑定 @click
2.6 WordCount.vue
- 实时统计
const charCount = computed(() => props.content.length); const wordCount = computed(() => { const words = props.content.match(/\b\w+\b/g); return words ? words.length : 0; });
- Vue3 语法点
defineProps
接收内容computed
统计各项数据defineEmits
关闭事件
3. 重要 Vue3 语法与实践详解(含代码片段)
ref
:const count = ref(0); count.value++;
reactive
:const state = reactive({ visible: false, text: '' });
computed
:const double = computed(() => count.value * 2);
watch
/watchEffect
:watch(() => state.text, (val) => { ... }); watchEffect(() => { ... });
v-model
:<input v-model="state.text" />
v-for
:<li v-for="item in list" :key="item.id">{{ item.name }}</li>
v-if
/v-show
:<div v-if="visible">显示</div> <div v-show="visible">显示</div>
defineProps
/defineEmits
:const props = defineProps<{ content: string }>(); const emit = defineEmits(['close']);
defineExpose
:defineExpose({ handleFormat });
onMounted
/onUnmounted
:onMounted(() => { ... }); onUnmounted(() => { ... });
provide
/inject
:provide('theme', currentTheme); const theme = inject('theme');
script setup
:<script setup lang="ts"> ... </script>
scoped style
:<style scoped> .editor { ... } </style>
- CSS 变量:
:root { --bg: #fff; } .dark { --bg: #1a1a1a; }
- 事件修饰符:
<button @click.stop="...">按钮</button> <input @keydown.prevent="..." />
- 组合式 API: 所有逻辑都用组合式 API 组织,便于复用和类型推断。
- TypeScript 类型声明:
const props = defineProps<{ content: string }>(); const count = ref<number>(0);
4. 编写思路与实现细节
- UI 设计:模仿 Typora,菜单栏+工具栏+分栏布局,主题色用 CSS 变量统一管理。
.dark .editor { background: #1a1a1a; color: #e6edf3; } .light .editor { background: #fff; color: #222; }
- 编辑/预览同步滚动
const handleEditorScroll = (e: Event) => { ... const editorScrollPercent = editor.scrollTop / (editor.scrollHeight - editor.clientHeight); const previewScrollTop = editorScrollPercent * (preview.scrollHeight - preview.clientHeight); preview.scrollTop = previewScrollTop; };
- 格式插入
function handleToolbarFormat(type: string) { // 触发 FormatToolbar 的 handleFormat ... }
- 代码高亮
marked.setOptions({ highlight: (code, lang) => hljs.highlight(code, { language: lang }).value, langPrefix: 'hljs language-', ... });
- 数学公式
import katex from 'katex'; // 渲染 $$公式$$
- 表格风格
case 'table-adv': let table = `\n<!--table-style:${style}-->\n|`; ...
- Electron 打包
// vite.config.ts export default defineConfig({ base: './', ... }); // electron/main.js mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
- 快捷键
document.addEventListener('keydown', handleKeyDown);
- 多语言支持
import { useI18n } from './useI18n'; const { t } = useI18n();
- 自定义主题
const currentTheme = ref('light'); // 切换主题 currentTheme.value = 'dark';
5. 遇到的错误与解决方案
- 代码高亮无效:
- 需注册所有用到的语言包,langPrefix 必须为 ‘hljs language-’,渲染器要加 hljs class。
hljs.registerLanguage('javascript', javascript); marked.setOptions({ langPrefix: 'hljs language-' });
- 斜体/删除线插入异常:
- 工具栏插入逻辑需区分有无选区,修正插入位置和包裹方式。
if (type === 'italic') { insert = selected ? `*${selected}*` : '*斜体*'; }
- 分组/分块插入不生效:
- 插入
<div class="group">...</div>
,渲染时用 CSS 区分。
insert = '<div class="group">' + (selected || '') + '</div>';
- 插入
- 打包后页面空白:
- vite.config.ts base 必须为 ’./‘,Electron main.js 需用相对路径加载 dist/index.html。
// vite.config.ts export default defineConfig({ base: './', ... }); // electron/main.js mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
- 菜单栏/工具栏样式错乱:
- 用 CSS 变量和 scoped style,主题切换时动态切换 class。
.dark .format-toolbar { ... } .light .format-toolbar { ... }
- 同步滚动卡顿:
- 用 setTimeout 限流,避免死循环。
setTimeout(() => { isScrolling = false; }, 50);
- 表格风格丢失:
- 插入时用注释标记风格,渲染时解析注释。
<!--table-style:striped--> | 表头1 | 表头2 | | --- | --- | | 内容1 | 内容2 |
- Git push/pull 报错:
- 处理 non-fast-forward、unrelated histories,需先 pull —rebase 或合并。
- TypeScript 类型报错:
- 组件 props、emit、ref、组合式函数都要加类型声明。
const props = defineProps<{ content: string }>();
- 事件冒泡/修饰符遗漏:
- 需加 @click.stop/@keydown.prevent 等,避免父子冲突。
<button @click.stop="...">按钮</button>
- 响应式丢失:
- ref/props/emit 用法不当会导致视图不更新,需严格用组合式 API。
6. 总结
- 熟悉了 Vue3 组合式 API、组件拆分、响应式原理。
- 尝试了 markdown 渲染、代码高亮、数学公式、主题切换等常见富文本编辑器实现,熟练了 markdown 的编写。
- 第一次前端工程化开发,学习了 Vite 、TypeScript 等。。
好像还有挺多 bug 的,以后有时间再优化一下。