最近肝了几天写了一个用于管理和编辑我们小团队的一个md文档的网页版typora,在过去我们的知识归纳都是各自使用typora编辑,编辑后每周定时提交文档,由一个人汇总后发布到csdn等平台。
但是这样做在文档出现问题后发布的文章如果需要持续更新的话无法让团队中的其他人看到,且使用typora保存的图片都是本地的,配置图床也没有那么方便,所以干脆花点时间整理了一下需求,想着做一个网页协作版的typora,有一个基本的账号机制,一个文档可以大家一起编辑,但是不是协作文档那种同时编辑,而是一个人在编辑的时候将该文档锁定,并提示有其他人正在编辑中。
整理了大概的需求就开锤了!由于是一个小项目并且最近个人正在从vue2转向vue3+ts,就用这个项目来踩踩坑,最终实现的效果如下图
通过初步的需求分析,在除了注册和登录外的主要页面就是一个类似typora的页面,分析如下:
顶部的tabs标签栏,用于切换文件目录和大纲。在文件目录标签内有一个顶部搜索框,一个文件管理的树状列表,底部栏有一个在根目录添加文件或文档的按钮以及用户的用户名显示,鼠标右键点击文件夹可以编辑文件或文件夹的状态。
展示了当前选中的文档,以及文档的创建者和属性,顶部右侧是一个开始编辑的按钮,当其他人在编辑时将进入disable状态,并且按钮文案变更为xx用户正在编辑中
中间是编辑器的主要页面,有预览状态和编辑状态,编辑状态隐藏左边栏
-
[email protected]及相关版本全家桶 + [email protected] + ts + element-plus +tailwindcss
node.js + [email protected] + sequelize + pm2
mysql
v-md-editor
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
根据官方文档安装指定依赖
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的样式代码提示,如下图
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"
yarn add @kangc/v-md-editor@next
yarn add vue-router@4 vuex@next --save
yarn add vue-router@4 vuex@next --save
其中vuex的模块化,类型化,持久化引入我在另一篇文章中有写
vue3+vuex的类型化和模块引入
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,是因为如果有人开始编辑文档,而我们这里并不知道文档已经在编辑状态了,就会导致两个人同时编辑后提交,后提交的覆盖了先提交的数据。因此我们希望前端可以在其他人开始编辑的时候实时接受到,并让其他用户不能编辑。
文档被占用时文件树会如下图所示
3248178d4a4a4c6833eab8.png)
开始编辑按钮也会无法点击
通过判断收到的消息中 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.connect("ws://websocketUrl", store.state.user.id)
websocket.heartbeat()
websocket.messageCallback = async (e) => {
//收到消息后执行的回调方法
}
每一个页面都由多个组件组合而成,而每一个组件内也由颗粒度更细的组件和其他依赖组成,例如项目中 文件树组件 的目录
这个组件中又三个部分组成,一个是组件hooks
,index.vue
是组件的入口文件,type.ts
是页面的类型文件
index.vue
就和我们vue2中的组件一样,将其他组件引入,封装好之后在其他组件或者页面中调用,但是和vue2最大的区别就是数据和方法的封装
在vue3中,我们可以使用composition-api
进行开发,这样做的好处就是我们可以把相同逻辑的代码写在一起,将他们封装为一个hook。这么做可以取代vue2中组件的mixin
操作
例如下面的代码,这里是一个叫useState
的hook,用于创建页面所需要的变量,它写在单独的一个文件中,并放在与组件入口文件同级的hook
文件夹里
// useState.ts
import {
ref, computed } from "vue-demi"
import {
useStore } from "@/store"
function useState() {
const store = useStore()
const btnLoading = ref<boolean>(false)
// 判断是否为可编辑文档
const editable = computed(() => {
if (store.state.markdown.editable) {
return true
} else {
if (store.state.markdown.user.id === store.state.user.id) {
return true
}
return false
}
})
// 判断文档是否被占用
const occupied = computed(() => {
if (store.state.markdown.occupyUser) {
return true
}
return false
})
return {
btnLoading, editable, occupied }
}
export default useState
然后我们在组件中引入这个hook,可以看到我们组件所需要的变量就已经定义好了,而且组件的代码中非常的干净清爽
<script setup lang="ts">
import useState from "./hooks/useState"
const {
btnLoading, editable, occupied } = useState()
</script>
那么如果组件中有更多的方法要怎么做呢,只需要写更多hook就OK啦,使用hook可以将我们的逻辑上相关联的数据和方法抽离出来单独维护,需要增加功能的时候只需要在hook中修改或者添加hook就可以了,下面的代码是项目中其中一个组件的script
,是不是看起来比vue2中代码清爽很多呢~
<script setup lang="ts">
import {
ElButton, ElTag } from "element-plus"
import {
useStore } from "@/store/index"
import useState from "./hooks/useState"
import useStartEdit from "./hooks/useStartEdit"
import useStopEdit from "./hooks/useStopEdit"
const store = useStore()
const {
btnLoading, editable, occupied } = useState()
const {
startEdit } = useStartEdit(btnLoading)
const {
stopEdit } = useStopEdit(btnLoading)
</script>
type.ts
也与组件的index.vue
同级,它用来存放一些该组件中需要复用的类型,例如下面的代码,如果我们有多个hook都需要用到这些类型,或者是类型的声明比较长,不想让它和业务代码混在一起就可以写在这里,当然实际开发过程还是根据自己的习惯而定啦~
//type.ts
import {
ElTree } from "element-plus"
export type TreeType = InstanceType<typeof ElTree>
export type FileItemType = {
id: number
name: string
parentId?: number
isDir: boolean
editable: boolean
occupyUserId?: number
}
暂时先写到这里,后面将会持续更新,前后端的源码也在整理准备开源啦!欢迎关注插眼!