前言
最近肝了几天写了一个用于管理和编辑我们小团队的一个md文档的网页版typora,在过去我们的知识归纳都是各自使用typora编辑,编辑后每周定时提交文档,由一个人汇总后发布到csdn等平台。
但是这样做在文档出现问题后发布的文章如果需要持续更新的话无法让团队中的其他人看到,且使用typora保存的图片都是本地的,配置图床也没有那么方便,所以干脆花点时间整理了一下需求,想着做一个网页协作版的typora,有一个基本的账号机制,一个文档可以大家一起编辑,但是不是协作文档那种同时编辑,而是一个人在编辑的时候将该文档锁定,并提示有其他人正在编辑中。
整理了大概的需求就开锤了!由于是一个小项目并且最近个人正在从vue2转向vue3+ts,就用这个项目来踩踩坑,最终实现的效果如下图
页面框架构思
通过初步的需求分析,在除了注册和登录外的主要页面就是一个类似typora的页面,分析如下:
左侧栏:
顶部的tabs标签栏,用于切换文件目录和大纲。在文件目录标签内有一个顶部搜索框,一个文件管理的树状列表,底部栏有一个在根目录添加文件或文档的按钮以及用户的用户名显示,鼠标右键点击文件夹可以编辑文件或文件夹的状态。
顶部栏:
展示了当前选中的文档,以及文档的创建者和属性,顶部右侧是一个开始编辑的按钮,当其他人在编辑时将进入disable状态,并且按钮文案变更为xx用户正在编辑中
编辑器:
中间是编辑器的主要页面,有预览状态和编辑状态,编辑状态隐藏左边栏
-
技术选型
前端
vue@3.2.x及相关版本全家桶 + vite@2.x + ts + element-plus +tailwindcss
后端
node.js + koa@2.x + sequelize + pm2
数据库
mysql
markdown编辑器
v-md-editor
ide工具
vscode(主要使用插件:volar,Tailwind CSS IntelliSense,TypeScript Extension Pack)
…
框架搭建
生成目录框架
首先我们要创建一个项目,根据vite官方文档使用以下命令创建一个项目,根据命令行提示我们选择vue+ts的框架
yarn create vite
再根据我们的技术选型以及相应的官方文档将整体目录创建好
如下图:
文件目录框架是vue项目中非常典型的一种,router和store分别对应vue-router和vuex的功能模块,style是全局样式,utils是全局方法,views和componets是页面和通用组件,每一个views中都有自己对应的components
文件夹和一个入口文件index.vue
安装tailwindcss
根据官方文档安装指定依赖
yarn add -D tailwindcss@npm:@tailwindcss/postcss7-compat @tailwindcss/postcss7-compat postcss@^7 autoprefixer@^9
初始化tailwind配置文件
npx tailwindcss init -p
使用vscode建议安装Tailwind CSS IntelliSense
插件,在写class时先敲一个空格就可以出现tailwindcss的样式代码提示,如下图
安装element-plus并修改主题色
yarn add element-plus
在src目录创建文件element-variables.scss,写入如下代码
@use "sass:math";
@use "sass:map";
$--colors: () !default;
$--colors: map.deep-merge(('white': #ffffff,
'black': #000000,
'primary': ('base': teal,
),
'success': ('base': #67c23a,
),
'warning': ('base': #e6a23c,
),
'danger': ('base': #f56c6c,
),
'error': ('base': #f56c6c,
),
'info': ('base': #909399,
),
),
$--colors);
$--font-path: 'element-plus/theme-chalk/fonts';
@import "element-plus/theme-chalk/src/index.scss"
安装v-md-editor
yarn add @kangc/v-md-editor@next
安装vuex和vue-router
yarn add vue-router@4 vuex@next --save
yarn add vue-router@4 vuex@next --save
其中vuex的模块化,类型化,持久化引入我在另一篇文章中有写
vue3+vuex的类型化和模块引入
安装axios并封装http请求
yarn add axios
http请求的封装参照这篇文章
vue3+ts+axios请求封装使用
在这个项目中,我并没有将所有的api请求单独封装,因为各个api的复用程度不高,而且传递的参数也比较简单,个人认为没有封装的必要
引入所有依赖
除了以上几个依赖,其他的都没有什么特殊点,可以直接在看文档安装,最终的main.ts如下
import { createApp } from "vue"
import App from "./App.vue"
import { store, key } from "./store"
import router from "./router"
import "element-plus/theme-chalk/display.css"
import "font-awesome/css/font-awesome.min.css"
import "./element-variables.scss"
import "tailwindcss/tailwind.css"
import "./style/global.scss"
import "@kangc/v-md-editor/lib/style/base-editor.css"
import "@kangc/v-md-editor/lib/theme/style/vuepress.css"
import VueMarkdownEditor from "@kangc/v-md-editor"
import vuepressTheme from "@kangc/v-md-editor/lib/theme/vuepress.js"
import Prism from "prismjs"
import createLineNumbertPlugin from "@kangc/v-md-editor/lib/plugins/line-number/index"
VueMarkdownEditor.use(vuepressTheme, {
Prism,
})
VueMarkdownEditor.use(createLineNumbertPlugin())
const app = createApp(App)
app.use(store, key)
app.use(router)
app.use(VueMarkdownEditor)
app.mount("#app")
页面实现
页面的实现代码比较多,大家可以直接去gitee看源码,主要讲一下开发的思路
登录和注册页面
页面的目录如下,三个主要页面,分别是登录页面,注册页面和文档页面
注册和登录比较简单,主要是element中 ElForm
, ElFormItem
两个组件的应用,可以直接在源代码中查看
编辑器页面
编辑器页面中有三个组件,分别是对应页面框架的三个部分,由于这三个组件之间的需要相互调用数据和方法耦合程度很高,如果使用传统的组件传值会非常麻烦,我们可以利用vuex来非常方便的进行数据的使用和修改
在store文件夹中,有五个数据模块,对应着页面中各个位置的数据
拿其中的fileTree.ts
模块来看,这个模块里的方法和数据不论是顶部栏还是左侧栏都会频繁用到,这就节省了很多父子,兄弟组件之间的交互。
import { Module } from "vuex"
import { RootState } from "../index"
import http from "@/utils/http"
import { FlatToTree } from "@/utils/format"
import type { FileItemType } from "@/views/home/components/LeftBar/components/FileTree/type"
const state = {
data: [] as Array<FileItemType>,//文件树的数据
flag: true,//左侧栏是否展开
treeExpandedArr: [] as Array<string>,//文件树需要展开的节点数组
}
export type FileTreeState = typeof state
export const store: Module<FileTreeState, RootState> = {
namespaced: true,
state,
mutations: {
//更新展开树节点的缓存数据
changeTreeExpandedArr(
state: FileTreeState,
TreeExpandedArr: FileTreeState["treeExpandedArr"]
) {
state.treeExpandedArr = TreeExpandviteedArr
},
//切换左侧树状文件夹的展开
switchFileTree(state: FileTreeState, flag: boolean) {
state.flag = flag
},
},
actions: {
//获取文件目录并从扁平转化为树状
async getFile({ state }) {
const res = await http.get<Array<FileItemType>>("/file/getFile")
if (res.code === 1) {
state.data = FlatToTree(res.data as Array<FileItemType>, "parentId")
}
},
},
}
websocket实现
之所以需要使用到websocket,是因为如果有人开始编辑文档,而我们这里并不知道文档已经在编辑状态了,就会导致两个人同时编辑后提交,后提交的覆盖了先提交的数据。因此我们希望前端可以在其他人开始编辑的时候实时接受到,并让其他用户不能编辑。
文档被占用时文件树会如下图所示
3248178d4a4a4c6833eab8.png)
开始编辑按钮也会无法点击
websocket的封装
通过判断收到的消息中 handle
字段进行文件的占用或释放的处理,heartbeat方法是用于建立心跳,在通讯因意外断开后可以及时的重连
type MessageType = {
handle: "occupy" | "release"
userId: number
fileId: number
name: string
}
export const websocket = {
ws: null as WebSocket | null,
status: false,
userId: 0,
url: "",
connect(url: string, userId: number) {
const ws = new WebSocket(url)
ws.onopen = () => {
ws.send(JSON.stringify({ userId }))
}
ws.onmessage = this.onmessage
ws.onclose = this.onclose
ws.onerror = this.onerror
this.status = true
this.ws = ws
this.userId = userId
this.url = url
},
onmessage(e: any) {
if ((e.data as MessageType | "pong") === "pong") {
this.status = true
} else {
websocket.messageCallback(JSON.parse(e.data))
}
},
messageCallback(e: MessageType) {},
onerror() {
websocket.status = false
},
onclose() {
websocket.status = false
},
heartbeat() {
setInterval(() => {
if (this.status) {
try {
;(this.ws as WebSocket).send("ping")
} catch {
this.status = false
this.connect(this.url, this.userId)
}
} else {
this.connect(this.url, this.userId)
}
}, 3000)
},
}
注意点
在上面的代码中,我们可以发现,onerror
和onclose
方法中写入的并不是this.status = false
而是 websocket.status = false
,这是因为我们将这两个方法传给了ws对象,当触发这个方法的时候this指向的就是ws对象而不是我们websocket 这个对象了,同理onmessage
也是一样,想要触发回调函数就得修改调用的对象
websocket的调用
websocket.connect("ws://websocketUrl", store.state.user.id)
websocket.heartbeat()
websocket.messageCallback = async (e) => {
//收到消息后执行的回调方法
}
暂时先写到这里,后面将会持续更新,前后端的源码也在整理准备开源啦!欢迎关注插眼!