中级前端向高级进阶的感悟与优化

8,947 阅读10分钟

背景概述

最近,我为了帮对象开发一个笔记应用,打算从零开始实现一个支持批注功能的富文本编辑器。

之所以为什么不用开源的?一是因为目前开源的富文本并不支持做批注这一功能,而想要扩展也不是件简单的事。二是希望自己有一个属于自己的项目,在简历里也可以增加一些亮点。三是希望通过这个项目去对自己的能力有一个比较大的提升。

这是目前写到的样子

image.png

技术积累与自我怀疑

因为自己平时有看书的习惯。而且又喜欢写代码,所以看过一些原理方面的知识,如《vue js 设计与实现》看了三遍,对响应式、虚拟dom、diff算法也比较了解了。而JS的一些知识如:原型、闭包、垃圾回收、事件循环也很熟悉。

自己全部看完过的技术书籍也有《代码整洁之道》、《架构整洁之道》、《敏捷软件开发》、《重构》、《JavaScript设计模式与开发实践》、《JavaScript高级程序设计》、《你不知道的js》上中下三册、《秒懂设计模式》、《深入解析css》、《网络是怎样连接的》、《穿越计算机的迷雾》。

image.png

image.png

外加上自己认真学过数据结构,像链表、栈、队列、数组、树、二叉树,这些在学习的时候花了很大的精力,并做了大量的笔记,算是比较熟了。而像内存、网络等相关的知识也花过一些时间了解过。

# 耗时5天写成的 C语言数据结构:线性表

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加深深度?

image.png

但学习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内容中,该文字已经被操作过,则还需要排除标签。

比如用户的界面是这样的

image.png

而代码中是这样的

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 富文本的批注实现