- 小程序流量主运营技巧
前言(写给入坑的小白) 本文不涉及任何需要资质的小程序(如:视频类目)。小程序流量主是个人和小微企业主要变现途径之一,满1000人即可开通流量主(登录mp.weixin.qq.com,左侧边栏-推广-流量主-开通即可)。开通后,开发者可从流量主-广告位管理添加广告位,目前有6种广告位。 [图片] 正文(本文约很多字,分为四大主类,手里有1-10个小程序建议全部看完;手里有10个以上小程序,可跳过1、2、3,均为个人观点,不喜使劲喷) 一、小程序定位 小程序定位目前有以下四种,均不需要任何资质,个人(商城除外)/小微企业都可以做,由于本人不擅长文字表达,每个类型只选择一个做分析,谅解。 1、工具类 工具类有很多可以做:题库、技术文档、教程、去水印等。目前最火爆的应该属于疫情相关类的工具,关于疫情数据类小程序不做分析,没资质也没权利,主要说疫情周边可运营的工具。头像口罩,代表小程序:头像加口罩、戴个口罩吧、戴上口罩(每日搜索量约等于2000),可参考以下做法: [图片] 以下为近7日访问数据量 [图片] 盈利方式:流量主 延伸参考:如果仅做头像加口罩的话,那么疫情过后,这个小程序会直线下降,将无任何作用。如果开发者手里目前有类似小程序,可参考“头像加口罩”做法,逐渐去延伸头像周边功能,例: ①、头像加字:头像+数字、头像加V、头像加字、头像加圣诞帽、新年头像边框、头像加福、头像加明星等 ②、聊天背景图、壁纸:武汉加油、卡通、美女(不要漏点太多)、二次元、跑车、科技等 ③、趣味九宫格配图:类似朋友圈9张图,中间获取用户自己头像,周围8张图弄点能吸引用户的等 ④、文字秀:微信昵称下标、上标、个性昵称等 运营分析:如果参考以上4点做法,首先你的程序再疫情结束后,不至于直线下滑,最起码能留住一些用户(UI很重要) 个人建议:工具类的好处就是不需要去长时间盯着后台,建议有想法的开发者,可以入门5-10个左右工具类小程序(功能不要相同)。 推广方式:参考本文第四大板块内容 2、返利类 主流返利平台:淘宝、天猫、拼多多、京东、蘑菇街、唯品会、网易考拉,以下参考 [图片] 盈利方式:返利(主)+流量主(辅) [图片] 基础分析:每个人微信里都会有一个或多个微信群是给你们购物优惠券链接的,他们盈利方式主要是靠每个平台的返利,比如淘宝天猫的叫“阿里妈妈”、拼多多的叫“多多进宝”等 运营分析: ①、平台功能:提供所有优惠券、商品返利、代理入驻、提现(个人可做收款码、企业可对接微信支付到零钱) ②、招代理商、可以给代理商(兼职、宝妈)50%以上的返利 ③、除了商品优惠券之外,可以把返利分给一部分给到用户。首先,用户会花更少的钱买到商品;其次,用户买完东西还会赚点小钱,每个月可提现到微信零钱。这样用户会发生裂变,省钱+赚钱。 个人建议:开发者至少有一个类似的返利小程序,每个月只需运营一天,工作内容一是把用户的返利发给用户&代理商,二是自己去各大平台领取每个月的“工资” 推广方式:参考本文第四大板块内容 3、商城类(个人开发者可跳过) 商城类,本人运营的比较少,每天就10-20单左右,卖啥就不做广告了 盈利方式:差价 基础分析:如果自己手里有一些商品低价资源,可以做一个“综合服务商城类目”,然后去试着用广告主去推一下 运营分析: ①、平台功能:砍价、返利、拼团、回购、入驻、积分、抽奖、游戏营销 ②、广告主曝光&点击报价不要最低,也不要最高,理由就是最低的话,80%的钱会给你推到一些质量很差的微信用户,比如我。 ③、对接圈子,虽然圈子刚起步,不确定能不能做大,万一呢? 个人建议:企业一定要有一个自己的商城,哪怕没人买。这种东西怎么说呢,就好比一个企业站,虽然没什么用,但是得放那儿,万一客户要看呢? 推广方式:参考本文第四大板块内容 4、游戏类(非小游戏) 答题、成语、找茬等类似运营的比较多,可自行搜索,不要认为这是游戏,开发者就望而却步,在线教育类目是可以通过的,这个开发者很多都不知道。以下可参考: [图片] 盈利方式:流量主 基础分析:基本所有的模式都是闯关类型,这种类型的小程序,基本都是用户消磨时间用 运营分析:关卡尽量多,入门、初级、中级、高级,高级模式可以做类比循环,形成无限关卡模式,闯关奖励机制,签到机制等。这种类型的小程序比较方便运营,裂变起来也快。 个人建议:裂变模式一定要有,虽然微信会严格把控这方面功能,但是开发者可以做一些技巧,不要让用户强制或者主动去触发,这样微信对开发者还是很友好的。 推广方式:参考本文第四大板块内容 二、小程序开发 有实力的开发者,自己开发,云开发很快,会前端就可以了,没实力的去正规平台买源码,论坛源码也很多,有部分论坛还是嵌入了比特币勒索,自己做好防护。个人建议:开发者能开发尽量自己开发,后期迭代方便,不要像我一样,50多个小程序80%是买现成去运营的。反正各有各的好处,开发者可自行决定,运营者可选择直接购买源码直接上线运营,前提是自己看好功能是不是和自己要的一样。有些SAAS平台的开发者实力还是可以的,支持定制功能。此处不做广告,自行搜索或者询问朋友。 三、广告位位置及利润 开发者的每个页面广告位一定要分开!一定要分开!一定要分开!这样做的目的是为了分析每个广告位的利润,好去做调整,把收益最大化。 失败案例举例:小程序的主页、个人中心页用同一个banner广告位,这样做出来一点好处都没有,后台只能看到banner收益是多少,看不到是哪个页面收益。极端情况,收益全部再首页,个人中心页没有广告收益,这种情况开发者是不知道的,如果把广告位分开,这种情况可以去优化个人页面,或者主页面换成视频banner。广告位分析页面:流量主--数据统计--广告数据--广告指标明细--细分数据 [图片] [图片] 1、很多人表示,疫情期间流量主收入下滑。这个原因不是因为微信调整流量主收益,根本问题是自己的用户质量。举个例子,当你开通流量主之后,你的用户还是这1000个,假如你第一天收益为100,你很开心,1000用户就能赚100,你第二天就放弃推广了,这样的话,你的用户质量是会逐渐下滑,微信方完全可以认定为你这1000人都是自己的号,去刷广告费的。长此以往下去,你的流量主利润会无限趋向于0。举个栗子: [图片] 2、广告位位置一定要合理好看,但是不代表“流氓”,比如全明星代言的某游戏“元宝无限收一刀999”点哪儿哪充值。开发者需要注意的是小程序的质量,需要用户在每个页面停留的时长最起码30秒,这样一个完整的视频广告才能曝光完。 3、banner广告收益是按有效点击计算的。很多人好几千曝光,但是点击只有几个、十几个,这种情况需要不断去优化接入的场景/位置,提高用户点击意愿。个人技巧:banner广告位尽量不要太多,1-2个就可以。尽量多放几个视频广告位,这样曝光也有收益。格子广告没试过,用过都说不好~ 4、激励广告作为流量主最高收益是有一定道理的,用户为了获取某些奖励是必须观看完整的,所以给开发者建议:用户如果可以获得小程序内某些奖励,可以适当多放一些激励广告位。 5、所有的广告位都是根据用户年龄、爱好等参数去调取相应的广告,开发者不需要去考虑 6、广告收益个人认为:激励》视频》插屏》前贴》banner》格子(格子没试过,暂放倒数第一) 四、小程序推广 尽量做成年人主打的小程序,有些开发者觉得好玩儿,做一些儿童益智类的小程序,你是认为儿童有手机,还是认为家长愿意让孩子玩儿手机呢?这个很不解。没有鄙视的意思,也许是情怀吧~~毕竟我做小程序比较俗,就是为了赚钱。 主流推广方式:公众号引流、截流,由于涉及一些不合常规的内容,本文只说常规操作,剩下的自己领悟,或者可以联系我~ 首先小程序的名字至关重要,一个好的名字可以带来无限的流量,再加上裂变功能(邪恶的微笑)。起名字的时候可以用到的工具:搜索小程序-微信指数,查询关键字,尽量找稳定再1000万以上的搜索量,从关键字中摸索自己的小程序名字。这样用户搜索到你的小程序几率会很高~ 1、工具类核心玩儿法(适用于所有小程序推广):文章引流,截取关键字,火爆主题,比如2019年12月19日庆余年全集泄露、2020年疫情(不要发疫情数据内容,要发一些正能量的有内容文章去引流),我阅读过的文章最低的阅读量8000左右,最高的10万+,据说有好几百万的阅读量。如果你的文章写的好,结尾放一个小广告:为防止疫情蔓延,请给您的头像带上口罩~,啪,一个卡片小程序(或二维码),流量自己想~ 推广对象:18-30岁 2、返利类核心玩儿法: ①、可以参考工具类玩儿法 ②、各大微信群、QQ群,去推广,招代理等方式,或者去买一些基础流量,进行裂变,实际运营看下效果,好继续针对用户群体去推广,建立自己的群体系,群内发商品返利链接。微信好友没人?给你举个例子,我这篇文章发完,如果加个我的二维码,最起码能有100人加我,不是我文章写的有多好,是你永远不知道用户有什么样的目的和需求~ 推广对象:18-60岁 3、商城类核心玩儿法 ①、可参考返利类核心玩儿法,拥有自己的客户群体系,发一些自己的商品还是可以的,一定要带分销体系,你懂得~(最高3级,再高就是传销了) ②、广告主、目前效益个人感觉不明显,每次花1000块钱做广告,利润基本没有,和发广告的钱持平,而且用户留存也不是很高,可能是我的商品比较单一等各方面因素吧,不过赚流量还是不错的。 推广对象:18-30岁(以我的商城为例,还需看商城出售的内容) 4、游戏类核心玩儿法(非小游戏) ①、一个好的名字就够了。举例:精选商品橱窗(腾讯官方),微橱窗(我朋友的)。不得不说,这波流量很高,遗憾的是,他不是火爆的游戏类小程序~ [图片] ②、参考工具类玩儿法,文章引流截流 推广对象:18-40岁 五、小程序矩阵 矩阵一定要有,矩阵一定要有,矩阵一定要有,防截流,底配10个小程序。不是纯矩阵,是微信开发规定,每个小程序可以跳转10个小程序,开发者可以利用这个功能去添加自己的矩阵来获取更多的流量收益,保证自己的用户在自己的矩阵圈活动。 [图片] 写这篇文章主要是给大家传授经验,希望小白能学到点东西,入门后的朋友可领悟到更多运营方法,江湖之大,附月账单有缘再见 [图片]
2020-05-25 - 从源码看微信小程序启动过程
一、写作背景 接触小程序一年多,真实体验就是小程序开发门槛相对而言确实比较低。不过小程序的开发方式,一直是开发者吐槽的,如习惯了 Vue,React 开发的开发者经常会吐槽小程序一个 Page 必须由多个文件组成,组件化支持不完善或者说不能非常愉快的开发组件。在以前小项目中没太大感觉,从加入有赞,参与有赞微商城小程序的开发,是真切的体会到对于大型小程序项目开发的复杂性。 有赞从微信小程序内测就开始开发小程序,在不支持自定义组件的时代,只能通过 import 的形式拆分模块或实现组件。在业务复杂的页面,可能会 import 非常多的模块,而相应的 wxss 也需要 import 样式,除了操作繁琐,有时候也难免遗漏。 作为开发者,我们当然希望可以让工作更简单,更愉快,也希望改善我们的开发方式。所以希望能够更了解微信小程序框架,减少不必要的试错,于是有了一次对小程序框架的 debug 之旅。(基础库 1.9.93) 通过三周空余时间的 debug,也算对小程序框架有了一些浅显的认识,达到了最初的目的;对小程序启动,实例,运行等有了真切的体会。这篇文章记录了小程序框架的基本代码结构,启动流程,以及程序实例化过程。 本文的目的是希望把我看到的分享给对小程序感兴趣或者正在开发小程序的读者,主要解答“框架对传入的对象等到底做了什么”。 二、从启动流程一窥小程序框架细节 在开发者工具中使用 help() 方法,可以查看一些指令和方法。使用其中的 openVendor 方法可以打开微信开发者工具在小程序框架所在目录。其中以包括以基础库命名的目录和其他帮助文件,如其中有两个工具 wcc,wcsc。wcc 可把 wxml 转换为对应的 JS 函数 —— $gwx(path, global),wcsc 可将 wxss 转换为 css。而基础库目录包括 WAService.js 和 WAWebview.js 文件。小程序框架在开发者工具中以 WAService.js 命名(WAWebview.js 不知其作用,听说在真机环境使用该文件)。 在开发中工具命令行使用 document.head 可以查看到小程序的启动流程大致如下: [图片] 以小节的方式分别介绍这些流程,小程序是如何处理的(小节编号与图中编号相同)。 1、初始化全局变量 下图是小程序启动是初始化的一些全局的变量: [图片] 那些使用“__”开头,未在文档中提及可使用变量是不建议使用的,wxAppCode 在开发者工具中分为两类值,json 类型和 wxml 类型。以 .json 结尾的,其 key 值为开发者代码中对应的 json 文件的内容,.wxml 结尾的,其 key 值为通过调用 $gwx(’./pages/example/index.wxml’) 将得到一个可执行函数,通过调用这个函数可得到一个标识节点关系的 JSON 树。 [图片] 2、加载框架(WAService.js) 使用工具对 WAService.js 进行格式化后进行 debug。可以发现小程序框架大致由: WeixinJSBridge、 NativeBuffer、 wxConsole、 WeixinWorker、 JavaScript兼容(这部分为猜测)、 Reporter、 wx、 exparser、 virtualDOM、 appServiceEngine 几部分组成。 其中除了 wx 和 WeixinJSBridge 这两个基础 API 集合, exparser, virtualDOM, appServiceEngine 这三部分作为框架的核心, appServiceEngine 提供了框架最基本的接口如 App,Page,Component; exparser 提供了框架底层的能力,如实例化组件,数据变化监听,view 层与逻辑层的交互等;而 virtualDOM 则起着链接 appServiceEngine 和 exparser 的作用,如对开发者传入 Page 方法的对象进行格式化再传入 exparser 的对应方法处理。 框架对外暴露了以下API:Behavior,App,Page,Component,getApp,getCurrentPages,definePlugin,requirePlugin,wx。 3、业务代码的加载 在小程序中,开发者的 JavaScript 代码会被打包为 [代码]define('xxx.js', function(require, module, exports, window, document, frames, self, location, navigator, localStorage, history, Caches, screen, alert, confirm, prompt, fetch, XMLHttpRequest, WebSocket, webkit, WeixinJSCore, Reporter, print, WeixinJSBridge) { 'use strict'; // your code }) [代码] 这里的 define 是在框架中定义的方法,在框架中提供了两个方法:require 和 define 用来定义和使用业务代码。其方式有些像 AMD 规范接口,通过 define 定义一个模块,使用 require 来应用一个模块。但是也有很大区别,首先 define 限制了模块可使用的其他模块,如 window,document;其次 require 在使用模块时只会传入 require 和 module,也就是说参数中的其他模块在定义的模块中都是 undefined,这也是不能在开发者工具中获取一些浏览器环境对象的原因。 在小程序中,JavaScript 代码的加载方式和在浏览器中也有些不同,其加载顺序是首先加载项目中其他 js 文件(非注册程序和注册页面的 js 文件),其次是注册程序的 app.js,然后是自定义组件 js 文件,最后才是注册页面的 js 代码。而且小程序对于在 app.js 以及注册页面的 js 代码都会加载完成后立即使用 require 方法执行模块中的程序。其他的代码则需要在程序中使用 require 方法才会被执行。 下面详细介绍了 app.js,自定义组件,页面 js 代码的处理流程。 4、加载 app.js 与注册程序 在 app.js 加载完成后,小程序会使用 require(‘app.js’) 注册程序,即对 App 方法进行调用,App 方法是对 appServiceEngine.App 方法的引用。 下图是框架对于 App 方法调用时的处理流程: [图片] App 方法根据传入的对象实例化一个 app 实例,其生命周期函数 onLaunch 和 onShow 因为使用不同的方式获取 options的参数。在有些需要根据场景值来实现需求的,或许使用 onShow 中的场景值更合适。 在实际开发过程中发现,在微信顶部唤起小程序和在小程序列表唤起的 options 也是不一样的。在该案例中通过点击分享的小程序进入后,关闭小程序,再通过不同方式进入小程序,通过顶部唤起的还是 options 的 path 属性还是分享出来的 path,但是通过列表中打开直接回到了首页,这里 App 中的 onShow 就会获取到不同的 options。 5、加载自定义组件代码以及注册自定义组件 自定义组件在 app.js 之后被加载,小程序会在这个过程中加载完所有的自定义组件(分包中自定义组件没有有测试过),并且是加载完成后自动注册,只有注册完成后才会加载下一个自定义组件的代码。 下图是框架对于 Component 方法处理流程: [图片] 图中介绍了框架如何对传入 Component 方法的对象的处理,其后面还有很多深入的对于组件实例化的步骤没有在图中表示出来,具体可以在文章最后的附件中查看。 自定义组件在小程序中越来越完善,其拥有的能力也比 Page 更强大,而后面会提到在使用自定义组件的 Page 中,Page 实例也会使用和自定义组件一样的实例化方式,也就是说,他拥有和自定义组件一样的能力。 6、加载页面代码和注册页面 加载页面代码的处理流程和加载自定义组件一样,都是加载完成后先注册页面,然后才会加载下一个页面。 下图是注册一个页面时框架对于 Page 方法的处理流程: [图片] Page 方法会根据是否使用自定义组件做不同的处理。使用自定义组件的 page 对象会被处理为和自定义组件的结构,并在页面实例化时使用不同的处理流程进行实例化。当然对于开发而言没任何不同。 从图中可以发现 Page 传入的(生命周期)代码并不会在这里被执行,可以通过下面小节了解 Page 实例化的详细过程。 7、等待页面 Ready 和 Page 实例化 还记得上面介绍的启动流程中最后一步等待页面 Ready?严格来讲是等待浏览器 Ready,小程序虽然有部分原生的组件,不过本质上还是一个 web 程序。 在小程序中切换页面或打开页面时会触发 onAppRoute 事件,小程序框架通过 wx.onAppRoute 注册页面切换的处理程序,在所有程序就绪后,以 entryPagePath 作为入口使用 appLaunch 的方式进入页面。 下图是处理导航的程序流程: [图片] 从图中可以看出页面的实例化是在进入页面时进行,下图是具体的实例化过程: [图片] 下图是最终可得到 Page 实例: [图片] 可以发现其中多了 onRouteEnd API,实际该接口不会被调用。其中以 component 标记的表示只有在使用了自定义组件时才会有的方法和属性。在前面第 5 小节提到了对于使用自定义组件的页面会按照自定义组件方式解析,这些属性和方法与自定义组件表现一致。 8、关于 setData 小程序框架是一个以数据驱动的框架,当然不能少了对他如何实现数据绑定的探索,下图是 Page 实例的 setData 执行流程: [图片] 其中 component:setData 表示使用自定义组件的 Page 实例的 setData 方法。 三、写在最后 这是一次不完全的小程序框架探索,是在微信开发工具中 debug 的结果。虽然对于实际开发没有什么太大的帮助,但是对框架如何对开发的 js 代码进行处理有了一个很明确的认识,在使用一些 js 特性时可以有明确的感知。如果你还疑惑“小程序框架对传入的对象等到底做了什么”那一定是我表达能力太差,说声对不起。 通过这一次 debug ,也给我引入了新的问题,还希望能够有更多的讨论: · 自定义组件太多启动时会耗时处理自定义组件 · 文件太多会耗时读文件 · 合理的设计分包很重要 当然最后对于框架中已有的能力,还是非常希望微信可以开放更多稳定的接口,并在文档中告知开发者,让开发变得简单一些。
2019-03-05 - 小程序流量主、广告位类型和广告收益分析
小程序流量主、广告位类型和广告收益分析 ## 本文介绍 最近在小程序的几个微信群,经常有朋友问到以下几个问题 1、小程序怎么盈利 2、小程序流量主是什么以及怎么开通 3、小程序广告有哪些类型,哪种广告类型相对收益最大 4、 ## 小程序如何盈利 目前对个人小程序开发者而言,只有通过开通流量主,并且按照官方规范要求添加广告位,才能获取收益,当然打赏除外。 ## 什么是流量主如何开通 流量主是微信对外提供的一个服务,通过开通流量主,就可以在小程序合适的位置引入广告位,进而实现收益 登录公众号后台( https://mp.weixin.qq.com/ )在左侧菜单中,找到 推广-流量主,点击进去会看到如下截图 [图片] 小程序流量主: 1.开通条件:小程序累计独立访客(UV)1000以上,且无违规记录,即可开通流量主功能。 温馨提示:如满足条件仍无法开通,可能是数据同步问题,建议等待1-2个工作日后再试。 2.申请方法:进入微信公众平台小程序后台,点击左侧面板“流量主”,满足开通门槛的小程序开发者点击“开通”,提交财务资料,待审核通过后成功开通流量主功能,即可创建相应的广告位。 3.广告接入指引: 广告接入可查看: 微信小程序广告接入指引 在开通流量主的过程中,会绑定个人银行卡,以方便进行后续的广告收益结算,目前结算每月两次,具体官方公告可以查阅 [流量主结算周期及开票规则调整说明][2019-12-03发布] https://mp.weixin.qq.com/promotion/readtemplate?t=notice/detail_page&time=1575340587¬ice_id=634169 ## 广告类型有哪些 Banner激励式视频插屏视频广告前贴视频 以下为各广告类型,截图示例, [banner广告] [图片] [插屏广告] [图片] 视频广告 [图片] 由于插屏广告会影响用户体验,所以不建议放太多场景使用。 具体不同类型广告体验,可以扫码 [图片] 首页模块-->>插屏广告使用说明-->>视频广告关于我们-->>banner广告 ## 哪种广告类型收益相对最大 [图片] 在10月30号,将banner广告同一替换为激励式视频广告和视频广告,收益很明显从30元上升到90元、150元 可以看到视频广告相对于banner广告,对于收益增加是有用的。 下图是某小程序12月4号一天的收益数据 [图片] 12月4号一天,不同广告类型,收益分析 总收益 194.74+23.27+147.82=365.83 具体分拆来看 广告类型点击量总收益单个点击收益(元)banner1956194.740.099插屏广告6223.270.375激励式视频广告152147.820.972 通过上图我们对比分析,不难得出以下结论:激励式视频广告单个点击的收益最大、 当然我们不能通过单一维度来了解哪种收益最好,还要综合考虑,比如哪种广告对用户影响最小,毕竟不管哪种方式,广告的接入肯定会带来交互体验上的障碍, 我们必须在交互体验和广告收益这两者之间做好权衡。 ## 系统公告 激励式广告于7月31日支持30秒视频素材,广告流量将逐步放开,MP后台-广告位管理模块可支持选择6-15秒视频或6-30秒视频素材的功能,请流量主根据产品进行调整。程序视频广告已于9月4日正式全量上线,开通后即按广告曝光获得分成收入,进一步提升流量变现收益。小程序视频前贴广告组件已于8月30日正式全量上线,开通后即按广告曝光获得分成收入,进一步提升流量变现收益。## 官方文档 小程序广告组件流量主操作指引https://wximg.qq.com/wxp/pdftool/get.html?id=BJSyDkLqz&pa=14&name=miniprogramAds_supplier_manual应用规范https://wxa.wxs.qq.com/mpweb/delivery/legacy/pdftool/get.html?id=rynYA8o3f&pa=10&name=miniprogramAds_supplier_guidance小程序流量主应用规范https://wximg.qq.com/wxp/pdftool/get.html?id=rynYA8o3f&pa=10&name=miniprogramAds_supplier_guidance处罚标准https://wxa.wxs.qq.com/mpweb/delivery/legacy/pdftool/get.html?id=BkTGkbs2G&pa=1&name=miniprogramAds_supplier_regulation小程序视频广告流量主指引https://wximg.qq.com/wxp/pdftool/get.html?post_id=1317小程序视频前贴广告流量主指引https://wximg.qq.com/wxp/pdftool/get.html?post_id=1318## 总结三点 从纯收益的角度来讲,在各种广告类型中,视频广告(包含激励式视频广告、视频广告、视频前贴广告)要比banner广告要好,而且好很多从用户体验来讲,插屏广告是首次打开带插屏广告的页面强制弹出的,但是广告过后,在页面是不占空间的,这是区分与其他广告的地方,banner广告、激励式视频广告、视频广告、视频前贴广告都是在页面中占固定的空间的,这一点要小程序运营同学权衡。Banner广告是按点击,激励式视频、视频广告、插屏广告都是按照曝光来收取广告费用的,这一点非常重要,难怪我每次手工点击我的视频广告没有见流量的增加[哭脸.jpg]。[感谢 @ 仙森 补充于2019年12月9号] 虽然对个人开发者而言,我们开发小程序的目的是为了收益(当然也有为了情怀而开发),在了解如何收益的情况下,我们还是应该尽量把精力放在小程序本身的开发上面。 感谢 在此特别感谢,小程序运营讨论群的两位小伙伴,微信号中间两位已打码 1、@迭戈 (yang_##chun) 2、@风猫 (cs##26)
2020-12-25 - 小程序多平台同构方案分析-kbone 与 remax
当前国内小程序平台众多,微信小程序、支付宝小程序、头条小程序、以及未来还会出现的新小程序平台,所以为了解决一套代码可以在多个小程序平台上运行,出现了多种方案来解决,京东的 Taro、蚂蚁的 Remax、微信的 Kbone,各有特点,主要归为两种类型,编译时与运行时适配两种。此文介绍国内主流小程序的架构,以及通过运行时适配可达到一套小程序代码运行在多个小程序平台上的方案,主要介绍 kbone 与 remax 两套方案,他们原理基本一致,所有小程序代码都在 worker 线程上运行,最终在 worker 线程生成一棵 dom tree,再把 dom tree 同步到 render 线程上通过 w/axml 进行渲染。 小程序架构小程序本质上是运行在 webview 上的一个 H5 应用,代码经过打包后分别运行在 render 线程与 worker 线程,这么做最大的原因是保证平台安全性,不能让开发者控制 render 线程,控制 render 线程将会造成小程序平台方管控困难,比如通过 js dom api 操作 dom 元素,通过 location.href 随意跳转,那整个小程序就完全不可控,可以轻意绕过小程序审核,上线时是个正常小程序,开发者可以随意控制界面上展示的内容或随意跳转到赌博或黄色页面。小程序平台就把 view 与逻辑分离,view 放在 render 线程,并提供了一种特殊的语言(微信叫 wxml 、支付宝叫 axml)来写 view,并且不能写入 js 代码,逻辑就放在 worker 线程,由于 worker 并不能操作 dom,所以就解决了上面管控困难的问题,架构如下: [图片] 每个小程序界面有 axml 与 js 文件,js 文件是页面逻辑,逻辑主要做两件事情: 响应 render 线程的事件,并执行小程序业务逻辑。 准备好数据,通过 setData 传到 page 中,由 page 进行渲染。 以上是国内微信、支付宝、头条小程序的架构,但是开发者如果要把一个小程序支持三个平台和 web 平台,就需要开发多次,目前出现了多种同构平台。有编译时与运行时动态转换两种。 编译时 Taro 做的很成功,Taro 可以让开发者用 React 写小程序,最终经过编译转换到不同平台的小程序。 今天讲的是另外一种方案,不靠编译时来完成,而是在运行时做适配,分别是微信提供的 kbone 与支付宝提供的 remax 两个方案。 两个方案对比: 相同点 都是在 worker 线程维护一棵 vdom tree,然后同步到 render 线程通过 w|axml 来进行渲染。 不同点 kbone 是适配了 js dom api ,上层可以用任何框架,如 react、vue、原生 js 来写小程序。remax 是自已写了一套 react 的 renderer,上层只支持 react。 remax 在 dom tree 发生变化时,不是把整棵 vdom tree 传到 render 线程,而是计算差异,把差异传到 render 线程,这点可以加快了两个线程之间的数据传输速度。 kbonekbone 在 worker 线程适配了一套 js dom api,上层不管是哪种前端框架(react、vue)或原生 js 最终都需要调用 js dom api 操作 dom,适配的 js dom api 则接管了所有的 dom 操作,并在内存中维护了一棵 dom tree,所有上层最终调用的 dom 操作都会更新到这棵 dom tree 中,每次操作(有节流)后会把 dom tree 同步到 render 线程中,通过 wxml 自定义组件进行 render。 流程如下: [图片] 因此所有小程序的代码都是放在 worker 上跑,开发者可以通过不同的前端框架(react、vue、angular) 或原生 js 来构建小程序了。 worker 线程worker 线程会运行所有的小程序代码,并适配了 js dom api 和定义一套数据结构来描述一棵 dom tree。 模拟 js dom api 就是把 api 函数重新实现一次,这些函数用来操作自己在内存中维护的 dom tree,例如如下 api 方法: document.createElement document.createTextNode … 在 worker 线程中本身是没有 document 对象的,只需要把自己模拟的 document 对象存放到全局变量中,那上层的前端框架或原生 js 代码就能调用到了。通过 document 创建的每个节点有四个重要的属性: type: 当前节点类型 parentNode:父节点对象 childNodes: 孩子节点对象数组 当 worker 线程创建好了 dom tree 后,在内存中的大概长下面这样: [代码]{[代码][代码] [代码][代码]"innerChildNodes": [],[代码][代码] [代码][代码]"childNodes": [{[代码][代码] [代码][代码]"nodeId": "b-1573463704434",[代码][代码] [代码][代码]"pageId": "p-1573463704431-/pages/index/index",[代码][代码] [代码][代码]"type": "element",[代码][代码] [代码][代码]"tagName": "div",[代码][代码] [代码][代码]"id": "app",[代码][代码] [代码][代码]"class": "h5-div node-b-1573463704434 ", [代码][代码] [代码][代码]"childNodes": [{[代码][代码] [代码][代码]"nodeId": "b-1573463704435",[代码][代码] [代码][代码]"pageId": "p-1573463704431-/pages/index/index",[代码][代码] [代码][代码]"type": "element",[代码][代码] [代码][代码]"tagName": "div",[代码][代码] [代码][代码]"id": "",[代码][代码] [代码][代码]"class": "h5-div node-b-1573463704435 ", [代码][代码] [代码][代码]"childNodes": [{[代码][代码] [代码][代码]"nodeId": "b-1573463704436",[代码][代码] [代码][代码]"pageId": "p-1573463704431-/pages/index/index",[代码][代码] [代码][代码]"type": "element",[代码][代码] [代码][代码]"tagName": "button",[代码][代码] [代码][代码]"id": "",[代码][代码] [代码][代码]"class": "h5-button node-b-1573463704436 ", [代码][代码] [代码][代码]}, {[代码][代码] [代码][代码]"nodeId": "b-1573463704438",[代码][代码] [代码][代码]"pageId": "p-1573463704431-/pages/index/index",[代码][代码] [代码][代码]"type": "element",[代码][代码] [代码][代码]"tagName": "span",[代码][代码] [代码][代码]"id": "",[代码][代码] [代码][代码]"class": "h5-span node-b-1573463704438 ", [代码][代码] [代码][代码]} ][代码][代码] [代码][代码]}][代码][代码] [代码][代码]}][代码][代码]}[代码] 这是一棵多叉树,每个节点定义了当前节点的属性和孩子节点。接下来就是把这棵树传到 render 线程,并由 render 线程把他显示出来。这里传到 render 线程采用的是小程序提供的方法 setData,把这棵 dom tree 当成数据传到 render 界面。 render 线程 [代码]<[代码][代码]view[代码][代码]>[代码][代码] [代码][代码]<[代码][代码]picker[代码][代码]></[代码][代码]picker[代码][代码]>[代码][代码] [代码][代码]<[代码][代码]button[代码][代码]>点我</[代码][代码]button[代码][代码]>[代码][代码] [代码][代码]<[代码][代码]Element[代码][代码]>[代码][代码] [代码][代码]<[代码][代码]button[代码][代码]></[代码][代码]button[代码][代码]>[代码][代码] [代码][代码]<[代码][代码]button[代码][代码]></[代码][代码]button[代码][代码]>[代码][代码] [代码][代码]</[代码][代码]Element[代码][代码]>[代码][代码]</[代码][代码]view[代码][代码]>[代码]上面代码是 wxml 语法写的一个小程序界面,worker 线程中的内存 dom tree 可以和 wxml 里的节点一一对应,只需要把 dom tree 通过递归迭代映射到 wxml 的节点。 kbone 定义了一个 [Element 自定义组件],用于渲染 dom tree 上的每个节点和他的孩子节点。 Element 节点做的事情比较简单,首先是把自己渲染出来,然后再把子节点渲染出来,同时子节点的子节点又通过 Element 来渲染,这样就通过自定义组件实现了递归功能,这是 wxml 自定义组件提供的自引用特性,每个节点通过 dom 节点的 type 来区分,从而把一棵内存 dom tree 通过 wxml 渲染出来了。 Element 代码如下(简略): [代码]<!--当前节点-->[代码][代码]<[代码][代码]cover-view[代码] [代码]wx:elif[代码][代码]=[代码][代码]"{{wxCompName === 'cover-view'}}"[代码] [代码]id[代码][代码]=[代码][代码]"{{id}}"[代码] [代码]class[代码][代码]=[代码][代码]"{{class}}"[代码] [代码]style[代码][代码]=[代码][代码]"{{style}}"[代码] [代码]hidden[代码][代码]=[代码][代码]"{{hidden}}"[代码] [代码]scroll-top[代码][代码]=[代码][代码]"{{scrollTop}}"[代码][代码]>[代码][代码] [代码][代码]<[代码][代码]template[代码] [代码]is[代码][代码]=[代码][代码]"subtree-cover"[代码] [代码]data[代码][代码]=[代码][代码]"{{childNodes: innerChildNodes}}"[代码] [代码]/>[代码][代码]</[代码][代码]cover-view[代码][代码]><[代码][代码]scroll-view[代码] [代码]wx:elif[代码][代码]=[代码][代码]"{{wxCompName === 'scroll-view'}}"[代码] [代码]id[代码][代码]=[代码][代码]"{{id}}"[代码] [代码]class[代码][代码]=[代码][代码]"{{class}}"[代码] [代码]style[代码][代码]=[代码][代码]"{{style}}"[代码] [代码]hidden[代码][代码]=[代码][代码]"{{hidden}}"[代码] [代码]scroll-x[代码][代码]=[代码][代码]"{{scrollX}}"[代码] [代码]scroll-y[代码][代码]=[代码][代码]"{{scrollY}}"[代码] [代码]upper-threshold[代码][代码]=[代码][代码]"{{upperThreshold}}"[代码] [代码]lower-threshold[代码][代码]=[代码][代码]"{{lowerThreshold}}"[代码] [代码]scroll-top[代码][代码]=[代码][代码]"{{scrollTop}}"[代码] [代码]scroll-left[代码][代码]=[代码][代码]"{{scrollLeft}}"[代码] [代码]scroll-into-view[代码][代码]=[代码][代码]"{{scrollIntoView}}"[代码] [代码]scroll-with-animation[代码][代码]=[代码][代码]"{{scrollWithAnimation}}"[代码] [代码]enable-back-to-top[代码][代码]=[代码][代码]"{{enableBackToTop}}"[代码] [代码]enable-flex[代码][代码]=[代码][代码]"{{enableFlex}}"[代码] [代码]bindscrolltoupper[代码][代码]=[代码][代码]"onScrollViewScrolltoupper"[代码] [代码]bindscrolltolower[代码][代码]=[代码][代码]"onScrollViewScrolltolower"[代码] [代码]bindscroll[代码][代码]=[代码][代码]"onScrollViewScroll"[代码][代码]>[代码][代码] [代码][代码]<[代码][代码]template[代码] [代码]is[代码][代码]=[代码][代码]"subtree"[代码] [代码]data[代码][代码]=[代码][代码]"{{childNodes: innerChildNodes, inCover}}"[代码] [代码]/>[代码][代码]</[代码][代码]scroll-view[代码][代码]>[代码][代码]<[代码][代码]live-player[代码] [代码]wx:elif[代码][代码]=[代码][代码]"{{wxCompName === 'live-player'}}"[代码] [代码]id[代码][代码]=[代码][代码]"{{id}}"[代码] [代码]class[代码][代码]=[代码][代码]"{{class}}"[代码] [代码]style[代码][代码]=[代码][代码]"{{style}}"[代码] [代码]hidden[代码][代码]=[代码][代码]"{{hidden}}"[代码] [代码]src[代码][代码]=[代码][代码]"{{src}}"[代码] [代码]mode[代码][代码]=[代码][代码]"{{mode}}"[代码] [代码]autoplay[代码][代码]=[代码][代码]"{{autoplay}}"[代码] [代码]muted[代码][代码]=[代码][代码]"{{muted}}"[代码] [代码]orientation[代码][代码]=[代码][代码]"{{orientation}}"[代码] [代码]object-fit[代码][代码]=[代码][代码]"{{objectFit}}"[代码] [代码]background-mute[代码][代码]=[代码][代码]"{{backgroundMute}}"[代码] [代码]min-cache[代码][代码]=[代码][代码]"{{minCache}}"[代码] [代码]max-cache[代码][代码]=[代码][代码]"{{maxCache}}"[代码] [代码]sound-mode[代码][代码]=[代码][代码]"{{soundMode}}"[代码] [代码]auto-pause-if-navigate[代码][代码]=[代码][代码]"{{autoPauseIfNavigate}}"[代码] [代码]auto-pause-if-open-native[代码][代码]=[代码][代码]"{{autoPauseIfOpenNative}}"[代码] [代码]bindstatechange[代码][代码]=[代码][代码]"onLivePlayerStateChange"[代码] [代码]bindfullscreenchange[代码][代码]=[代码][代码]"onLivePlayerFullScreenChange"[代码] [代码]bindnetstatus[代码][代码]=[代码][代码]"onLivePlayerNetStatus"[代码][代码]>[代码][代码] [代码][代码]<!--递归-->[代码][代码] [代码][代码]<[代码][代码]template[代码] [代码]is[代码][代码]=[代码][代码]"subtree-cover"[代码] [代码]data[代码][代码]=[代码][代码]"{{childNodes: innerChildNodes}}"[代码] [代码]/>[代码][代码]</[代码][代码]live-player[代码][代码]>[代码] [代码]<!--子节点-->[代码][代码]<[代码][代码]block[代码] [代码]wx:for[代码][代码]=[代码][代码]"{{childNodes}}"[代码] [代码]wx:key[代码][代码]=[代码][代码]"nodeId"[代码] [代码]wx:for-item[代码][代码]=[代码][代码]"item1"[代码][代码]>[代码][代码] [代码][代码]<[代码][代码]block[代码] [代码]wx:if[代码][代码]=[代码][代码]"{{item1.type === 'text'}}"[代码][代码]>{{item1.content}}</[代码][代码]block[代码][代码]>[代码][代码] [代码][代码]<[代码][代码]image[代码] [代码]wx:elif[代码][代码]=[代码][代码]"{{item1.isImage}}"[代码] [代码]data-private-node-id[代码][代码]=[代码][代码]"{{item1.nodeId}}"[代码] [代码]data-private-page-id[代码][代码]=[代码][代码]"{{item1.pageId}}"[代码] [代码]id[代码][代码]=[代码][代码]"{{item1.id}}"[代码] [代码]class[代码][代码]=[代码][代码]"{{item1.class || ''}}"[代码] [代码]style[代码][代码]=[代码][代码]"{{item1.style || ''}}"[代码] [代码]src[代码][代码]=[代码][代码]"{{item1.src}}"[代码] [代码]rendering-mode[代码][代码]=[代码][代码]"{{item1.mode ? 'backgroundImage' : 'img'}}"[代码] [代码]mode[代码][代码]=[代码][代码]"{{item1.mode}}"[代码] [代码]lazy-load[代码][代码]=[代码][代码]"{{item1.lazyLoad}}"[代码] [代码]show-menu-by-longpress[代码][代码]=[代码][代码]"{{item1.showMenuByLongpress}}"[代码] [代码]bindtouchstart[代码][代码]=[代码][代码]"onTouchStart"[代码] [代码]bindtouchmove[代码][代码]=[代码][代码]"onTouchMove"[代码] [代码]bindtouchend[代码][代码]=[代码][代码]"onTouchEnd"[代码] [代码]bindtouchcancel[代码][代码]=[代码][代码]"onTouchCancel"[代码] [代码]bindtap[代码][代码]=[代码][代码]"onTap"[代码] [代码]bindload[代码][代码]=[代码][代码]"onImgLoad"[代码] [代码]binderror[代码][代码]=[代码][代码]"onImgError"[代码][代码]></[代码][代码]image[代码][代码]>[代码][代码] [代码][代码]<[代码][代码]view[代码] [代码]wx:elif[代码][代码]=[代码][代码]"{{item1.isLeaf || item1.isSimple}}"[代码] [代码]data-private-node-id[代码][代码]=[代码][代码]"{{item1.nodeId}}"[代码] [代码]data-private-page-id[代码][代码]=[代码][代码]"{{item1.pageId}}"[代码] [代码]id[代码][代码]=[代码][代码]"{{item1.id}}"[代码] [代码]class[代码][代码]=[代码][代码]"{{item1.class || ''}}"[代码] [代码]style[代码][代码]=[代码][代码]"{{item1.style || ''}}"[代码] [代码]bindtouchstart[代码][代码]=[代码][代码]"onTouchStart"[代码] [代码]bindtouchmove[代码][代码]=[代码][代码]"onTouchMove"[代码] [代码]bindtouchend[代码][代码]=[代码][代码]"onTouchEnd"[代码] [代码]bindtouchcancel[代码][代码]=[代码][代码]"onTouchCancel"[代码] [代码]bindtap[代码][代码]=[代码][代码]"onTap"[代码][代码]>[代码][代码] [代码][代码]{{item1.content}}[代码][代码] [代码][代码]<[代码][代码]block[代码] [代码]wx:for[代码][代码]=[代码][代码]"{{item1.childNodes}}"[代码] [代码]wx:key[代码][代码]=[代码][代码]"nodeId"[代码] [代码]wx:for-item[代码][代码]=[代码][代码]"item2"[代码][代码]>[代码][代码] [代码][代码]<[代码][代码]block[代码] [代码]wx:if[代码][代码]=[代码][代码]"{{item2.type === 'text'}}"[代码][代码]>{{item2.content}}</[代码][代码]block[代码][代码]>[代码][代码] [代码][代码]<[代码][代码]image[代码] [代码]wx:elif[代码][代码]=[代码][代码]"{{item2.isImage}}"[代码] [代码]data-private-node-id[代码][代码]=[代码][代码]"{{item2.nodeId}}"[代码] [代码]data-private-page-id[代码][代码]=[代码][代码]"{{item2.pageId}}"[代码] [代码]id[代码][代码]=[代码][代码]"{{item2.id}}"[代码] [代码]class[代码][代码]=[代码][代码]"{{item2.class || ''}}"[代码] [代码]style[代码][代码]=[代码][代码]"{{item2.style || ''}}"[代码] [代码]src[代码][代码]=[代码][代码]"{{item2.src}}"[代码] [代码]rendering-mode[代码][代码]=[代码][代码]"{{item2.mode ? 'backgroundImage' : 'img'}}"[代码] [代码]mode[代码][代码]=[代码][代码]"{{item2.mode}}"[代码] [代码]lazy-load[代码][代码]=[代码][代码]"{{item2.lazyLoad}}"[代码] [代码]show-menu-by-longpress[代码][代码]=[代码][代码]"{{item2.showMenuByLongpress}}"[代码] [代码]bindtouchstart[代码][代码]=[代码][代码]"onTouchStart"[代码] [代码]bindtouchmove[代码][代码]=[代码][代码]"onTouchMove"[代码] [代码]bindtouchend[代码][代码]=[代码][代码]"onTouchEnd"[代码] [代码]bindtouchcancel[代码][代码]=[代码][代码]"onTouchCancel"[代码] [代码]bindtap[代码][代码]=[代码][代码]"onTap"[代码] [代码]bindload[代码][代码]=[代码][代码]"onImgLoad"[代码] [代码]binderror[代码][代码]=[代码][代码]"onImgError"[代码][代码]></[代码][代码]image[代码][代码]>[代码][代码] [代码][代码]<[代码][代码]view[代码] [代码]wx:elif[代码][代码]=[代码][代码]"{{item2.isLeaf || item2.isSimple}}"[代码] [代码]data-private-node-id[代码][代码]=[代码][代码]"{{item2.nodeId}}"[代码] [代码]data-private-page-id[代码][代码]=[代码][代码]"{{item2.pageId}}"[代码] [代码]id[代码][代码]=[代码][代码]"{{item2.id}}"[代码] [代码]class[代码][代码]=[代码][代码]"{{item2.class || ''}}"[代码] [代码]style[代码][代码]=[代码][代码]"{{item2.style || ''}}"[代码] [代码]bindtouchstart[代码][代码]=[代码][代码]"onTouchStart"[代码] [代码]bindtouchmove[代码][代码]=[代码][代码]"onTouchMove"[代码] [代码]bindtouchend[代码][代码]=[代码][代码]"onTouchEnd"[代码] [代码]bindtouchcancel[代码][代码]=[代码][代码]"onTouchCancel"[代码] [代码]bindtap[代码][代码]=[代码][代码]"onTap"[代码][代码]>[代码][代码] [代码][代码]{{item2.content}} [代码][代码] [代码][代码]</[代码][代码]view[代码][代码]>[代码][代码] [代码][代码]<!--递归-->[代码][代码] [代码][代码]<[代码][代码]element[代码] [代码]wx:elif[代码][代码]=[代码][代码]"{{item2.type === 'element'}}"[代码] [代码]in-cover[代码][代码]=[代码][代码]"{{inCover}}"[代码] [代码]data-private-node-id[代码][代码]=[代码][代码]"{{item2.nodeId}}"[代码] [代码]data-private-page-id[代码][代码]=[代码][代码]"{{item2.pageId}}"[代码] [代码]id[代码][代码]=[代码][代码]"{{item2.id}}"[代码] [代码]class[代码][代码]=[代码][代码]"{{item2.class || ''}}"[代码] [代码]style[代码][代码]=[代码][代码]"{{item2.style || ''}}"[代码] [代码]bindtouchstart[代码][代码]=[代码][代码]"onTouchStart"[代码] [代码]bindtouchmove[代码][代码]=[代码][代码]"onTouchMove"[代码] [代码]bindtouchend[代码][代码]=[代码][代码]"onTouchEnd"[代码] [代码]bindtouchcancel[代码][代码]=[代码][代码]"onTouchCancel"[代码] [代码]bindtap[代码][代码]=[代码][代码]"onTap"[代码] [代码]generic:custom-component[代码][代码]=[代码][代码]"custom-component"[代码][代码]></[代码][代码]element[代码][代码]>[代码][代码] [代码][代码]</[代码][代码]block[代码][代码]>[代码][代码] [代码][代码]</[代码][代码]view[代码][代码]>[代码][代码] [代码][代码]<[代码][代码]element[代码] [代码]wx:elif[代码][代码]=[代码][代码]"{{item1.type === 'element'}}"[代码] [代码]in-cover[代码][代码]=[代码][代码]"{{inCover}}"[代码] [代码]data-private-node-id[代码][代码]=[代码][代码]"{{item1.nodeId}}"[代码] [代码]data-private-page-id[代码][代码]=[代码][代码]"{{item1.pageId}}"[代码] [代码]id[代码][代码]=[代码][代码]"{{item1.id}}"[代码] [代码]class[代码][代码]=[代码][代码]"{{item1.class || ''}}"[代码] [代码]style[代码][代码]=[代码][代码]"{{item1.style || ''}}"[代码] [代码]bindtouchstart[代码][代码]=[代码][代码]"onTouchStart"[代码] [代码]bindtouchmove[代码][代码]=[代码][代码]"onTouchMove"[代码] [代码]bindtouchend[代码][代码]=[代码][代码]"onTouchEnd"[代码] [代码]bindtouchcancel[代码][代码]=[代码][代码]"onTouchCancel"[代码] [代码]bindtap[代码][代码]=[代码][代码]"onTap"[代码] [代码]generic:custom-component[代码][代码]=[代码][代码]"custom-component"[代码][代码]></[代码][代码]element[代码][代码]>[代码][代码]</[代码][代码]block[代码][代码]>[代码] remaxremax 是通过 react 来写小程序,整个小程序是运行在 worker 线程,remax 实现了一套自定义的 renderer,原理是在 worker 线程维护了一套 vdom tree,这个 vdom tree 会通过小程序提供的 setData 方法传到 render 线程,render 线程则把 vdom tree 递归的遍历出来。 所以整体实现和 kbone 类似,都是在 worker 线程维护一棵 dom tree,再把这棵 dom tree 传到 render 线程进行渲染,唯一的区别是 remax dom tree 发生变化时,会计算差异,而不需要把整棵树都传到 render 线程,此功能是 react 提供的,就是在 diff 完后找出差异,则把差异传到 render 线程,例如: [图片] 差异里面记录好了是哪个节点要进行删除或添加,其中 path 变量标识是树上的哪个节点,如 root.children.0.children.1,他代表的意思就是顶节点下第 0 个孩子节点下的第 1 个孩子节点。 render 线程会记录一棵 vdom tree 在内存中,每次 worker 线程传过来的 patch 会标识要操作树上的哪些节点,把这些节点 patch 到 render 线程的 vdom tree 上后,再更新到界面上。 总结小程序同构方案出现过很多,把 vue 或 react 替换掉现有的小程序开发方式真是很不错,开发者可以拿自己熟悉的开发框架来开发小程序,同时 vue 与 react 的社区生态这么成熟,如组件库、状态管理框架等都可以直接拿来使用,加快了小程序的开发速度。 kbone 与 remax 两套方案,感觉 kbone 发展前景不错,他可以让你通过 vue 与 react 等所有框架来开发小程序。但是里面肯定还有很多坑要解决,一个成熟的框架还需要相关配套都成熟,目前 kbone 与 remax 这两块做的还不够,希望后期他们可以加快开发速度,完善相关配套。
2019-11-13 - 有开通小程序流量主的吗?收益怎么样啊?
求大神赐教。想开发
2019-01-07 - 小程序请求数据双向混合加密和防篡改+防重放攻击的实现
前言 大家好,借着中秋放假明日又要上班的这个晚上,平常又没空,趁这个时间点就决定来一篇。 [图片] 我们都知道微信小程序的服务端API上,官方可谓是做足了心思,对用户的数据进行加密,虽然对我们开发者来说似乎是一种麻烦,但是从长远角度来看,是十分有必要的,用户隐私高于一切。 那么在小程序的开发过程中与后端对接接口时是否有想过这样的问题呢? HTTPS真的百分百安全吗? 数据被成功抓包后安全吗? 数据被篡改后还有效吗? 请求被重放后安全吗? 世界上没有绝对安全的系统,但我们可以让它被破解的成本变高。 本篇文章专业性并不高,如果存在错误请大家为我指出来,以免误导别人,谢谢! 以下我们将小程序端称为C端,服务端称为S端,服务端代码是Node.js,仅供参考,但原理都一样,后端可以是其它语言。 思考 围绕着以上的问题,探究一下问题的答案? 本部分只对问题做思考,具体实现请参考下面的实现部分。 HTTPS真的百分百安全吗?只能说是相对安全,当然,在微信小程序的沙箱环境里,HTTPS通信会更加安全,否则官方可能会要求我们对请求加密了对吧。但百密一疏,C端是否存在漏洞,假设C端安全了难道S端就安全嘛?细思恐极,退一万步讲,百分百安全是不存在的,由于篇幅问题相关漏洞大家可以搜索探究。 假设数据被中间人成功抓包,如果数据是明文传输,那么将导致数据泄露,因此对数据进行加密是必要的,但应该如何加密呢?如何做到密钥安全?C端和S端如何进行数据的加解密?RSA和AES加密应该使用哪种加密明文?如何充分发挥RSA和AES两个加密算法的特长? 假设数据被篡改,如果因为请求数据被篡改而导致严重后果,那么很大程度上其实是代码设计有问题,正常设计中安全应被摆在重要的位置,至少不应该出现购买某样商品时是通过C端发送商品价格给S端调起支付那样(只需篡改商品价格即可支付极少的钱购买商品),如果代码设计并不存在严重问题,那么数据被篡改也是不可忽视的,我们需要进行数据签名,让不同的数据拥有唯一的MD5哈希值,如果数据被篡改,通过哈希值即可判断数据是否被篡改,当然也可能会有人问,既然数据被篡改,那攻击者会不会重新生成一个哈希值代替?是的。但是我们有接下来会说到的key,key会作为签名的数据变量之一,由于攻击者并不知道key值因此无法重新签名,key值建议不是固定值而是周期性更换的随机值,例如随着用户的登录态而产生并随之抹除。 假设请求被重放,大部分时候由于数据被加密和防篡改处理过,攻击者并无法直接获得数据或篡改,但如果通过劫持到的登录认证请求的原始数据并重新发起该请求,则攻击者将可能获得重要的认证数据,使得系统将攻击者作为正常用户处理,后续的请求攻击者仍能伪装成正常的用户进行后续攻击。 期望效果 在开始实现之前先看一下完成后的效果,以下是截取了Network中其中一个GET请求发送的数据: [图片] 实际上发送的数据是如下图所示: [图片] 然后是该请求返回的数据: [图片] 实际上返回的数据是如下图所示: [图片] 整个流程: [图片] 实现 由于整个流程在C端的实现上顺序反过来的,因此下面的步骤也将是反向而行。 工具库下载 工欲善其事,必先利其器!这三个库是经过修改压缩的,支持在小程序上使用并且体积可观(总共69.1KB,如果不涉及密码哈希处理只需要前两个库,体积只有65.3KB),接下来的实现操作将会使用到,建议大家可以根据实际情况对功能进行二次封装: CryptoJS.js:点击下载 RSA.js:点击下载 SHA256.js(可选):点击下载 在线的各类加解密工具(可选,可以收藏起来,平时测试挺有用):点击访问 请求数据防重放 要防范请求重放攻击,首先需要了解Unix时间戳 timestamp概念,和时间戳不一样的是它的单位是秒,事实上这个需求也只需要秒级即可。除此之外还将用到另一个值:nonce,它是一个随机产生并只能被使用一次的值,长度自定,请求越频繁长度需要越长(降低同一时间产生相同nonce的几率),C端发送请求时需要将timestamp和生成的nonce加入发送的参数中。那么,如何将两者结合呢?我这为防重放的目标下了一个简单的定义。 同样的请求只能发生一次,且请求必须在规定时间内发出。 何为同样的请求?不是指两次发送的参数一致就是一样的,而是连timestamp和nonce也一样才算是同样的请求。 那么S端如何确认其是同样的请求呢? S端每次接收到一个请求,都会将该请求的nonce存入缓存并保持60秒(这个阈值不一定是60秒,可以根据实际需要定义),时间过后该值将被移除,建议S端采用Redis存储nonce,这样可省去检测和移除nonce的代码。如果S端发现当前请求的nonce存在于已存储的nonce之中,则此请求发生重复,那么timestamp有何用? 如果只使用nonce我们只能保证该请求60秒内不会重复,但60秒后依然任人宰割,这不是要的结果。所以timestamp将用来限制时间,S端时间戳减去C端发送请求的时间戳,得到的差值为N秒,如果N秒大于60秒则此请求过期,那么则可以保证,60秒内因为nonce相同而被判为请求重放,60秒后因为时间差超过而被判为请求已过期,因此确保了请求不会被重放。。 以下展示三种情况: [代码]C端时间戳:1568487720 //2019/9/15 03:02:00 C端NONCE:5rKbMs2Fm3 C端发送请求 -> S端接收请求 S端时间戳:1568487722 //2019/9/15 03:02:02 CS端时间差:1568487722 - 1568487720 = 2 C端NONCE是否存在于缓存:false 【重放校验通过】 [代码] [代码]C端时间戳:1568487722 //2019/9/15 03:05:22 C端NONCE:IzFEs52bAC C端发送请求 -> S端接收请求 S端时间戳:1568487922 //2019/9/15 03:02:00 CS端时间差:1568487922 - 1568487722 = 200 C端NONCE是否存在于缓存:false 【重放校验不通过,请求已过期,因为时间差超过60秒】 [代码] [代码]C端时间戳:1568487720 //2019/9/15 03:02:00 C端NONCE:IxwPHQU0nA C端发送请求 -> S端接收请求 S端时间戳:1568487722 //2019/9/15 03:02:02 CS端时间差:1568487722 - 1568487720 = 2 C端NONCE是否存在于缓存:true 【重放校验不通过,此请求为重放请求,因为nonce已经存在,此请求已经完成,不可重复】 [代码] timestamp和nonce将作为参数参与下面部分的签名。 请求数据防篡改 C端数据签名 首先通过对参数按照参数名进行字典排序(调过一些第三方API的朋友应该明白),假设当前需要传输的参数如下: [代码]{ "c": 123, "b": 456, "a": 789, "timestamp": 1568487720, "nonce": "5rKbMs2Fm3" } [代码] 进行字典排序,参数名顺序应为: [代码]const keys = Object.keys(data); //获得参数名数组 keys.sort(); //字典排序 console.log(key); //["a", "b", "c", "nonce", "timestamp"]; [代码] 参数字典排序后应和参数一起拼接为字符串,至于使用什么拼接符就要与S端商量了,如果参数值是一个数组或一个对象(如c为[1,2,3])那么可以将数据值转为JSON字符串再拼接。以上参数拼接后字符串如下: [代码]a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720 [代码] 下一步是计算拼接字符串的MD5哈希值了嘛? 不是。因为这样拼接的字符串很容易被攻击者伪造签名并篡改数据,这样就失去了签名的意义了。也就是说缺了一个key值。key值又从何而来?上面思考部分有提到建议从登录后发放,并且这个key与该用户的登录态绑定,登录态有效期间,将使用这个key进行请求的签名与验签,至于如何鉴别用户,我们会在最终发送给S端的参数加入一个sessionId作为登录态唯一标识,这里不加是因为这部分数据是需要参与后续的加密的,而sessionId不参与加密。 但是可能又会有一个问题,登录前没有key怎么实现的登录请求?事实上,登录请求并不怕篡改,因为攻击者自己也不知道账号密码,所以无需提供key用于登录请求的签名。 提到登录密码这个需要注意一点,密码不能明文传输,请计算哈希值后传输,S端比对账户密码哈希值即可确认是否正确,同样S端非特殊情况也不能明文存储密码,建议SHA-1或更高级的SHA-256计算后的值,MD5值可能被使用彩虹表(一种为各种常见密码建立的MD5映射表)破解。SHA256计算的库已在上面工具库下载提供。 拼接上我们登录时随机生成的key: [代码]a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720&key=gUelv79KTcFaCkVB [代码] 接下来计算32位MD5哈希值 为什么上面说MD5会被破解而这里却用MD5计算?因为此处计算MD5的目的并不是为了隐藏明文数据,而只是用于数据校验 此处引入了CryptoJS.js [代码]const CryptoJS = require('./CryptoJS'); const signStr = "a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720&key=gUelv79KTcFaCkVB"; const sign = CryptoJS.MD5(signStr).toString(); //a42af0962de99e698d27030c5c9d3b0e [代码] 这么一来我们的数据签名阶段就完成了,然后需要把签名加入参数之中,将和参数一起传输,需要注意的是传输参数无需在意参数名排序。以下是当前的参数处理结果: [代码]{ "c": 123, "b": 456, "a": 789, "timestamp": 1568487720, "nonce": "5rKbMs2Fm3", "sign": "a42af0962de99e698d27030c5c9d3b0e" } [代码] 其实从上面这两部分内容来看,会发现防重放和防篡改是相辅相成的,就像两兄弟一样,少了谁都干不好这件事。 S端验证签名 既然有签名那必定也有验签,验签流程其实就是重复C端的前面流程并比对CS两端得出的签名值是否一致。S端取得请求数据后(假设数据未加密,暂时不讨论解密),将除了sign之外的参数名进行字典排序,sign用一个临时变量存下,然后排好序的参数和C端一样拼接得到字符串。 接下来根据上面部分提到的未加密参数sessionId获得用户登录态并获取到该状态的临时key拼接到字符串末尾,接下来进行MD5计算即可得到S端方获得的签名,此时与请求中携带的sign比较是否一致则可确定签名是否有效,如果不一致返回签名错误。具体流程请参考以下: [代码]C端发送请求 -> S端接收请求 //请求参数为data const _sign = data.sign; //排序并拼接除sign外的参数 let signStr = a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720 const sessionId = ...; //用户的sessionId const sessionData = getUserSessionData(sessionId);//根据sessionId查询用户登录态sessionData并取出key,并拼接到字符串尾部 const key = sessionData.key signStr += key; //MD5计算并比对,代码仅供参考 const sign = crypto.md5(signStr); if(sign !== _sign) { //签名错误! } [代码] 请求数据加解密 有了上面部分的参数处理铺垫后,接下来就该开始本文章最核心的加解密,在这之前我们先了解AES和RSA两种加密算法。了解概念后我们再来思考一下两者的一些特性。 AES加解密需要密钥,除某些模式外还需要提供初始化向量,可使用密钥解出明文,是对称加密算法。 RSA加解密需要一对密钥,分别为公钥和私钥,公钥加密,私钥解密,是非对称加密算法。 两者在加密长文本性能上AES占优势。 根据这些让我们发现,他们可以形成互补关系,RSA加解密安全性高但长文本处理性能不及AES。AES加解密长文本性能优于RSA但需要明文密钥和向量加解密,密钥的安全性成问题。 那么在C端生成随机的AES密钥和向量,使用密钥和向量使用aes-128-cbc加密模式(也可以根据实际需要采用其它的模式)加密真正需要传输的参数(参数则是经过防重放+防篡改处理的参数)得到encryptedData,然后将该密钥使用RSA公钥加密得到encryptedKey,下面我顺便把AES加密的向量也一起加密了得到encryptedIV,这样就完美的互补了对方的缺点,既能够较快的完成数据加密又能保证密钥安全性,两全其美。 整个个加解密流程如下图所示: [图片] 下面的四个小章节将逐一描述流程实现: C端加密数据 C端加密后的数据应如下(并非固定格式,根据自己需要定制): [代码]{ "sessionId": "xxxxxxxx", "encryptedData": "xxxxxx", "encryptedKey": "xxxxxx", "encryptedIV": "xxxxxxx" } [代码] 但RSA公钥又是怎么发放到C端的呢?答案是在登录认证的时候服务器下发的,登录成功时服务器会创建RSA密钥对并把公钥发放给C端,私钥存在服务器上该用户的登录态数据中。流程如下所示: [代码]C端发起登录请求 -> S端接收登录请求 S端登录认证是否通过 true S端生成RSA密钥对 - publicKey , privateKey S端查询相关用户信息 S端生成登录态信息 -> 向登录态信息存入privateKey私钥,登录态信息类似如下 "xxxxxx": { id: "xxxxx", authData: { key: "xxxxxx", privateKey: "xxxxxx" } } S端返回登录态唯一标识sessionId和publicKey公钥以及相关用户信息 -> C端 C端存储登录态信息于本地,后续请求将使用服务器提供的公钥进行加密 [代码] 其中privateKey就是该用户当前登录态所使用的解密私钥,C端通过公钥加密后的AES密钥数据只能用该私钥解密。 如果希望登录阶段的请求也加密,那么可以手动生成一个RSA密钥对,然后客户端放置一个固定的公钥,服务器也使用一个固定的私钥进行登录阶段的加解密。 具体实现如下: 此处引入了CryptoJS.js和RSA.js [代码]const CryptoJS = require('./CryptoJS'); const RSA = require('./RSA.js'); //假设当前已登录成功并获得S端下发的RSA公钥且已存入本地存储 //createRandomStr为生成随机大小写英文和数字的字符串 const aesKey = createRandomStr(16); //生成AES128位密钥 16字节=128位 const aesIV = createRandomStr(16); //生成初始化向量IV const raw = JSON.stringfly({ "c": 123, "b": 456, "a": 789, "timestamp": 1568487720, "nonce": "5rKbMs2Fm3", "sign": "a42af0962de99e698d27030c5c9d3b0e" }); const encryptedData = CryptoJS.AES.encrypt(data: raw, key: aesKey, { iv: aesIV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); //使用CBC模式和Pkcs7填充加密 const authData = wx.getStorageSync("authData"); //读取本地存的RSA公钥 RSA.setPublicKey(authData.publicKey); //设置RSA公钥 const encryptedKey = RSA.encrypt(aesKey); //RSA加密AES加密密钥 const encryptedIV = RSA.encrypt(aesIV); //RSA加密AES加密初始化向量,是否加密向量可由自己决定 //最后的处理结果 const result = { sessionId: authData.sessionId, encryptedData, encryptedKey, encryptedIV }; [代码] S端解密数据 [代码]const crypto = require('crypto'); const cryptojs = require('crypto-js'); const { sessionId, encryptedData, encryptedKey, encryptedIV } = requestData; const authData = getUserSessionData(sessionId); //获取用户登录态数据 const privateKey = authData.privateKey; const aesKey = crypto.privateDecrypt({ key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, encryptedKey); //使用私钥解密得到AES密钥 const aesIV = crypto.privateDecrypt({ key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, encryptedIV); //使用私钥解密得到AES iv向量 const key = cryptojs.enc.Base64.parse(aesKey); const iv = cryptojs.enc.Utf8.parse(aesIV); const decryptedData = cryptojs.AES.decrypt(encryptedData, key, { iv, mode: cryptojs.mode.CBC, padding: cryptojs.pad.Pkcs7 }); //采用与加密统一的模式和填充进行解密 const { "c": 123, "b": 456, "a": 789, "timestamp": 1568487720, "nonce": "5rKbMs2Fm3", "sign": "a42af0962de99e698d27030c5c9d3b0e" } = decryptedData; //至此解密得到C端传来的数据 [代码] S端加密数据 当S端处理完C端的请求后应加密响应数据,那么加密响应数据应该使用什么密钥呢?既然C端已经将加密的密钥发送过来了,那么干脆将C端使用的AES密钥拿来加密响应数据就可以了。加密的数据传回C端后,C端只需使用该请求所使用的AES加密密钥进行解密即可。响应的加密数据如下: [代码]const cryptojs = require('crypto-js'); const responseData = JSON.stringify(...); //此为S端需要返回给C端的数据 const aesKey = ...; //此为之前C端用来加密数据的AES密钥 const aesIV = createRandomStr(16); //生成初始化向量IV const encryptedData = cryptojs.AES.encrypt(data, aesKey, { iv: aesIV, mode: cryptojs.mode.CBC, padding: cryptojs.pad.Pkcs7 }); //加密响应数据 const encryptedResponse = { "encryptedData": encryptedData, "iv": aesIV }; //得到加密后的响应数据并返回给C端 [代码] C端解密数据 C端接收到S端的响应数据后应对加密的数据进行解密,此次解密就是单纯的AES解密了,使用发起请求时用于加密数据的AES密钥配合响应数据的iv向量对encryptedData进行解密,得到解密后的数据即为S端真正的响应数据。实现过程如下: 此处引入了CryptoJS.js [代码]const CryptoJS = require('./CryptoJS'); const key = ...; //之前用于加密请求参数的AES加密密钥 const { encryptedData, iv } = responseData; const aesIV = CryptoJS.enc.Utf8.parse(iv); const aesKey = CryptoJS.enc.Utf8.parse(key); const decryptedData = CryptoJS.AES.decrypt(encryptedData, aesKey, { iv: aesIV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); //AES解密响应数据 const { ... } = decryptedData; //得到解密后的响应数据 [代码] 结语 第一次写这么长的文章,可能存在大量纰漏,如果大佬发现问题欢迎指出(`・ω・´)我会马上修改。 也许小程序的运行环境会比我想象中的更安全 也许HTTPS也会比我想象中的更安全 也许Web服务器引擎也比我想象中的更安全 但,安全不总是先行一步的吗?
2019-10-24 - 如何用小程序实现类原生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 - 利用第三方授权 更新并发布多套小程序
开发一个小程序管理平台,其他小程序管理员只需一次授权给第三方,第三方平台即可帮助他发布小程序,不同管理员的配置参数不同,其他功能都基本相同 开发步骤: 一、注册开放平台: 到微信开放平台注册账号 :https://open.weixin.qq.com/cgi-bin/readtemplate?t=regist/regist_tmpl&lang=zh_CN 二、申请第三方平台开发 申请第三方平台必须拥有一定的开发者资质,必须先通过开发者资质认证,才可以开始第三方平台开发,在开发平台账号管理中可进行资质认证 三、创建第三方平台 申请完成后,在开发平台的管理中心,点击第三方平台,在下方可看到创建第三方按钮 [图片] 点击创建第三方平台,进入下方页面,选择平台型服务商, [图片] 1.填写基本信息,与定制化服务商一致 2.选择权限,只能选择业务必须的权限集,否则无法通过审核,公众号或小程序也可能会拒绝授权给你。(权限集是公众号或小程序的权限集合,用于实现业务) 3.填写开发资料 4.开发资料 ①授权发起也域名(即用户打开我们自己的授权页域名) ②授权事件接收URL(我们接收所有授权小程序或公众号取消授权通知、授权成功通知、授权更新通知事件的url地址 , 包括接收微信平台推送的ticket) ③消息与事件接收URL (我们接收所有授权小程序或公众号的消息和事件推送,例如客服消息 微信就会推送到这个地址上) 这里要注意一点:该参数按规则填写(需包含/$APPID$,如www.abc.com/$APPID$/callback) 填写的地址需要包含/$APPID$ 我们后续可以用nginx 重写地址 把访问指向同一个地址就可以了 例如:填写的地址是 www.abc.com/msg/$APPID$/msgEventPath.php nginx重写地址: rewrite ^/msg/(.)/(.).php /msgEventPath.php last; ④其它按照提示填写就可以了,添加上白名单ip 然后提交审核就可以了,如果信息没有问题是马上就能审核成功的,然后再管理中心的第三方平台即可看到改第三方服务商,详情里面即有改第三方平台相关的配置信息 四、小程序管理员授权给第三方平台 只有小程序管理员授权给第三方,第三方才能为该小程序发布,更新部署代码。 授权开发步骤: 1.保存component_verify_ticket, 微信端会定时推送消息到配置好的授权事件接收URL(创建三方平台时填写的,可在该三方详情中查看) 上,我们需要保存这个component_verify_ticket和 不断更新,component_verify_ticket必须保持是微信端推送的最新一个 2.用component_verify_ticket去换取第三方平台的token(第三方平台指的就是我们自己在开发的平台)token是有有效期的,所以我们要保存它的过期时间,并将token做缓存,当token没过期时就不用再去换取,反之我们要利用最新的component_verify_ticket去重新获取token 3.换取预授权码pre_auth_code,pre_auth_code是用来换取微信端的授权二维码的 4.跳转到授权页面(两种方式),建议第二种,方便 用户授权的时候会先打开我们自己的一个页面 (例如 http://www.abc.com/authorization.php ),这个页面里需要做一个按钮或者用js去跳转到微信的授权页面 ①扫码授权 :跳转后得到授权码,注意这个页面只能用网页访问,小程序访问不了,因为不能将微信域名配置为业务域名用户扫码后 就可以授权给第三方平台了 https://mp.weixin.qq.com/cgi-bin/componentloginpage?component_appid=xxxx&pre_auth_code=xxxxx&redirect_uri=xxxx&auth_type=xxx。 ②点击移动端链接快速授权https://mp.weixin.qq.com/safe/bindcomponent?action=bindcomponent&auth_type=3&no_scan=1&component_appid=xxxx&pre_auth_code=xxxxx&redirect_uri=xxxx&auth_type=xxx&biz_appid=xxxx#wechat_redirect 请求参数(两种方式一样) component_appid 第三方平台方appid pre_auth_code 预授权码 redirect_uri 回调URI 必须和授权地址同一个域名 auth_type 要授权的帐号类型:1则商户点击链接后,手机端仅展示公众号、2表示仅展示小程序,3表示公众号和小程序都展示。如果为未指定,则默认小程序和公众号都展示。第三方平台开发者可以使用本字段来控制授权的帐号类型。 前四步总结(移动端快速授权流程): 用户自己获取授权连接: 需要后台配合,给出一个接口,请求该接口则直接返回最新的预授权码(pre_auth_code),拿到授权码之后,再通过拼接返回一个授权地址,跳转到改地址,即为授权页面下方图二 ,用户点击授权即可授权给第三方。用户点击授权后,授权页会自动跳转进入回调URI,并在URL参数中返回授权码和过期时间(redirect_url?auth_code=xxx&expires_in=600),我们可以通过 $GET[‘authcode’] 去获取授权用户的小程序或二维码 调用接口的accesstoken(有效期两小时) 并将其保存/更新,然后我们就可以获取授权用户小程序或公众号的信息 [图片] 5.使用授权码换取公众号或小程序的接口调用凭据和授权信息 接口调用请求说明 http请求方式: POST(请使用https协议) https://api.weixin.qq.com/cgi-bin/component/api_query_auth?component_access_token=xxxx(component_access_token在第二步可获取) POST请求参数示例: { “component_appid”:“appid_value” ,//第三方平台appid “authorization_code”: “auth_code_value”//授权code,会在授权成功时返回给第三方平台 } 请求成功后拿到 authorizer_access_token:授权方接口调用凭据(在授权的公众号或小程序具备API权限时,才有此返回值),也简称为令牌,后面调用小程序待开发的api中使用, authorizer_refresh_token:接口调用凭据刷新令牌(在授权的公众号具备API权限时,才有此返回值),刷新令牌主要用于第三方平台获取和刷新已授权用户的access_token,只会在授权时刻提供,请妥善保存。 一旦丢失,只能让用户重新授权,才能再次拿到新的刷新令牌 五、小程序模板开发 第三方平台帮助旗下已授权的小程序进行代码管理时,需先开发完成小程序模版,再将小程序模版部署到旗下小程序帐号中,具体流程如下: 第一步:绑定开发小程序 (1)第三方平台的开发人员需先到微信公众平台(mp.weixin.qq.com)申请一个普通的小程序并完善小程序的头像、昵称、简介、服务类目等信息。 (2)进入微信开放平台,在第三方平台详情中,将该小程序添加为开发小程序。 注意:绑定为开发小程序后,该小程序的在开发工具中上传,代码会直接上传到开放平台,不会上传到公众平台。 第二步:小程序模版的开发和上传 使用开发小程序的开发者微信号登录微信web开发者工具(IDE),开发者工具中按照正常的小程序开发流程进行代码开发和调试。开发完成后,在开发工具中点击上传。更新模板后需要更部署到旗下小程序之前必须上传到模板库。注意:上传时版本号要求不一样,一样的版本号会被默认为同一版本,判断为管理员没有更新 第三步:添加到小程序模版库,获得模版ID 从开发者工具中上传的代码,会先存在草稿箱中,每个开发小程序只保留最新一份上传记录。开发者可将草稿箱中的代码添加到小程序模版库中,小程序模版库中的模版不会被覆盖。最多可以有五十个代码模版,添加后可以获得模版ID(TemplateID) 拿到模板ID后,再加上之前获取到的authorizer_access_token(令牌),就能为授权过给该第三方平台的小程序部署代码了。 六、为旗下小程序进行代码管理 举个例子:为授权的小程序帐号上传小程序代码 1、为授权的小程序帐号上传小程序代码 请求方式: POST(请使用https协议) https://api.weixin.qq.com/wxa/commit?access_token=TOKEN POST数据示例 { “template_id”:0, “ext_json”:“JSON_STRING”, //ext_json需为string类型,请参考下面的格式 “user_version”:“V1.0”, “user_desc”:“test”, } 参数说明: access_token 请使用第三方平台获取到的该小程序授权的authorizer_access_token template_id 代码库中的代码模版ID ext_json 第三方自定义的配置 user_version 代码版本号,开发者可自定义(长度不要超过64个字符) user_desc 代码描述,开发者可自定义 通过此请求,第三方平台会自动将模板中的代码自动部署到授权给该第三方的小程序上 更多代码管理查看文档 https://open.weixin.qq.com/cgi-bin/showdocument?action=dir_list&t=resource/res_list&verify=1&id=open1453779503&token=1a70ae891ca6e0339cf56bd1b3c322b0ec86eec9&lang= 持续更新中… 相关文章:https://developers.weixin.qq.com/community/develop/doc/0000ee097e0f00dcd55b8e40856800?jumpto=reply&parent_commentid=00004e9efb84388cd95ba8023514&commentid=000c0623e98068f0e85be2b97564
2020-12-09