背景概述
最近,我为了帮对象开发一个笔记应用,打算从零开始实现一个支持批注功能的富文本编辑器。
之所以为什么不用开源的?一是因为目前开源的富文本并不支持做批注这一功能,而想要扩展也不是件简单的事。二是希望自己有一个属于自己的项目,在简历里也可以增加一些亮点。三是希望通过这个项目去对自己的能力有一个比较大的提升。
这是目前写到的样子
技术积累与自我怀疑
因为自己平时有看书的习惯。而且又喜欢写代码,所以看过一些原理方面的知识,如《vue js 设计与实现》看了三遍,对响应式、虚拟dom、diff算法也比较了解了。而JS的一些知识如:原型、闭包、垃圾回收、事件循环也很熟悉。
自己全部看完过的技术书籍也有《代码整洁之道》、《架构整洁之道》、《敏捷软件开发》、《重构》、《JavaScript设计模式与开发实践》、《JavaScript高级程序设计》、《你不知道的js》上中下三册、《秒懂设计模式》、《深入解析css》、《网络是怎样连接的》、《穿越计算机的迷雾》。
外加上自己认真学过数据结构,像链表、栈、队列、数组、树、二叉树,这些在学习的时候花了很大的精力,并做了大量的笔记,算是比较熟了。而像内存、网络等相关的知识也花过一些时间了解过。
TS的掌握应该算是比较熟悉,像一般的TS工具类,自己也可以很快的手写(毕竟就那么一两行代码🤣。
declare type MyPartial<T extends object> = {
[P in keyof T]?: T[P];
};
declare type MyRecord<K extends keyof any, T> = {
[P in K]: T;
};
当慢慢的技术增长缓慢后,就很怀疑自己,我究竟和大厂的技术人员差距有多大,是否我懂的这些他们都知道,并且觉得这并没有什么。但很糟糕的是,我并没有和大厂员工一起工作过,这就让我有些焦虑了。 自己的未来究竟在哪里?到底什么才是高级前端?究竟高级在哪里?
广度与深度的抉择
每当想到这些,自己就开始思考,究竟该如何分配时间与精力在广度与深度方面?比如自己vue3已经用的很熟了,但毫无疑问的是还是有很多进步空间,那像react我完全没有接触过,如果有一个星期学习,究竟该学习下react以拓宽广度,还是该继续深入vue加深深度?
但学习react也并非只是拓宽广度,因为学习的过程中肯定会和vue做比较,比如它的函数式组件、虚拟dom、diff算法等。
我个人一直秉持的想法是,向内加深深度:算法、原理、设计模式等,要对代码了如指掌,要知道写的这一行究竟在做些什么,如何实现的,以保证可以灵活运用,出现问题了也可以很快的分析出问题。 另一个是向外拓宽广度:这并不是指有什么就学什么,而是说如果出现了本质上不同的技术,是可以拓宽一下知识面。广度更重要的一点还并非是技术的延展,而是站在一个更全面的视角去由上而下的去设计模块。
项目实践与反思
这就引出了我做的那个项目。 它是一个富文本,我原本写这个项目就很简单。想写一个功能就加一个功能。
比如刚开始,我想到了选中文字,将原先的文本内容添加标签、ID、样式(如加粗、删除线、下划线等。)我搭完框架后,就直接在一个vue 文件中写了这个功能。
//index.vue
<el-scrollbar class="p-3 pb-7" @click="onClick">
<div
v-html="articleStore.getCurrentArticle?.htmlContent"
class="editable-div"
id="edit_container"
placeholder="为你的翻译开始做笔记吧!"
contenteditable
@mouseup.stop="editableEvents.onSelectedTextMouseUp"
></div>
</el-scrollbar>
//js
// 功能集合
articleStyles: MyPartial<MyRecord<keyof ArticleStyles, string | Function>> = {
deleteLine: `text-decoration: line-through;`,
underLine: "text-decoration: underLine;",
note: (options: HighLightOptions) => `background-color:${options.bgColor}; color:${options.color}`,
bold: "font-weight: bold;",
}
//鼠标抬起事件
function onSelectedTextMouseUp(event: MouseEvent) {
// 转译特殊字符
const regex = new RegExp(signTranslateRegExp(selectedText), "g");
// 转换为操作过的html客串
const changedContent = htmlContent.replace(
regex,
`<span style="${style}" id="${uuid}">$&</span>`
);
}
// hoverBarType 为功能key
const styleInfo = this.articleStyles[hoverBarType]!;
const style = typeof styleInfo === "string" ? styleInfo : styleInfo(options);
// 转译特殊字符
function signTranslateRegExp(str: string) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& 表示整个匹配的字符串
}
我采用的是v-html 操作字符串,因为只是给我对象用,就没有做xss 这些。当内容操作成功后,获取HTML值,保存到本地并更新v-html 变量,发现光标消失了。
开始在重新赋值变量前,保存当前光标所在在节点相对于根节点的位置与节点内偏移量,赋值后根据信息找到该节点,并创建光标对象插入节点中。
//range.ts
//内部代码被我删减过,如果发现有调用的函数,但找不到,这是正常的。
interface Position {
index: number;
tagName: string;
parentTagName: string | undefined;
isTextNode: boolean;
}
class RangeHandler {
private position: Position[] = [];
private anchorOffset: number = 0;
//创建并将光标插入节点
createAndInsertRange(insertNode: Node, offset: number) {
const selection = getSelection()!;
const range = document.createRange();
// 清除当前的选择
selection.removeAllRanges();
// 添加新的range到selection中
range.setStart(insertNode, offset);
range.setEnd(insertNode, offset);
selection.addRange(range);
}
//保存光标的相对于editor节点的相对位置,并生成Position
async saveCursorPosition(node?: HTMLElement) {
this.clearPosition();
const section = getSelection()!;
let currentNode = node || section.anchorNode!;
const EDITOR_ID = "edit_container";
while (
currentNode?.parentNode &&
(currentNode as HTMLElement)?.id !== EDITOR_ID
) {
const index = Array.from(currentNode.parentNode?.childNodes).findIndex(
(node) => node === currentNode
);
const tagName =
(currentNode as HTMLElement).tagName || currentNode.nodeName;
const isTextNode = currentNode.nodeType === Node.TEXT_NODE;
this.position.unshift({
index,
tagName: tagName,
parentTagName: currentNode.parentElement?.tagName,
isTextNode: isTextNode,
});
currentNode = currentNode.parentNode;
}
}
//保存光标偏移量
async saveAnchorOffset(): Promise<number> {
const section = getSelection()!;
return new Promise((res) => {
// 同步获取时,anchorOffset 获取不到
setTimeout(() => {
this.anchorOffset = section.anchorOffset;
res(this.anchorOffset);
});
});
}
// 获取Position,默认获取缓存的,如果想获取当前光标的Position,则会先计算并保存当前Position
getCursorPosition(current?: boolean) {
if (current) this.saveCursorPosition();
return this.position;
}
// 获取光标相对于整个editor的偏移量,因为range对象的anchorOffset是相对于当前节点的
getCursorOffset() {
const position = this.getCursorPosition();
let current: ChildNode | HTMLElement = getEditorDom()!;
let totalTextLength = 0;
for (let item of position) {
totalTextLength += Array.from(current?.childNodes ?? [])
.filter((_, i) => i < item.index)
.reduce((pre, cur) => {
const isTextNode = cur.nodeType === Node.TEXT_NODE;
const textContent = cur.textContent?.length || 0;
if (isTextNode) return pre + textContent;
const innerText = (cur as HTMLElement).innerText;
const innerTextLength = innerText.replaceAll("\n", "").length;
return pre + innerTextLength;
}, 0);
current = current.childNodes?.[item.index];
}
return totalTextLength + this.anchorOffset;
}
// 通过Position,找到并返回节点,用来插入光标
findNodeByPosition(position: Position[]) {
const ROOT_ID = "edit_container";
let current: ChildNode | HTMLElement = document.getElementById(ROOT_ID)!;
for (let item of position) {
current = current.childNodes[item.index];
}
return current;
}
private clearPosition() {
if (this.position.length > 0) {
this.position = [];
}
}
}
export const rangeHandler = new RangeHandler();
其实当这些写完后,JS的代码就很多了。再加上为了提升自己的技术,项目内用的技术,几乎都是自己实现的。
比如封装了虚拟列表,支持定高与不定高;封装了右键自定义指令与组件。
右键指令的封装参考:作者:神奇程序员。文章:使用vue封装右键菜单插件
//右键指令的封装 ts文件
const createComp = (comp: Component, prop: RightMenuProps) => {
const app = createApp(comp, prop as any);
const div = document.createElement("div");
document.body.appendChild(div);
//将组件挂载到节点上
app.mount(div);
return div;
};
function onContextMenu(
el: HTMLElement,
binding: globalThis.DirectiveBinding<any, string, string>,
e: MouseEvent
) {
// 阻止浏览器右键菜单
e.preventDefault();
if (menuVM) {
// 销毁上次触发的右键菜单
document.body.removeChild(menuVM);
menuVM = null;
}
//收集所有的文本和对应的处理函数
const textArray = binding.value.map((item: RightMenuItem) => item.text);
const handlerArray = binding.value.map((item: RightMenuItem) => item.handler);
if (!textArray || !handlerArray) {
throw new Error("右键菜单内容与事件处理函数为必传项");
}
if (textArray.length !== handlerArray.length) {
throw new Error("每个右键菜单项都要有对应的事件处理函数");
}
const menuList = binding.value.map((item: RightMenuItem, index: number) => ({
...item,
id: index + 1,
}));
menuVM = createComp(RightMenuComponent, {
rightMenuStatus: "block",
rightMenuTop: e.clientY + "px",
rightMenuLeft: e.clientX + "px",
rightMenuList: menuList,
rightClickEvent: e,
});
}
export default {
install(app: App): void {
app.directive("rightClick", (el, binding) => {
function handleContextMenu(e: MouseEvent) {
onContextMenu(el, binding, e);
}
el.removeEventListener("contextmenu", handleContextMenu);
el.addEventListener("contextmenu", handleContextMenu);
});
document.body.addEventListener("click", () => {
if (menuVM) {
// 销毁右键菜单DOM
document.body.removeChild(menuVM);
menuVM = null;
}
});
},
};
//右键指令的封装 vue 文件
<template>
<section
id="rightMenuDom"
class="right-menu"
:style="{
display: props.rightMenuStatus,
top: props.rightMenuTop,
left: props.rightMenuLeft,
}"
>
<ul>
<li>
<div v-for="item in rightMenuList" :key="item.id">
<span :class="item.disabled ? 'disabled' : ''" @click="item.handler">
{{ item.text }}
</span>
</div>
</li>
</ul>
</section>
</template>
<script setup lang="ts">
import type { RightMenuProps } from ".";
const props = defineProps<RightMenuProps>();
</script>
<style scoped lang="scss">
// 右键菜单样式
.right-menu {
@apply fixed left-0 top-0 w-56 h-auto p-2 rounded-md bg-white hidden;
border: solid 1px #c2c1c2;
box-shadow: 0 10px 10px #c2c1c2;
ul {
li {
@apply py-1 list-none;
border-bottom: 1px solid rgb(216, 216, 217);
&:nth-last-child(1) {
border-bottom: none;
}
div {
span {
@apply py-2 px-8 rounded-md text-sm block;
&:hover {
@apply cursor-pointer bg-[#f1f1f1];
}
}
.disabled {
color: #666666;
&:hover {
cursor: not-allowed;
background-color: #f2f2f2;
color: #666666;
}
}
}
}
}
}
</style>
目前还支持做批注:选中文字弹出textarea,输入内容,之后移入元素会显示该批注内容,支持新增与编辑,但目前还不支持删除。
其实目前实现的功能并不多,但bug不少,操作html实在是问题很多,包括跨标签的操作,嵌套的操作。自己选中的内容是文字,在html字符串中查找该文字并包裹便签。
如果匹配到出现不止一次的内容,还需要记录内容偏移量,如果html内容中,该文字已经被操作过,则还需要排除标签。
比如用户的界面是这样的
而代码中是这样的
hel<span style="text-decoration: line-through;" id="7794fe9e-ee6b-4cb2-b077-da3e9fb1877d">lo world h</span>ello
这时当用户想将第二个hello加粗时,在代码中根本无法匹配到 hello 这个字符,因为其早就被标签分隔开了。
而这个时候,即使还有很多功能没有实现,但JS的代码量已经很大很大了。写到这时已经发现有些功能应该高度内聚,如文章功能、笔记功能、光标操作等。
而很多地方也应该低耦合,如:dom的操作,html字符串操作等,他们都在多个地方使用。
单向数据流
当写到这个时刻,自己已经很累了,代码的维护成本极高。比如之前在文章操作中,向外提供了一个getCurrentArtical 功能,但由于这个文章获取到的是个对象,其与文章列表的当前项指向同一地址。有时为了简便,直接就修改了数据。导致后面有一次找bug,找了很久,因为不知道是哪里操作了数据。这就感觉到单向数据流在维护上会更好。
设计模式与思想
这时,再去想想自己在实际运用设计模式时,总感觉单一职责总有的时候无法按照这个来。
有的功能目的比较明确,命名时好语义化。有的功能比较模糊,函数内部每行代码都是单独的一个操作,我就用handle做前缀。
如果handle函数内部的代码中间突然要添加一个其他操作,又操作了其他函数,感觉这handle函数又产生了副作用。
策略模式使用时,如果key不是一个值,而是一个判断,写起来感觉也不是很舒服。
其实这时候发现当我们去学习设计模式时,应该学习的是它的思想,如果从未有人说过设计模式是好的,那我们当接触设计模式时,是否可以感觉到它带来的实际价值。还是必须有人告诉我们:你看,只是好的,然后自己才发现这是好的。那如果有天有人和我们说:你看,这是不好的。那我们又该如何是好?
这时我发现像高内聚、低耦合,往往模块都快写完了,才发现哪些功能该高内聚、哪些功能又该低耦合,这时又要不停地去重构。
晋升之路的思考
那如果想要写出一个可维护、可扩展、可读性高的代码,不仅仅需要代码细节的优美,更需要在写代码之初时,能更好的设计一个模块,从一个更广的角度去看待这个整体,思考出这个模块的每一次层都该提供什么样的功能,层层向下,再到具体实施。
看来,这就是我思考出来的,晋升到高级前端的一条路!
我的项目
希望在不停地优化中,达到最好的样子。
打算参考@wangEditor,进行一波改版,也要基于slate.js 实现
链接到我的项目:note 富文本的批注实现