随着小程序在用户规模和商业化上取得的极大成功,各大平台都推出了自己的小程序。然而这些平台的小程序开发在语法上又不尽相同,不同平台小程序代码的维护需要投入很大的精力,在逻辑性上也很难达到统一的效果。虽然也有各种转换工具可以基于某一个平台转换出其他平台的代码,但转换的效果也是差强人意,往往还需要人工去修改。使用小程序跨端开发框架来实现一次开发、到处运行以提升效率,已经成为开发者强烈而迫切的需求。
目前,小程序跨端开发框架主要可以按照技术栈和实现原理两个维度进行分类。从技术栈来说,主流的跨端框架基本遵循 React、Vue 这两个前端开发最常使用的框架。由于所在团队主要使用的是React,所以本文主要介绍采用React语法的框架。从实现原理上,开源社区的跨端框架可分为编译时(compile time)和运行时(runtime)。
主流框架及其特点介绍如下表1-1所示:
表1-1 React语法小程序跨端框架举例
框架 |
厂家 |
特征 |
Kbone |
腾讯 |
不限技术栈,微信小程序和 Web 端同构的运行时解决方案,模拟了一套dom和bom接口,用以兼容现有的前端体系,只能用于Web兼容微信小程序,无法满足其他平台小程序的开发 |
Taro1/2 |
京东 |
类React,静态编译型框架仅在开发时遵循React语法,编译后运行时与React无关 |
Nanachi |
去哪儿 |
React,静态编译型框架 |
Rax |
阿里巴巴 |
以运行时方案为基础,支持局部场景使用编译时方案。运行时的支持基于Kbone,使用的是类React语法的Rax框架 |
Remax |
蚂蚁金服 |
使用原生React来构建小程序,运行时框架,从Remax2.0开始支持Web应用的构建 |
Taro3 |
京东 |
不限技术栈,使用一套runtime层来兼容各种DSL,诞生于Remax之后 |
compile time编译时的跨端框架,主要的工作量在编译阶段。框架会把用户写的业务代码解析成 AST 树,然后通过语法分析将用户写的原始代码转换成符合小程序规则的代码。运行时模式的跨端框架通过适配层,实现自定义渲染器,是真正的在小程序的逻辑层中运行起React或Vue框架的方式,这种方式比静态编译有天然的优势。
编译时主要存在以下问题:灵活的JSX语法既可以写出非常复杂灵活的组件,同时也增加了编译阶段框架去分析和优化的难度。这就导致适配工作量巨大,维护成本较高,即使这样,也无法适配所有的写法。例如京东的Taro 1/2 用穷举的方式对 JSX 可能的写法进行了一一适配,但依然需要开发者遵循大量的语法约束,避免很多动态写法。否则代码就不能正常编译运行,开发效率难以保证。此外,由于 DOM 和 BOM API 的缺失,Web 上累积的各种前端生态,基本无法在编译时小程序中复用。京东的 Taro 1/2 , 去哪儿的 Nanachi都属于静态编译型的React或类React跨端框架。
与之相比,运行时方案的优势就在于能直接复用现有的前端生态。拿Remax来说,它最大的优势是可以几乎没有限制的使用React的语法完成代码编写,正如它的口号一样——使用真正React来构建跨平台小程序。此外,从Remax2.0开始,remax/one支持Web应用的构建。
我们团队做选型的时候Taro3还是待发布状态,所以没有做太多的考虑,下面着重去比较一下Rax和Remax。Rax和Remax都是出自阿里系但两个框架从设计思路上完全不同。
Remax从诞生之初就是为了支持小程序跨端框架Rax为了能尽量压缩React体积对React进行了重写,又引入了Driver机制来适配多端,这就意味着Rax有额外的学习成本,并且Rax不能随着React的迭代而更新。虽然Rax看似比较完善,提供了一套开箱即用的跨端API和完整的跨端UI控件支持,但它过多的依赖阿里的构建体系,似乎不太适合做为开源框架的选择。
综合以上几点考虑最终我们选择了Remax。
图2-1到2-3分别为首页,列表页和详情页在Web和微信小程序上的运行效果,实现了一套代码多端运行,同时能做到同步更新。
图2-1 携程船票首页web和微信小程序运行结果
图2-2 携程船票列表页web和微信小程序运行结果
图2-3 携程船票详情页web和微信小程序运行结果
下面介绍一下Remax的基本用法。
3.1 创建/安装依赖/运行
使用create-remax-app创建的小程序
npxcreate-remax-app my-app ? name my-app //填写你的appName ? author test<test@test.c> ? descriptionRemax Project //填写项目描述 ? platform ❯ 跨平台小程序 微信小程序 阿里(支付宝)小程序 头条小程序 百度小程序
因为我们介绍的是跨端小程序,这里选择跨平台小程序。
运行项目
cd my-app&& npm install npm run dev web//web端预览 npm run devwechat //微信小程序端
小程序端运行后会在项目目录里生成 dist/wechat、dist/ali各个小程序的产物,用小程序的IDE导入对应的目录即可预览。
项目的目录结构大致如下:
其中public目录在编译时会被复制到dist目录下,里面的原生页面pages目录也会和Remax的pages目录合并,这一部分后面会详细介绍。
3.2 Remax的跨平台机制
以下详细介绍Remax的跨平台机制
以上就是一个页面的基本文件目录结构,但是我们翻一遍Remax的文档就会发现它提供的跨平台的接口是少之又少,只有9个组件以及5个路由相关的API。所以Remax又提供了一种用文件名后缀来区分不同平台代码的方法:
如上面的目录所示,在页面的目录里增加相应后缀的文件在build相应平台的时候就会优先使用对应后缀的文件。
Remax还提供了环境变量区分平台的机制,在代码中直接通过
process.env.REMAX_PLATFORM区分平台。例如:
if (process.env.REMAX_PLATFORM==='wechat') {}
以上代码在编译后只会保留对应平台的部分,所以无需担心兼容多平台带来额外的代码size增加。
除了上面说到的9个跨平台的组件和5个跨平台的API外,Remax还可以直接使用各个平台的组件及API,无需使用useComponents声明。
import React from'react'; import { View } from'remax/one'; import NativeCard from'./native-card';//native-card 是一个原生自定义组件 // 组件 exportdefault () => ( <View> <NativeCard /> </View> ); // API if (process.env.REMAX_PLATFORM==='ali') { systemInfo = my.getSysyemInfoSync(); } elseif (process.env.REMAX_PLATFORM==='wechat') { systemInfo = my.getSysyemInfoSync(); }
根据以上机制我们就可以自定义出任何我们需要的跨平台API及组件。
按照Remax的说法之所以不提供更多的跨平台组件和API是因为他们在设计之初没有标准去抹平各个平台的差异。这当然给使用框架的开发者带来了一些麻烦,许多的组件和API不能开箱即用,需要额外的去做一层封装。但这也是Remax这个框架的优势,只保留了核心的组件及API使得它本身不占太多的size,也使得要兼容一个新的平台变得非常容易。
4.1混合原生
之前我们说到了public目录里的原生代码会拷贝到dist目录并且pages目录会合并,我们可以利用这一机制最大程度的复用现有小程序的代码,这也是我们团队选择Remax这个框架的原因之一。
让我们来体验一下,先创建一个原生小程序,以微信小程序为例:
小程序的文件目录大致如下
现在把整个小程序的代码放入public目录
此时npm run dev wechat就会把pages以及utils拷贝到dist目录下并且pages目录合并了。
虽然合并了,但是我们观察一下发现原生小程序的app.json的内容不见了,难道要在Remax里面把app.json都写一遍么?带着疑问我们仔细翻看Remax的文档,发现remax.config.js里是可以配置动态生成app.json的配置的:
onAppConfig() { ... // 获取原始小程序配置originConfig和 remax app.config.js配置tmp // 做合并处理 const appJSON =JSON.parse(JSON.stringify(originConfig)); tmp.pages.forEach(function (item) { if (appJSON.pages.indexOf(item) ==-1) { appJSON.pages.push(item); } }); tmp.subPackages.forEach(function (item) { let needAdd =true; for (let i =0, a = appJSON.subPackages; i < a.length; i++) { if (a[i].root=== item.root) { needAdd =false; a[i] = item;break; } } if (needAdd) { appJSON.subPackages.push(item); } }); ... return appJSON; }
经过以上处理,原小程序的app.json内容就和remax.config.js内容合并了,以上代码只处理了pages和subPackages,如果觉得还有什么需要合并的也可以在这里处理。此时,build生成的产物app.json,就保留了原小程序的内容并且合并了Remax小程序的内容。
产物里的app.js也没有原生小程序里的代码,那原有的逻辑怎么办呢。重新在Remax写一遍吗?大可不必。
我们可以自定义一个运行时插件:
function createBothFun(remaxConfig, originConfig, key) { const remaxFun = remaxConfig[key]; const originFun = originConfig[key]; return function () { // 这里的this就是微信的app 里的this remaxFun.apply(this,arguments); originFun.apply(this,arguments); }; } const lifeCycles=['onLaunch','onShow','onHide','onPageNotFound','onError'] function tryMergeConfig(remaxConfig, originConfig) { for (const key in originConfig) { if (key ==='constructor') { console.log('xxx'); } elseif (lifeCycles.indexOf(key) >=0) { remaxConfig[key] =createBothFun(remaxConfig, originConfig, key); } else { remaxConfig[key] = originConfig[key]; } } } const mergeConfig = (remaxConfig, originConfig) => { tryMergeConfig(remaxConfig, originConfig); return remaxConfig; }; export default { onAppConfig({ config }) { let __app = App; let originConfig; App =function (origin) { originConfig = origin; }; __non_webpack_require__('./app-origin.js'); App = __app; //merge config config =mergeConfig(config, originConfig); const onLaunch = config.onLaunch; config.onLaunch= (...args) => { if (onLaunch) { onLaunch.apply(config, args); } }; return config; }, };
把原来的app.js重命名为app-origin.js,然后在onAppConfig函数中使用__non_webpack_require__('./app-origin.js'); 请注意,这里的相对路径是产物里的相对路径,经过以上一番操作后,我们的原小程序就可以真正和Remax混合运行了。
但是这样一来,我们的Remax似乎变得不能跨端了,因为它只能编译成你public目录里放置的原生小程序类型。
难道混合和跨端只能是鱼和熊掌不可兼得?后面我们将介绍使用工程化的方法来实现鱼和熊掌兼得。
你可能注意到了Remax的文档里面列举了10个控件,但我说它只有9个控件,官方的文档上也说只有9个控件,为什么呢?因为Modal严格的说来不能算是一个控件。
Modal实际上是调用createPortal这个API来创建一个节点覆盖在其它内容之上,在web端是使用的ReactDOM的createPortal,在小程序端是使用的@remax/runtime这个包里提供的同名方法。实际上portal在两端挂载的位置也不太一样,在Web端是直接的body上创建了一个新的div,而在小程序上则是挂在页面实例一个叫modalContainer的节点。在实际使用中,使用Modal组件去显示弹窗非常不方便,所以还是得把它变成API调用的方式。
以小程序端为例:
import... import { createPortal } from'@remax/runtime'; import {ReactReconcilerInst } from'@remax/runtime/esm/render'; let createPortal__ = createPortal; let ReactReconcilerInst__ = ReactReconcilerInst; const styles = { modalContainer: { ... }, }; export default function withModal(TargetComponent) { const WrappedModalComponent = (props) => { const { mask,...other } = props; const component =useRef(); const container =getCurrentPage().modalContainer; return createPortal__( <View style={{ ...styles.modalContainer,pointerEvents: mask ?'auto':'none' }}> <TargetComponent {...other} mask={mask} show={show} close={close} ref={component} /> </View>, container ) }; WrappedModalComponent.hide= (conext) => { const container =getCurrentPage().modalContainer; if (container._rootContainer) { ReactReconcilerInst__.updateContainer(null, container._rootContainer,null,function () { }); } container.applyUpdate(); }; WrappedModalComponent.show= (props) => { const container =getCurrentPage().modalContainer; if (!container._rootContainer) { container._rootContainer=ReactReconcilerInst__.createContainer(container,false,false); } const element = React.createElement(WrappedModalComponent, props); ReactReconcilerInst__.updateContainer(element, container._rootContainer,null,function () { }); context.modalContainer.applyUpdate(); }; return WrappedModalComponent; } export { withModal };
使用示例:
//在需要弹窗的组件上使用withModal装饰器 @withModal export default MyComponent(props) { ... return<View>{...}</View> } //在不支持装饰器的模式下也可以直接调用 function MyComponent(props) { ... return<View>{...}</View> } const ModaledComponent =withModal(MyComponent) //在需要使用弹窗的地方使用 ModaledComponent.show(props);//展示弹窗 ModaledComponent.hide();
4.3工程化
考虑到我们的小程序是多部门多团队共同合作的项目,不可能让整个公司同时都使用Remax重写原来的业务,那样会有极大不可控风险。所以Remax只能是在部分业务试用,并且能渐进的切换原有的业务,这就要求我们必须有工程化的方案。
我们期望的小程序产物的结构如下:
而 Web 端的产物结构如下:
这表示小程序端是依赖原有小程序的,Web端是可以单个业务单独发布的,于是我们在编译过程中给小程序和Web生成两套不同的壳工程。
编译小程序的过程中拉取壳工程,壳工程的目录结构大致如下:
其中remaxA和remaxB的页面代码是在拉取壳工程的时候动态生成的,我们在壳工程里放入一个配置文件bundle.js,用来描述该壳工程有哪些Remax业务代码:
module.exports= { remaxA: { git:"git@remaxA.git" }, remaxB: { gitL "git@remaxB.git" } }
在拉取壳工程的同时clonebundle.js所配置的仓库到临时目录packages,此时packages目录如下:
然后根据Remax业务代码里的app.config.js,重新在壳工程生成新的页面和页面配置,其核心逻辑如下:
const template = (projectName, path) => { return`import ${projectName} from'~packages/${projectName}/src/pages${path ?`/${path}`:''}'; ${projectName}.prototype.onShareAppMessage; ${projectName}.prototype.onPageScroll; ${projectName}.prototype.onShareTimeline; exportdefault ${projectName}; `; } const pageHandler = (projectName) => { const projectPath =`${rootDir}/packages/${projectName}`; shell.cd(projectPath); let conf =require(`${projectPath}/src/app.config.js`); let platConf = conf[platform] || conf; const projectAllPages = []; ... // 遍历 pages 和subPackages配置并替换路径 pagePath.replace('pages/',`pages/${projectName}/`); ... subPackage.root= subPackage.root.replace('pages/',`pages/${projectName}/`); ... // 将pages subPackages里所有的页面路径合并到一起 let allPages = [...platConf.pages] allPages.push(path.join(subPackage.root, page)); // 遍历页面配置生成新的页面 allPages.forEach((mapPath) => { const pagePath = path.resolve(rootDir,'src','pages', projectName,`${mapPath}.js`); fse.ensureFileSync(pagePath); const data =template(projectName, mapPath); fs.writeFileSync(pagePath, data); }); }; const complier = () => { ... //获取子项目git地址,下载至packages目录下 const packagesPath = path.resolve(rootDir,'packages'); const subDirs = fs.readdirSync(packagesPath); // 遍历packages根据packages里的app.config.js重新生成合并路径后的页面 subDirs.forEach((name) => { let file = fs.statSync(`${packagesPath}/${name}`); if (file.isDirectory()) { pageHandler(name); } }); ... }; module.exports= complier
生成的页面代码如下:
import remaxA from'~packages/remaxA/src/pages/index/index'; remaxA.prototype.onShareAppMessage; remaxA.prototype.onPageScroll; remaxA.prototype.onShareTimeline; exportdefault remaxA;
这个可以修改上面代码中template按需求修改, 这段代码中之所以有类似
remaxA.prototype.onShareAppMessage; 这样的无用代码是因为Remax在编译过程中会收集页面代码里的生命周期函数的关键字,非必需的生命周期在页面代码没有出现的时候,编译产物里也不会有。
生成的页面路径如下:
同样的,在Web端也会有相似的操作。我们Web是使用node容器发布的,所以壳工程就弄成了node工程了。如果使用静态发布那就不需要壳工程了,直接build发布产物就可以了。
此外,由于小程序的单包size限制的原因,在小程序webpack配置方面需要做一些额外的配置,避免多个Remax业务不共同依赖的代码也打到主包去,导致主包的单包size超出限制,这里给一个例子,仅供参考:
configWebpack:function (options) { let config = options.config; let subpackageGroups = {}; Object.keys(projects).forEach((key) => { let packagePages = projectsPages[key]; let allPages = packagePages.allPages.map((page) =>`pages/${key}/${page}`); let pages = packagePages.pages; subpackageGroups[`${key}Common`] = { name:`package-${key}-common`, test: (module) =>newRegExp(`[\\/]packages[\\/]${key}[\\/]src[\\/]`).test(module.userRequest), chunks:'all', minChunks:2, minSize:0, priority:91, filename:`pages/${key}/package-${key}-common.js`, }; }); config.optimization.merge({ splitChunks: { maxAsyncRequests:100, maxInitialRequests:100, automaticNameDelimiter:'-', enforceSizeThreshold:50000, cacheGroups: { ..., ...subpackageGroups }, }, }); },
本文旨在给大家提供一些新的思路,在选型方面应该从多方面去考量,各个方案可能没有明显的好坏之分,适合的才是最好的。就拿Taro来说,得益于有官方团队的支持,Taro3的发展速度非常之快,各个方面都做的比较完善。而Remax社区似乎没有那么活跃,所以发展速度相对来说比较慢。期待有更多的朋友来参与开源框架的贡献。
【作者简介】汽车票前端研发团队,致力于提供更便捷更智慧的出行方式,关注前端技术方向的探索和实践。