一、背景
云文档转HTML邮件
基于公司内部的飞书办公套件,早在去年6月,我们就建设了将飞书云文档转译成HTML邮件的能力,方便同学们在编写邮件文档和发送邮件时,都能有较好的体验和较高的效率。
当下问题
要被邮件客户端识别,飞书云文档内容需要转译成HtmlEmail格式,该格式为了兼容各种版本的邮箱客户端(特别是Windows Outlook),对于现代HTML5和CSS3的很多特性是不支持的,飞书云文档的多种富文本块格式都需要转译,且部分格式完全不支持,造成编辑和预览发送不一致的情况。
因此,我们对转译工具做了一次大改版和升级,对大部分常用文档块做了高度还原。
实现效果
经过我们的不懈努力,最终实现了较为不错的还原效果:
二、系统架构改版
飞书云文档结构
在展开我们如何做升级之前,先要简单了解下飞书云文档的信息结构(详情可参考官方API),在此仅做简单阐述。
TypeScript简要定义,一个平铺的文档块数组,根据block_id和parent_id确定各块的父子关系,从而形成一个树:
{
/** 文档块唯一标识。*/
block_id: string;
/** 父块 ID。*/
parent_id: string;
/** 子块 ID 列表。*/
children: string[];
/** 文档块类型。*/
block_type: BlockType;
/** 页面块内容描述。*/
page?: { ... };
/** 文本块内容描述。*/
text?: { ... };
/** 标题 1 块内容描述。*/
heading1?: { ... };
/** 有序列表块内容描述。*/
ordered?: { ... };
/** 表格块内容描述。*/
table?: { ... };
// 总计 43 个块定义。
...
}[];
我们用思维导图简单举例,整个文档块的树结构大致是这样的,有些块根据缩进递进,会形成父子关系,有些块天然就会成为父块(比如表格、引用等):
旧版架构
那么我们初版转译工具是怎么做的呢,比较遗憾的是,由于当时需求的还原度诉求较低,我们的代码主要是复用现有部分实现,整体的架构设计可以用一个词概括,基本是面向过程编程:
上方的图:经过了一些抽取和封装,主流程核心代码仍有528行;下方的图:文档块核心转译渲染代码,基本没有写任何还原样式,通过Switch、Case来一个个渲染文档块。
新版架构设计
这次我们痛定思痛,势必要将转译工具的转译效果做到尽可能还原,也有了多位同学一起投入。因此首要思考和急需解决的问题来了:在老旧的架构下,如何才能做好代码扩展、多人协同、高效样式编写以及样式还原?
IoC 与DI
是的,几乎一刹那,凭借过往丰富的多人协同以及项目经验,很快我们就想到了,这个事需要基于IoC的设计原则,并通过DI的方式来实现。
那么什么是IoC和DI呢,根据维基百科的解释:控制反转(Inversion of Control,缩写为IoC),是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度,其中最常见的方式叫做依赖注入(Dependency Injection,缩写为DI)。
这么说可能有点抽象,我们可以看下新版的架构设计,从中便能窥见其精妙:
可以看到,关键的文档块预处理和渲染器,在该架构中是反向依赖核心的createDocTranspiler了,与我们常识中的理解(文档转译渲染依赖各个块的预处理和渲染器)是相反的,这就是控制反转(IoC),通过这样的依赖倒置,我们能够把多人协同过程中,由各个同学负责开发的预处理器和渲染器的开发调试解耦出去,互不影响、互不依赖,且合码过程中基本没有代码冲突,大大提效了多人协同合作开发。同时由于实现的方式是依赖注入(DI),或者说注册,未来我们想要支持更加深水区的文档块,比如「画板」、「文档小组件」等,可以很方便地注册新的预处理器和渲染器,做增量且解耦的代码开发;如果想要取消对某一个文档块的渲染,直接unregister即可,由此也实现了文档块渲染的快速插拔和极高的可拓展性。
整个转译主干代码如下:
创建转译器,注册预处理器,注册渲染器
转译渲染,后处理,完成渲染。代码行数缩减到只有138行。
函数式编程
接下来我们将目光聚焦到核心函数createDocTranspiler中,这块是IoC架构的核心实现,根据维基百科描述,IoC是面向对象编程中的一种设计原则,那么我们真的是用面向对象的编程方式吗?
显然不是,我们是高标准的前端同学,在JavaScript编程中,面向对象编程显然不是社区推崇的设计原则,以React框架为例,早在React 16.8版本,就推出了函数组件和Hooks编程,以取代较为臃肿的类组件编程,这些都是前端老生常谈的理念了,大家可以去Google深入学习函数式编程理念,在此不再赘述。
这里说一下为什么核心代码createDocTranspiler我要用函数式编程,说一下我的理解:第一是非常优雅,用起来很舒服;第二是得益于JavaScript函数闭包,一些局部(想要private化)的变量或者方法,直接在函数内声明和定义即可,不用担心像类一样会暴露出去(尽管TS有private关键字,但只是约束,不代表你不能用);第三是简单,无需维护类的实例,若有主动销毁场景,返回的结构中暴露销毁函数即可。
整个核心代码如下:
上方的图:内置的变量和函数,用于存储各种预处理器和渲染器,并实现文档树的递归渲染;下方的图:返回并暴露出去的函数,用于注册各种预处理器、渲染器,以及转译渲染。整个核心代码只有158行,非常精炼。
“CSS-in-JS”
然后再来说一下如此大量的样式还原工作,我们是如何实现的。由于我们要把文档树转译成最终的一个完整的HTML字符串,在模板字符串中写内联样式(style=“width: 100px;…”)会非常痛苦,代码可读性会很差,开发调试的效率也会很低。
为了解决这个问题,我们立即想到了React CSSProperties的写法,并调研了一下它的源码实现,其实就是将CSSProperties中的驼峰属性名,转换成内联样式中连字符属性名,并额外处理了Webkit、ms、Moz、O等浏览器属性前缀,同时针对number 类型的部分属性的值,转换时自动加上了px后缀。详细代码如下:
// 样式处理工具函数库。
import { CSSProperties } from 'react';
/* 是否是,值可能是数字类型,且不需要指定 px 为单位的 CSSProperties 属性。*/
const isUnitlessNumber: Record<string, boolean> = {
// ...
fontWeight: true,
lineClamp: true,
lineHeight: true,
// ...
// SVG-related properties.
fillOpacity: true,
floodOpacity: true,
stopOpacity: true,
// ...
};
// 各浏览器 CSS 属性名前缀。
const cssPropertyPrefixes = ['Webkit', 'ms', 'Moz', 'O'];
// 针对 isUnitlessNumber,填充各浏览器 CSS 属性名前缀。
Object.keys(isUnitlessNumber).forEach(property => {
cssPropertyPrefixes.forEach(prefix => {
isUnitlessNumber[`${prefix}${property.charAt(0).toUpperCase()}${property.substring(1)}`] =
isUnitlessNumber[property];
});
});
export { isUnitlessNumber };
/** 针对 CSSProperties 属性值,可能添加单位 px,并返回合法的值。*/
export function addCSSPropertyUnit<T extends keyof CSSProperties>(property: T, value: CSSProperties[T]) {
if (typeof value === 'number' && !isUnitlessNumber[property]) {
// 值是数字类型,且需要添加单位 px,则添加单位 px。
return `${value}px`;
}
return value;
}
然后再编写createInlineStyles方法,入参即为Record<string, CSSProperties>大样式对象:
/* 将 CSSProperties 转为内联 style 字符串,e.g. { width: 100, flex: 1 } => style="width: 100px; flex: 1;"。*/
export function convertCSSPropertiesToInlineStyle(style: CSSProperties) {
const upperCaseReg = /[A-Z]/g;
const inlineStyle = Object.keys(style)
.map(
property =>
`${property.replace(
upperCaseReg,
matchLetter => `-${matchLetter.toLowerCase()}`,
)}: ${addCSSPropertyUnit(property as keyof CSSProperties, style[property])};`,
)
.join(' ');
if (inlineStyle) {
return `style="${inlineStyle}"`;
}
return '';
}
/** 根据输入的样式表(CSSProperties 格式),输出内联样式表(格式为 style="..." 的字符串),e.g. { container: { position: 'relative' }, title: { fontSize: 18 } } => { container: 'style="position: relative;"', title: 'style="font-size: 18px;"