Learn Helper 4.0.0 开发感想

 

从寒假之前就立下了一个 flag,要重写因为网络学堂发布 2018 失效的 Learn Helper。如今,它终于被我拔掉了。在开学两天前,4.0.0 版本正式发布了,也由原作者推送到了 Chrome 应用商店,在我博客上也有一个 专门的页面就当是我送给大家的开学礼物吧。

技术细节

原本打算在项目原有代码的基础上修改(因为不会写UI),但是读来发现 codebase 实在是太乱了:基本没有分前后端,页面显示全靠 JS 拼接 HTML 塞进 DOM 里面,读起来比较头痛。再加上网络学堂新版的 API 发生了很大的变化,原本的代码也需要大幅改动。一不做二不休,干脆写了个爬网络学堂信息的库 thu-learn-lib (赶在年前发布了,介绍看 这里)。最终,选定我的项目用这个库当作后端,React 作为 UI 框架,Material-UI 提供 UI 组件,redux 进行状态管理。

算上 thu-learn-lib,这就是我第二个完全用 JS 写的项目了。不得不说 TypeScript 真香,把大量的问题都转移到了编译期。尽管 any 常常比定义类型更诱人,然而事实证明几乎写下的 每个 any 都给我带来了不少的 undefined 问题。其实这里比较重要的类型只有:

  • React 组件的 Props 和 State
  • Redux 的 State 和 Action

把这些认真解决以后,剩余的都比较简单(用了不少就地的 typeof 类型)。其中还用了不少 Partial<T> 的类型,用在 react-redux 中间件的 mapStateToPropsmapDispatchToProps 这些方法上。

踩过的坑

断断续续加起来写了三个星期,遇到的坑真的是……一言难尽。

状态的持久化

redux 的状态里面存了网络学堂的数据和一些UI的状态,用 redux-persist 做了持久化,又借助 redux-persist-chrome-storage 存到 Chrome 的 LocalStorage 里面。原本想保存到同步的空间中,结果发现 Chrome 对每一个 key 对应的 object 大小有限制;鉴于计算大小用的是对象执行 JSON.stringify 以后的字符串,所以实际上能存的东西不多。这时候,万能的 npm 又给我提供了一个库叫 chrome-storage-largeSync,通过内部切分的方式,把 sync 这个存储包装得和 local 一样。万万没想到的是,还是不够,因为数据总大小已经超出了 Chrome 对插件能够同步的数据的限制 QUOTA_BYTES,目前是 102400 字节。最后我只能放弃同步使用了本地的空间。顺便,我发现 Chrome 提供的 API 居然还是基于回调的,就顺手 Promisify 了一下。

我原本在存储的时候用了很多 HashMap,然而 redux-persist 的工作方式基于序列化,所以并不能存储 HashMap。理论上可以写一个中间件自己解决这个问题,但是我发现有 redux-persist-transform-immutable 这个库可以把 immutable.js 的各种类型持久化,于是就把所有的数据类型换成了 immutable 的。这些类型的各种方法都会返回对象,而绝不会修改原始对象,我在修改的时候也踩了几个坑。

虽然吃了不少苦头,还是要说 redux 真的好用!

UI设计和实现

我完全不会设计,也完全不会CSS,于是整个前端过程中 Chrome Developer Tool 就成了我的救星。我能做的就是反复添加一些CSS属性,靠名字猜作用,要是对了就写进代码里面,不对就加 !important 再试,不惜用最脏的方法实现想要的效果。最后虽然看起来是我想要的效果,但是只有我知道实现有多垃圾。

由于我没用 Material UI 推荐的 jss 方法给 component 添加 class,而是只用了 CSS Module,导致我的 class 总是在 Material 自带的后面,优先级比较低,这也让我简单粗暴地用了不少的 !important,而没有正确地 override 组件的 class。Anyway, it works!

在 UI 的设计上,CircuitCoder 给了我非常大的帮助,帮我重写了很多非常糟糕的 CSS,也提供了非常多的建议。

数据加载和渲染

插件有一个非常重要的组件就是卡片列表,显示了当前筛选出的所有项目信息。最原始的想法是,在用户每次改变数据或者过滤器(包括类型、课程和标题的过滤)时,都从 redux 的后端中读取当前所有数据、筛选出需要的并进行排序(按照星标——已读——时间排序),送给前端。这样带来了严重的性能问题,导致频繁发生 DOM 节点的添加删除,以及 React 组件的构建和渲染,平均改变一个过滤条件需要 1s 左右才能完成加载。当时看 Chrome 的 profiling 结果,大量的时间花在脚本的执行上(甚至有大部分是用于取消 Ripple 动画效果的 timeout)。

事实上,我已经对数据的获取进行了尽可能的优化,在大多数情况下都不会直接从后端获取数据,也尽可能不重新排序,可见 这个函数。但是渲染还是花去了太多的时间。

在如此糟糕的情况下,又是 CircuitCoder 出现救了我的命!他指出我应该渲染所有的数据,而过滤器的结果只控制卡片的显示和隐藏,而后再对这些卡片进行排序。这样,DOM节点的存在性不会发生改变,只有显示/隐藏和顺序的移动,是比较高效的。

在此基础上,他还给列表添加了按需加载功能,默认只加载一些(当前为10条),只有当滚动到比较靠下的位置时才加载下一批,进一步提高了渲染的速度。目前,除了插件冷启动时由于需要读取数据并进行渲染,约有 1s 左右的不响应时间,其他的点击和输入操作都不会导致 UI 失去响应超过 200ms。这是一个令人满意的结果。

打包的冗余体积

开发完成以后,编译出的 JS 单文件尺寸是比较可观的,达到了 1.19 MB。事实上一开始文件尺寸一度达到 4 MB,已经经过了比较多的裁剪。研究了一下,存在的坑大概有下面这些:

  • ES 的模块导入尽量精确到文件:如 import { A } from 'lib' 语句中,很可能 lib/index.js 中已经导入了所有文件;尽量应该 import A from 'lib/A',使得导入更精确。虽然理论上 tree-shaking 过程可以去掉没有用到的文件,但是 Webpack 的分析有时候并不如人意(可能是认为存在副作用),手动优化导入的效果更好。@material-uifontawesome 两个库都存在这个问题,优化后可以大幅减少体积
  • 有比较多用不到的文件:如 thu-learn-lib 为了提供 node 支持间接依赖了 tough-cookie-no-native 库处理 cookie,其中有一个巨大的(~200KB)用于 pubsuffix.js 判断域名是否为公共后缀。但由于浏览器自行处理 cookie,我们不需要这个库的支持。直接用 WebPack 的 null-loader 之类移除会导致加载出错,因此我写了一个 stub文件 ,配合 NormalModuleReplacementPlugin 解决了这个问题
  • 功能重复的支持库:用于解析 DOM 的 cheerio 依赖的 parser5 中自带 HTML 实体的映射关系,而它居然选择用 entities.js 实现这个功能,这导致编译出的文件中事实上有两份 HTML 实体库。这个问题基本无法解决,好在只有这一例,也只引入了 40KB 左右的体积

要说的话

仔细回想,除掉课程作业,这好像是我第一个基本独立完成的实用中型项目(~5K 行代码)。以前从没接触过前端,也算是看着文档、查着 Google, StackOverflow 和 MDN 就赶鸭子上架了。

考虑到 GitHub 反馈不太方便,Chrome 商店也墙了,今天下午做了个公众号(见 这里),用来发布版本更新提示和接受用户反馈。目前看来大家还是比较认可这个插件的,文章阅读也比较多。在此对所有在开发过程中支持我的人表示感谢,名字就不一一列举了;也要对插件的原作者许欣然表示感谢。没有你们的认可,写好这个插件是不可能的。

Flags

下一步有生之年的开发计划包括:

  • 移植到 Electron 之类的桌面端框架,增加自动更新功能,解决墙内不好用的问题
  • 学习一些前端知识,小改目前的布局和排版,增加美观程度
  • 增加对助教和教师端的支持(要等 thu-learn-lib 先支持,大约的确是不会做了

欢迎大家一起来插/拔 flag!