- 揭开JS无埋点技术的神秘面纱
一、背景 相信很多人都接触过**“埋点”这个概念,无论是前端还是后端开发,我们都可以使用这门技术来生产出一些运营性质的原始数据(接口耗时、程序安装/启动、用户交互行为等等),然后分析它们得到一些抽象指标(例如留存率、转化率),进而决定产品运营或者代码优化的方向。现在业界有许多比较知名数据平台,比如Google Analytics、Facebook Pixel、Mixpanel、GrowingIO、诸葛IO、TalkingData、神策数据等数不胜数一大票,这些平台有单纯做数据分析的,也有服务于特定领域例如广告监测转化的,都提供了多端(Android、iOS、Web、小程序、ReactNative)的埋点SDK和比较全面的BI服务。这一两年,不少平台都开始宣传一种叫“无埋点”**的技术,下面以Web端为例,揭开它的神秘面纱。 二、什么是无埋点? **“无埋点”在国外一些平台被叫做[代码]Codeless Tracking[代码],顾名思义就是可以写“更少”的埋点代码。而“代码埋点”**一般需要开发人员编写代码,监听某个html元素的产生的事件,然后调用上报数据的接口,发送数据。而无埋点则可以由非技术人员(例如运营、产品),在可视化的工具中作出配置,然后就可以将html元素中产生的行为上报到后台。下面是Mixpanel平台的可视化工具的截图。 [图片] 在这个工具里,需要首先输入页面的url,页面加载完成后,会出现可视化配置的工具条。点击创建事件,就可以进入元素选择模式,用鼠标点击页面上的某个元素(例如button、a这些element),就可以在弹出的对话框里面,设置这个事件的名称(比如叫[代码]TEST[代码])。保存这个配置之后,如果页面在浏览器中被浏览,刚才配置的那个按钮发生点击时,就会向后台上报一个[代码]TEST[代码]事件。我们还可以设置上报[代码]TEST[代码]事件的时候,带上一些属性(properties),这些属性同样也是在页面中用鼠标去选择,然后保存起来的。 看到这里,首先从产品层面上,我们比较具体的了解到“无埋点”到底是干什么的了,无埋点就是用可视化工具配置页面中需要被监测的元素,并设置这个元素产生行为的时候需要上报的数据。但是还有非常关键的一点必须提到,要让“无埋点”工作起来,页面里面还是必须嵌入了一段JS SDK的基础代码,只是不需要再去调用SDK具体的数据上报接口罢了。 所以,“无埋点”技术的关键是: 操作可视化配置工具,保存配置 SDK基础代码如何根据配置上报行为 下面介绍一下如何实现这两个关键。 三、关键技术 1. 基础代码 和代码埋点一样,要让“无埋点”工作起来,网页里也必须有一段“基础代码”。 [代码]<!-- start Mixpanel --><script type="text/javascript">(function(e,a){if(!a.__SV){var b=window;try{var c,l,i,j=b.location,g=j.hash;c=function(a,b){return(l=a.match(RegExp(b+"=([^&]*)")))?l[1]:null};g&&c(g,"state")&&(i=JSON.parse(decodeURIComponent(c(g,"state"))),"mpeditor"===i.action&&(b.sessionStorage.setItem("_mpcehash",g),history.replaceState(i.desiredHash||"",e.title,j.pathname+j.search)))}catch(m){}var k,h;window.mixpanel=a;a._i=[];a.init=function(b,c,f){function e(b,a){var c=a.split(".");2==c.length&&(b=b[c[0]],a=c[1]);b[a]=function(){b.push([a].concat(Array.prototype.slice.call(arguments, 0)))}}var d=a;"undefined"!==typeof f?d=a[f]=[]:f="mixpanel";d.people=d.people||[];d.toString=function(b){var a="mixpanel";"mixpanel"!==f&&(a+="."+f);b||(a+=" (stub)");return a};d.people.toString=function(){return d.toString(1)+".people (stub)"};k="disable time_event track track_pageview track_links track_forms register register_once alias unregister identify name_tag set_config reset opt_in_tracking opt_out_tracking has_opted_in_tracking has_opted_out_tracking clear_opt_in_out_tracking people.set people.set_once people.unset people.increment people.append people.union people.track_charge people.clear_charges people.delete_user".split(" "); for(h=0;h<k.length;h++)e(d,k[h]);a._i.push([b,c,f])};a.__SV=1.2;b=e.createElement("script");b.type="text/javascript";b.async=!0;b.src="undefined"!==typeof MIXPANEL_CUSTOM_LIB_URL?MIXPANEL_CUSTOM_LIB_URL:"file:"===e.location.protocol&&"//cdn4.mxpnl.com/libs/mixpanel-2-latest.min.js".match(/^\/\//)?"https://cdn4.mxpnl.com/libs/mixpanel-2-latest.min.js":"//cdn4.mxpnl.com/libs/mixpanel-2-latest.min.js";c=e.getElementsByTagName("script")[0];c.parentNode.insertBefore(b,c)}})(document,window.mixpanel||[]); mixpanel.init("46042714e64a7536dde6f02af1aec923");</script><!-- end Mixpanel --> [代码] 上面是Mixpanel平台的基础代码,不同平台家的这段基础代码,大同小异,都是一段IIFE形式的、压缩过的js代码,执行完成之后,在head里面插入了一个新的script标签,异步去下载真正的核心SDK代码下来工作。所以并不是基础代码可以根据配置上报行为,而是基础代码会下载一段**“更大”**的SDK核心代码,这段代码才是SDK真正的功能实现。 这样子做的好处是,基础代码很短,加载的时候不会影响到网页的性能,而且核心SDK代码的更新也不需要用户去更新这段基础代码。 2. 页面的唯一标识 在配置元素行为的时候,需要唯一标识一个页面,这样才能保证A页面的配置,不会下发给在B页面,不会导致B页面产生出A页面里配置的行为。在Web里面标识页面靠的是url,url由protocol、domain、port、path和参数组成,存储配置的时候要将url的参数提出来再存。而url的参数位置是可以变化的,比如urlA([代码]http://a.b.com/c.html?pa=1&pb=2[代码])和urlB([代码]http://a.b.com/c.html?pb=2&pa=1[代码])虽然[代码]urlA !== urlB[代码],但是其实它们是一个页面。 3. 元素的唯一标识 唯一标识页面后,接下来就要唯一标识页面里面的元素,这样才能保证A页面中配置的元素A1可以被SDK找到,从而监听它产生的事件。 在html里面,元素是以DOM Tree组织的,如果沿着元素A1出发,一直向上记录它的parent和它在parent中的index,直到根节点body,那么就可以得到元素A1在DOM Tree中的唯一路径。 html的元素还会拥有很多属性,例如css class、id可以用来定位元素。通过Chrome开发者工具可以看到Mixpanel的可视化工具在配置元素的时候,使用的是https://github.com/Autarc/optimal-select这个库来生成element的唯一标识的。而Github上还有https://github.com/rowthan/whats-element这样的库,也可以生成元素在DOM Tree中的唯一标识。 此外,还有平台在标识元素的时候,采用了[代码]xpath[代码],这也是一个思路。 4. 如何查找元素 上面说到元素可以有唯一标识,那么有了唯一标识,就可以利用它的原理,找到这个元素。有一个很好用的API是[代码]document.querySelector()[代码],这个API可以根据CSS选择器找到对应的元素。此外,根据元素的标识方法,还可以使用[代码]document.getElementById()[代码]、[代码]document.getElementByName()[代码]来实现元素的查找。 这里需要重点强调的是,如果页面在配置完成之后又发生了修改,导致DOM Tree发生变化,此时需要被监测的元素的唯一标识可能也会发生改变。很可能导致根据之前的配置无法找到该元素了,或者找到的并不是我们希望监测的元素,从而导致产生的事件数量发生比较明显的变化。为了数据的稳定性和准确性,应该设有相应的监测告警处理这种case,并提示用户去重新配置页面。我个人认为这是无埋点最大的缺点。 5. 标记元素时的高亮效果和可视化交互实现 这是一个比较细节的点,其实熟悉js的大牛们都知道,有无数种方式去实现鼠标移动到元素上时的[代码]类hover[代码]效果,点击元素后弹出一个对话框,让用户输入配置的信息也so easy。但是我想说的是,一旦我们采用向页面中动态添加元素的方式去实现可视化工具的交互界面,那么有可能会破坏掉页面原来的DOM Tree结构。从而导致生成元素唯一标识的时候出现误差,所以这里必须要好好处理,保证生成的元素标识不会受到影响。 Mixpanel采用了[代码]CustomElement[代码]和[代码]ShadowDOM[代码],把可视化工具所有的功能都用自定义的[代码]Web Component[代码]实现了,虽然目前只有Chrome支持[代码]Web Component[代码],但是真的有点叼。。这样自定义的元素和交互不会对用户的网页DOM产生影响。当然,如果你的可视化工具实现做的很轻,比如只是将用户的网页放在一个[代码]iframe[代码]里面,大部分交互都交给iframe的parent页面去处理,那也可以在配置的时候,最小程度的破坏用户的网页了。 6. 配置工具中如何控制页面的跳转 当进入可视化配置状态时,我们可以让用户点击一个元素,然后弹一个对话框,让用户对这个元素进行配置。此时,如果这个元素本身的[代码]click[代码]行为是页面跳转呢?我们应该怎么处理? 这里本质上是一个交互设计的问题。在可视化配置工具中,应该有两种基本交互操作。一种是让用户选中某一个元素,进行配置;另一种,是让用户可以触发页面原有的行为。 为什么要有第二种交互?因为我们的工具肯定要支持用户进行二级页面的可视化配置对不对?或者说,用户的页面中可能会弹出一个对话框,对话框里面有一个按钮,用户对监测这个按钮,对它做配置,对不对?简单来说,就是用户页面中原有的点击行为,可能会导致页面结构产生变化,例如跳转,页面内弹出对话框等等。 那问题就好解了,除了点击,再设计一种交互来支持用户网页中原有的点击行为不就好了。用“右键点击”或者“按住shift+点击”之类都可以。反正不要再和网页默认的交互很容易产生冲突的方式就行。 最后再提一下,之前想很久没有想明白,如何能够能防止用户点击的时候页面产生跳转。后来才知道,DOM的事件流分三个阶段:捕获、目标、冒泡。所以为了避免用户的点击产身页面跳转,给document在捕获阶段加一个listener,拦截掉这个事件的继续分发就行了。 [图片] 简单的示例代码如下: [代码]document.addEventListener('click', e => { // 如果是按住shift的点击,那么保持原有的行为 if (e.shiftKey) { return; } // 如果是单纯的点击,那么拦截分发 e.preventDefault(); e.stopImmediatePropagation(); // 获取元素的唯一标识,然后让用户进行配置等等 this._selectElement(e.target); }, true); // useCapture必须为true [代码] 四、总结 可以看到“无埋点”并不是零侵入,用户的网页中依然需要加载SDK的代码(除非你是浏览器厂商,可以在加载网页的时候,给网页加inject基础代码)。只是每一个行为事件的上报代码不需要开发人员手动编写,而是由运营人员用可视化工具配置,所以叫它**“可视化埋点”**也许更加合适。我们知道数据采集是数据分析的基础和先决条件,数据采集做不好,其他的东西都是空中楼阁。 这里可以小结一下“无埋点”技术的优劣。无埋点的好处是技术成本低,对用户非常友好,不需要重新部署,配置完成就可以生效。但是其缺点也非常明显,不具有代码埋点的灵活性和深度,只能采集到用户肉眼可见的数据,无法获取内存里的数据,同时也无法适应页面结构的变化,所以在实际生产中,要选择性地在合适的地方使用无埋点技术。 多扯一点产品设计和技术方案的选择,产品上是否可以支持采集内存数据呢?当然可以,比如微信小程序的“自定义分析”,就可以支持上报页面[代码]data[代码]下面的属性,这时虽然同样是可视化配置,运营人员肯定不会知道代码里面的变量名字,必须得有开发人员参与配置才行。关于页面结构发生变化之后的数据丢失,也是有方案可以破的。比如Mixpanel平台的Codeless Tracking,实际上采集了页面中所有页面的点击事件上报,然后在后台再去根据用户的配置计算转化数量。这样做的好处就是如果页面变化后,用户接到告警,修改了配置,那么用于数据上报方案是全量的,所以平台是由能力将过去的数据回溯出来的。而上面我们说的根据配置下发,查找监测指定元素,再上报数据的方案属于按需上报,数据出现误差是无法回溯的。不过全量上报数据大家也知道,太不友好了,这个数据量太大,不仅前端消耗资源多,如果为了做数据回溯,后台的存储压力也会加大,而存储的数据大部分还是无效的,这个成本有点高了。 五、参考资料 JS埋点技术分析 https://github.com/Autarc/optimal-select https://github.com/rowthan/whats-element https://www.zhihu.com/question/38000812
2019-04-01 - 路由的封装
小程序提供了路由功能来实现页面跳转,但是在使用的过程中我们还是发现有些不方便的地方,通过封装,我们可以实现诸如路由管理、简化api等功能。 页面的跳转存在哪些问题呢? 与接口的调用一样面临url的管理问题; 传递参数的方式不太友好,只能拼装url; 参数类型单一,只支持string。 alias 第一个问题很好解决,我们做一个集中管理,比如新建一个[代码]router/routes.js[代码]文件来实现alias: [代码]// routes.js module.exports = { // 主页 home: '/pages/index/index', // 个人中心 uc: '/pages/user_center/index', }; [代码] 然后使用的时候变成这样: [代码]const routes = require('../../router/routes.js'); Page({ onReady() { wx.navigateTo({ url: routes.uc, }); }, }); [代码] query 第二个问题,我们先来看个例子,假如我们跳转[代码]pages/user_center/index[代码]页面的同时还要传[代码]userId[代码]过去,正常情况下是这么来操作的: [代码]const routes = require('../../router/routes.js'); Page({ onReady() { const userId = '123456'; wx.navigateTo({ url: `${routes.uc}?userId=${userId}`, }); }, }); [代码] 这样确实不好看,我能不能把参数部分单独拿出来,不用拼接到url上呢? 可以,我们试着实现一个[代码]navigateTo[代码]函数: [代码]const routes = require('../../router/routes.js'); function navigateTo({ url, query }) { const queryStr = Object.keys(query).map(k => `${k}=${query[k]}`).join('&'); wx.navigateTo({ url: `${url}?${queryStr}`, }); } Page({ onReady() { const userId = '123456'; navigateTo({ url: routes.uc, query: { userId, }, }); }, }); [代码] 嗯,这样貌似舒服一点。 参数保真 第三个问题的情况是,当我们传递的参数argument不是[代码]string[代码],而是[代码]number[代码]或者[代码]boolean[代码]时,也只能在下个页面得到一个[代码]string[代码]值: [代码]// pages/index/index.js Page({ onReady() { navigateTo({ url: routes.uc, query: { isActive: true, }, }); }, }); // pages/user_center/index.js Page({ onLoad(options) { console.log(options.isActive); // => "true" console.log(typeof options.isActive); // => "string" console.log(options.isActive === true); // => false }, }); [代码] 上面这种情况想必很多人都遇到过,而且感到很抓狂,本来就想传递一个boolean,结果不管传什么都会变成string。 有什么办法可以让数据变成字符串之后,还能还原成原来的类型? 好熟悉,这不就是json吗?我们把要传的数据转成json字符串([代码]JSON.stringify[代码]),然后在下个页面把它转回json数据([代码]JSON.parse[代码])不就好了嘛! 我们试着修改原来的[代码]navigateTo[代码]: [代码]const routes = require('../../router/routes.js'); function navigateTo({ url, data }) { const dataStr = JSON.stringify(data); wx.navigateTo({ url: `${url}?jsonStr=${dataStr}`, }); } Page({ onReady() { navigateTo({ url: routes.uc, data: { isActive: true, }, }); }, }); [代码] 这样我们在页面中接受json字符串并转换它: [代码]// pages/user_center/index.js Page({ onLoad(options) { const json = JSON.parse(options.jsonStr); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] 这里其实隐藏了一个问题,那就是url的转义,假如json字符串中包含了类似[代码]?[代码]、[代码]&[代码]之类的符号,可能导致我们参数解析出错,所以我们要把json字符串encode一下: [代码]function navigateTo({ url, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } // pages/user_center/index.js Page({ onLoad(options) { const json = JSON.parse(decodeURIComponent(options.encodedData)); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] 这样使用起来不方便,我们封装一下,新建文件[代码]router/index.js[代码]: [代码]const routes = require('./routes.js'); function navigateTo({ url, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } function extract(options) { return JSON.parse(decodeURIComponent(options.encodedData)); } module.exports = { routes, navigateTo, extract, }; [代码] 页面中我们这样来使用: [代码]const router = require('../../router/index.js'); // page home Page({ onLoad(options) { router.navigateTo({ url: router.routes.uc, data: { isActive: true, }, }); }, }); // page uc Page({ onLoad(options) { const json = router.extract(options); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] route name 这样貌似还不错,但是[代码]router.navigateTo[代码]不太好记,[代码]router.routes.uc[代码]有点冗长,我们考虑把[代码]navigateTo[代码]换成简单的[代码]push[代码],至于路由,我们可以使用[代码]name[代码]的方式来替换原来[代码]url[代码]参数: [代码]const routes = require('./routes.js'); function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const url = routes[name]; wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } function extract(options) { return JSON.parse(decodeURIComponent(options.encodedData)); } module.exports = { push, extract, }; [代码] 在页面中使用: [代码]const router = require('../../router/index.js'); Page({ onLoad(options) { router.push({ name: 'uc', data: { isActive: true, }, }); }, }); [代码] navigateTo or switchTab 页面跳转除了navigateTo之外还有switchTab,我们是不是可以把这个差异抹掉?答案是肯定的,如果我们在配置routes的时候就已经指定是普通页面还是tab页面,那么程序完全可以切换到对应的跳转方式。 我们修改一下[代码]router/routes.js[代码],假设home是一个tab页面: [代码]module.exports = { // 主页 home: { type: 'tab', path: '/pages/index/index', }, uc: { path: '/pages/a/index', }, }; [代码] 然后修改[代码]router/index.js[代码]中[代码]push[代码]的实现: [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; if (route.type === 'tab') { wx.switchTab({ url: `${route.path}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${route.path}?encodedData=${dataStr}`, }); } [代码] 搞定,这样我们一个[代码]router.push[代码]就能自动切换两种跳转方式了,而且之后一旦页面类型有变动,我们也只需要修改[代码]route[代码]的定义就可以了。 直接寻址 alias用着很不错,但是有一点挺麻烦得就是每新建一个页面都要写一个alias,即使没有别名的需要,我们是不是可以处理一下,如果在alias没命中,那就直接把name转化成url?这也是阔以的。 [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; const url = route ? route.path : name; if (route.type === 'tab') { wx.switchTab({ url: `${url}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } [代码] 在页面中使用: [代码]Page({ onLoad(options) { router.push({ name: 'pages/user_center/a/index', data: { isActive: true, }, }); }, }); [代码] 注意,为了方便维护,我们规定了每个页面都必须存放在一个特定的文件夹,一个文件夹的当前路径下只能存在一个index页面,比如[代码]pages/index[代码]下面会存放[代码]pages/index/index.js[代码]、[代码]pages/index/index.wxml[代码]、[代码]pages/index/index.wxss[代码]、[代码]pages/index/index.json[代码],这时候你就不能继续在这个文件夹根路径存放另外一个页面,而必须是新建一个文件夹来存放,比如[代码]pages/index/pageB/index.js[代码]、[代码]pages/index/pageB/index.wxml[代码]、[代码]pages/index/pageB/index.wxss[代码]、[代码]pages/index/pageB/index.json[代码]。 这样是能实现功能,但是这个name怎么看都跟alias风格差太多,我们试着定义一套转化规则,让直接寻址的name与alias风格统一一些,[代码]pages[代码]和[代码]index[代码]其实我们可以省略掉,[代码]/[代码]我们可以用[代码].[代码]来替换,那么原来的name就变成了[代码]user_center.a[代码]: [代码]Page({ onLoad(options) { router.push({ name: 'user_center.a', data: { isActive: true, }, }); }, }); [代码] 我们再来改进[代码]router/index.js[代码]中[代码]push[代码]的实现: [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; const url = route ? route.path : `pages/${name.replace(/\./g, '/')}/index`; if (route.type === 'tab') { wx.switchTab({ url: `${url}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } [代码] 这样一来,由于支持直接寻址,跳转home和uc还可以写成这样: [代码]router.push({ name: 'index', // => /pages/index/index }); router.push({ name: 'user_center', // => /pages/user_center/index }); [代码] 这样一来,除了一些tab页面以及特定的路由需要写alias之外,我们也不需要新增一个页面就写一条alias这么麻烦了。 其他 除了上面介绍的navigateTo和switchTab外,其实还有[代码]wx.redirectTo[代码]、[代码]wx.navigateBack[代码]以及[代码]wx.reLaunch[代码]等,我们也可以做一层封装,过程雷同,所以我们就不再一个个介绍,这里贴一下最终简化后的api以及原生api的映射关系: [代码]router.push => wx.navigateTo router.replace => wx.redirectTo router.pop => wx.navigateBack router.relaunch => wx.reLaunch [代码] 最终实现已经在发布在github上,感兴趣的朋友可以移步了解:mp-router。
2019-04-26 - 如何用小程序实现类原生APP下一条无限刷体验
1.背景 如今信息流业务是各大互联网公司争先抢占的一个大面包,为了提高用户的后续消费,产品想出了各种各样的方法,例如在微视中,用户可以无限上拉出下一条视频;在知乎中,也可以无限上拉出下一条回答。这样的操作方式用户体验更好,后续消费也更多。最近几年的时间,微信小程序已经从一颗小小的萌芽成长为参天大树,形成了较大规模的生态,小程序也拥有了一个很大的流量入口。 2.demo体验 那如何才能在小程序中实现类原生APP效果的下一条无限刷体验? 这篇文章详细记录了下一条无限刷效果的实现原理,以及细节和体验优化,并将相关代码抽象成一个微信小程序代码片段,有需要的同学可查看demo源码。 线上效果请用微信扫码体验: [图片] 小程序demo体验请点击:https://developers.weixin.qq.com/s/vIfPUomP7f9a 3.实现原理 出于性能和兼容性考虑,我们尽量采用小程序官方提供的原生组件来实现下一条无限刷效果。我们发现,可以将无限上拉下一篇的文章看作一个竖向滚动的轮播图,又由于每一篇文章的内容长度高于一屏幕高度,所以需要实现文章内部可滚动,以及文章之间可以上拉和下拉切换的功能。 在多次尝试后,我们最终采用了在[代码]<swiper>[代码]组件内部嵌套一个[代码]<scroll-view>[代码]组件的方式实现,利用[代码]<swiper>[代码]组件来实现文章之间上拉和下拉切换的功能,利用[代码]<scroll-view>[代码]来实现一篇文章内部可上下滚动的功能。 所以页面的dom结构如下所示: [代码]<swiper class='scroll-swiper' circular="{{false}}" vertical="{{true}}" bindchange="bindChange" skip-hidden-item-layout="{{true}}" duration="{{500}}" easing-function="easeInCubic" > <block wx:for="{{articleData}}"> <swiper-item> <scroll-view scroll-top="0" scroll-with-animation="{{false}}" scroll-y > content </scroll-view> </swiper-item> </block> </swiper> [代码] 4.性能优化 我们知道view部分是运行在webview上的,所以前端领域的大多数优化方式都有用。例如减少代码包体积,使用分包,渲染性能优化等。下面主要讲一下渲染性能优化。 4.1 dom优化 由于页面需要无限上拉刷新,所以要在[代码]<swiper>[代码]组件中不断的增加[代码]<swiper-item>[代码],这样必然会导致页面的dom节点成倍数的增加,最后非常卡顿。 为了优化页面的dom节点,我们利用[代码]<swiper>[代码]的[代码]current[代码]和[代码]<swiper-item>[代码]的[代码]index[代码]来做优化,控制是否渲染dom节点。首先,仅当[代码]index <= current + 1[代码]时渲染[代码]<swiper-item>[代码],也就是页面中最多预先加载出下一条,而不是将接口返回的所有后续数据都渲染出来;其次,对于用户已经消费过的之前的[代码]<swiper-item>[代码],不能直接销毁dom节点,否则会导致[代码]<swiper>[代码]的[代码]current[代码]值出现错乱,但是我们可以控制是否渲染[代码]<swiper-item>[代码]内部的子节点,我们设置了仅当[代码]current <= index + 1 && index -1 <= current[代码]时才会渲染[代码]<swiper-item>[代码]中的内容,也就是仅渲染当先文章,及上一篇和下一篇的文章内容,其他文章的dom节点都被销毁了。 这样,无论用户上拉刷新了多少次,页面中最多只会渲染3篇文章的内容,避免了因为上拉次数太多导致的页面卡顿。 4.2 分页时setData的优化 setData工作原理 [图片] 小程序的视图层目前使用[代码]WebView[代码]作为渲染载体,而逻辑层是由独立的 [代码]JavascriptCore[代码] 作为运行环境。在架构上,[代码]WebView[代码] 和 [代码]JavascriptCore[代码] 都是独立的模块,并不具备数据直接共享的通道。当前,视图层和逻辑层的数据传输,实际上通过两边提供的 [代码]evaluateJavascript[代码] 所实现。即用户传输的数据,需要将其转换为字符串形式传递,同时把转换后的数据内容拼接成一份 [代码]JS[代码] 脚本,再通过执行 [代码]JS[代码] 脚本的形式传递到两边独立环境。 而 [代码]evaluateJavascript[代码] 的执行会受很多方面的影响,数据到达视图层并不是实时的。 每次 [代码]setData[代码] 的调用都是一次进程间通信过程,通信开销与 setData 的数据量正相关。 [代码]setData[代码] 会引发视图层页面内容的更新,这一耗时操作一定时间中会阻塞用户交互。 [代码]setData[代码] 是小程序开发中使用最频繁的接口,也是最容易引发性能问题的接口。 避免不当使用setData [代码]data[代码] 应仅包括与页面渲染相关的数据,其他数据可绑定在this上。使用 [代码]data[代码] 在方法间共享数据,会增加 setData 传输的数据量,。 使用 [代码]setData[代码] 传输大量数据,通讯耗时与数据正相关,页面更新延迟可能造成页面更新开销增加。仅传输页面中发生变化的数据,使用 [代码]setData[代码] 的特殊 [代码]key[代码] 实现局部更新。 避免不必要的 [代码]setData[代码],避免短时间内频繁调用 [代码]setData[代码],对连续的setData调用进行合并。不然会导致操作卡顿,交互延迟,阻塞通信,页面渲染延迟。 避免在后台页面进行 [代码]setData[代码],这样会抢占前台页面的渲染资源。可将页面切入后台后的[代码]setData[代码]调用延迟到页面重新展示时执行。 优化示例 无限上拉刷新的数据会采用分页接口的形式,分多次请求回来。在使用分页接口拉取到下一刷的数据后,我们需要调用[代码]setData[代码]将数据写进[代码]data[代码]的[代码]articleData[代码]中,这个[代码]articleData[代码]是一个数组,里面存放着所有的文章数据,数据量十分庞大,如果直接[代码]setData[代码]会增加通讯耗时和页面更新开销,导致操作卡顿,交互延迟。 为了避免这个问题,我们将[代码]articleData[代码]改进为一个二维数组,每一次[代码]setData[代码]通过分页的 [代码]cachedCount[代码]标识来实现局部更新,具体代码如下: [代码]this.setData({ [`articleData[${cachedCount}]`]: [...data], cachedCount: cachedCount + 1, }) [代码] [代码]articleData[代码]的结构如下: [图片] 4.3 体验优化 解决了操作卡顿,交互延迟等问题,我们还需要对动画和交互的体验进行优化,以达到类原生APP效果的体验。 在文章间上拉切换时,我们使用了[代码]<swiper>[代码]组件自带的动画效果,并通过设置[代码]duration[代码]和[代码]easing-function[代码]来优化滚动细节和动画。 当用户阅读文章到底部时,会提示下一篇文章的标题等信息,而在页面上拉时,由于下一篇文章的内容已经加载出来了,这样在滑动过程中会出现两个重复的标题。为了避免这种情况出现,我们通过一个占满屏幕宽高的空白[代码]<view>[代码]来将下一篇文章的内容撑出屏幕,并在滚动结束时,通过[代码]hidden="{{index !== current && index !== current + 1}}"[代码]来隐藏这个空白[代码]<view>[代码],并对这个空白[代码]<view>[代码]的高度变化增加动画,来实现下一篇文章从屏幕底部滚动到屏幕顶部的效果: [代码].fake-scroll { height: 100%; width: 100%; transition: height 0.3s cubic-bezier(0.167,0.167,0.4,1); } [代码] [图片] 而当用户想要上拉查看之前阅读过的文章时,我们需要给用户一个“下滑查看上一条”提示,所以也可以采用同上的方式,通过一个占满屏幕宽高的提示语[代码]<view>[代码]来将上一篇文章的内容撑出屏幕,并在滚动结束时,通过[代码]wx:if="{{index + 1 === current}}"[代码]来隐藏这个提示语[代码]<view>[代码],并对这个提示语[代码]<view>[代码]的透明度变化增加动画,来实现下拉时提示“下滑查看上一条”的效果: [代码].fake-previous { height: 100%; width: 100%; opacity: 0; transition: opacity 1s ease-in; } .fake-previous.show-fake-previous { opacity: 1; } [代码] 至此,这个类原生APP效果的下一条无限刷体验的需求的所有要点和细节都已实现。 记录在此,欢迎交流和讨论。 小程序demo体验请点击:https://developers.weixin.qq.com/s/vIfPUomP7f9a
2019-06-25 - 借助云开发实现小程序朋友圈的发布与展示
随着小程序云开发越来越成熟,现在用云开发可以做的事情也越来越多,今天就来带大家实现小程序朋友圈功能。 知识技能点 1,小程序云开发 2,小程序云存储 3,小程序云数据库 4,图片大图预览 5,图片选择与删除 先给大家画个发布的流程图 [图片] 下面是我们真正存到数据库里的数据。 [图片] 然后我们在朋友圈页只需要请求数据库里的数据,然后展示到页面就如下图所示 [图片] 所以我们接下来就来实现发布和展示的功能 发布朋友圈 一,首先要创建一个小程序项目 这里就不多说了,如果你还不知道如何创建小程序项目可以去翻看下我之前的文章,也可以看下我录制的《10小时零基础入门小程序开发》 注意:一定要用自己的appid,所以你需要注册一个小程序(个人的就行) 二,创建发布页面 我们发布页布局比较简单,一个文字输入框,一个图片展示区域,一个发布按钮。 [图片] 先把发布页布局wxml贴出来 [代码]<textarea class="desc" placeholder="请输入内容" bindinput="getInput" /> <view class="iamgeRootAll"> <view class="imgRoot" wx:for="{{imgList}}" wx:key="{{index}}" bindtap="ViewImage" data-url="{{imgList[index]}}"> <view wx:if="{{imgList.length==(index+1)&& imgList.length<8}}" class="imgItem" bindtap="ChooseImage"> <image class="photo" src="../../images/photo.png"></image> </view> <view wx:else class="imgItem" data-index="{{index}}"> <image class="img" src='{{item}}' mode='aspectFill'></image> <image class="closeImg" bindtap="DeleteImg" src="../../images/close.png" data-index="{{index}}"></image> </view> </view> <!-- 一开始用来占位 --> <view wx:if="{{imgList.length==0}}" class="imgItem" bindtap="ChooseImage"> <image class="photo" src="../../images/photo.png"></image> </view> </view> <button type="primary" bindtap="publish">发布朋友圈</button> [代码] 这里唯一的难点,就是下面的图片分布,因为我们每次用户选择的图片个数不固定,这就要去分情况考虑了。 wx:if="{{imgList.length==(index+1)&& imgList.length<8}}"这段代码是用来控制我们发布的那个➕ 号的显示与隐藏的。 [图片] 这个➕号有下面三种情况需要考虑 1,没有添加任何图片时,只显示➕号 2,有图片,但是不满8条时,我们需要展示图片和加号。 3,有8张图片了,加号就要隐藏了。 仔细看下上面的wxml代码,代码里都有体现。 三,实现图片选择和显示功能 图片选择很简单,就用官方的api即可。实现代码如下 [代码] //选择图片 ChooseImage() { wx.chooseImage({ count: 8 - this.data.imgList.length, //默认9,我们这里最多选择8张 sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有 sourceType: ['album'], //从相册选择 success: (res) => { console.log("选择图片成功", res) if (this.data.imgList.length != 0) { this.setData({ imgList: this.data.imgList.concat(res.tempFilePaths) }) } else { this.setData({ imgList: res.tempFilePaths }) } } }); }, [代码] 这里单独说明下 8 - this.data.imgList.length。因为我这里规定最多只能上传8张图片。所以用了count8 ,至于后面为什么要减去this.data.imgList.length。主要是我们用户不一定一次选择8张图片,有可能第一次选择2张,第二次选择2张。。。 所以我们做选择时,每次传入的数量肯定不一样的。而这个imgList.length就是用户已经选择的图片个数。用8减去已选择的个数,就是下次最多能选择的了。 上面代码在选择成功后,会生成一个临时的图片链接。如下图所示,这个链接既可以用来展示我们已经选择的图片,后面的图片上传也要用到。 [图片] 四,实现图片删除功能 我们每张图片的右上角有个删除按钮,点击删除按钮可以实现图片的删除。 [图片] 这里比较简单,把代码贴给大家 [代码] //删除图片 DeleteImg(e) { wx.showModal({ title: '要删除这张照片吗?', content: '', cancelText: '取消', confirmText: '确定', success: res => { if (res.confirm) { this.data.imgList.splice(e.currentTarget.dataset.index, 1); this.setData({ imgList: this.data.imgList }) } } }) }, [代码] 五,发布功能 1,发布之前我们需要先校验下内容和图片是否为空 [图片] 2,由于我们发布的时候要保证所有的图片都要上传成功,所以这里我们这么处理。 [代码] const promiseArr = [] //只能一张张上传 遍历临时的图片数组 for (let i = 0; i < this.data.imgList.length; i++) { let filePath = this.data.imgList[i] let suffix = /\.[^\.]+$/.exec(filePath)[0]; // 正则表达式,获取文件扩展名 //在每次上传的时候,就往promiseArr里存一个promise,只有当所有的都返回结果时,才可以继续往下执行 promiseArr.push(new Promise((reslove, reject) => { wx.cloud.uploadFile({ cloudPath: new Date().getTime() + suffix, filePath: filePath, // 文件路径 }).then(res => { // get resource ID console.log("上传结果", res.fileID) this.setData({ fileIDs: this.data.fileIDs.concat(res.fileID) }) reslove() }).catch(error => { console.log("上传失败", error) }) })) } //保证所有图片都上传成功 Promise.all(promiseArr).then(res => { //图片上传成功了,才会执行到这。。。 }) [代码] 我们这里用Promise来确保所有的图片都上传成功了,才执行后面的操作。 把完整的发布代码贴给大家吧 [代码]/** * 编程小石头 * wehchat:2501902696 */ let app = getApp(); Page({ data: { imgList: [], fileIDs: [], desc: '' }, //获取输入内容 getInput(event) { console.log("输入的内容", event.detail.value) this.setData({ desc: event.detail.value }) }, //选择图片 ChooseImage() { wx.chooseImage({ count: 8 - this.data.imgList.length, //默认9,我们这里最多选择8张 sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有 sourceType: ['album'], //从相册选择 success: (res) => { console.log("选择图片成功", res) if (this.data.imgList.length != 0) { this.setData({ imgList: this.data.imgList.concat(res.tempFilePaths) }) } else { this.setData({ imgList: res.tempFilePaths }) } } }); }, //删除图片 DeleteImg(e) { wx.showModal({ title: '要删除这张照片吗?', content: '', cancelText: '取消', confirmText: '确定', success: res => { if (res.confirm) { this.data.imgList.splice(e.currentTarget.dataset.index, 1); this.setData({ imgList: this.data.imgList }) } } }) }, //上传数据 publish() { let desc = this.data.desc let imgList = this.data.imgList if (!desc || desc.length < 6) { wx.showToast({ icon: "none", title: '内容大于6个字' }) return } if (!imgList || imgList.length < 1) { wx.showToast({ icon: "none", title: '请选择图片' }) return } wx.showLoading({ title: '发布中...', }) const promiseArr = [] //只能一张张上传 遍历临时的图片数组 for (let i = 0; i < this.data.imgList.length; i++) { let filePath = this.data.imgList[i] let suffix = /\.[^\.]+$/.exec(filePath)[0]; // 正则表达式,获取文件扩展名 //在每次上传的时候,就往promiseArr里存一个promise,只有当所有的都返回结果时,才可以继续往下执行 promiseArr.push(new Promise((reslove, reject) => { wx.cloud.uploadFile({ cloudPath: new Date().getTime() + suffix, filePath: filePath, // 文件路径 }).then(res => { // get resource ID console.log("上传结果", res.fileID) this.setData({ fileIDs: this.data.fileIDs.concat(res.fileID) }) reslove() }).catch(error => { console.log("上传失败", error) }) })) } //保证所有图片都上传成功 Promise.all(promiseArr).then(res => { wx.cloud.database().collection('timeline').add({ data: { fileIDs: this.data.fileIDs, date: app.getNowFormatDate(), createTime: db.serverDate(), desc: this.data.desc, images: this.data.imgList }, success: res => { wx.hideLoading() wx.showToast({ title: '发布成功', }) console.log('发布成功', res) wx.navigateTo({ url: '../pengyouquan/pengyouquan', }) }, fail: err => { wx.hideLoading() wx.showToast({ icon: 'none', title: '网络不给力....' }) console.error('发布失败', err) } }) }) }, }) [代码] 到这里我们发布的功能就实现了,发布功能就如下面这个流程图所示。 [图片] 我们最终的目的是要把文字和图片链接存到云数据库。把图片文件存到云存储。这就是云开发的方便之处,不用我们编写后台代码,就可以轻松实现后台功能。 接下来讲朋友圈展示页。 [图片] 这个页面主要做的就是 1,从云数据库读取数据 2,展示列表数据 1,读取数据 这里读取数据挺简单,就是从云数据库读数据,这里我们做了一个排序,就是最新发布的数据在最上面。代码如下 [代码]wx.cloud.database().collection('timeline') .orderBy('createTime', 'desc') //按发布视频排序 .get({ success(res) { console.log("请求成功", res) that.setData({ dataList: res.data }) }, fail(res) { console.log("请求失败", res) } }) [代码] 云数据库的读取也比较简单,有不会的同学,或者没有听说过小程序云开发的同学,可以去翻看下我之前发的文章,也可以看下我录的《10小时零基础入门小程序云开发》 2,朋友圈列表的展示 这里也比较简单,直接把布局代码贴给大家。dataList就是我们第一步请求到的数据。 [代码]<block wx:for="{{dataList}}" wx:key="index"> <view class="itemRoot"> <view> <text class="desc">{{item.desc}}</text> </view> <view class="imgRoot"> <block class="imgList" wx:for="{{item.fileIDs}}" wx:for-item="itemImg" wx:key="index"> <image class="img" src='{{itemImg}}' mode='aspectFill' data-img='{{[itemImg,item.fileIDs]}}' bindtap="previewImg"></image> </block> </view> </view> </block> [代码] 3,这里还有一个图片预览的功能 功能实现很简单就下面几行代码,但是我们从wxml获取组件上的数据时比较麻烦。 [代码] // 预览图片 previewImg: function(e) { let imgData = e.currentTarget.dataset.img; console.log("eeee", imgData[0]) console.log("图片s", imgData[1]) wx.previewImage({ //当前显示图片 current: imgData[0], //所有图片 urls: imgData[1] }) }, [代码] 4,点击图片时通过 data- 获取图片列表和当前图片数据 我们点击组件时,可以通过data- 传递数据,但是一个点击如果像传多条数据呢。这时候可以用 data-xxx=’{{[xxx,xxx]}}’ 来传递数据了。如下代码 [代码]<block wx:for="{{item.fileIDs}}" wx:key="item2" wx:for-item="item2"> <image src='{{item2}}' data-img='{{[item2,item.fileIDs]}}' mode='aspectFill' bindtap="previewImg"></image> </block> //我们再js里可以接收两个数据 previewImg: function(e) { let imgData = e.currentTarget.dataset.img; console.log("item2", imgData[0]) console.log("item.fileIDs", imgData[1]) //大图预览 wx.previewImage({ //当前显示图片 current: imgData[0], //所有图片 urls: imgData[1] }) }, [代码] 上面代码就可以实现,一次点击,通过data- 传递多个数据到js里。 到这里我们就完整的实现了,朋友圈的发布与展示了 [图片] 朋友圈展示的比较简陋,后期再抽时间做美化吧。 源码我已经上传到网盘,需要的同学可以加我微信2501902696获取 [图片] 后面我也会录制一套视频来专门讲解。敬请关注。
2019-10-12 - 借助云开发轻松实现后台数据批量导出丨实战
小程序导出数据到excel表,借助云开发后台实现excel数据的保存 我们在开发小程序的过程中,可能会有这样的需求:如何将云数据库里的数据批量导出到excel表里? 这个需求可以用强大的云开发轻松实现! 这里需要用到云函数,云存储和云数据库。可以说通过这一个例子,把小程序云开发相关的知识都用到了。下面就来介绍如何实现 实现思路 1,创建云函数 2,在云函数里读取云数据库里的数据 3,安装node-xlsx类库(node类库) 4,把云数据库里读取到的数据存到excel里 5,把excel存到云存储里并返回对应的云文件地址 6,通过云文件地址下载excel文件 一、创建excel云函数 关于如何创建云开发小程序,这里我就不再做具体讲解。不知道怎么创建云开发小程序的同学,可以去翻看腾讯云云开发公众号内菜单【技术交流-视频教程】中的教学视频。 创建云函数时有两点需要注意的,给大家说下 1、一定要把app.js里的环境id换成你自己的 [图片] 2,你的云函数目录要选择你对应的云开发环境(通常这里默认选中的) 不过你这里的云开发环境要和你app.js里的保持一致 [图片] 二、读取云数据库里的数据 我们第一步创建好云函数以后,可以先在云函数里读取我们的云数据库里的数据。 1、先看下我们云数据库里的数据 [图片] 2、编写云函数,读取云数据库里的数据(一定要记得部署云函数) [图片] 3、成功读取到数据 [图片] 把读取user数据表的完整代码给大家贴出来。 [代码]// 云函数入口文件 const cloud = require('wx-server-sdk') cloud.init({ env: "test-vsbkm" }) // 云函数入口函数 exports.main = async(event, context) => { return await cloud.database().collection('users').get(); } [代码] 三、安装生成excel文件的类库 node-xlsx 通过上面第二步可以看到我们已经成功的拿到需要保存到excel的源数据,我们接下来要做的就是把数据保存到excel 1、安装node-xlsx类库 [图片] 这一步需要我们事先安装node,因为我们要用到npm命令,通过命令行npm install node-xlsx[图片] 可以看出我们安装完成以后,多了一个package-lock.json的文件 [图片] 四、编写把数据保存到excel的代码, 下图是我们的核心代码: [图片] 这里的数据是我们查询的users表的数据,然后通过下面代码遍历数组,然后存入excel。这里需要注意我们的id,name,weixin要和users表里的对应。 [代码] for (let key in userdata) { let arr = []; arr.push(userdata[key].id); arr.push(userdata[key].name); arr.push(userdata[key].weixin); alldata.push(arr) } [代码] 还有下面这段代码,是把excel保存到云存储用的 [代码] //4,把excel文件保存到云存储里 return await cloud.uploadFile({ cloudPath: dataCVS, fileContent: buffer, //excel二进制文件 }) [代码] 下面把完整的excel里的index.js代码贴给大家,记得把云开发环境id换成你自己的。 [代码]const cloud = require('wx-server-sdk') //这里最好也初始化一下你的云开发环境 cloud.init({ env: "test-vsbkm" }) //操作excel用的类库 const xlsx = require('node-xlsx'); // 云函数入口函数 exports.main = async(event, context) => { try { let {userdata} = event //1,定义excel表格名 let dataCVS = 'test.xlsx' //2,定义存储数据的 let alldata = []; let row = ['id', '姓名', '微信号']; //表属性 alldata.push(row); for (let key in userdata) { let arr = []; arr.push(userdata[key].id); arr.push(userdata[key].name); arr.push(userdata[key].weixin); alldata.push(arr) } //3,把数据保存到excel里 var buffer = await xlsx.build([{ name: "mySheetName", data: alldata }]); //4,把excel文件保存到云存储里 return await cloud.uploadFile({ cloudPath: dataCVS, fileContent: buffer, //excel二进制文件 }) } catch (e) { console.error(e) return e } } [代码] 五、把excel存到云存储里并返回对应的云文件地址 经过上面的步骤,我们已经成功的把数据存到excel里,并把excel文件存到云存储里。可以看下效果。 [图片] 接着,就可以通过上图的下载地址下载excel文件了。 [图片] 其实到这里就差不多实现了基本的把数据保存到excel里的功能了,但是为了避免每次导出数据都需要去云开发后台下载excel的麻烦,接下来介绍如何动态获取下载地址。 六、获取云文件地址下载excel文件 [图片] 通过上图我们可以看出,我们获取下载链接需要用到一个fileID,而这个fileID在我们保存excel到云存储时,有返回,如下图。我们把fileID传给我们获取下载链接的方法即可。 [图片] 1、我们获取到了下载链接,接下来就要把下载链接显示到页面 [图片] 2、代码显示到页面以后,我们就要复制这个链接,方便用户粘贴到浏览器或者微信去下载。 [图片] 下面是完整代码: [代码]Page({ onLoad: function(options) { let that = this; //读取users表数据 wx.cloud.callFunction({ name: "getUsers", success(res) { console.log("读取成功", res.result.data) that.savaExcel(res.result.data) }, fail(res) { console.log("读取失败", res) } }) }, //把数据保存到excel里,并把excel保存到云存储 savaExcel(userdata) { let that = this wx.cloud.callFunction({ name: "excel", data: { userdata: userdata }, success(res) { console.log("保存成功", res) that.getFileUrl(res.result.fileID) }, fail(res) { console.log("保存失败", res) } }) }, //获取云存储文件下载地址,这个地址有效期一天 getFileUrl(fileID) { let that = this; wx.cloud.getTempFileURL({ fileList: [fileID], success: res => { // get temp file URL console.log("文件下载链接", res.fileList[0].tempFileURL) that.setData({ fileUrl: res.fileList[0].tempFileURL }) }, fail: err => { // handle error } }) }, //复制excel文件下载链接 copyFileUrl() { let that=this wx.setClipboardData({ data: that.data.fileUrl, success(res) { wx.getClipboardData({ success(res) { console.log("复制成功",res.data) // data } }) } }) } }) [代码] 梳理下上面代码的逻辑: 1、先通过getUsers云函数去云数据库获取数据。 2、把获取到的数据通过excel云函数把数据保存到excel,然后把excel保存的云存储。 3、获取云存储里的文件下载链接。 4、复制下载链接,到浏览器里下载excel文件。 到这里我们就完整的实现了把数据保存到excel的功能了。 文章有点长,知识点有点多,但是大家理解上述内容后,就可以对小程序云开发的云函数、云数据库、云存储有一个较为完整的了解过程。 如果你想要了解更多关于云开发CloudBase相关的技术故事/技术实战经验,请扫码关注【腾讯云云开发】公众号 ~ [图片]
2019-09-10