腾讯课堂小程序开发实践与思考
2022-11-28 加入收藏
本文由 InfoQ 整理自腾讯 CSIG 在线教育部前端高级开发工程师陈天忱在 GMTC 全球大前端技术大会(深圳站)2021 的分享《腾讯课堂小程序开发实践》。
你好,我是陈天忱,来自腾讯 CSIG 在线教育部。我所在的团队主要负责腾讯课堂平台的开发和维护,我从加入团队以来就围绕着小程序做了很多探索和优化,目前也是腾讯课堂小程序的负责人。
我本次分享的内容分为五个部分,首先我们从整体的角度来看一下腾讯课堂小程序的技术演进过程,接着会分别从开发体验、性能优化以及监控体系三个角度分享一些实践和经验,最后进行一下总结。
在我刚进入团队的时候,腾讯课堂小程序的工具链还处在比较原始的阶段。除了在编码层面利用了 web 比较成熟的 scss、postcss、lint、typescript 结合 gulp 做一些语法层面的编译以外,在测试、构建 npm、上传、设置体验版、发布等阶段都是依赖的小程序开发者工具和管理后台,人工手动操作来完成的。
石器时代
在这个阶段,大部分都是简单地利用一些现有的工具,我们称之为腾讯课堂小程序的石器时代。
这个阶段存在几个明显的问题:
构建和上传依赖人工操作,有可能会因为流程操作失误而导致现网事故;
由于发布流程的不规范,需求并行时经常会出现发布撞车的情况,导致体验版相互覆盖造成预发布验证成本。
为了解决人工操作带来的隐患,我们从零开始基于小程序提供的命令行工具打造了小程序 CI。让源码编译、构建 npm、上传、生成开发版 / 体验版二维码、自动化测试等流程在 CI 流水中自动流转。
同时我们也将小程序 CI 与企业微信打通,将小程序的构建进度和小程序二维码实时同步过去,而且也支持通过企业微信主动触发小程序构建,解决了在测试过程中因为开发版二维码过期而中断测试的问题。
为了解决发布流程不规范的问题,我们将小程序的发布也接入到了业务发布平台。在发布平台上进行 CheckList、CodeReview、发布评审、发布环境管理、发布静态资源等流程的流转,确保需求发布的质量、合规和有序。
解决了开发流程中的问题之后,我们将更多的精力放到了小程序的研发效能与性能上。开发和构建阶段我们打造跨端的公共模块,通过 kbone 进行同构开发,利用云开发来辅助首屏性能优化,以及代替部分后台的开发,在构建方面将构建工具从 gulp 迁移到 webpack,能对构建常务进行更细致的优化。
在发布之后,通过完善监控告警,将发布质量做到可视化的体现,并能够对出现的问题得到及时的接收和感知,减少用户的反馈。
到这里我们可以看到整个技术演进的过程,它涵盖了小程序开发、构建、测试、部署发布以及监控,形成了小程序的 DevOps 开发模式,这其中具体是怎么做的呢?
首先是开发阶段,我们想要形成一个可以称之为“爽”的开发体验,并不仅仅是指 coding 阶段,还需要覆盖到测试以及发布阶段:
编码阶段——业务逻辑跨端可复用
测试阶段——改动持续集成、测试与开发解藕
发布阶段——规范化、流程化、可追溯
为此我们分别通过打造跨端可复用的公共模块、小程序 CI、统一业务发布平台来提升开发体验。
这里从一个实际场景出发,我们之前有这样一个需求:产品希望在各端的课程详情页有一个提示当前此机构正在直播的课程,可以引导用户跳转到直播间听老师讲解课程的细节。
梳理一下这个需求的流程,发现其实还是挺简单的:
详情页渲染完成后 -> 调用接口拉取直播间数据 -> 渲染引导模块 -> 用户点击跳转直播
可以看到,业务的主逻辑在各端都是一样的,但如果去看这些逻辑的细节就会发现其实各端需要的是不一样的实现,比如发起请求的 api 在浏览器和在小程序中是不一样的;提示的疲劳度控制需要用到本地的缓存能力,浏览器和小程序的 api 也是不一样的;然后在业务上,直播间在三端的页面地址也是不一样的。
通过 ifelse 或者 switch 的方式,在运行时判断当前的执行环境,然后调用不同的分支逻辑当然是能够实现需求的,但是这种方式会让一个端同时存在三端的逻辑,这样的逻辑多了之后,会造成比较明显的代码冗余,而在小程序端由于有 2M 的包大小限制,对于代码冗余是比较敏感的。
要解决代码冗余的问题,大家会很自然地想到构建时注入一个环境变量,通过 tree-shaking 的能力在不同端构建出对应端所需要用到的逻辑。但这个方案对于构建工具有着一定的要求,而在实际的工作场景中,新老项目往往由于历史的原因,不仅仅是源码,在构建上的技术栈也是有很多历史包袱的,比如 gulp、fis、webpack 等,如果要全部统一起来成本和风险都会比较大。
所以我们需要一个能够跨端复用,按需打包,而且不依赖项目构建体系的公共模块。
我们基于 git submodule 的方式从组件、业务、工具三个维出发,每个维度根据具体的逻辑按照执行环境将其拆分成同构目录 (isomorph)、浏览器目录 (lib)、小程序目录 (wx),各个项目将 lib 目录或者 wx 目录作为引用的入口,而入口文件会继承或者透传导出 isomorph 目录下的逻辑,对环境有依赖的特殊逻辑则在 lib 目录和 wx 目录下分别实现。
在开发阶段通过路径别名来统一引用路径,例如小程序的项目中设置tsconfig.json的paths为"ke-modules/*": "submodules/ke-modules/*/wx",这样就可以统一业务层面的代码逻辑。在构建阶段submodule会通过ts单独编译成js,如果是h5和PC的项目就只会将isomorph和lib目录构建到产物中,小程序的项目就只将isomorph和wx目录构建到产物中。
这样就确保了在保证兼容性的基础上不会产生冗余的代码,以此来满足我们之前提出的几个需求。
我们搭建小程序的 CI/CD 的起因,是由于开发者工具中很多人工操作带来的一系列问题,比如:
在构建过程中,很容易漏掉构建 npm 依赖
在上传时的版本信息和版本号也不规范
不同需求的体验版需要管理后台切换,需求并行非常不友好
开发版二维码需要开发者实时提供,影响测试进度要解决以上这些问题就需要从自动化和流程控制来入手。
我们先是基于小程序官方提供的一个命令行工具进行了封装和扩展,支持小程序的 npm 构建、上传、获取二维码、自动获取版本号、版本信息等功能,并作为小程序 CI 流水线中的核心插件。
CI 流水线支持通过 git hook、OpenAPI、手动的方式触发执行。在流水线的流转执行中,完成代码拉取、分支检查、版本号迭代及版本信息更新、小程序代码包上传、开发 / 体验版二维码获取,同时归档小程序产物、sourcemap 等文件便于对性能和错误的分析。
同时通过 CI 的插件与企业微信机器人打通,将构建进度以及构建后的小程序二维码同步到企业微信中,同时也支持通过 @企业微信机器人以 openAPI 的形式主动触发流水线执行,让产品和测试都可以实时获取最新的小程序二维码进行测试和体验。
在发布阶段,与 web 项目一样接入统一的业务发布平台,在发布平台上对发布流程进行规范,确保发布之前的 CheckList、CodeReview、发布评审等流程正确执行。发布开始对发布环境进行管理,设置门禁,确保同一时间只会有一个需求处在发布流程中,避免多需求并行出现的发布混乱问题,发布完成并现网观察没有问题之后再将环境释放给下一个需求。
建设了这样一套 CI/CD 的流程之后,之前遇到的问题就都得到了解决。
通过在构建过程中获取依赖的 npm 信息来判断是否需要更新及构建 npm,并自动执行;
上传时根据 Angular 的 git commit 规范,自动迭代 major、minor、patch 的版本号,更新 changelog;
CI 使用机器人账号上传小程序,通过业务发布平台对小程序的发布环境进行管理,避免发布冲突;
提供企业微信触发小程序构建的能力,测试和产品可实时出发构建并获取最新二维码。
这是我们在 CI/CD 上面的一些实践经验,以及在开发体验上面的一些处理方案。
小程序的启动方式分为冷启动和热启动,而小程序的性能瓶颈大部分也都集中在冷启动这一阶段。
冷启动阶段分为如上几个步骤,其中环境初始化对于开发者来说是个黑盒,目前还无法介入,而下载代码包和加载代码包的耗时,主要与小程序代码包的体积正相关,数据拉取需要开发者对请求时机进行优化,页面渲染则需要优化渲染策略。
业务代码的体积优化需要通过构建来解决,以一个项目的常规结构来看,我们一般会将一些有可能复用的模块放置到公共模块中。如下图所示,引用关系如果只进行编译的话,根据小程序的规则,公共模块和组件的大小都会被计算到主包中,我们希望通过构建来优化产物结构,避免主包太大的问题。
再者,随着需求的迭代,可能某一个组件的引用就丢失了,这种情况在小程序的规则下,依然会被计算在主包里面,可以看下面这张图。我们希望能通过构建将未使用的模块或者组件进行过滤。
另外,如果某一个分包与主包引用了同一个模块,这时候将这个模块计算到主包中是 OK 的,但如果这个分包是一个独立分包的情况下,再去引用主包的模块,是有可能报错的。上面这种情况需要通过构建的方式将模块复制一份放到独立分包下面才能保证小程序的正确执行。
我们面对的上面三个问题有一个核心思路是需要在构建的过程中,针对小程序的规则进行依赖分析。下面是我们对比目前市面上比较成熟的构建工具,从四个维度进行了分析:
根据对比的结果,webpack 对于需求的支持还是比较成熟的,我们最终决定选择 webpack 作为小程序的构建工具,但是 webpack 也不支持小程序的组件,这一点就需要我们自己进行支持了。
以 app.js 作为入口文件,根据小程序的配置规则找到对应的 json 文件,逐层递归就可以将整个小程序所使用到的页面和组件分析出来,并将所有的页面和组件都作为 webpack 的 entry,就可以获取到小程序中 js 模块的引用信息了。
通过 plugin 对引用信息根据一定策略进行计算 chunk:
例如,某一个页面引用了一个模块,先判断模块是否在分包内,如果在分包内则按照常规方案打包;不在分包内则判断引用它的分包是否为独立分包。如果是独立分包则复制一份 (新建一个 chunk);是普通分包则收集是否被多个分包引用。若不是,则将模块移动到分包下 (新建一个 chunk,并将原来的删除)。
计算完 chunk 之后就可以通过 webpack 的 load 去处理另外的资源文件,包括 css、image、font,提取静态资源文件,替换引用路径。
处理完成后的效果也相当明显,我们的主包从 1900 多 kb 优化到了 900 多 kb,优化幅度达到 50%,总包的一些体积也优化了 27%。
优化的体积主要来自以下三个方面:
模块下沉到了分包
对未使用到的组件和模块进行了过滤
静态资源文件上到 CDN
我们的优化在实际的启动耗时上也有比较显著的效果,主包下载耗时优化了 43%,js 的注入耗时优化了 18%。
另一方面,当小程序需要通过 npm 的形式使用一个比较复杂的 SDK 时,由于小程序的 npm 包需要单独构建一次,无法做到编译时按需打包,这也会遇到体积较大的问题。
在我们的实际业务场景中就有这样的问题,腾讯课堂作为在线教育业务,有个核心能力是直播互动,就是用户在线上上课的过程中聊天、举手、连麦、抽奖等形式的交互行为。
为了让这个核心能力能够达到跨端跨业务的复用效果,我们团队开发了一个直播互动的 SDK,对外抛出简单的 API,内部设计了接口层、适配层、通道层、策略层,结构非常清晰,使用起来也很方便,初始化后开发只需要监听或请求对应的命令字即可,无需关心内部的转化,并且能够利用 ts 的类型推断能力直接拿到通道返回的数据类型。
但是当我们在小程序端进行接入时,遇到了几个问题:
为了支持跨端跨业务,SDK 内置了所有功能的逻辑,在小程序端使用会造成大量的包体积浪费
针对 web 设计,不兼容小程序;单独维护一个小程序的版本成本比较大
不同项目和业务对 SDK 的迭代会导致版本管理混乱
要解决上面这些问题,我们必须对 SDK 进行升级改造。
我们改造的方案是进行插件化处理,将接入层(业务层)和适配层作为 SDK 的内核抽离出来,并添加了 pluginAdaptor 对插件进行适配管理,将策略层和通道连接层的逻辑进行抽象处理,制订好对应的规范,根据抽象类和业务需求实现对应的策略插件和通道插件。
在业务中的改造非常简单,只需要初始化之前注册当前场景和功能所需要的插件,后续在使用上与之前完全一致,业务的改造成本非常低。
兼容性上通过 rollup 打包,在构建时注入不同的环境变量,输出对应端所需要用到的 bundle。
插件化改造之后,好处就显而易见了:
按需引入,运行时它的体积是最小的,改造前后 SDK 运行时体积从 384KB 减少到 42KB,优化了近 90%
多包结构迭代比较清晰,内核和抽象通道也很稳定,各个插件可以进行单独的版本迭代
跨端复用能力得到了扩展,统一维护
请求的优化也是小程序性能优化中很重要的一环,在冷启动和页面跳转的过程中,我们分别对请求时机以及弱网阻塞两种情况进行了优化。
请求时机上,可以利用小程序的全局 app 实例将数据请求的时机提前到页面加载之前,进一步利用小程序的数据预加载能力,将首屏数据的请求时机提前到启动小程序时:
页面加载前发起请求的流程如下,在 onLaunch 或者页面跳转时就直接发起下一个页面的请求,并将请求的 Promise 挂载在 app 实例上,当页面加载完成出发 onLoad 的时候则直接通过 app 上的 Promise 返回进行渲染,根据我们的统计平均可以优化 100ms 的耗时,而且相对静态的数据可以通过本地缓存的方式,在二次加载此页面时通过缓存数据渲染,达到秒开的效果。
而数据预拉取则类似于 web 的服务端渲染,在启动小程序时通过云函数根据启动参数调用业务后台的服务获取数据并返回给小程序,小程序启动后就可以直接使用预拉取的数据进行渲染,预拉取成功可以平均优化 90% 的首屏数据请求耗时。
除了上面常规情况的请求优化,我们还注意到小程序有一个网络使用限制,最大的并发限制是 10 个,这就会造成隐患。因为在小程序加载和用户交互的过程中会产生很多的上报请求,例如 PV 上报、错误日志等,在弱网的情况下,很容易出现上报请求响应慢而阻塞了业务请求的发送导致超时,而我们也确实收到了类似情况的反馈。
为了优化弱网情况下存在的隐患,我们对请求队列进行了优化,通过设置请求池与等待队列,并劫持 wx.request,在发送请求时对请求的 url 进行优先级排序,将业务请求设置为高优先级的请求,上报请求的优先级降低。当请求通道相对紧张时会将高优先级的请求优先发送,低优先级的请求在请求通道空闲时再进行补发。
在实际运行过程中的逻辑如下图:
视图渲染和更新可以优化的方向在于,将非首屏和非核心模块数据延后更新,因为 setData 更新视图数据太大会增加通信和解析的时间。以我们最复杂的课程详情页为例,因为模块比较多,导致页面比较长,一般会有 5~7 屏的内容。如果需要等到页面的所有数据全部出完再开始更新试图,那么白屏时间和视图更新时间都会比较长,比较合理的渲染策略应该是首屏优先,分步渲染。
但由于小程序的双线程模式,通过 setData 的方式更新视图是同步更新逻辑层数据,异步更新视图层数据,所以并不能简单地在处理完一部分数据后调用 setData 再继续处理其余的数据,甚至通过 Promise 也做不到分步渲染,而使用 setData 的回调或者 setTimeout 的方式又会出现逻辑嵌套的问题,降低代码的可读性和可维护性。
针对这个问题,我们的解决方案是基于 setTimeout 根据 Promise 的表示封装了一个 PromiseMacro 的类,这样我们就可以向使用 Promise 一样通过 then 方法将小程序的渲染拆分成多个步骤达到渐进式渲染的效果。
通过分步渲染的方式,可以将我们首屏渲染的起始时间从 230ms 提前到 90ms,达到减少用户等待焦虑,提升用户体验的效果。
一个产品的质量不仅仅是靠好的产品设计和代码质量,还有很大一部分需要通过收集操作和性能日志,为技术优化提供方案,而对现网报错的比例及数量进行监控,能让我们及时响应并修复。之前我们的小程序上报也依赖了好几个上报系统来完成:
通过 BadJS 来收集前端报错和操作日志
通过 Wang 进行测速上报
通过 Monitor 进行打点监控告警
通过 Tdw 进行产品需求上报
通过不同的系统进行上报会存在一些问题:
依赖的 SDK 比较多,每一个 SDK 的 API 都不一致,学习和维护的成本会比较高,这一点对于新人来说尤其明显;
每个 SDK 上报的数据结构也不一样,想要查找对应的数据,就必须去对应的统计平台进行搜索。
解决这些问题的首要任务就是需要对这些上报的 SDK 进行整合,根据产品和业务需求将日志、测速、监控、上报收归到一个 SDK 里面,并统一上报的数据结构,部分功能会在 SDK 中进行备份转发,保证原上报系统的功能也能得到利用。
再结合小程序提供的几个 API 就可以在日志收集的同时对用户进行多维度的统计:
在统一上报数据结构的前提下,就可以自定义制定多维度的统一看板,降低质量监控的成本。
以上是这次分享的主要内容,我们简单做一个总结。
随着我们课堂小程序的技术演进,围绕小程序逐渐形成了 DevOps 的开发模式:开发阶段,我们打造了兼容小程序端的公共模块,提升了小程序 30%~40% 的研发效率,同时在 CI/CD 建设方面,减少了人工操作的风险,充分利用工程化自动化来解放开发的生产力,而且小程序 CI 建设的很通用化,公司内部有 140+ 的项目接入。
在性能优化方面,我们通过在体积、请求、渲染方面的优化,将冷启动下的首屏性能优化了 1.5s,达到了 42.7% 的优化比例。
在监控告警方面,通过小程序的 API 再结合收拢的上报 SDK,可以让统计力度更细,而且可视化可定制。
接下来小程序还有一些非常好用的特性可以加以利用,我们比较关注的是分包异步化和自动化测试。
异步化打包策略
分包异步化可以极大地缩小首屏包的大小,目前分包异步化的特性已经适配了 2.11.2 的基础库版本,兼容性的问题也已经得到了解决;接入分包异步化的能力,可以尝试在小程序构建打包时将一个页面拆分成首屏包 + 异步逻辑包 + 异步组件包的形式,结合分包预加载功能,将首屏的代码包下载耗时和加载耗时降至最低。
基于录制回放的自动化测试
小程序团队很早就支持了小程序的自动化测试,但是在 UI 自动化测试方面,测试用例的维护成本是最大的痛点,我们尝试在本地录制操作流程,按照一定的约定转换成测试用例,可以极大地降低测试用例的维护成本,提高代码质量。