如何将现有的微信原生小程序转其他平台的小程序?我想如果打算做这么一件事,或许大多数同学和我一样可能没什么思路。我第一次听说是在一次小程序生态技术大会上,一公司的一位前端技术人员谈到他们公司主要是将自己的微信小程序通过团队开发的工具转成其他平台的小程序,所以当时我也很想了解这个工具是怎么做的,实现过程是什么?
恰好近期有这么一次需求要将现有的微信小程序上开发别家的小程序,这个事要么通过现在比较好的方案uni-app来做,要么就用对应的原生小程序来写。但是从零开始做,工作量实在太大了,周期好长呀,那么多页面,得搞到啥时候。就在决定开始调研uni-app来做的时候,恰好有一天在微信上看见了一篇技术文章:开源|wwto:小程序跨端迁移解决方案——微信转其他小程序 最后放弃了使用uni-app来做,尝试通过这个工具来转换。
下面也将围绕 wwto 这个工具库,通过将我们现有的微信小程序转支付宝小程序来了解其转换原理,同时呢会说下在转换的过程中遇到的各种问题是如何解决的,希望对有需要的同学能有所帮助
在了解 wwto 这个工具库后,它大致的架构是这样的,下面这张图是使用作者的,更权威一点。
通过了解 编译 以及 运行时 这两个模块的实现过程就能够明白小程序转换的过程中做了那些事情以及怎么去做的了
下面对这两个阶段所做的事简单说下:
1、在 编译 阶段主要对4个文件做了处理,分别是:*.js、 *.json、 *.wxml、 *wxss
*.wxml 的处理部分代码如下,可看源码wxml.js
function convert(wxmlText, isWpy) { return wxmlText .replace(/wx:/g, 'a:') .replace(/a:for-items/g, 'a:for') .replace(/a:for-key/g, 'a:key') // data-set 全部转为小写 .replace(/data-[^=\s]=/g, (match) => match.toLocaleLowerCase()) // // s:for-index="{{idx}}" -> s:for-index="idx" .replace(/a:for-index=['"]({{\w+}})['"]/ig, (match) => match.replace('{{', '').replace('}}', '')) // 自定义组件命名不能用驼峰 .replace(/<[\w]+/ig, (match) => { return isWpy ? match : match.replace(/[A-Z]/g, (m) => ['-', m.toLowerCase()].join('')); }) // 事件绑定名称对齐 .replace(/\s+catch[\w]+=['"]/ig, (match) => match.replace(/catchsubmit/ig, 'onSubmit') .replace(/catch(\w)/g, (m, p1) => ['catch', p1.toUpperCase()].join(''))); ... 省略 } module.exports = convert; 复制代码
通过对文件的处理:例如
<view bind:cellTap='onTabMyCrad' wx:if="{{hasJoin}}">...</view> 变成了 <view onCellTap='onTabMyCrad' a:if="{{hasJoin}}">...</view> 也就是把微信的语法转换为目标小程序的语法结构。 复制代码
*.js 的处理部分代码如下,源代码script.js
function convert(jsText, isWpy) { return jsText .replace(/(require\(['"])(\w+)/g, '$1./$2') .replace(/(from\s+['"])(\w+)/g, (match, p1) => { // 相对路径以./开头 return match.replace(p1, [p1, isWpy ? '' : './'].join('')); }) .replace(/\.properties/g, (match) => { return match.replace('.properties', '.props'); }) .replace(/Component\([\s\S]+methods:[^{]*{/, (match) => { return [ match, `,\r\ntriggerEvent: function(name, opt) { this.props['on' + name[0].toUpperCase() + name.substring(1)]({detail:opt}); },\r\n` ].join(''); }) .replace(/[\s\S]+/, (match) => { // 只处理组件 if (!match.match(/Component\(/)) return match; ... 省略 }); } module.exports = convert; 复制代码
通过对组件的处理如图:
这么转换的目的也就是原文中开源|wwto:小程序跨端迁移解决方案——微信转其他小程序 提到的 :支付宝小程序组件的生命周期函数与微信小程序完全不一样,也没有一一对应的关系。这种情况无法使用简单的方法名正则替换,本方案是注入支付宝小程序组件的生命周期函数,在这些生命周期函数中在调用微信小程序的生命周期函数,这样以来就避免了方法名替换无法一一对应的问题,也能更方便地书写适配代码。
对 *.json 以及 *wxss 的处理就不列出代码了,可看源码: json.js 、wxss.js
2、在 运行时 阶段又做了哪些事情呢?...
主要在每个js文件头部加入了适配代码adaptor.js
截取部分实现代码如下: 源代码可参考converter.js
function convert(opt = {}) { const src = opt.source || './src'; const dest = opt.target || './alibaba'; const assets = opt.assets || config.getAssets(src); ...省略 // 注入适配器代码 gulp.src(sysPath.resolve(__dirname, '../../../node_modules/mp-adaptor/lib/alibaba.js')) .pipe(rename('adaptor.js')) .pipe(gulp.dest(dest)).on('end', () => { logger.info('复制 adaptor.js 完成!'); }); // 处理脚本文件 gulp.src(`${src}/**/*.js`) .pipe(replace(/[\s\S]*/g, (match) => converter.script(match))) .pipe(through2.obj(function(file, enc, cb) { const path = file.history[0].replace(file.base, ''); const spec = path.split(sysPath.sep); const adaptor = new Array(spec.length - 1).fill('..').concat('adaptor.js').join('/'); const str = [ `import wx from '${adaptor.replace(/^\.\./, '.')}';`, ab2str(file.contents) ].join('\r\n'); file.contents = str2ab(str); this.push(file); cb(); })) .pipe(gulp.dest(dest)); } module.exports = convert; 复制代码
加入的adapter.js 代码是什么样的呢? 参考源码alibaba.js
function getInstance() { // eslint-disable-next-line no-undef const wx = my; wx.has_ali_hook_flag = true; const { getSystemInfo } = wx; wx.getSystemInfo = function(opt) { ...省略 return getSystemInfo.call(this, opt); }; ...省略 return wx; } export default getInstance(); 上面的适配代码:主要就是包装抹平微信与支付宝两个平台间api的调用差异,既可以使用原微信wx.*的方式来调用,也可以使用支付宝小程序平台my.*的方式来调用api,说白了就是对微信的api包装了一层。 复制代码
通过分析 wwto 这个工具库的实现过程,也就学习到了如何基于现有的微信小程序转其他平台小程序的实现过程了。下面说下这次转换的过程中遇到了那些问题以及怎么解决的。
微信小程序代码转换阶段-实践
转换的时候遇见这么一些问题:
首先,wwto工具做不到运行时 diff 的抹平,也做不到一个 API 从无到有的过程
1、现阶段我们的微信小程序依赖 vantUI 组件库,使用wwto来转换压根就不支持
2、微信小程序中常用的api:selectComponent 在支付宝里小程序里面不支持
3、微信的分包加载是否支持?不支持又该如何处理等?
对于第二个问题,需要修改 wwto 工具库的代码,使其支持这个api,我这边的实现思路如下: 如Page A 页面依赖 Component B组件,可以在B组件的ready生命周期阶段把当前组件实例this挂载到全局对象getApp()中的某个属性上,然后在Page A中实现selectComponent这个api,这个api就来获取挂载到getApp()上对应的实例组件。
修改处在script.js代码中,可以打开文件比对 如下:
对于第三个问题,通过了解支付宝的分包机制文档,完全可以支持微信小程序的,但是,这里我在调试的时候支付宝开发者工具和到真机预览的时,两者差异完全不一样,在开发者工具完全运行正常,可是在真机预览的时候会遇见各种奇葩问题,大部分都是adaptor.js 重写wx.* 的api导致的问题,通过调试了好长时间,终于找到了问题的根源所在,我已经在githup上 向wwto 开源者提issue了,可查看adaptor.js 重复执行 了解,并且我已提交了PR进行了修正
对于第二个大问题,做的事就相对比较多了,如果在不了解wwto这个工具库代码实现思路情况下,可能是无法解决的,当时能想到的解决办法就是,仿照vantUI组件库的代码实现,重新采用微信自定义组件的形式重写,但是这样做工作量又上去了,比起使用uni-app来,这个不可行,工作量也好大呀!这个时候,我几乎又要放弃使用这个工具来转换了。那这里能不能换别的思路去解决呢?答案肯定是有的,前提就是了解wwto工具的代码实现过程以及思路:wwto是在转换的时候,通过修改原微信小程序的文件,那么我就能够仿照其思想在小程序运行时添加兼容的代码来让vantUI微信小程序组件库能够跑在支付宝小程序中,听起来是多么一件有趣的事
如何去做呢?通过查看了vantUI组件库的代码实现,是可以按这种思路实现的,大致需要修改组件库中两处代码
1、源代码basic.js 修改如下,主要是解决微信小程序triggerEvent api的功能,获取组件实例
let Behavior = p => p export const basic = Behavior({ methods: { $emit(...args) { let name = args[0]; let onPropsfn = this.props['on' + name[0].toUpperCase() + name.substring(1)]; // 可以正则匹配 data-*值 ['datadata-mm', 'data-', 'data-1'].filter(v => v.match(/^data-\w+/)) let index = this.data && this.data['data-index']; if (onPropsfn) { if (args.length == 1) { onPropsfn({detail: undefined}) } else if (args.length == 2) { onPropsfn({detail: args[1], currentTarget:{dataset: {index: index}}}) } else if (args.length >= 3) { onPropsfn.apply(this, args); } } // this.triggerEvent(...args); }, ... 省略 } }); 添加的代码实现:都是参考wwto实现的思路 复制代码
2、源代码component.js 修改如下,主要是解决微信小程序中一系特性功能如:externalClasses、properties、behaviors => 模拟到支付宝小程序中,如果有兴趣可以比对添加的代码,如何抹平这些特性差异,其中微信的relations组件特性,没法模拟,替代方案就只能用支付宝小程序相关组件了
import { basic } from '../mixins/basic'; import { observe } from '../mixins/observer/index'; function mapKeys(source, target, map) { Object.keys(map).forEach(key => { if (source[key]) { target[map[key]] = source[key]; } }); } function noop() {} function VantComponent(vantOptions = {}) { const options = {}; mapKeys(vantOptions, options, { data: 'data', props: 'properties', mixins: 'behaviors', methods: 'methods', beforeCreate: 'created', created: 'attached', mounted: 'ready', relations: 'relations', destroyed: 'detached', classes: 'externalClasses' }); options.properties = options.properties || {}; // relations 微信组件特性,暂时没法模拟到支付宝 const { relation } = vantOptions; if (relation) { options.relations = Object.assign(options.relations || {}, { [`../${relation.name}/index`]: relation }); } // add default externalClasses options.externalClasses = options.externalClasses || []; options.externalClasses.push('custom-class'); // add default behaviors options.behaviors = options.behaviors || []; options.behaviors.push(basic); // map field to form-field behavior if (vantOptions.field) { options.behaviors.push('wx://form-field'); } // add default options options.options = { multipleSlots: true, addGlobalClass: true }; observe(vantOptions, options); /** * 参照wwto => 运行时调整options */ /** * mixins */ options.mixins = options.mixins || []; options.mixins = options.mixins.concat(options.behaviors); /** * const lifeCircleNames = ['created', 'attached', 'ready', 'detached']; */ options.methods = options.methods || {}; const lifeCircleNames = ['created', 'attached', 'ready', 'detached']; lifeCircleNames.forEach(name => { let methods = options.methods[name] = options.methods[name] || options[name] || noop; // fix selectComponents api if (name == 'ready') { options.methods[name] = function() { if(this.data.id){ var app = getApp(); app.globalData.insComp = app.globalData.insComp || {}; app.globalData.insComp[this.data.id] = this; }; methods(); } } }) /** * 处理this.__observers */ let has = Object.prototype.hasOwnProperty; let propMap = {}; let observerMap = null; let walkProps = obj => { Object.keys(obj).forEach((key) => { if (!has.call(obj, key)) return let item = obj[key]; // String Number Boolean 设定默认值 if (item === String) { propMap[key] = ''; } else if (item === Boolean) { propMap[key] = false; } else if (item === Number) { propMap[key] = 0; } else if (item && typeof item == 'object') { let type = item.type; if (!('value' in item)) { if (type === String) { propMap[key] = ''; } else if (type === Boolean) { propMap[key] = false; } else if (type === Number) { propMap[key] = 0; } else { propMap[key] = ''; // 暂时默认值 } } else { propMap[key] = item.value; } if (item.observer) { // debugger observerMap = observerMap || {}; if (typeof item.observer === 'function') { observerMap[key] = item.observer; } else { // 微信小程序中observer也可以使用对象的形式 observerMap[key] = function() { this[item.observer] && this[item.observer].apply(this, arguments); }; } } } else { propMap[key] = item; } }); } // 处理properties => props let properties = options.properties; walkProps(properties); let mininsProperties = options.mixins.filter(item => item.properties); mininsProperties.forEach(item => { walkProps(item.properties); }) /** * 处理 externalClasses 同时手动拷贝class */ let externalClasses = options.externalClasses; externalClasses.forEach(clas => { propMap[clas.replace(/-(\w)/g, (match, p) => p.toUpperCase())] = ''; }) options.props = propMap; options.props.__observers = observerMap /** * my生命周期函数 */ options.didMount = function(){ this.data = Object.assign({}, this.data, this.props); this.created && this.created.apply(this, arguments); this.attached && this.attached.apply(this, arguments); this.ready && this.ready.apply(this, arguments); /** * 解决初始化observer component 组件 */ if (this.props.__observers) { Object.keys(this.props.__observers).forEach(key => { this.props.__observers[key].call(this, this.props[key]) }) } } options.didUnmount = function(){ this.detached && this.detached.apply(this, arguments); } options.didUpdate = function(prevProps, preData) { for (let key in this.props) { if (this.props.__observers && typeof(this.props.__observers[key]) === 'function') { if (JSON.stringify(prevProps[key]) !== JSON.stringify(this.props[key]) && JSON.stringify(preData[key]) !== JSON.stringify(this.props[key])) { this.setData(Object.assign({}, this.data, {[key]: this.props[key]})); this.props.__observers[key].apply(this, [this.props[key], prevProps[key]]); } } else if (this.props[key] !== prevProps[key]) { this.data[key] = this.props[key]; this.setData(this.data); } } } Component(options); } export { VantComponent }; 复制代码
到这里主要的问题解决了,其他一些微信小程序到支付宝小程序的差异就不都列出来了,可以灵活的修改wwto 的代码来实现转换时的差异, 如果后期有同样需求的同学尝试转换时有遇见问题,也可留言交流。
最初在决定到底要不要使用wwto这个工具来转换微信小程序的时候,心里面也是没底的,毕竟刚开源,我估计是第一个在刚开源的时候来做转换的。而且本身也从未开发过支付宝小程序,也不知道支付宝小程序和微信小程序有哪些大致的差异,再加上调研技术上也没有给充裕的时间来决策到底用什么方案来实现其他平台的小程序。最后决定使用wwto来做这件事,主要是不想做重复的工作,对仅仅使用新的技术框架uni-app来重写,估计对我来说短期也不会有太多的技术积累收益,当然同时呢我也想快速的了解微信和支付宝的一些差异,重要的一点就是wwto开源的,每个部分的源代码都能debug。综合以上几点于是决定来趟一下这个浑水,总的结果来说,项目周期缩短了好多,大致花了两周的时间来完成了,也了解了支付宝小程序和微信小程序之间的一些差异,解决了好多问题,解决问题的过程中也很头疼...
最后转换后的效果图就不给出了,欢迎在微信和支付宝中搜索: “咔咔找车” 小程序看两者差异。