引言
当我的朋友把 【长文】wangEditor V4.0 探索以团队的形式做开源项目 这篇文章分享给我的时候,我也是抱着对朋友负责的态度读了一下,读完之后,看到互联网前辈的一些分享和感悟,我也有所触动。然后那天午休的时候,我就没睡着,闭着眼睛沉思了下,我想这可能是我的一个机会:认识更多小伙伴,认识一个互联网前辈的机会,也是提升自己的机会。
已经做了四年多前端开发,还没有正式的去参加一个社区的开源项目,在公司的团队中还是跟同事一起做了一些前端基建,包括组件库、脚手架、公司内部的SDK等等,所以对一些工作流、开发规范、工具等还算有一些了解。以前没有参加开源项目可能是没勇气,没机会。现在也可能是觉得自己业务开发比较忙,没那么多时间,其实说这么多,可能都是给自己找借口,一直不敢突破自己。所以那天看到了这样一个机会,我想是时候突破一下,加上最近也在学习研究TS相关的知识,所以打算试一下看是否能加入 WangEditor的开发团队,学到更多的新知识。
于是有了这篇文章,加入团队的面试题是:一周内使用 Typescript 和 Webpack 技术栈开发一个支持标题、加粗、斜体的简易富文本编辑器,并且附上一篇开发过程的博客。下面我会介绍从项目搭建开始到开发完成最后构建发布的完整过程,其中包括项目的初始化、工作流的添加、工具链的选择、从调研到方案设计、最后完成开发、编写测试、构建发布等等。
项目初始化
因为是从零开始,而且只是写一个简易版本的项目,我就没有依赖什么脚手架去初始化项目,直接手动开始撸。首先直接使用:
yarn init -y
初始化项目的 package.json 文件,因为项目是 ts 项目,所以也需要初始化tsconfig.json文件。接着,添加基本的项目文件:.gitignore、README.md等等,我先大概创建了一个项目的目录结构,其中包括:
- src:放核心的代码;
- build:放webpack构建相关的配置;
- example:放
Editor相关的demo; - test:放单元测试相关的文件。
建好大致的目录结构,我就开始在自己的 github 创建了一个仓库,然后通过 git 命令关联起来,将代码推送到远程仓库。有了基本的项目骨架,接下来开始初始化项目的工作流。
初始化工作流
对于代码规范,我还是用了比较主流的 eslint 方案,因为是 Typescript 项目,所以需要添加一个 @typescript-eslint/parser 的 parser,这样 eslint 才能校验 ts 代码。为了能更自动化的对项目的代码做一些美化,所以引入 prettier 也是有必要的,这样可以让我们团队协作的时候没有注意到一些编码规范时,prettier 能帮我们自动做一些风格统一,而且也能一定程度上美化代码。
因为在团队中养成的习惯,所以我对于自己的项目或者平时公司的业务项目,一般都会添加 commitlint 检验,我们使用的是跟 angular 团队差不多的风格,这也是目前业界内应用较多的方案。如果想了解更多的信息,可以查看阮老师的这篇文章:Commit message 和 Change log 编写指南。
添加 babel 和 jest
无论使用 ts-loader 还是 babel-loader 理论上都是能帮助我们完成将 ts 和 es6 或者 esnext 转化成 es5 或者更低版本的 es 代码,这里我选了 babel 配合 @babel/preset-typescript preset完成对 ts 代码的转换,当然还要配合一些其它的 @babel/preset-env preset和 babel 插件。
对于测试工具,我选择了 jest,功能全面,自带断言库,也支持 ts,所以也很方便。这些工具都还算是比较常见的,所以我在这里也不过多介绍了。
添加webpack配置
因为最近 webpack5 刚好 release 了,所以我也抱着尝鲜的想法,直接上了最新版本。为了方便复用,还有开发模式下的调式,我添加了 webpack.base.config.js 、webpack.dev.config.js、webpack.prod.config.js 三个文件,这样无论是对于配置的复用,还是开发模式和生产构建配置的维护和扩展都是更加有利的。这里加一个小插曲,因为在开发模式下,我加入了 webpack-dev-server 作为开发环境的服务器,但是当我使用命令 webpack-dev-server --config ./build/webpack.dev.config.js 运行的时候,收到一个错误:
然后我在 webpack-dev-server issue 上找到了答案, 原来是我在使用 webpack-cli@4 的时候,这也是跟随 webpack5 一起 release 的版本,直接将 webpack-dev-server 的调用内置到了 webpack serve 命令中,所以,修改命令为 webpack serve --config ./build/webpack.dev.config.js 即可,最后成功启动:
其它的配置,跟之前版本没有太大的区别。webpack5 的大版本升级主要针对构建性能、项目架构上的优化,当然还有一些特色功能,比如模块联邦(Module Federation)等,这里我也不深入介绍,感兴趣的可以去看官方的 release 博客,地址:webpack5 release。
到此,我已经从项目初始化、项目目录设计、工作流的选择、构建工具和测试框架等的集成介绍了项目的搭建过程,开发前的准备工作已经几乎完成,后续开发中还发现有不足的地方可以再改进,最后我的项目目录结构大概入下图:
接下来介绍简易富文本框从设计到开发过程。
先调研还是先开发
我一直比较认同的一个观点是,无论对于业务技术方案,还是个人自己开发业余的项目,在真正开始动手之前,一定要做一定的调研,参考别人的一些好的方案,我们可以避免少踩很多坑,也能从别人的方案学到一些新的知识,这样才能保持不断学习进步。如果做什么都是自己拍脑袋完成,那么可能永远活在自己的世界里,没法进步。最近在负责公司的一个比较复杂的业务需求,我们最近一周也是一直在做调研,参考竞品的设计,然后吸收改进方案,最后设计自己的方案。
所以我先大概找了点资料了解下一个富文本开发需要注意的一些地方,甚至翻看一些开源项目的代码,找些灵感,其实我在看一些 API 的时候大概有一些想法。比如 toolbar 的设计应该是可以由用户定制,做得更强大点,可以做成插件化,用户可以自定义自己的特色功能。不过在我的设计里,先不用考虑那么复杂,只暂时留个配置项可以由用户自定义。
经过上述的调研,我大概有一些底了,对于富文本的开发一般有两个主流套路:
- 利用
contenteditable结合document.exceCommandAPI,比较出名的例子有:UEditor、WangEditor等; - 由自己模拟实现selection、包括视图渲染等一切,经典的例子有:有道云笔记、Google Doc等。
对第一种方案,由于依赖于浏览器提供的原生 API,有天然的优势,我们只需要关注一些边界情况的处理和对于 range 的存储处理。缺点可能就是依赖原生API,对于一些不是很符合我们需求的行为,我们没法干涉和灵活定制。
对于第二种方案,由于自己定义所有行为,灵活度和可扩展能力更强,但是实现难度比较大。
这里,我果断采用了第一种方案,对我来说实现成本相对来说比较低。
简单的架构设计
虽然只是一个简易版本的富文本框实现,我也大概画了下简单的架构图,如下:
虽然一个富文本框的 toolbar 部分和编辑区在 UI 视觉上是在一起的,但我们在技术设计的时候可以分开设计,并且尽量减少它们之间的耦合,它们之间只通过 EventBus 模块进行通信。这样当富文本功能越来越复杂的时候,对于维护性和扩展性也是更加有益的。
有了大概的架构图,接下我们开始具体实现。
具体实现
从前面的架构图我们可以大概很清楚需要实现的三个核心的模块:EventBus、编辑区域、toolbar,下面我们分别来实现:
EventBus
EventBus也是业界比较常见的通信方案了,所以社区内也有很多的实现方案,它是发布订阅设计模式的一种实现,在解耦模块与模块之间的关系上,往往能发挥很好的作用。针对本项目,我就没有引入外部方案,自己写了个简单的版本:
type Fn = (...args: any[]) => void
class CEvent {
events: Map<string, Fn[]>
constructor() {
this.events = new Map()
}
on(eventName: string, callback: Fn) {
const eventList = this.events.get(eventName)
if (eventList != null) {
eventList.push(callback)
} else {
this.events.set(eventName, [callback])
}
}
emit(eventName: string, ...args: any[]) {
const eventList = this.events.get(eventName)
if (eventList != null) {
for (let i = 0; i < eventList.length; i++) {
const fn = eventList[i]
fn.call(null, ...args)
}
}
}
off(eventName?: string) {
if (eventName == null) {
this.events.clear()
} else {
this.events.delete(eventName!)
}
}
}
export default CEvent
满足目前的需求基本够了,有了通信的模块,下面实现具体的UI模块。
编辑区域
首先是编辑区域,前面我们在介绍富文本的技术方案的时候,有提到我这里选择的是 contenteditable 结合 execCommand API 的方案,关于可编辑区域,本质是一个设置了 contenteditable 的 HTML 元素,这里,我选择了比较常见的 div 元素,下面是大概的实现:
class ContentEditable {
value?: string
element: HTMLElement
constructor(value?: string) {
this.value = value
this.element = this.createContentEditable()
}
createContentEditable(): HTMLElement {
const contentEditable = document.createElement('div')
contentEditable.className = 'editor-content'
contentEditable.id = generateUniqueId('EditorContent')
contentEditable.contentEditable = 'true'
if (this.value != null) {
contentEditable.innerHTML = this.value
}
return contentEditable
}
}
当然,目前我这里主要只是创建了 contenteditable 区域,暂时没有给这个模块添加其它功能。随着富文本功能的增加,可以进行一定的扩展。
toolbar
接着是 toolbar 的实现,这个模块如果要设计得强大一点,可以考虑实现插件化的机制,并提供一定的接口给用户开发自己的插件。由于时间问题,我就没有具体去实现了,我就留个简单的接口,使得用户可以自定义 toolbar,添加和删除自己想要的功能。toolbar 最核心的点还是在于和主模块之间的通信,这里就需要用到我们前面实现的 EventBus:
import { DEFAULT_TOOLBAR_BUTTONS, ToolbarButton } from './default'
import { toolbarButtonClickAction } from './Action'
import CEvent from '../utils/Event'
export interface ToolbarOptions {
toolbars?: ToolbarButton[]
event: CEvent
}
class Toolbar {
toolbar: ToolbarButton[]
event: CEvent
constructor(options: ToolbarOptions = { event: new CEvent() }) {
this.toolbar = options.toolbars ?? DEFAULT_TOOLBAR_BUTTONS
this.event = options.event
}
initToolbar() {
const toolbarContainer = document.createElement('div')
toolbarContainer.className = 'editor-toolbar'
for (let i = 0; i < this.toolbar.length; i++) {
const item = this.toolbar[i]
const button = document.createElement('button')
button.id = `editorToolbarButton-${item.name}`
button.className = `editor-toolbar-button editor-toolbar-button-${item.name}`
const img = document.createElement('img')
img.src = item.icon
button.appendChild(img)
button.addEventListener('click', () => {
const action = toolbarButtonClickAction(item.name, null)
this.event.emit(action.type)
})
toolbarContainer.appendChild(button)
}
return toolbarContainer
}
addButton(button: ToolbarButton) {
this.toolbar.push(button)
}
}
export default Toolbar
代码中的 DEFAULT_TOOLBAR_BUTTONS 变量用于存放默认的 Toolbar 选项,其结构大概如下:
export const DEFAULT_TOOLBAR_BUTTONS: ToolbarButton[] = [
{
name: 'heading',
icon: headIcon,
},
{
name: 'bold',
icon: boldIcon,
},
{
name: 'italic',
icon: italicIcon,
},
]
如果用户初始化编辑器的时候没有传入 toolbar 的参数,编辑器就会使用默认的这份配置。而上面的代码中比较重核心的设计就是:
// 绑定button的点击事件,通知编辑器点击了toolbar的某个按钮
button.addEventListener('click', () => {
const action = toolbarButtonClickAction(item.name, null)
this.event.emit(action.type)
})
这里,我们会绑定每个按钮的点击事件,然后点击按钮的时候,通过 EventBus 将对应的事件发送给编辑器的主模块,这样就实现了通信,后面讲到编辑器的主要实现的时候还会详细介绍。
有了前面这些模块的设计,接下来无非是把它们组合起来,成为一个完整的 Editor。先看具体的代码:
class Editor {
selector: string | HTMLElement
toolbar: Toolbar
event: CEvent
value: string | undefined
id: string
contentEditable: ContentEditable
container?: HTMLElement
constructor(selector: string | HTMLElement, options?: EditorOptions) {
this.selector = selector
this.value = options?.value
this.id = generateUniqueId('Editor')
this.event = new CEvent()
for (let i = 0; i < DEFAULT_TOOLBAR_BUTTONS.length; i++) {
const type = DEFAULT_TOOLBAR_BUTTONS[i].name
const action = toolbarButtonClickAction(type)
this.event.on(action.type, () => {
this.setTextStyle(type)
})
}
this.toolbar = new Toolbar({
event: this.event,
toolbars: options?.toolbars,
})
this.contentEditable = new ContentEditable(this.value)
// 监听contenteditable失去焦点事件,存储当前的可能存在的range
this.contentEditable.element.addEventListener('blur', () => {
this.saveRange()
})
this.init()
}
private init() {
const selector = this.selector
let container: HTMLElement | null
if (selector instanceof HTMLElement) {
container = selector
} else {
container = document.querySelector(selector)
}
if (container == null) {
throw new EditorError(
`can not find editor container element which selector is ${this.selector}`
)
}
container.className = 'editor-container'
if (container.id === '') {
container.id = generateUniqueId(`EditorContainer`)
}
const contentArea = this.contentEditable.element
const toolbar = this.toolbar.initToolbar()
container.appendChild(toolbar)
container.appendChild(contentArea)
this.container = container
}
setTextStyle(type: string) {
// 添加效果之前,先设置range
this.setRange()
if (type === 'heading') {
document.execCommand('formatBlock', false, 'h1')
} else {
document.execCommand(type)
}
}
// 保存当前range
saveRange() {
const selection = window.getSelection()
if (selection == null || selection.rangeCount === 0) {
return
}
const content = this.contentEditable.element
for (let i = 0; i < selection.rangeCount; i++) {
// 从selection中获取第一个Range对象
const range = selection.getRangeAt(0)
let start = range.startContainer
let end = range.endContainer
// 兼容IE11 node.contains(textNode) 返回false的bug
start = start.nodeType === Node.TEXT_NODE ? start.parentNode! : start
end = end.nodeType === Node.TEXT_NODE ? end.parentNode! : end
if (content.contains(start) && content.contains(end)) {
this.range = range
break
}
}
}
setRange() {
const selection = window.getSelection()
if (selection == null) return
// 清除当前range
selection.removeAllRanges()
if (this.range != null) {
selection.addRange(this.range)
} else {
// 如果没有,创建一个新的range存起来
const content = this.contentEditable.element
const row = document.createElement('br')
const range = document.createRange()
content.appendChild(row)
range.setStart(row, 0)
range.setEnd(row, 0)
selection.addRange(range)
this.range = range
}
}
destroy() {
if (this.container != null) {
this.container.innerHTML = ''
}
}
}
对于 Editor 实现,也是参考了大多数主流的编辑器初始化的方式,我们可以这样初始化:
const editor = new Editor('#id', {
value: '<div>默认内容</div>'
})
而第二个 options 参数,目前我也只是支持了的几个简单的参数,下面是它的类型定义:
interface EditorOptions {
value?: string
toolbars?: ToolbarButton[]
}
因为考虑到可能会将该类型暴露给用户使用,所以这里我们使用 interface 定义,方便用户扩展。除了将我们前面定义的 ContentEditable 和 Toolbar 拼凑起来,需要注意的是,我们在初始化编辑器的时候,需要创建 EventBus 实例,并且订阅每个 Toolbar 按钮的点击事件,并且把这个 EventBus 实例传递给 Toolbar。前面我们讲过,Toolbar 里面的 EventBus 会将每个按钮的点击事件发布出来,所以在这里我们就能订阅到每个按钮的点击事件了。
在 Editor 的代码中,有两个比较重要的地方。一个就是对于 range 的处理,还有另一个就是调用 execCommand API 实现最终的效果。对于 range 的处理,这里实现了 saveRange 和 setRange 两个函数,我们考虑一个场景,就是如果用户选择内容区的某段文字,不小心点击编辑器的外其它区域,再点击加粗就不生效了。所以为了解决这个问题,我们需要通过 window.getSelection API 获取当前的 range 并且将 range 存下来。然后在最后执行 execCommand 的时候,再通过 API 设置存储的 range,就解决了这个问题。
下面就是最终 setTextStyle 函数的实现:
setTextStyle(type: string) {
// 添加效果之前,先设置range
this.setRange()
if (type === 'heading') {
document.execCommand('formatBlock', false, 'h1')
} else {
document.execCommand(type)
}
}
监听到 heading、bold 和 italic 按钮的点击,就调用 execCommand API对编辑区里面的文本进行加粗、斜体、标题化等处理。
这里需要提一下的是,我没有直接使用 document.execCommand('heading', false, 'h1') 是因为该参数在 chrome 中不支持,可以使用如下命令验证:
console.log(document.queryCommandSupported("heading")) // false in chrome
所以,在这里我改成了 formatBlock。到目前我们已经基本实现了简单的富文本框,它支持标题化、加粗、斜体等功能,当然对于 h1~h5 我就没有一一实现了,它们的实现思路是一样的。下图是一个最后的效果图:
测试
对于一个开源项目,或者说自己公司一些比较通用的组件、模式库,一定的测试覆盖率是必不可少的,不然谁敢使用。作为一个专业的前端,测试也是一项必不可少的技能。前面搭建项目的时候,我们选择了 jest 作为测试框架。但是对于富文本这样的交互性比较强的工具,又跟UI挂钩,实际上要达到不错的覆盖率,还是很有难度的。而且,对于编写UI相关的单元测试,一直是我比较薄弱的一块,希望后续也能加强一下。所以这里我就先对于 EventBus 模块添加了几个用例:
import Event from '../../src/utils/Event'
const eventType = 'eventType'
describe('EventBus', () => {
test('should can listen a specify event', () => {
const event = new Event()
const fn = jest.fn()
event.on(eventType, fn)
event.emit(eventType)
expect(fn).toBeCalledTimes(1)
})
test('should can off a specify event', () => {
const event = new Event()
const fn = jest.fn()
event.on(eventType, fn)
event.off(eventType)
event.emit(eventType)
expect(fn).not.toBeCalled()
})
test('should can off all listenner', () => {
const event = new Event()
const otherEventType = 'otherEventType'
const fn1 = jest.fn()
const fn2 = jest.fn()
event.on(eventType, fn1)
event.on(otherEventType, fn2)
event.off()
event.emit(eventType)
event.emit(otherEventType)
expect(fn1).not.toBeCalled()
expect(fn2).not.toBeCalled()
})
})
然后运行 test 命令:
done!所有用例通过。当然如果有时间,可以添加更多的测试用例,尽量提高项目的测试覆盖率。
构建发布
经过一定的测试,基本上已经到了可以发布的阶段。当然在发布之前,我们需要对代码进行构建处理,在前面我们提到过,我们选择的是 webpack 作为构建工具。为了保持核心代码和样式的分离,所以分别添加了入口:
module.exports = [
{
mode: 'production',
entry: {
index: './src/style/index.css',
},
optimization: {
minimizer: [new OptimizeCSSAssetsPlugin({})],
},
module: {
rules: [
{
test: /\.css$/i,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: () => [require('autoprefixer')({ grid: true, remove: false })],
},
},
},
],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css',
chunkFilename: '[id].css',
}),
],
},
merge(baseConfig, {
mode: 'production',
entry: {
index: './src/index.ts',
},
output: {
path: path.resolve(__dirname, '../dist'),
filename: 'editor.umd.js',
library: 'Editor',
libraryTarget: 'umd',
libraryExport: 'default',
globalObject: 'this',
},
}),
]
对于最后输出的代码,我们也希望尽可能支持多种方式的引用,所以选择了 umd 格式。这样用户既可以直接在浏览器通过 script 方式引用,也可以在基于模块方案的项目中使用 commonjs 和 AMD 的模块化方式引用。因为这里是一个练手的项目,我就没有发布到 npm 上了,发布到 npm 上也比较简单,只需要在 npm官网 上注册账号,使用 npm login 命令在命令行工具上登录,直接通过 npm publish 即可。
总结
到这里,我们就基本上完成了从项目初始化->到方案调研->技术方案设计->具体编码实现->测试->构建发布等一条龙的简易的富文本框编辑器的实现。当然调研的过程应该一般在真正开始搭建项目之前进行,这里我介绍的时候只是调换了顺序。其实自己从零开始做一个项目也基本上是这样一个流程,如果设计到多人协作,可能还要涉及到其它的项目成员的 code review,通过后才能最终合并到主分支,进行构建上线。当然,如果想要做得更好一点,我们还需要编写详细的文档,添加 changelog 等,方便自己或者用户追溯项目的版本迭代的历史。
当然,这里我做的也比较简单,对于一些边界的情况或者更精细的交互体验没有做处理,比如目前将文本设置为标题后没有做还原的功能。而且,该方案的设计也纯粹是对于富文本领域所了解的还属于小白阶段的我的一次尝试,如有错误和不足之处,欢迎指出。