第一次尝试用 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/reactiveconst markdownText = ref('');computedconst 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/onUnmountedonMounted(() => { ... }); onUnmounted(() => { ... });defineProps/defineEmitsconst props = defineProps<{ isVisible: boolean }>(); const emit = defineEmits(['close']);watchEffect/watchwatchEffect(() => 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 语法点
definePropsconst props = defineProps<{ textareaSelector?: string }>();defineExposedefineExpose({ 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 语法点
computedreturn 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/defineEmitsref/computedv-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 的,以后有时间再优化一下。
评论
预览将在此处显示...
这是一个示例评论。GitHub风格的评论区已经成功集成到您的博客中!