一、背景
跨端是前端研发过程中经久不衰的话题,为应对与不同场景的跨端需求也会有很多不一样的跨端解决方案诞生。为支持各端的研发诉求,我们为跨端解决方案提供了多编译内核,并以此逐步拓展各编译内核的特性,为开发者的研发体验提速提供更多可能性,这也就是所谓多编译内核生态。
多编译内核生态
不论是在各种前端工程化或是跨端解决方案中,Webpack
的出现频率高是毋庸置疑的,同样在 Taro 中也是如此,基于 Webpack
实现的编译内核,就能够为包括各类小程序在内的终端项目提供很好的研发体验,达成一整套开端解决方案来实现所谓「一套代码,多端运行」的能力特性。
但实际上在跨端方案不断迭代的过程中,在个别端生态中可能遇到各种问题和瓶颈也逐渐凸显,比如说我们基于 React Native
实现的 App 端就是如此。
基于 Webpack
实现输出 RN 项目的能力,并借助于 Metro
完成 RN App 的打包和调试等等工作,会是十分便捷的方案,但在实际项目中却依旧会遇到诸如框架层面不能与 Metro 直接交互等等很多问题,维护起来也有诸多不便。
这也就是为什么我们需要在框架内提供 Metro
编译内核,来统一 RN 端的打包工具,为 React Native
生态提供更好的兼容性。
相较于 Webpack
,使用 Vite
编译项目在 Web 端会有很多优势,在社区内一直以来也有很高的呼声,不在少数的开发者希望能够在跨端解决方案中支持使用 Vite 编译项目。那么如果能够基于 Vite 实现与 Webpack
编译内核基本一致的能力,来完成多终端项目的编译工作,实际体验又会如何呢?
正是由于许多诸如此类的因素存在,也让我们不断思考解决的方案,最终在面向多终端的研发场景中,继跨端、跨框架之后,有了新的目标——即支持多编译内核的研发生态。
面向多终端研发
在面向多终端研发的场景中,通过借助 Webpack
、Vite
、Metro
这类编译工具,可以实现包括 Web、App、小程序、鸿蒙等多终端的研发场景,开发者也能根据项目按需选择,以期获得更佳的研发体验。
尽管我们主动选择在编译环节使用多编译内核,但这样的架构却并不只有优势,同时还要不少问题亟待解决。
譬如需要通过提供一致的生命周期支持,抹平各个插件对支持不同编译内核时的差异;框架配置也不会由于编译内核切换,导致额外的学习成本……编译内核的切换能够在研发和生态等各层面都能平滑无感,这也是我们正努力去实现的。
二、极速构建
多编译内核或许是一个很好的架构方案,能够支撑各研发场景的特性实现,并给开发者更多选择的自由,但作为跨端解决方案,却不能局限于此。在面向多终端的研发场景中,多编译内核的研发生态下,想要为开发者提供极致的,极速研发体验又该怎样去做呢?
以各个编译内核及其生态为出发点,在框架内为不同研发场景提供极速构建的能力,结合框架架构内的插件系统,最终能够基于多编译内核方案得到全新的架构。
Webpack 编译内核
在这些编译工具中,相信大家对于 Wepback
都不陌生,将开发者的代码输入 Wepback
内核编译就能够得到 Web 或小程序等等终端应用。
在 Webpack
编译内核中,我们通过 combination
为多端编译提供项目配置处理,不同模块的加载规则等通用能力;小程序端还有插件处理小程序模块和原生模块加载等特性;web 端也需要插件根据打包模式来处理应用和页面入口等等。结合框架的插件机制,开发者也能介入编译流程中,为项目提供自定义拓展,满足个性化的需求。
想要在 Webpack
内核中达成更好的构建体验,除了自定义插件还有更多方案可以选择,比如 Webpack5
的持久化缓存就是其中之一,考虑到编译安全相比于速度更为重要,该特性会默认关闭。当然在绝大多数情况下,持久化缓存都能够以较稳定的方式帮助开发者提升研发体验,如果当项目研发过程中受困于编译速度时这会是个不错的选择。
借助于 ESBuild
等不同工具,我们提供了各可配置模块供开发者选择,依照需求选择更适合项目的工具,依赖预编译也是为此设计的重要特性之一,通过提前编译依赖可以在实际项目中极大提升我们的研发效率,在社区内也广受好评。
依赖预编译,也就是 PreBundle
特性通过 ESBuild
扫描项目依赖并提前编译,借助于 Webpack
的模块联邦能力分别构建 Host 和 Remote 应用,就能实现这一有意思的特性,帮助开发者提升多终端研发的编译体验。
原理其实不复杂,但对应不同端实际执行的操作会有一定不同,在小程序端包括了以下几个步骤。
首先需要扫描项目内的依赖,通过 ESBuild
能够很好的识别项目内使用到的 JS 模块,但是也有不少细节需要注意并处理的,比如识别 Vue
中 setup
语法,避免误判导致遗漏需要预编译的模块;同时其它终端所需的依赖也需避免误判被编译,导入终端不支持的依赖等等。
得到项目所需的依赖后,再次通过 ESBuild
将其打包就能够得到项目所需的 Bundle,但在这里我们还需要需解析对应依赖的入口模式,同时借助于自定义的 SWC
插件优化代码编译能力,将基于 CJS
模式输出的依赖转换成 ESM
后的编译结果进行兼容。
想要在 ESBuild 输出结果中保留原始路径,将会使最终结果难以预测,所以我们需要将其扁平化处理,仅通过虚拟模块保留原始路径。最后对编译内容进行缓存,避免多次编译造成性能损耗。
紧接着,我们需要借助 Webpack
通过 MF 插件打包出 Remote 应用,在这个过程中同时需要传入编译所需的各种插件,比如说 ProviderPlugin
用于提供小程序依赖的运行时环境,并缓存编译结果。Host 应用必要的配置将会同时生成,并将 Host 应用及其资产注入到小程序目录,供最终的项目运行使用。
与小程序端相比,Web 端有很多步骤非常相似,比如前置的依赖扫描与打包等等,尽管具体配置会存在一定差异,比如 Web 端用于 API Shaking 的自定义 Babel
插件就需要通过 SWC
插件重新实现避免基于 Taro 的组件库在 PreBundle
特性中无法得到支持。同时由于小程序和 Web 端本身的差异,后续的步骤中也会有较大差异,比如需要为 Remote 应用配置代理。
虽然 Web 端编译 Remote 应用与小程序一样,都需要通过 ContainerPlugin
创建额外的容器入口,但却并不需要借助 ProviderPlugin
注入 runtime 等等依赖,同时也无需将远程依赖修改为同步模式等等操作。
在配置 Host 应用插件时,需要通过 ContainerReferencePlugin
将 Remote 提供的所有依赖修改为远程模块,并通过特定的引用添加作为外部资源的容器,允许它们加载远程模块,同时为公共的运行时注入必须的 Webpack
工具函数。最后在使用前,我们还需要为 Remote 应用设置代理,供 Host 应用加载应用这些依赖。
实际上,当我们在 Host 应用中直接使用未经加载的 Remote 依赖时,会导致应用报错。在业内一些类似的方案中,往往会需要通过 Babel
修改引用依赖相关的代码逻辑,但实际上如果在应用入口嵌套一层动态加载,Webpack
就会预加载这些 Remote 依赖避免相关问题出现,这也是在官方 MF 示例中推荐的方法。而在该特性方案中,为支持包括外出使用、多页应用构建等特性,我们选择基于 virtual-module
实现这一操作,最大程度借助于 Webpack
自有的能力与生态。
在通过 NutUI
的示例项目测试编译预编译时,可以发现通过 PreBundle
特性能够得到不错的优化,在社区开发者的体验当中,我们也收到了类似的反馈,小程序编译速度约为使用前的 20%,Web 应用也将耗时降低至原方案的约 30%。
这样的性能提升结果让人十分满意,但在跨端环境下使用模块联邦能力,特别是在小程序环境中使用真的如此容易?受限于小程序本身,借助于网络请求来实现该特性会极大程度的降低小程序的使用体验,牺牲用户体验不应该是一个成熟框架应该做的。
模块联邦特性实际是基于 ModuleFederationPlugin
封装提供给开发者使用,如果我们基于它内置的两个插件实现逻辑调整能否优化这一实现呢?
在这两个内置插件中,ContainerPlugin
在构建小程序端使用的 Remote 应用时,需要在插件内为小程序创建额外的容器入口,并提供模块自动注册相关的逻辑,通过劫持 ContainerEntryDependency
将依赖异步逻辑改为同步;与此同时还需要收集使用到的 Webpack
工具函数,并删除冗余的入口、runtime 等 chunk。
而在 ContainerReferencePlugin
中,需要将预编译依赖设置为远程模块,并注入收集到的 Webpack
工具函数,修改 remote
模块中的 id
映射对象,并插入自动注册模块相关的逻辑,在 app.js
中通过 require
依赖所有预编译生成的 chunk
和 remoteEntry
;同时需要删除冗余的文件,比如已经无用的远程模加载模块,减少不必要的代码输出。
通过基于提供 ContainerPlugin
、ContainerReferencePlugin
的魔改插件 TaroModuleFederationPlugin
供框架使用,就能在小程序中不损失用户体验的同时使用这一特性。开发者也可以自行配置使用,不过如果你的项目正在使用跨端框架,直接使用 PreBundle
特性会是个更好的选择。
当然不论是 TaroModuleFederationPlugin
还是 PreBundle
特性,都可以在第三方的项目中配置使用,以 PreBundle
为例,通过 Chain
传入 Webpack
配置,并执行实例上的 run
方法,我们就能得到最终所需的 Webpack
项目配置文件,并启动项目。
目前也有不少外部项目都通过接入该特性,来获得更佳的研发体验。
Vite 编译内核
实现 Vite
编译内核,可以说是在追赶潮流,也是当下社区开发者们的呼声,但更多也是希望能够通过支持 Vite
编译提供更多元的研发场景,让开发者可以根据实际需求,自由选择项目在对应场景下所需的编译内核。
相较于传统的编译模式,启动项目时需优先扫描整个项目依赖和代码,才能在应用完成构建后提供服务;Vite
通过使用 ESBuild
构建依赖,并在浏览器请求源码时,将对应模块转换为原生 ESM 按需提供源码,研发速度要快很多。
通过 Vite
我们可以给 Web
端提供更好的研发体验,当然还需要兼容各种小程序的特性,比如说路由、TabBar 等等,还有各类 API 和跨端组件库,也需要兼容性配置。
同样,在 Webpack
编译内核中已经支持的各种能力和特性也不能抛下,包括 React
、Preact
等前端 UI 框架的支持,多路由模式等等,也需要在 Vite
编译内核中得到支持。
相比于在 Web 端能够得到不小的提升,在适配小程序场景中的实际收益相对就要低上不少,由于缺少持久化缓存,在编译大型项目时的实际体验与 Webpack
相比并无明显改善,但是通过配置跳过更新的页面也能够给需要的项目提供不错的研发体验。
同样由于小程序自身的限制,Vite
并不能以开发模式来完成小程序的编译,我们在生产模式下提供应用和各页面入口的编译,写入到对应的小程序目录中,并以虚拟模块的形式更新页面,配合适配小程序端的 Vite
插件就可以完成小程序端编译工作。
总结来说,在 Vite
编译核心内,我们通过为小程序和 Web 端提供 config、entry、style 插件来适配各端的能力,当然小程序还需要 page 插件来为页面提供额外的处理。结合框架自身的插件机制,我们就能让 Vite
在适配跨端解决方案的同时,与其它编译内核形成一个互补选项,供开发者使用。
在整个编译系统中,除了我们提供的 Vite
编译内核,CLI 还有配置工具也需要为此同步做出调整,结合内核提供的代码和样式编译能力,就能为绝大多数的编译场景提供支持。同时一些工作我们也在社区内逐步去推进完成,比如小程序的分包能力,还有 Web 端的多路由应用模式等等,都会在之后的版本里一一补齐。
当然在使用 Vite
内核还是可能遇到一些问题,我们在后续也会尝试去针对性优化,比如打包输出成 CJS 模式,在配置 splitChunk
后可能会导致循环依赖,在小程序中报错;另一个就是刚刚有提到的编译缓存,在小程序中二次编译速度可能不及基于其它打包工具的编译内核。
Metro 编译内核
从 Webpack
输出 RN 项目,到改用 Metro
是一个很自然的选择,借助于 Metro
我们也能将适配 React Native
的很多操作,从编译时转向到运行时,为开发者提供更好的研发体验。
从 CLI 读取项目配置,到 Metro
内核完成编译,并启动调试服务,Metro
都能比 Webpack
更好的兼容 React Native
已有的生态环境。在框架层面需要做的,仅是为开发者提供合并定义 Metro
配置的能力,并让 Metro
可以理解我们在适配其它端时对 Webpack
的操作,并在一定程度上复刻。
基于 @tarojs/rn-transformer
处理 RN 的入口文件,在编译时注入页面的包装方法等,并将入口的全局样式注入到页面中;同时对运行时进行一定程度的修改,比如基 React Navigation
对路由进行封装,提供动态创建导航的方法,并且封装导航相应的 API 内容;在 runtime 中对应用和页面配置进行处理等等。
相比于过往方案,Metro
能够提升编译速度,提供更好的 SourceMap 支持,提升研发效率和体验;同时规避由于 Webpack
提供的多 entry 模式导致的问题,并降低包体大小等等;也与 React Native
默认配置保持一致,支持更灵活的合并方式。
在 App 端,我们通过 Metro
在 Transformer
和 Runtime
两个层面对项目进行修改,让方案可以在支持多端的同时,更加顺滑的支持使用 RN 完成 App 端的研发工作。
那如果希望在框架之外,使用这些开发好的 RN 能力,也有比较简单的方法快速接入,通过 @tarojs/rn-supporter
就能提供编译、运行时配置,和资源样式解析方案的支持。
具体使用如下图代码所示,需要注意的是,在该外部使用的方案中,路由相关的逻辑需要自行处理。
总结来说,在我们的编译系统方案中,通过 CLI 和插件系统结合多个编译内核来为开发者提供跨端解决方案。
在编译内核之外,插件系统也能够很好的帮助框架为开发者根据编译平台,提供解决方案。
通过 CLI 完成编译工作时,我们也可以通过加载不同平台插件的方式,来完成编译时拓展和运行时能力的注入,提供最终的编译结果。
在插件系统中,我们从 CLI、编译过程和编译平台三个方向提供能力的拓展,结合各个生命周期,比如:onBuildStart
、onCompilerMake
、modifyWebpackChain
等等,各个编译内核都能为不同端编译提供更加个性化的定制服务。借助于此,我们可以提供 React
、Vue
、Flutter
等等不同 DSL 的插件;也能为小程序、鸿蒙等多端提供端平台插件,辅助完成编译。
在这样的架构下,开发者也能根据我们提供的生命周期,横向或纵向拓展编译能力,为各个终端拓展编译能力支持,比如说飞书、钉钉、企微等等小程序还有鸿蒙端平台插件,都是 Taro 技术委员会或者社区开发者通过这样的方式提供的。
同时在其它方面,为提升构建体验也有不少小优化,比如 SWC
虽然与 Babel
实际上还存在差异,但在很多特殊的应用场景下已经足够使用,比如在依赖预编译中提供代码编译,以及应用与页面配置读取等等,都有不少提升。
ESBuild
也是如此,除了 PreBundle
特性中的使用,我们还在压缩代码和样式中提供了相关的配置,不过相比于 terser
和 csso
,还存在压缩效果等问题,加之对其稳定性的考量,我们并没有将其配置为默认选项,可以由开发者自行选择。
三、总结与展望
跨端解决方案结合多编译内核生态,我们能够在开放式的跨端跨框架解决方案中,为开发者提供更高的自由度和研发体验。
通过 React
、Vue
、Svelte
等 DSL 插件,结合 Web 端渲染规范,小程序的标准 API 和组件库等等,我们能够为不同终端提供可靠的跨端能力,同时在 Webpack
、Vite
、Metro
等多个编译内核的帮助下,我们能够提供更加高效的研发体验,为日常的开发工作提供更多的便利。
对于框架本身来说,在未来我们也会提供更多的前端 UI 框架或编译工具的支持,比如 Solid 等等。
性能方面,也希望为小程序提供更好的热重载、预渲染等能力,提升多端研发体验,提供适用于更多场景的多端性能优化方案。
同时根据场景提示性能优化方案,以及性能指标测评工具,为项目优化提供指引。
对于 Flutter
编译内核的支持也在日程中,在社区内持续推进;通过 Web Assembly
对小程序的运行时进行优化等等,也都在 Taro 未来的路线规划当中,敬请期待。
同时,我们还预期提供框架的测试方案,通过 TestRenderer
为跨端方案的测试提供更好的体验。
最后关于 Taro 开源工作,目前已经由 Taro 技术委员会主导,在 Taro 生态超过 700 位贡献者中,有 50 余位行业内各个公司的同学长期参与 Taro TOC
的会议分享,所有贡献者们都为推进 Taro 跨端解决方案不断迭代作出了诸多努力和贡献,正因如此才能够让我们在多编译内核生态下,获得更加极致的研发体验。
正如技术委员会成员 @xueshuai
在投稿 v3.6 版本代号时说道,“「Reach」代表着不屈和希望,给开发者带来更好的用户体验,是全体开发者的坚守“,希望社区的积极氛围可以引导 Taro 的生态更加美好。