- 一键完成小程序国际化
随着小程序使用的人数增长,小程序管理后台陆续收到非中文母语的用户要求支持英文的请求。小程序一开始是直接在程序里使用中文字符串的方式,要做国际化只能把这些硬编码中文的地方全部替换为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 - 微信小程序-封装请求API——promise方式
目录 第一步:在app.js同级目录下,创建一个文件夹 第二步:封装wx.request方法成promise对象 第三步:页面中引用封装的请求API 1.设置基础请求路径 2.解构传入的参数 3.根据不同的url接口添加不同的header 4.添加请求发起时页面loading效果 完整的封装的API 的 js文件 微信小程序原生的请求API就是wx.request [代码]wx.request({ url: 'example.php', //仅为示例,并非真实的接口地址 data: { x: '', y: '' }, header: { 'content-type': 'application/json' // 默认值 }, success (res) { console.log(res.data) } }) [代码] 有时候不能很好的适配我们的开发需求,比如我们要加一些基础url路径、请求前后的loading效果、不同接口名称下的header。而且,在success回调方法里写请求成功后的操作,看起来代码不太清晰。接下来讲一下封装的逻辑,完整代码放在最后。 第一步:在app.js同级目录下,创建一个文件夹 [图片] 在utils文件夹里新建一个service.js文件,用来放封装的wx.request方法 第二步:封装wx.request方法成promise对象 使用promise对象能很好的解决回调地狱,在.then(res=>{}).catch(err=>{})中能很清晰地看出代码的逻辑 [代码]export const 返回出去的方法名 = (parmas) => { // 返回一个promise对象 return new Promise((resolve, reject) => { wx.request({ url: parmas.url, //仅为示例,并非真实的接口地址 data:parmas.data, header: { 'content-type': 'application/json' // 默认值 }, success: (res)=> { // 请求成功,就将成功的数据返回出去 resolve(result) }, fail: (err) => { reject(err) }, }) }) } [代码] 注意:success: (result) => {} 使用箭头函数,防止出现this指向错误 这样,就算是封装了一个最简单、最基础(简陋)的请求API了,在需要使用这个方法的页面的js文件中,引入它 第三步:页面中引用封装的请求API [代码]/** * 小程序中要引用方法,哪个页面要用,就在哪个页面引入 */ import { cjRequest } from "../../utils/service"; Page({ /** * 页面的初始数据 */ data: { .......... .......... getGoodsList () { //因为返回的是promise对象,所以通过.then来获取resolve出来的请求成功的返回数据 cjRequest({ url: "https://xxxtest/goods/search", data: this.QueryParams }) .then((res) => { console.log(res); }) } [代码] 现在,来详细扩展一下封装的请求API 1.设置基础请求路径 [代码]// 基础url const baseUrl = "https://xxxtest" [代码] 这样,就可以简化调用这个方法时url的参数内容了,也方便统一修改开发环境地址、生产环境地址 [代码]// url: "/goods/search" ==》 url中不用再写前面的一长串了 cjRequest({ url: "/goods/search", data: this.QueryParams }).then((res) => { console.log(res); }) [代码] 2.解构传入的参数 我们可以通过ES6中的扩展运算符,将传入到封装方法里的参数直接全部解构出来,不用再一个个获取赋值给对应的键值对了 …parmas 直接解构出传入的参数 [代码]export const 返回出去的方法名 = (parmas) => { //设置基础请求头 const baseUrl = "https://xxxtest" // 返回一个promise对象 return new Promise((resolve, reject) => { /** * ...parmas ===>就是将传进来的参数扩展开,一行行展示在这里面 * 比如:传进来 * { * url:'xx', * data:{key1:val1,key2:val2} * } * 那么通过 ...parmas 就会把这些内容展示到这里了 */ wx.request({ ...parmas, // 注意,此行必须放在 ...parmas 之下,才能覆盖其解构出的,传入的url:xxx参数 url: baseUrl + parmas.url, success: (res)=> { // 请求成功,就将成功的数据返回出去 resolve(result) }, fail: (err) => { reject(err) }, }) }) } [代码] 3.根据不同的url接口添加不同的header header中的Authorization字段一般存储token,用来做身份验证,但有些请求不需要携带token,所以这个封装的API中需要能根据传入的url来判断什么时候该给请求添加上token,什么时候不用可以添加。 [代码]``` export const 返回出去的方法名 = (parmas) => { /** * 根据不同的url接口,来设置不同的header请求头 ** 判断 url中是否带有 /my/ 请求的是私有的路径 带上header token ** { ...parmas.header } ==> 先解构出传进来的header对象,然后再往这个对象里面添加 Authorization字段数据,这样即使有传入header的其他字段也能保留下来 * 如果传入的parmas参数中没有header,那myHeader就是个空的对象 {} 因为啥都没有 */ let myHeader = { ...parmas.header }; //通过includes方法查找字符串中是否包含指定内容,进而判断是否要添加token if (parmas.url.includes("/neddToken/")) { // 往myHeader这个对象里插入键值对 带上Storage中存储的token myHeader["Authorization"] = wx.getStorageSync("token"); } //设置基础请求头 const baseUrl = "https://xxxtest" // 返回一个promise对象 return new Promise((resolve, reject) => { /** * ...parmas ===>就是将传进来的参数扩展开,一行行展示在这里面 * 比如:传进来 * { * url:'xx', * data:{key1:val1,key2:val2} * } * 那么通过 ...parmas 就会把这些内容展示到这里了 */ wx.request({ ...parmas, // 注意,此行必须放在 ...parmas 之下,才能覆盖其解构出的,传入的url:xxx参数 url: baseUrl + parmas.url, /** * !可以设置上默认的content-type,然后再扩展出传入的myHeader,如果传入的myHeader 为空,那header就还是默认的content-type一个键值对 * !{ 'content-type': 'application/json', ...myHeader } ==》 扩展出myHeader这 个对象中的键值对; */ header: { 'content-type': 'application/json', ...myHeader }, success: (res)=> { // 请求成功,就将成功的数据返回出去 resolve(result) }, fail: (err) => { reject(err) }, }) }) [代码] } [代码][代码] 此时,在使用这个封装的API的时候,就可以对header进行设置了,例如: [代码] ``` cjRequest({ url: '/neddToken/home/swiperdata', // 使用的时候也可以传入一些header的字段 header: { 'content-type': 'application/json', 'Date': 'Tue, 15 Nov 2021 08:12:31 GMT' }, method: 'GET', }).then((result) => { console.log(result) }) [代码] [代码][代码] 此时,因为请求url中含有【neddToken】,就会在header中插入token [图片] 此时,这个请求的header中就会添加上token(也就是Authorization这个字段了) [图片] 4.添加请求发起时页面loading效果 当页面在加载数据的时候,最好要有一个loading的提示,同时有遮罩,防止用户乱点 所以,就需要在封装的API中加入微信小程序的wx.showLoading遮罩层了 [代码] // 显示加载中loading效果 wx.showLoading({ title: "加载中", mask: true //开启蒙版遮罩 }); ... ... // 关闭正在等待loading效果 wx.hideLoading(); [代码] 如果直接在封装的API的开始加上loading,在请求结束加上隐藏loading效果,那乍看一下,好像没错,但是细想一下,如果一个页面同时触发了多个请求呢?比如打开一个页面,同时加载多个模块,需要从不同的接口请求数据,那就会使用多次这个封装的API。 此时,就会出现,第一个请求结束,直接关闭了loading效果,而后面几个请求就没有loading效果的遮罩了。 所以,需要在封装的js文件中设置一个全局变量,每次调用这个封装的文件时,就对这个变量++,每次请求结束,返回数据出去的时候,就对这个变量–,最后判断一下这个变量是否为0(也就是所有请求的结束了),在决定是否关闭loading效果 [图片] [图片] 完整的封装的API 的 js文件 [代码]// 同时发送异步代码的次数 let ajaxTimes = 0; export const cjRequest = (parmas) => { // 当有地方调用请求方法的时候,就增加全局变量,用于判断有几个请求了 ajaxTimes++; // 显示加载中loading效果 wx.showLoading({ title: "加载中", mask: true //开启蒙版遮罩 }); /** * 根据不同的url接口,来设置不同的header请求头 ** 判断 url中是否带有 /my/ 请求的是私有的路径 带上header token ** { ...parmas.header } ==> 先解构出传进来的header对象,然后再往这个对象里面添加Authorization字段数据,这样即使有传入header的其他字段也能保留下来 *? 如果传入的parmas参数中没有header,那myHeader就是个空的对象 {} 因为啥都没有 */ let myHeader = { ...parmas.header }; if (parmas.url.includes("/neddToken/")) { // 往myHeader这个对象里插入键值对 带上Storage中存储的token myHeader["Authorization"] = wx.getStorageSync("token"); } // 基础url const baseUrl = "https://api-hmugo-web.itheima.net/api/public/v1" return new Promise((resolve, reject) => { /** * ...parmas ===>就是将传进来的参数扩展开,一行行展示在这里面 * 比如:传进来 * { * url:'xx', * data:{key1:val1,key2:val2} * } * 那么通过 ...parmas 就会把这些内容展示到这里了 */ wx.request({ ...parmas, // 注意,此行必须放在 ...parmas 之下,才能覆盖其传入的url:xxx参数 url: baseUrl + parmas.url, /** * !可以设置上默认的content-type,然后再扩展出传入的myHeader,如果传入的myHeader为空,那header就还是默认的content-type一个键值对 * !{ 'content-type': 'application/json', ...myHeader } ==》 扩展出myHeader这个对象中的键值对; */ header: { 'content-type': 'application/json', ...myHeader }, success: (result) => { // 请求成功,就将成功的数据返回出去 resolve(result) }, fail: (err) => { reject(err) }, // 不管请求成功还是失败,都会触发 complete: () => { /** * !loading效果同时被多个请求触发是可以显示一个的,但是关闭loading一旦被第一个请求完成后关闭,后面的请求触发的loading效果就没了 * !所以,需要通过全局设置一个变量,来监听同时触发了几个请求,当最后一个请求完成后,再关闭loading * ?每次结束请求后,就减少全局变量,当为0时,就表示这是最后一个请求了 */ ajaxTimes--; // 此时就可以关闭loading效果了 if (ajaxTimes === 0) { // 关闭正在等待loading效果 wx.hideLoading(); } } }); }) } [代码] 如果需要按照原生微信请求的格式来封装(调用时要传各种方法、参数),请参考: 微信小程序-封装请求API——原生方式_五速无头怪的博客-CSDN博客[图片]https://blog.csdn.net/black_cat7/article/details/120697607 参考:黑马微信商场小程序 黑马程序员微信小程序开发前端教程_零基础玩转微信小程序_哔哩哔哩_bilibili[图片]https://www.bilibili.com/video/BV1nE41117BQ?p=3
2023-10-18 - 小程序开发起步
学习 5 节课程,从 0 至 1 做第一个属于你的小程序,深入浅出了解小程序开发。本系列视频,由腾讯课堂 NEXT 学院、微信学堂联合出品。
2022-03-24 - 【交互方案】针对不小心触发返回按钮的交互
17年 因为业务上有该类需求,所以提过一个问题。 想实现监听左上角按钮来做其他操作,比如跳转其他页面。 https://developers.weixin.qq.com/community/develop/doc/92f7cdbacdf724cba640955423a8444f 此交互优化只应对表单等内容提交回填的处理 应用场景: 某个表单提交页,页面挺多内容,用户填写完后,不小心触发了左上角返回,或者不小心点了物理按键,那么用户填写的内容就丢失了,那么最开始大家的想法都是,点击左上角返回按钮或者物理按键返回能触发监听我们再弹窗提醒用户是否退出当前页面。这个交互应该是再正常不过的了,在各种app都有见过这种交互。 网页里: h5里都是通过监听popstate,以及设置pushstate实现。 小程序里: 然而小程序的翻遍官方文档,没找到该方法,发帖询问后也是得到官方童鞋回复,不会提供该方法。 官方童鞋给的原因是:会有某些开发者,阻止用户退出某些页面,以达到一些xxx目的,所以官方为了防止开发者滥用,并不打算开放该功能。 翻了社区大家实现方式,有大佬给了一个方案: https://developers.weixin.qq.com/community/develop/article/doc/000844b537c230b04b999a54f56013 该监听方法的缺点: [图片] 最后确实没发现有什么好的监听方案了,那既然代码无法实现,那么我们可以优化用户体验来达到该效果。 实现操作方案如下: [代码]// app.js下跟onLaunch同级新增个globalData字段。 globalData: { formData: {} // 这里需要默认填写该字段,不然其他地方使用了会报错。 } // 首先用户填写任意字段都存储一个对象到globalData下。 <input type="text" placeholder="请输入用户昵称" bindinput="handleUserName" /> handleUserName(e) { getApp().globalData.formData.userName = e.detail.value } [代码] 这样将用户填写的内容都存到globalData下,而我们最初的交互是,存储后用户点击返回下次进来自动回填。 [代码]onShow() { this.setData({ userName: getApp().globalData.formData.userName || '' }) } [代码] 而最终的交互是这样操作: 如果用户填写完一整页内容,而内容我们都存到了globalData下,用户不小心返回了上一页,那么我们在用户重新进入该页面时,判断globalData的formData下是否存在内容,如果存在,弹窗提醒用户是否回填上次填写的内容,如果用户确认回填那么我们给用户自动回填上次填写的信息,如果用户取消回填,那么我们将globalData下的formData设置为空对象即可。 如此 我们即从交互上规避了不小心点击返回导致需要重新输入的问题,并且交互体验得到极大提升。。
2020-06-04 - 教你怎么监听小程序的返回键
更新:2020年7月28日08:51:11 基础库2.12.0起,可以调用wx.enableAlertBeforeUnload监听原生右上角返回、物理返回以及wx.navigateBack时弹框提示 AIP详情请看: https://developers.weixin.qq.com/miniprogram/dev/api/ui/interaction/wx.enableAlertBeforeUnload.html //======================================== 怎么监听小程序的返回键? 应该有很多人想要监听用户的这个动作吧,但是很遗憾,小程序不会给你这个API的,那是不是就没辙了? 幸好我们还可以自定义导航栏,这样一来我们就可以监听用户的这一动作了。 什么?这你已经知道啦? 那好咱们就不说自定义导航栏的返回监听了,说一下物理返回和左滑?右滑?(不管了,反正是滑)返回上一页怎么监听。 监听物理返回 首先说一下这个监听方法的缺点,虽说是监听,但是还是无法真正意义上的监听并拦截来阻止页面跳转,页面还是会返回上一页,而后重新载入刚刚的页面,如果这不是你想要的,那可以不用往下看了 其次说一下用到什么东西: wx.onAppRoute、wx.showModal 最后是一些主要代码: 重写wx.showModal,主要是加个confirmStay参数和使wx.showModal Promise化 [代码]const { showModal } = wx; Object.defineProperty(wx, 'showModal', { configurable: false, // 是否可以配置 enumerable: false, // 是否可迭代 writable: false, // 是否可重写 value(...param) { return new Promise(function (rs, rj) { let { success, fail, complete, confirmStay } = param[0] param[0].success = (res) => { res.navBack = (res.confirm && !confirmStay) || (res.cancel && confirmStay) wx.setStorageSync('showBackModal', !res.navBack) success && success(res) rs(res) } param[0].fail = (res) => { fail && fail(res) rj(res) } param[0].complete = (res) => { complete && complete(res) (res.confirm || res.cancel) ? rs(res) : rj(res) } return showModal.apply(this, param); // 原样移交函数参数和this }.bind(this)) } }); [代码] 使用wx.onAppRoute实现返回原来的页面 [代码]wx.onAppRoute(function (res) { var a = getApp(), ps = getCurrentPages(), t = ps[ps.length - 1], b = a && a.globalData && a.globalData.pageBeforeBacks || {}, c = a && a.globalData && a.globalData.lastPage || {} if (res.openType == 'navigateBack') { var showBackModal = wx.getStorageSync('showBackModal') if (c.route && showBackModal && typeof b[c.route] == 'function') { wx.navigateTo({ url: '/' + c.route + '?useCache=1', }) b[c.route]().then(res => { if (res.navBack){ a.globalData.pageBeforeBacks = {} wx.navigateBack({ delta: 1 }) } }) } } else if (res.openType == 'navigateTo' || res.openType == 'redirectTo') { if (!a.hasOwnProperty('globalData')) a.globalData = {} if (!a.globalData.hasOwnProperty('lastPage')) a.globalData.lastPage = {} if (!a.globalData.hasOwnProperty('pageBeforeBacks')) a.globalData.pageBeforeBacks = {} if (ps.length >= 2 && t.onBeforeBack && typeof t.onBeforeBack == 'function') { let { onUnload } = t wx.setStorageSync('showBackModal', !0) t.onUnload = function () { a.globalData.lastPage = { route: t.route, data: t.data } onUnload() } } t.onBeforeBack && typeof t.onBeforeBack == 'function' && (a.globalData.pageBeforeBacks[t.route] = t.onBeforeBack) } }) [代码] 改造Page [代码]const myPage = Page Page = function(e){ let { onLoad, onShow, onUnload } = e e.onLoad = (() => { return function (res) { this.app = getApp() this.app.globalData = this.app.globalData || {} let reinit = () => { if (this.app.globalData.lastPage && this.app.globalData.lastPage.route == this.route) { this.app.globalData.lastPage.data && this.setData(this.app.globalData.lastPage.data) Object.assign(this, this.app.globalData.lastPage.syncProps || {}) } } this.useCache = res.useCache res.useCache ? reinit() : (onLoad && onLoad.call(this, res)) } })() e.onShow = (() => { return function (res) { !this.useCache && onShow && onShow.call(this, res) } })() e.onUnload = (() => { return function (res) { this.app.globalData = Object.assign(this.app.globalData || {}, { lastPage: this }) onUnload && onUnload.call(this, res) } })() return myPage.call(this, e) } [代码] 在需要监听的页面加个onBeforeBack方法,方法返回Promise化的wx.showModal [代码]onBeforeBack: function () { return wx.showModal({ title: '提示', content: '信息尚未保存,确定要返回吗?', confirmStay: !1 //结合content意思,点击确定按钮,是否留在原来页面,confirmStay默认false }) } [代码] 运行测试,Oj8K 是不是很简单,马上去试试水吧,效果图就不放了,静态图也看不出效果,动态图懒得弄,想看效果的自己运行代码片段吧 代码片段 https://developers.weixin.qq.com/s/hc2tyrmw79hg
2020-07-28 - (6)微信登录能力优化
小程序和小游戏内的用户登录,我们推荐使用以下两种方式获取用户信息: ▷ 按钮组件的登录方式,用户主动点击按钮可以拉起用户授权弹框,获取用户头像、昵称等信息; ▷ 在不获取用户信息的情况下,可展示用户头像昵称。 用户在没有任何操作的情况直接弹出授权的登录方式将逐渐不再支持,受影响的有 wx.getUserInfo 接口,以及 wx.authorize 接口传入 scope="scope.userInfo" 的情况。 1 为什么平台要做接口调整? 我们提供了 wx.login 和 wx.getUserInfo 接口,用于获取用户的 openID 和基本信息。 推出这两个接口的初衷是希望:当用户使用小程序时,只有访问到真正需要登录的页面,才需要授权并登录。 但在实际应用中,我们发现很多开发者在打开小程序时直接弹出授权框,如果用户点击拒绝授权,无法使用小程序。 在没有任何提示和背景的情况下,直接弹框想要获得用户信息授权,用户脑子里可能会闪过几个哲学问题: 你是谁? 我在哪里? 我为什么要同意? …… 相当一部分用户下意识会点击“拒绝“授权——这样不合理的登录流程既造成了用户的困扰,还使得用户流失。 用户通过小程序可以快速获取服务,因此在访问小程序的第一个页面非常重要。 对于一个互联网产品而言,第一个页面决定了用户对这个产品的认知,用户会选择是否继续使用这个产品。 一个优秀的互联网产品,能够给用户留下一个好的第一印象,用户可以快速了解你的产品,接收到你想要传递的服务信息,从而产生相应的操作行为。 一个优秀的小程序会吸引用户在小程序里进行探索,完成你期望他们去做的事,比如会员注册、商品购买等。 试想一下如果一个品牌的商品官网,一进入要求用户登录才能查看产品信息是什么感觉呢? 因此良好的用户登录体验非常重要。 2 如何设计登录流程? 用户打开小程序时,看第一眼的时候,开发者需要专注以下两个目标: ▷ 精准快速地传达产品理念,开发者要让用户能够快速了解自己的产品和服务; ▷ 将用户流量进行转化,让用户能方便操作或者交易。 一般而言,用户打开小程序后看到的第一个页面,先不要直接弹出授权框,第一个页面可以包含以下内容: ▷ 展示你的小程序功能(如产品、服务、活动等) ,让用户清晰地知道小程序是做什么用的,这些内容可以是你的精选内容; ▷ 激发用户的探索欲,通过描述或者图片吸引用户注意力; ▷ 按照自己的产品目标,给用户提供清晰明确的下一步操作(查看详情、购买等)。 如果某些特殊小程序在使用前一定需要用户登录,或者已经进行到需要用户登录的操作时,可以将 button 组件(其中 open-type 属性指定为 getUserInfo)放置到页面中,页面上可以大致说明以下要点: 为什么需要我授权? 需要我什么信息? 授权后我得到什么好处呢? 接下来在页面上放置一个明显的登录按钮, 建议这个页面上不要有额外的点击区域,以免分散用户注意力,让用户专注于登录这件事情。 3 简单的开发建议 1 当用户打开小程序时访问第一个页面时,先通过 wx.login,获取用户 openID 。这时无需弹框授权,开发者拿到 openID 可以建立自身的帐号 ID。 2 在第一步中,拿到 openID 后,判断是新用户还是老用户。如果是老用户,可以直接登录;如果是新用户,可先在小程序首页展示你的信息服务,让用户对这个小程序有大概的了解,再引导用户进行下一步的操作。 3 当需要获取用户头像昵称的时候,对用户展示一个登录页面,这个页面只有一个最重要的操作,引导用户进行登录。 小程序中,在页面中加入一个 button 按钮,并将 open-type 属性设置为 getUserInfo 。 以小程序为例: 微信登录 对于功能较简单的小程序或者小游戏而言,如果不是必须要获得用户的头像昵称,建议可先通过wx.login 拿到 openID 后,使用 open-data 方式或者开放数据域的方式展示用户信息,整个过程都无需用户授权。 Tips: 1、在用户登录后,开发者需要存储用户的 unionID,而且建议只把 unionID 作为互通的用户标识,不要直接使用 unionID 作为用户 ID。因为一旦小程序迁移到其他的开放平台下,unionID 是会改变的,而 openID 是不变的。 2、用 button 组件的方式获得用户授权后,调用 wx.getUserInfo 就可以直接获取用户信息。这个的意义在于获取过一次之后,用户有可能改昵称头像,因此为了及时同步,最好是定期获取用户信息。 这里两个小提示: ▷ 定期使用 wx.getUserInfo 获取并更新用户的信息; ▷ 如果用户授权过一次之后,又在设置中关掉了授权(或者本地删除了小程序),那这时再调用 wx.getUserInfo 也是不会成功的,需要重新获得授权。 相关开发文档参考: ▷ 小程序 1、小程序 wx.login 2、button 组件,并将 open-type 指定为 getUserInfo 类型,获取用户基本信息 3、open-data 展示用户基本信息 ▷ 小游戏 1、小游戏 wx.login 2、用户信息按钮 UserInfoButton 3、开放数据域下的展示用户信息
2018-08-17 - (4)获取用户信息
背景 我们发现大部分小程序都会使用 [代码]wx.getUserInfo[代码] 接口,来获取用户信息。原本设计这个接口时,我们希望开发者在真正需要用户信息的情况下才去调取这个接口,但很多开发者会直接调用这个接口,导致用户在使用小程序的时候产生困扰,归结起来有几点: 开发者在小程序首页直接调用 [代码]wx.getUserInfo[代码] 进行授权,弹框获取用户信息,会使得一部分用户点击“拒绝”按钮。 在开发者没有处理用户拒绝弹框的情况下,用户必须授权头像昵称等信息才能继续使用小程序,会导致某些用户放弃使用该小程序。 用户没有很好的方式重新授权,尽管我们增加了[代码]设置[代码]页面,可以让用户选择重新授权,但很多用户并不知道可以这么操作。 此外,我们发现开发者默认将 [代码]wx.login[代码] 和 [代码]wx.getUserInfo[代码] 绑定使用,这个是由于我们一开始的设计缺陷和实例代码导致的([代码]wx.getUserInfo[代码] 必须通过 [代码]wx.login[代码] 在后台生成 [代码]session_key[代码]后才能调用)。同时,我们收到开发者的反馈,希望用户进入小程序首页便能获取到用户的 [代码]unionId[代码],以便识别到用户是否以前关注了同主体公众号或使用过同主体的App 。 为了解决以上问题,针对获取用户信息我们更新了三个能力: 1.使用组件来获取用户信息 2.若用户满足一定条件,则可以用[代码]wx.login[代码] 获取到的[代码]code[代码]直接换到[代码]unionId[代码] 3.[代码]wx.getUserInfo[代码] 不需要依赖 [代码]wx.login[代码] 就能调用得到数据 获取用户信息组件介绍 [代码][代码] 组件变化: [代码]open-type [代码]属性增加 [代码]getUserInfo[代码] :用户点击时候会触发 [代码]bindgetuserinfo[代码] 事件。 新增事件 [代码]bindgetuserinfo[代码] :当 [代码]open-type[代码]为 [代码]getUserInfo[代码] 时,用户点击会触发。可以从事件返回参数的 [代码]detail[代码] 字段中获取到和 [代码]wx.getUserInfo[代码] 返回参数相同的数据。 示例: [代码]<button open-type="getUserInfo" bindgetuserinfo="userInfoHandler"> Click me button>[代码]和 [代码]wx.getUserInfo[代码] 不同之处在于: 1.API [代码]wx.getUserInfo[代码] 只会弹一次框,用户拒绝授权之后,再次调用将不会弹框; 2.组件 [代码][代码][代码][代码] 由于是用户主动触发,不受弹框次数限制,只要用户没有授权,都会再次弹框。 通过获取用户信息的组件,就可以解决用户再次授权的问题。 直接获取unionId开发者申请 [代码]userinfo[代码] 授权主要为了获取 [代码]unionid[代码],我们鼓励开发者在不骚扰用户的情况下合理获得[代码]unionid[代码],而仅在必要时才向用户弹窗申请使用昵称头像。为此,凡使用“获取用户信息组件”获取用户昵称头像的小程序,在满足以下全部条件时,将可以静默获得 [代码]unionid[代码]: 1.在微信开放平台下存在同主体的App、公众号、小程序。 2.用户关注了某个相同主体公众号,或曾经在某个相同主体App、公众号上进行过微信登录授权。 这样可让其他同主体的App、公众号、小程序的开发者快速获得已有用户的数据。 不依赖登录的用户信息获取某些工具类的轻量小程序不需要登录行为,但是也想获取用户信息,那么就可以在 [代码]wx.getUserInfo[代码] 的时候加一个参数 [代码]withCredentials: false[代码] 直接获取到用户信息,可以少一次网络请求。 这样可以在不给用户弹窗授权的情况下直接展示用户的信息。 最佳实践 1.调用 [代码]wx.login[代码] 获取 [代码]code[代码],然后从微信后端换取到 [代码]session_key[代码],用于解密 [代码]getUserInfo[代码]返回的敏感数据。 2.使用 [代码]wx.getSetting[代码] 获取用户的授权情况 1) 如果用户已经授权,直接调用 API [代码]wx.getUserInfo[代码] 获取用户最新的信息; 2) 用户未授权,在界面中显示一个按钮提示用户登入,当用户点击并授权后就获取到用户的最新信息。 3.获取到用户数据后可以进行展示或者发送给自己的后端。 One More Thing 除了获取用户方案介绍之外,再聊一聊很多初次接触微信小程序的开发者所不容易理解的一些概念: 1.关于OpenId和UnionId [代码]OpenId[代码] 是一个用户对于一个小程序/公众号的标识,开发者可以通过这个标识识别出用户。 [代码]UnionId[代码] 是一个用户对于同主体微信小程序/公众号/APP的标识,开发者需要在微信开放平台下绑定相同账号的主体。开发者可通过[代码]UnionId[代码],实现多个小程序、公众号、甚至APP 之间的数据互通了。 同一个用户的这两个 ID 对于同一个小程序来说是永久不变的,就算用户删了小程序,下次用户进入小程序,开发者依旧可以通过后台的记录标识出来。 2.关于 getUserInfo 和 login 很多开发者会把 [代码]login[代码] 和 [代码]getUserInfo[代码] 捆绑调用当成登录使用,其实 [代码]login[代码] 已经可以完成登录,[代码]getUserInfo[代码] 只是获取额外的用户信息。 在 [代码]login[代码] 获取到 [代码]code[代码] 后,会发送到开发者后端,开发者后端通过接口去微信后端换取到 [代码]openid[代码] 和[代码]sessionKey[代码](现在会将 [代码]unionid[代码] 也一并返回)后,把自定义登录态 [代码]3rd_session[代码]返回给前端,就已经完成登录行为了。而 [代码]login[代码] 行为是静默,不必授权的,用户不会察觉。 [代码]getUserInfo[代码] 只是为了提供更优质的服务而存在,比如展示头像昵称,判断性别,开发者可通过 [代码]unionId[代码] 和其他公众号上已有的用户画像结合来提供历史数据。因此开发者不必在用户刚刚进入小程序的时候就强制要求授权。 可以在官方的文档中看到 [代码]login[代码] 的最佳实践: [图片] Q & A Q1: 为什么 login 的时候不直接返回 openid,而是要用这么复杂的方式来经过后台好几层处理之后才能拿到? A: 为了防止坏人在网络链路上做手脚,所以小程序端请求开发者服务器的的请求都需要二次验证才是可信的。因为我们采取了小程序端只给 [代码]code[代码] ,由服务器端拿着 [代码]code[代码] 和 [代码]AppSecrect[代码] 去微信服务器请求的方式,才会给到开发者对应的[代码]openId[代码] 和用于加解密的 [代码]session_key。[代码] Q2: 既然用户的[代码]openId[代码] 是永远不变的,那么开发者可以使用[代码]openId[代码] 作为用户的登录态么? A: 不行,这是非常危险的行为。因为 [代码]openId[代码] 是不变的,如果有坏人拿着别人的 [代码]openId[代码] 来进行请求,那么就会出现冒充的情况。所以我们建议开发者可以自己在后台生成一个拥有有效期的 [代码]第三方session[代码] 来做登录态,用户每隔一段时间都需要进行更新以保障数据的安全性。 Q3: 是不是用户每次打开小程序都需要重新[代码]login[代码]? A: 不必,可以将登录态存入[代码]storage[代码]中,用户再次登录就可以拿[代码]storage[代码] 里的登录态做正常的业务请求,只有当登录态过期了之后才需要重新[代码]login[代码] 。这样子做一则可以减少用户等待时间,二则可以减少网络带宽。 目前微信的[代码]session_key[代码] 有效期是三天,所以建议开发者设置的登录态有效期要小于这个值。
2018-08-17 - 微信小程序Page间数据传递解决方案分析
内容概要 利用微信小程序的路由堆栈信息解决小程序内Page间的数据(或事件)传递的问题。通过对各种方案的对比、分析、总结,得出我们的升华版解决方案,满足你的不满足。 场景再现 工作中我们经常会遇到B页面需要A页面内的部分或全部数据;C页面内的一个函数执行完之后需要改变B页面内的显示样式;也或者是A和B两个页面用到了同样的网络数据,在其中一个页面做出修改后另一个页面也要随之改变以保证回传服务器时数据的准确性,等等诸如此类的页面间数据传递的问题。 现有方案梳理 当然针对上面场景中各种问题我们可以有很多种的解决方案。例如利用微信API中reLaunch、redirectTo、navigateTo 函数的url路径携带参数到目标页面;利用EventChannel信道实现打开页面与被打开页面通信;利用全局文件共享数据;利用路由堆栈获取目标页面实例等等,我目前用过的方案就以上四种,如果你用过其他方案也可以提出来我们一起讨论学习。接下来我们就针对以上四种方案进行一个简略的分析。 方案一:reLaunch、redirectTo、navigateTo 函数url路径携带参数到目标页面 这种方案大家应该都用过,在页面跳转过程中传递一些简单的数据还是十分方便的,但是它的缺点也很明显,在五个路由函数中只有三个可以在跳转路径上携带参数,而且参数不可以是对象类型,在遇到携带"?"等特殊字符的参数还需要进行转码操作。对于跨多个页面的数据传递比较繁琐。归纳如下: 优点:操作简单方便。 缺点:单项传递;复杂数据传递局限性较大;跨多页面数据传递繁琐;只能在reLaunch、redirectTo、navigateTo 三个函数中使用。 适用情况:页面间简单数据的单项传递。 方案二:EventChannel信道实现打开页面与被打开页面通信 从基础库2.7.3开始支持EventChannel。开发者可以通过navigateTo路由函数在页面跳转切换过程中自定义两个页面的数据交互函数。至于EventChannel自定义规则可参考微信API中的 navigateTo 函数。该方案解决了方案一中的复杂数据传递的局限性。可是它仅限于navigateTo函数中使用,并且在跨多页面传递时操作起来复杂性仍然很高。归纳如下: 优点:使用灵活性较高;可传递较复杂数据;可双向传递; 缺点:跨多页面数据传递繁琐;只能在navigateTo一个函数中使用。 适用情况:由navigateTo控制路由的两个页面间数据传递。 方案三:全局文件共享数据 全局共享数据无非就是定义一份谁都可以使用和修改的数据。这个方案很简单,而且很轻松的解决了方案一和方案二中的跨多个页面的数据传递问题。可是数据处理的及时性却大打折扣,只能期待各个页面自己触发自身的事件。归纳如下: 优点:实现简单,可跨多页面。 缺点:及时性欠缺,灵活度不够。 适用情况:不要求事件的及时性和功能比较集中的模块。 方案四:利用路由堆栈获取目标页面实例 兵法中常说“擒贼先擒王”,如果我们拿到了某个页面的实例索引,那就相当于是在战场上控制了敌方的将领,我们说要粮草他就得给粮草,我们说要兵器他就得乖乖的给兵器。所以该方案我们也可以戏称为“擒王方案”。如果我们给“擒王方案”做一个归纳的话,应该是这样的: 优点:灵活性/及时性高;数据类型不限;可跨多页面使用; 缺点:代码重复性较高;不在堆栈内的页面无法进行操作; 适用情况: 确定页面实例在堆栈内的交互性比较强多页面。 提炼升华 在上一节中我们对四个方案进行了一下简单的梳理,每个方案也各有优缺点,上述四种方案可能已经满足了我们工作中的使用,可是作为程序员的我们不应该停下追逐更优更好的脚步。我们尽量把上述方案的优点集中起来,并且规避掉缺点。整理出一个相对完善的方案。首先自定义一个跨页面(当然也可在页面内使用)的事件处理类,暂时命名其为funbus。具体的处理逻辑如下: 一:定义全局事件缓存Map。 [代码]// 事件缓存 const events = {}; [代码] 二:根据 getCurrentPages() 函数获取被操作页面实例。 [代码]/** * 同步执行,会立即执行并拿到被执行函数的返回结果 * * pagePath 页面名称或路径 * method 执行的方法名 * params 方法参数 */ function callFun(pagePath, method, params) { let pages = getCurrentPages(); let page = null; for (let i = 0; i < pages.length; i++) { if (pages[i].route.indexOf(pagePath) > -1) { page = pages[i]; break; } } if (page) { try { return page[method](...params); } catch (err) { console.error('FunBus Error: ', err); return null; } } return null; } [代码] 三:当然我们也需要处理如果被操作的页面不在堆栈内的情况。 1.事件注册和解绑。 [代码]/** * 注册事件 * * key 值命名规则 页面名称-描述 (如: index-refresh) * event 是一个对象: {path: '被注册事件发生的页面路径', method: '被注册的方法名称', params: [...被注册的方法需要的参数]} * * 调用范例: subscribe("index-refresh", {path:"pages/index", method:"refreshPage", params: [1,2,3]}); */ function subscribe(key, event) { events[key] = event; } /** * 解除绑定 */ function unSubscribe(key) { delete events[key]; } [代码] 2.之前未在堆栈内的页面加入到堆栈时(即展示到前台时)在适当时机触发之前缓存的事件。 [代码]/** * 唤醒/执行之前订阅的事件 */ function notifyEvent(key, remove) { let event = events[key]; if (event) { // 只有remove的值是布尔类型的false时才不会移除当前事件,其他任何值该事件都会被移除 if (!remove && remove !== false) { remove = true; } remove && delete events[key]; return callFun(event.path, event.method, event.params) } return null; } [代码] 四:工具类的使用 1. 直接使用 [代码]let result = funbus.callFun('a/a', 'returnBpageData', [1, 2]); this.setData({ astring: result, }); [代码] 2. 页面未在堆栈内的使用 [代码]// C页面内 // 由于B页面跳转C是重定向 redirectTo 跳过来的,所以B页面不在路由堆栈内,我们要先注册,然后再B页面里适当的时机出发该函数。 funbus.subscribe('b-changebgc', { path: 'b/b', method: 'changeBgColor', params:['yellow']}); // B页面内 // 触发在C页面注册的函数 funbus.notifyEvent('b-changebgc'); [代码] 五:传送门 Demo 下载 总结 本文大致可以分为两个重点部分。前一部分我们把页面之间进行数据传递的四种常用的方案做了一个简要的分析总结。后一部分主要是根据前一部分的优缺点整理得出一个通用的数据传递和事件处理的工具类,并对其实现和使用进行了简要说明。在文末也提供了Demo的github地址,若使用中有什么不合理不完善的地方还请不吝指出。
2019-11-14 - 小程序全局事件监听
代码片段 https://developers.weixin.qq.com/s/g0tZKYmj7OhD //全局存储 var event = {}; //接收消息 传入params={name:"string 监听事件名",success:"function 监听事件回调",tg:"当前页面或App.js 传入this即可"} const $on = function(params) { if (!params) { return false; } if (!params.name) { console.error("事件监听未设置名称 属性key=name"); return false; } if (!params.success) { console.error("事件监听未设置回调函数 属性key=success"); return false; } if (!params.tg) { console.error("事件监听未设置目标对象 属性key=tg"); return false; } if (event[params.name]) { var list = event[params.name]; list.push([params.tg, params.success]); } else { event[params.name] = [ [params.tg, params.success] ]; } pageStatus(params.tg); } //消息发送 传入{name:"string 发送消息事件名",data:"可选 可以是任意数据类型"} const $emit = function(params) { if (!params) { return false; } if (!params.name) { console.error("事件监听未设置名称 属性key=name"); return false; } if (event[params.name]) { var list = event[params.name]; list.forEach(item => { item[1].call(item[0], params.data); }) } } //移除监听 传入params={name:"string 监听事件名",tg:"当前页面或App.js 传入this即可"} //如未移除监听 页面onUnload时将会自动移除 const $remove = function(params) { if (!params) { return false; } if (!params.tg) { console.error("事件监听未设置目标对象 属性key=tg"); return false; } if (params.name && event[params.name]) { event[params.name] = event[params.name].filter(a => { return a[0] != params.tg; }) } else { for (let key in event) { event[key] = event[key].filter(a => { return a[0] != params.tg; }) } } } //页面onUnload触发监听 const pageStatus = function(self) { if (self["onUnload"]) { var s = self["onUnload"]; self["onUnload"] = function(a) { s.call(this, a); $remove({ tg: this }); } } else { self["onUnload"] = function() { $remove({ tg: this }); } } } exports.$on = $on; exports.$emit = $emit; exports.$remove = $remove; 此方法可挂载至全局getApp()调用 也可引入文件调用 写的不好 大家见谅
2020-06-09 - 正式版小程序出现vconsole按钮
[图片] 发布正式版后有些手机会显示vConsole按钮,影响体验啊>。<,这是怎么回事呢?
2018-10-31