历时百日,我用TypeScript重构了5W Star的JavaScript开源项目

在Web上展示PDF,这是一个很普遍的需求。但是长久以来,社区中一直缺乏一个好用的库来帮助开发者做好这件事。pdf.js能够实现展示PDF的需求,但是在集成进业务系统这件事上,却显得有些力不从心。它其实还是有一些问题的:

  • 接入pdf.js很容易,但是想和后端整合,却不太十分方便。
  • PDF阅读器的基本功能都具备,但是开发者却难通过代码控制这些功能。
  • 源代码较为复杂,不易阅读、修改和扩展,更不要谈进行定制了。
  • 缺乏强有力的文档的支撑,官方文档不够清晰,社区文档深度广度都不够。

随着时代不断地向前发展,一些问题正变得越来越棘手。我曾帮助过好几位客户解决他们对pdf.js较为一些深入的需求。比如PDF分页加载、批注的持久化、定制批注的开发、疑难BUG的定位等。

在和他们沟通了解的过程中,我意识到他们所面临的大部分问题,都是具备一定共性的通用问题。从技术上来看,是可以通过代码的复用来实现的。至于剩下一小部分的个性问题,则是可以通过插件和定制代码的方式来解决。

于是我决定基于pdf.js开发一个进阶的版本。并设定了两个基础的目标,第一个目标是加强API和文档这两个方面,让用户能够丝滑的将pdf.js接入到自己的系统。前端开发者能够使用API来操作PDF阅读器,后端开发者能够持久化PDF阅读器中各式各样的信息。第二个目标是针对性的开发一些更实用的功能,比如更丰富的批注系统、PDF权限管理、图片文本的提取、定制化的水印等。

但是当我试图去做这件事的时候,我发现这件事不仅难度非常大,而且效果也很差。我使用工具扫描了pdf.js的源码,发现它的代码量其实比我想象的要更多。如果空行和注释计算在内,它的总代码量大约在20万行这个级别。即使去除掉空行和注释,总代码量也有十多万行。起初,我改了两三个礼拜,遇到的问题远比我一开始估计的要多很多。在现有代码的基础上,即使只是实现一些最基本的目标,都十分的困难。比如我试图将PDF阅读器由上下滑动的风格,改造成翻页的风格时,我发现不仅需要改动大量的代码,而且这样的改动还会影响到其它的功能。并且即使我开发出了这个功能,我也不太好提供自由定制的接口和API,甚至源代码中函数参数这些信息,都难以以一种方便的方式进行告知愿意阅读代码的人。

初步尝试过后,我发现我无法用简单的方法解决上面提到的那些看似简单的问题------事实上,如果小修小补式的改进就能将这些问题通通化解掉,我相信这个活儿,早就被人做掉了。即便不是pdf.js的团队本身,也会有其他活跃在社区的开发者来完成这件事。

于是我准备干一票大的。我打算直接用TypeScript重构pdf.js,保留其PDF处理的核心逻辑,改造其展示PDF相关的代码。相关的工具链与开发风格,也通通都要跟上时代的脚步。我决定要放弃npm,移除gulp,用pnpm和vite取而代之。传统的单模块写法也不再使用,monorepo这种多模块的方式是更好的选择。

我之前主攻后端,只是偶尔带着写一写前端,所以对前端的开发理解的不是很深,对前端的生态也知之甚少。但是在重构中大型项目这件事上,我还是有不少经验的。我重构过如仓储物流之类的业务系统,也重构过如知识图谱之类的大数据系统。总的来说,重构的挑战是有的,阵痛期是有的,而且这个阵痛期甚至还可以说并不短。阵痛期期间,新功能的产出是几乎没有的,bug可能是稀奇古怪的,困难是一环接一环的。但是当这阵痛期结束之后,整个项目就都开始柳暗花明了。原来无法定位的bug,现在可以被定位了。原来无法开发的功能,现在可以开发了。原来低下的开发效率,现在也高了起来。原来无法接入的各种主流组件,现在也轻轻松松能接进来了。所以,想要充分发挥pdf.js的潜力,让它能够完美的融入到我们的日常开发当中去,一个彻底的重构,是不可避免的。

pdf.js在接入业务系统这件事上表现的不太好,根源就在于它的定位并不是一个供开发者调用的库,而是一个供使用者查看PDF的工具。pdf.js在做PDF查看工具这件事上,做的是非常的好的。它也被集成到不少的工具当中去了,比如VSCode、IDEA、chrome等。使得这些工具的使用者,能够很轻松的就能够在这些工具上查看PDF。对于普通的计算机用户,它们也可以直接使用chrome来查看pdf,而无须安装任何别的查看工具。相对的,尺有所短寸有所长,pdf.js无法完美的兼顾开发者和使用者。对于开发者来说,只有稍微委屈一点了。能够接入pdf.js,但是接入之后效果却没有那么的好。

我在重构pdf.js这件事上,首先要做的就是重新确立它的定位和目标------它不直接为使用者服务,它为开发者服务。开发者应该可以像下面一样,通过简单的代码打开一个PDF,并通过一系列的配置来对自己的PDF阅读器进行一定的定制,而不是必须通过iframe这样的方式来嵌入。

typescript 复制代码
const viewer = WebSerenViewer.init('app', {
  viewerScale: 0.7
});

viewer.open({
  url: 'compressed.tracemonkey-pldi-09.pdf',
  verbosity: VerbosityLevel.WARNINGS
}).then(() => {
  const controller = viewer.getViewController();
  bindEvents(controller);
})

function bindEvents(controller: WebViewerController) {
  document.getElementById("pdf-page-up")?.addEventListener("click", () => {
    controller.pageUp();
  })
  document.getElementById("pdf-page-down")?.addEventListener("click", () => {
    controller.pageDown();
  })
}

对于单个的功能而言,我要提供的不是具体的按钮或图标,而是开发者能够操作的API、参数、回调和各类事件等。 对于想要调试、定制、贡献源代码的开发者,我要提供的主流的工具链、清晰的参数、明确的调用关系等,确保他们能够一个并不高成本来了解、调试和改动相关代码。

在花了一百多天进行攻坚之后,我在这些事上迈出了关键的几大步。代码所使用到的主要语言、工具已经改造成了TypeScript、pnpm、vite。代码开发风格已经改造成多模块的monorepo:

所有的函数的参数类型都清晰下来了,硬编码都相继被我消灭掉了。整个代码质量已经有了一个相当程度的提升。并且最基础的demo也已经被我调通了:

在这个示例中,我就是用的上前文代码中的初始化方法,用init创建了一个PDF阅读器,用open打开了一个pdf文件,并将上一页、下一页两个按钮绑定到了相关的接口上。

这个过程充满着挑战性,当我写了一个脚本简单粗暴的将所有js结尾的后缀,都改造成ts结尾后。光是直接报错的error,就有多达2万多个。随后接近两个月的时间,我一直如愚公移山一般,一个一个的修复这些error。当我修补好一个error之后,可能又会蹦出几个新的error。比如我将某个函数的参数明确下来之后,原来隐藏的"该对象没有xx属性""该对象可能为空"之类的报错也显现了出来。因此实际修复的报错,也是不止2万多个的。

在修补过程中,我还发现了不少写法不规范、耦合等问题。比如某个方法明明参数只有数字,但是某段特定的代码为了图方便,将参数写成false;反射用起来很随意导致代码之间的调用链断掉;大量使用record而不是map;硬编码;调用关系混乱等。我都将他们通通改过来了。至少是从面向过程改的面向对象了。下面是一段改造前后的代码对比,改造前:

javascript 复制代码
handler.on("GetPage", function (data) {
  return pdfManager.getPage(data.pageIndex).then(function (page) {
    return Promise.all([
      pdfManager.ensure(page, "rotate"),
      pdfManager.ensure(page, "ref"),
      pdfManager.ensure(page, "userUnit"),
      pdfManager.ensure(page, "view"),
    ]).then(function ([rotate, ref, userUnit, view]) {
      return {
        rotate,
        ref,
        refStr: ref?.toString() ?? null,
        userUnit,
        view,
      };
    });
  });
});

这段代码监听了"GetPage"事件,但是这个事件的参数类型却是不知道的(即function(data)中的data类型),想要分析需要花很长时间。监听到这个代码之后,pdfManager要调用page中的rotate的get方法,就直接使用这样一种硬编码+反射的方式来做。这样的代码就不太好改,也容易出问题,比如当有人不小心修改了page中的rotate的名称之后,这里可能就会成为遗漏的点,并导致错误。 在经过TypeScript的改造之后,所有的参数都清晰了,调用也明显了,page和rotate之间的关联也成为强关联了:

typescript 复制代码
handler.onGetPage(async data => {
  return pdfManager!.getPage(data.pageIndex).then(async page => {
    return Promise.all([
      pdfManager!.ensure(page, page => page.rotate),
      pdfManager!.ensure(page, page => page.ref),
      pdfManager!.ensure(page, page => page.userUnit),
      pdfManager!.ensure(page, page => page.view),
    ]).then(([rotate, ref, userUnit, view]) => {
      return { rotate, ref, refStr: ref?.toString() ?? null, userUnit, view };
    });
  });
});

诸如此类的改动还有很多,我会在后续的代码中详细的讨论这些问题,并分享我的经验。 改造完成之后,实际上并不能直接运行,一运行,发现bug还是一大堆。只不过这个时候代码库已经通过编译了。重构其中的一些大文件、大方法,大批量修改record为map,带来了不少的bug,甚至有几个bug都花了我一整天才定位出来。修复了这些bug之后,我重构后的代码,终于是跑通了。但是这还不够,为了能够给读者展示一个最基本的案例,即前面的翻页案例。我又做了不少工作,将参数和API都抽取出来,确保如果有人要使用本库,那么它可以直接通过配置和API来掌控创建的PDF阅读器。

尽管代码已经跑通了,但是距离真正生产可用仍旧相当一段距离。我还会继续花上一些时间,对新代码进行一个丰富的测试,确保pdf.js中所有的pdf在新旧代码上的表现都是一致的。API、回调事件,这些东西的改造目前也是尚未完成,这些关系到开发者对用本库创建的阅读器的控制能力,也是很重要的。也许我不会一次性把所有的扩展点都开发完。但是把那些关键的扩展点开发好,仍旧是必要的。等这些事情都完成后,我将会发布第一个版本,并配上配套的案例、代码、文档。确保这个库是高效且易用的。

这个项目的代码我已经放在Github上了:

github.com/xingshen24/...

调试这个库相对来说也是比较简单的,只要使用pnpm recursive install安装好依赖,再进入到 packages-private/seren-viewer-develop 目录下,使用pnpm run dev运行,即可。

开发好这样一个库需要大量的时间精力,如果有愿意赞助和合作或以其它方式给于支持的,欢迎私信我。

相关推荐
程序员黄同学23 分钟前
解释 TypeScript 中的枚举(enum),如何使用枚举定义一组常量?
javascript·ubuntu·typescript
Xlbb.29 分钟前
SpiderX:专为前端JS加密绕过设计的自动化工具
前端·javascript·自动化
beibeibeiooo34 分钟前
【ES6】01-ECMAScript基本认识 + 变量常量 + 数据类型
前端·javascript·ecmascript·es6
庸俗今天不摸鱼1 小时前
【万字总结】前端全方位性能优化指南(三)
前端·性能优化·gpu
前端南玖2 小时前
深入理解Base64编码原理
前端·javascript
aircrushin2 小时前
【PromptCoder + Trae 最新版】三分钟复刻 Spotify 页面
前端·人工智能·后端
木木黄木木2 小时前
从零开始实现一个HTML5飞机大战游戏
前端·游戏·html5
NoneCoder2 小时前
工程化与框架系列(30)--前端日志系统实现
前端·状态模式
计算机毕设定制辅导-无忧学长2 小时前
HTML 基础夯实:标签、属性与基本结构的学习进度(一)
前端·学习·html