- 一键完成小程序国际化
随着小程序使用的人数增长,小程序管理后台陆续收到非中文母语的用户要求支持英文的请求。小程序一开始是直接在程序里使用中文字符串的方式,要做国际化只能把这些硬编码中文的地方全部替换为i18n调用。写了个脚本跑了下,发现小程序里涉及到需要转换硬编码的地方有2000+处。。。手动修改不太科学,于是写了个名为mina-i18n的工具,用于小程序的国际化转换,有兴趣的同学可以用自己的小程序项目实验一下: npm install -g mina-i18n mina-i18n /path/to/origin/mina/project /path/to/i18n/mina/project工具主要包含对JS和WXML两种文件的处理,JS使用babel处理,WXML使用htmlparser库处理,json和css由于没法使用函数调用,这个需要用户自行处理了。 JS处理: JS文件的处理思路:找到中文字符串,将其改为以中文字符串为参数的i18n函数调用。我们的目标是一键转换后可以直接运行,而替换的行为发生在任何一个文件里,所以我们需要一个全局可访问的函数。在小程序里,有两个可以全局访问的变量,一个是getApp(),一个是wx,为了代码的简洁性,我们决定在wx上挂载一个L函数作为全局i18n函数。即: "中文" 转换为 wx._t("中文")直接使用正则表达式匹配是无法做到的符合语法的替换,这里使用babel完成保留程序上下文的替换,替换逻辑写在babel 的插件里的 StringLiteral 的回调里(所有JS程序里解析到的字符串都会进入这个回调),代码如下: const visitor = { StringLiteral(path) { const parentPath = path.parent // 每个中文字符串只处理一次,识别到已处理过就退出,不然会死循环 if (t.isCallExpression(parentPath)) { const callee = parentPath.callee if (t.isMemberExpression(callee)) { const { object, property } = callee if ( t.isIdentifier(object) && object.name === MINA_I18N_JS_FUNCTION_CALLEE && property.name === MINA_I18N_FUNCTION_NAME ) { return } } } const reg = /\s*[\u4E00-\u9FA5]+\s*/g const stringValue = path.node.value if (reg.test(stringValue)) { path.replaceWith(t.CallExpression( t.MemberExpression( t.identifier(MINA_I18N_JS_FUNCTION_CALLEE), t.identifier(MINA_I18N_FUNCTION_NAME) ), [t.stringLiteral(stringValue)] )) } } } WXML处理: 对WXML的处理思路与JS类似,就是找出WXML文件里的中文文本,替换为以文本为参数的i18n函数调用。在小程序里使用函数调用需要用到 WXS语言 ,这个语言无法使用小程序API,只有极少数的类库,反正你当成是个纯运算逻辑的WXML语法助手就行。因为WXS语言没法从外部获取数据,或者WXML里的i18n函数调用只能是类似 i18nFunc("中文","en") 这种形式,就是说具体要转为哪种语言,必须明确地告知WXS的函数。一般用户的语言都是从系统信息中获取,或者用户手动选择后存在服务端,这个语言的变量信息我们在JS里可以获取,而且这个变量每个页面都会用到,而WXML里是没法访问到getApp()和wx这些全局变量的,我们只能在每个页面的Page函数的data变量里加上这个语言变量Lang,才能在每个WXML里使用类似 i18nFunc("中文", Lang) 的形式来做文本替换。处理代码如下: const visitor = { ObjectProperty(path, state) { if (path.node.key.name === 'data') { const parentPath = path.parentPath || {} const parentParentPath = parentPath.parentPath || {} const node = parentParentPath.node if ( t.isObjectExpression(parentPath) && t.isCallExpression(parentParentPath) && node && node.callee && t.isIdentifier(node.callee) && node.callee.name === 'Page' ) { // 变量插入只处理一次,识别到已处理过就退出,不然会死循环 if ( t.isCallExpression(path.node.value) && t.isObjectExpression(path.node.value.arguments[0]) && path.node.value.arguments[0].properties[0].key.name === MINA_I18N_LANG_NAME ) { return } path.replaceWith( t.objectProperty( path.node.key, t.CallExpression( t.MemberExpression( t.identifier('Object'), t.identifier('assign') ), [ t.objectExpression([ t.objectProperty( t.identifier(MINA_I18N_LANG_NAME), t.CallExpression( t.MemberExpression( t.identifier(MINA_I18N_JS_FUNCTION_CALLEE), t.identifier(MINA_I18N_GETLANG_FUNCTION_NAME) ), [] ) ) ]), path.node.value ] ) ) ) } } } } 有了可在WXML里运行的 i18n函数,接下来就是对WXML源文件里的中文文本进行替换。WXML的转换处理要比JS复杂一些,处理JS的时候babel帮我们做好了语法树的解析、遍历和代码生成过程,我们只要实现语法树访问的回调就行,WXML就没有这么好用的工具了,所以需要自己做多点工作。WXML的解析用了 htmlparser2 ,这个库会返回给你一棵DOM树,按着根节点就能遍历整棵树。不过在实际解析生成WXML的时候,发现这个库有个地方没法满足,就是它解析出来的节点属性无法区分开是为空还是 boolean attributes ,也就是说<view hidden></view>和<view hidden=""></view>解析出来的结果都是一样的{hidden: ""},这其实有很大的歧义,因为在小程序里<view hidden></view>会表示这个view的hidden=true,而<view hidden=""></view>这会被解释为hidden=false,因此按照这个解析结果生成的话,就发现原来小程序里应该隐藏的view都被显示了。。。搜了下其他有其他人给作者提过这个问题的 issue ,我看作者的意思大概是这个库不是用来处理序列化或者生成代码这类事的,在github搜了下好像这个库又确实是JS里HTML解析库里排名第一的,于是就自己fork出来 一份,加了个选项来做boolean attributes 的区分。 在做WXML处理的时候,我犯了个错误,就是把处理WXML跟处理HTML这两件事等同起来了,可能之前做过一些HTML相关的处理工作,所以写着写着就很顺地按着原来用正则表达式处理的方式去做了,结果发现在处理节点属性为{{xxx}}的动态属性时总是有edge case处理不到。中间隔了两天去做其他需求后再回来一想,这{{xxx}}里的xxx就是JS代码啊,直接用babel不就完事了吗?困在原来处理HTML的思路里调试了大半天浪费时间。正确的处理WXML的方式就是用htmlparser解析静态文本,解析到的{{xxx}}语法交给babel处理,最后再重新合成WXML代码。这里面还有属性的单双引号处理、uincode与汉字的一些处理等细节,具体就不展开了,代码里都有: function buildWXML(root) { if (Array.isArray(root)) { let xmlString = '' root.forEach(node => { xmlString += buildWXML(node) }) return xmlString } else if (root.type === 'text') { const text = root.data return processMinaTemplateText(text) } else if (root.type === 'tag') { const tagName = root.name.replace('wx-', '') let tagString = `<${tagName}` const attr = root.attribs || {} Object.keys(attr).forEach(key => { if (attr[key] === null) { tagString += ` ${key}` } else { const attrValue = processMinaTemplateText(attr[key], { isAttrValue: true }) tagString += ` ${key}="${attrValue}"` } }) const children = root.children || [] if (isSelfCloseTag(tagName) && children.length === 0) { tagString += '/>' } else { tagString += '>' children.forEach(node => { tagString += buildWXML(node) }) tagString += `</${tagName}>` } return tagString } else if (root.type === 'comment') { return `<!--${root.data}-->` } else { return '' } } function processMinaTemplateText(text, options = {}) { const textArray = text.split(/({{[^}]*}})/g) let returnText = '' textArray.forEach(item => { if (item.startsWith('{{') && item.endsWith('}}')) { let expression = item.substring(2, item.length - 2) returnText += '{{' + processMinaTemplateExpression(expression) + '}}' } else { returnText += processPlainText(item) } }) return returnText } function processMinaTemplateExpression(code) { const visitor = { StringLiteral(path) { const parentPath = path.parent if (t.isCallExpression(parentPath)) { const callee = parentPath.callee if (t.isIdentifier(callee) && callee.name === MINA_I18N_FUNCTION_NAME) { return } } const reg = /\s*[\u4E00-\u9FA5]+\s*/g const stringValue = path.node.value if (reg.test(stringValue)) { path.replaceWith(t.CallExpression( t.identifier(MINA_I18N_FUNCTION_NAME), [t.stringLiteral(stringValue), t.identifier(MINA_I18N_LANG_NAME)] )) createI18NData(stringValue) } } } try { const result = babel.transform(code, { plugins: [ { visitor } ], generatorOpts: { quotes: 'single', compact: false } }) const i18nScriptContent = transformText(toSingleQuotes(result.code)) return i18nScriptContent.replace(/[\r\n]+$/g, '').replace(/^;/g, '').replace(/;$/g, '') } catch (e) { console.log(`parser expression : ${code}, error: ${JSON.stringify(e)}`) return code } } 翻译:一开始做这个工具的目的,就是要做到一键转换后就可以在微信开发者工具里跑起来。经过上面两步的处理,我们已经把项目里出现过的所有中文字符串都提取并替换,接下来我们就要实现 i18n 翻译函数。 最简单的是简体和繁体的转换,因为已经有 opencc 这个优秀的开源库可以处理 。 const OpenCC = require('opencc') const opencc = new OpenCC() const hant_text = opencc.convertSync(text) 中英文的翻译没法离线,只能找线上服务。在找了google,有道,金山,必应,百度等翻译服务后,发现微软的必应是最方便的,不需要申请token或者去对抗防爬虫,翻译效果也不错,非常适合用在一个可以无条件使用的工具里,接口调用非常简洁: request.post({ url: 'https://cn.bing.com/ttranslatev3', form: { fromLang: 'zh-Hans', to: 'en', text: text }, json: true, timeout: 2000 }).then(res => { return res[0].translations[0].text }) 绝大多数中文项目的i18n做到繁体+英文就够了,外贸项目等特殊情况的话,改一下调用bing API的语言参数就行,bing支持多种语言翻译。 至此,工具也算基本完成了,使用了自己小程序和github上找的几个开源小程序项目做测试,基本都是一键转换后就能直接在开发者工具上跑的,真机预览也没出问题。有兴趣的同学可以在自己项目的小程序上跑跑看。我看了下日常使用的各个小程序,可以说几乎100%都是没有提供切换语言选项的,万一以后有需求的话,希望这个工具可以方便到大家。 对源码有兴趣的同学可以到 github 看一下,如果转换的小程序跑起来有错误的话也欢迎提issue,我会尽快解决的。
2019-08-13 - 基于加速度计判断横竖屏
也许有人会问,小程序中都是竖直app形态,要横竖屏判断有什么用?即使判断出了横屏状态,你能把小程序横过来?答案是不能的,但是判断当前设备处于横屏或者竖屏状态来实现一些友好的用户体验交互方式的需求确实存在。例如手机横屏,让视频播放自动全屏,手机竖屏,让视频切换回来小屏。 然而,截止至目前,小程序官方的API中并没有提供这样的横竖屏判断的方法。那么我们只能自己想办法实现这样的判断。小程序的设备API中提供了加速度计的监听方法,使用方法如下: [代码]wx.onAccelerometerChange([代码][代码]function[代码][代码](res) {[代码] [代码] [代码][代码]console.log(res.x)[代码] [代码] [代码][代码]console.log(res.y)[代码] [代码] [代码][代码]console.log(res.z)[代码] [代码]})[代码] 加速度计的三轴以下是一般移动设备的加速度计三轴坐标系示例图: [图片] 以手机竖直面向用户为例,加速计的三轴坐标系统的X、Y、Z轴定义如下: 沿着手机屏幕顶部向上是Y轴正方向,向下是Y轴负方向; 当手机顶部朝上时,沿着手机屏幕向右是X轴正方向,向左是X轴负方向; 正对手机时,垂直屏幕向外是Z轴正方向,垂直屏幕向里是Z轴负方向; 当手机处于静止状态时,手机此时只受一个重力加速度(1g=9.8m/s²)的作用,加速度计返回的res.x、res.y、res.z的值就是设备的三轴受到的加速度的值,取值范围从[-1g,1g]。设备以不同方式放置时,x/y/z的值如下: [图片] 计算姿态角在stackoverflow上找到了根据加速度计三轴的值计算姿态角公式(https://stackoverflow.com/questions/3755059/3d-accelerometer-calculate-the-orientation),经过结合设备的三轴坐标方向对公式进行调整,最终得出了公式: [代码]Pitch = atan2(Y, Z) * 180/M_PI;Roll = atan2(-X, sqrt(Y*Y + Z*Z)) * 180/M_PI;[代码] [代码][代码]Roll = atan2(-X, sqrt(Y*Y + Z*Z)) * 180/M_PI;[代码][代码] Roll(绕Y轴旋转的角度) 当设备绕着自身Y轴旋转时(表示手机左侧或右侧翘起的角度),该角度值将会发生变化,取值范围是-90到90度。 Pitch(绕X轴旋转的角度) 当手机绕着自身的Y轴旋转(表示手机顶部或尾部翘起的角度),该角度会发生变化,值的范围是-180到180度。 [图片] 接下来就是根据自己对横竖屏角度的观测,再结合微信小程序中,视频全屏只能以手机向左旋转方式全屏的特性,只对用户左侧横屏判断为横屏状态,实现代码片段如下: [代码] // 0为竖屏,1为横屏[代码][代码] [代码][代码]let lastState = 0;[代码][代码] [代码][代码]let lastTime = Date.now();[代码] [代码] [代码][代码]wx.startAccelerometer();[代码] [代码] [代码][代码]wx.onAccelerometerChange((res) => {[代码][代码] [代码][代码]const now = Date.now();[代码][代码] [代码] [代码] [代码][代码]// 500ms检测一次[代码][代码] [代码][代码]if[代码] [代码](now - lastTime < 500) {[代码][代码] [代码][代码]return[代码][代码];[代码][代码] [代码][代码]}[代码][代码] [代码][代码]lastTime = now;[代码] [代码] [代码][代码]let nowState;[代码] [代码] [代码][代码]// 57.3 = 180 / Math.PI[代码][代码] [代码][代码]const Roll = Math.atan2(-res.x, Math.sqrt(res.y * res.y + res.z * res.z)) * 57.3;[代码][代码] [代码][代码]const Pitch = Math.atan2(res.y, res.z) * 57.3;[代码] [代码] [代码][代码]// console.log('Roll: ' + Roll, 'Pitch: ' + Pitch)[代码] [代码] [代码][代码]// 横屏状态[代码][代码] [代码][代码]if[代码] [代码](Roll > 50) {[代码][代码] [代码][代码]if[代码] [代码]((Pitch > -180 && Pitch < -60) || (Pitch > 130)) {[代码][代码] [代码][代码]nowState = 1;[代码][代码] [代码][代码]} [代码][代码]else[代码] [代码]{[代码][代码] [代码][代码]nowState = lastState;[代码][代码] [代码][代码]}[代码] [代码] [代码][代码]} [代码][代码]else[代码] [代码]if[代码] [代码]((Roll > 0 && Roll < 30) || (Roll < 0 && Roll > -30)) {[代码][代码] [代码][代码]let absPitch = Math.abs(Pitch);[代码] [代码] [代码][代码]// 如果手机平躺,保持原状态不变,40容错率[代码][代码] [代码][代码]if[代码] [代码]((absPitch > 140 || absPitch < 40)) {[代码][代码] [代码][代码]nowState = lastState;[代码][代码] [代码][代码]} [代码][代码]else[代码] [代码]if[代码] [代码](Pitch < 0) { [代码][代码]/*收集竖向正立的情况*/[代码][代码] [代码][代码]nowState = 0;[代码][代码] [代码][代码]} [代码][代码]else[代码] [代码]{[代码][代码] [代码][代码]nowState = lastState;[代码][代码] [代码][代码]}[代码][代码] [代码][代码]}[代码][代码] [代码][代码]else[代码] [代码]{[代码][代码] [代码][代码]nowState = lastState;[代码][代码] [代码][代码]}[代码] [代码] [代码][代码]// 状态变化时,触发[代码][代码] [代码][代码]if[代码] [代码](nowState !== lastState) {[代码][代码] [代码][代码]lastState = nowState;[代码][代码] [代码][代码]if[代码] [代码](nowState === 1) {[代码][代码] [代码][代码]console.log([代码][代码]'change:横屏'[代码][代码]);[代码][代码] [代码][代码]} [代码][代码]else[代码] [代码]{[代码][代码] [代码][代码]console.log([代码][代码]'change:竖屏'[代码][代码]);[代码][代码] [代码][代码]}[代码][代码] [代码][代码]}[代码][代码] [代码][代码]});[代码] 然后就可以在横竖屏切换的状态下,去切换视频的横竖屏了 [代码]if[代码] [代码](state === 1) {[代码][代码] [代码][代码]video.requestFullScreen();[代码][代码]} [代码][代码]else[代码] [代码]{[代码][代码] [代码][代码]video.exitFullScreen();[代码][代码]}[代码] 其他 另外,在这里发现小程序的一个小bug,就是当进入一个页面,马上就调用requestFullScreen()方法去拉起视频全屏时,会破坏整个页面的布局,并且再调用全屏方法时,视频就无法再全屏了,像这样: [图片] 所以为了防止用户直接以横屏的状态进入一个视频播放页,而我们的横屏判断检测生效立即触发全屏引发bug,我将监听横竖屏的事件通过setTimeout(listener, 3000)延迟3s监听,这样横屏才不会触发bug。 最后 文中的很多知识点很多都是从网络文章学来,可能存在错误的理解,如有错误,欢迎各位指正。 最后再打个广告,欢迎喜欢看游戏直播的小伙伴来试用我们的《TG电竞》直播小程序,这里聚合各大平台的知名主播,总有一款适合你哦。 [图片]
2018-01-25