把飞书云文档变成HTML邮件:问题挑战与解决历程

一、背景

云文档转HTML邮件

基于公司内部的飞书办公套件,早在去年6月,我们就建设了将飞书云文档转译成HTML邮件的能力,方便同学们在编写邮件文档和发送邮件时,都能有较好的体验和较高的效率。

当下问题

要被邮件客户端识别,飞书云文档内容需要转译成HtmlEmail格式,该格式为了兼容各种版本的邮箱客户端(特别是Windows Outlook),对于现代HTML5和CSS3的很多特性是不支持的,飞书云文档的多种富文本块格式都需要转译,且部分格式完全不支持,造成编辑和预览发送不一致的情况。

因此,我们对转译工具做了一次大改版和升级,对大部分常用文档块做了高度还原。

实现效果

经过我们的不懈努力,最终实现了较为不错的还原效果:

1.jpg

二、系统架构改版

飞书云文档结构

在展开我们如何做升级之前,先要简单了解下飞书云文档的信息结构(详情可参考官方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 个块定义。
  ...
}[];

我们用思维导图简单举例,整个文档块的树结构大致是这样的,有些块根据缩进递进,会形成父子关系,有些块天然就会成为父块(比如表格、引用等):

2.jpg

旧版架构

那么我们初版转译工具是怎么做的呢,比较遗憾的是,由于当时需求的还原度诉求较低,我们的代码主要是复用现有部分实现,整体的架构设计可以用一个词概括,基本是面向过程编程:

3.jpg

4.jpg

上方的图:经过了一些抽取和封装,主流程核心代码仍有528行;下方的图:文档块核心转译渲染代码,基本没有写任何还原样式,通过Switch、Case来一个个渲染文档块。

新版架构设计

这次我们痛定思痛,势必要将转译工具的转译效果做到尽可能还原,也有了多位同学一起投入。因此首要思考和急需解决的问题来了:在老旧的架构下,如何才能做好代码扩展、多人协同、高效样式编写以及样式还原?

IoC 与DI

是的,几乎一刹那,凭借过往丰富的多人协同以及项目经验,很快我们就想到了,这个事需要基于IoC的设计原则,并通过DI的方式来实现。

那么什么是IoC和DI呢,根据维基百科的解释:控制反转(Inversion of Control,缩写为IoC),是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度,其中最常见的方式叫做依赖注入(Dependency Injection,缩写为DI)。

这么说可能有点抽象,我们可以看下新版的架构设计,从中便能窥见其精妙:

5.jpg

可以看到,关键的文档块预处理和渲染器,在该架构中是反向依赖核心的createDocTranspiler了,与我们常识中的理解(文档转译渲染依赖各个块的预处理和渲染器)是相反的,这就是控制反转(IoC),通过这样的依赖倒置,我们能够把多人协同过程中,由各个同学负责开发的预处理器和渲染器的开发调试解耦出去,互不影响、互不依赖,且合码过程中基本没有代码冲突,大大提效了多人协同合作开发。同时由于实现的方式是依赖注入(DI),或者说注册,未来我们想要支持更加深水区的文档块,比如「画板」、「文档小组件」等,可以很方便地注册新的预处理器和渲染器,做增量且解耦的代码开发;如果想要取消对某一个文档块的渲染,直接unregister即可,由此也实现了文档块渲染的快速插拔和极高的可拓展性。

整个转译主干代码如下:

6.jpg
创建转译器,注册预处理器,注册渲染器

7.jpg
转译渲染,后处理,完成渲染。代码行数缩减到只有138行。

函数式编程

接下来我们将目光聚焦到核心函数createDocTranspiler中,这块是IoC架构的核心实现,根据维基百科描述,IoC是面向对象编程中的一种设计原则,那么我们真的是用面向对象的编程方式吗?

显然不是,我们是高标准的前端同学,在JavaScript编程中,面向对象编程显然不是社区推崇的设计原则,以React框架为例,早在React 16.8版本,就推出了函数组件和Hooks编程,以取代较为臃肿的类组件编程,这些都是前端老生常谈的理念了,大家可以去Google深入学习函数式编程理念,在此不再赘述。

这里说一下为什么核心代码createDocTranspiler我要用函数式编程,说一下我的理解:第一是非常优雅,用起来很舒服;第二是得益于JavaScript函数闭包,一些局部(想要private化)的变量或者方法,直接在函数内声明和定义即可,不用担心像类一样会暴露出去(尽管TS有private关键字,但只是约束,不代表你不能用);第三是简单,无需维护类的实例,若有主动销毁场景,返回的结构中暴露销毁函数即可。

整个核心代码如下:

8.jpg

9.jpg

上方的图:内置的变量和函数,用于存储各种预处理器和渲染器,并实现文档树的递归渲染;下方的图:返回并暴露出去的函数,用于注册各种预处理器、渲染器,以及转译渲染。整个核心代码只有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;"
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值