- Skyline|探秘下拉二楼,打造更丰富的内容展示
下拉二楼是一种常见的交互设计,可以为应用中的内容展示提供更多的可能性。 通过下拉操作,开发者可以在二楼展示更丰富、更多样化的内容,从而增加用户的点击量和留存率,例如宣传视频、精选商品、走心故事等等。 在小程序中,下拉二楼一直是一种难以实现的交互设计,即使部分小程序实现了,但效果和性能都很差。 为了丰富小程序的内容展示,提高用户的使用体验,小程序官方近期推出了下拉二楼的能力,方便小程序开发者使用。 效果展示 让我们来看看小程序 scroll-view 实现下拉效果的效果~ [图片] 实现步骤 接下来,我们来看下如何使用 scroll-view 实现下拉二楼 1、配置下拉相关属性 scroll-view 新增了以下接口供开发者配置下拉二楼的能力,开发者可以根据业务需要配置相关的属性 属性 说明 refresher-two-level-enabled 开启下拉二级能力,配置开启需同时配置 refresher-two-level-triggered 设置打开/关闭二级 refresher-two-level-threshold 下拉二级阈值 refresher-two-level-close-threshold 滑动返回时关闭二级的阈值 refresher-two-level-scroll-enabled 处于二级状态时是否可滑动 refresher-ballistic-refresh-enabled 惯性滚动是否触发下拉刷新 refresher-two-level-pinned 即将打开二级时否定住 [代码]<scroll-view type="list" scroll-y // 开启下拉刷新(下拉二级必须开启下拉刷新) refresher-enabled="{{true}}" // 开启下拉二级能力 refresher-two-level-enabled="{{true}}" // 处于二级状态是否可滑动 refresher-two-level-scroll-enabled="{{true}}" > ... </scroll-view> [代码] 2、实现二楼内容 配置完下拉二楼属性之后,接着就是将我们的二楼实现在 scroll-view 中。 在 scroll-view 放置一个子节点,声明 slot=“refresher”,该节点中的内容即为下拉二楼的内容。 [代码]<scroll-view ... > <view slot="refresher"> 这里是二楼的内容 </view> </scroll-view> [代码] 3、根据下拉状态回调进行个性化处理 接着我们需要根据业务小程序自身的诉求,根据下拉状态的回调进行个性化的处理,例如:下来完成跳转页面等。 在 scroll-view 绑定 bind:refresherstatuschange 监听下拉状态,下拉状态有以下几种 属性 说明 Idle 空闲 CanRefresh 超过下拉刷新阈值 Refreshing 下拉刷新 Completed 下拉刷新完成 Failed 下拉刷新失败 CanTwoLevel 超过下拉二级阈值 TwoLevelOpening 开始打开二级 TwoLeveling 打开二级 TwoLevelClosing 开始关闭二级 [代码]<scroll-view bind:refresherstatuschange="onStatusChange" ... > <view slot="refresher"></view> ... </scroll-view> // .js onStatusChange(e) { const status: RefreshStatus = e.detail.status if (status === RefreshStatus.TwoLeveling) { const that = this // 当打开二级之后,跳转到新的页面 wx.navigateTo({ url: '../goods/index', events: { nextPageRouteDone: function(data) { // 新页面打开之后,关闭下拉二楼 that.scrollContext.closeTwoLevel({ duration: 1 }) } } }) } } [代码] 我们来演示一下松手立即跳转(图左)、完全打开二楼后跳转(图右) [图片] 丰富小程序展示内容和形式,欢迎大家使用小程序下拉二楼,为小程序的内容展示提供更多的可能性和创意发挥的空间。 通过下拉二楼,可以展示更丰富、更多样化的内容,也为小程序的发展带来了更多的机会和挑战~ 赶紧 mark 下这个 代码片段 来接入使用吧~
2023-08-03 - Skyline|原生级卡片转场,小程序轻松实现
在上一篇文章《在小程序中实现原生相册》中,我们学习了自定义路由搭配共享元素实现的原生相册效果,共享元素可以让用户在体验小程序时视觉关联性更强。 除了相册实现之外,常见的卡片转场也非常适合。 [图片] ⬆️ 演示效果:默认动画 vs 卡片转场动画 👇 下面我们来看看卡片转场中通过 共享元素 + 自定义路由 来实现无痕跳转。 [图片] 这里的转场稍微有点复杂,涉及到以下 3 个点 旧卡片:图片放大、内容渐隐新页面:按比例放大、页面渐显手势搭配1、旧卡片:图片放大、内容渐隐 在本示例中,列表页采用的是 scroll-view 瀑布流布局的实现。 [图片] 这里我们的共享元素是卡片,即 grid-view 中的内容 card,卡片包括 图片、内容描述。 [图片] 默认情况下,共享元素是整个节点进行飞跃的,由于前后页面的图片元素一致但文本内容不一致, 导致在第一帧或者最后一帧会有跳动的效果。 为了让转场动画更加自然,我们需要在飞跃的过程中渐隐旧卡片的内容描述。 [图片] 在这里,我们需要先用 this.applyAnimatedStyle 来给对应的节点绑定 worklet 驱动动画。 .card_wrap 节点:整个卡片按比例放大.card_desc 节点:内容描述渐隐[图片] 关于动画执行的时机,我们可以通过配置项修改。 immediate:设置是否立即执行驱动动画flush:shareValue 更新时,applyAnimatedStyle 的 updater 函数刷新时机在本例中,需要保证共享元素的图片与目标页面图片位置重叠,所以 flush 设置 sync 在当前时间片刷新。 [图片] 绑定完驱动动画之后,我们需要给共享元素绑定帧回调事件,根据当前动画进度改变共享变量的值来驱动共享动画 [图片] 2、新页面:按比例放大、页面渐显 新页面在路由中的动画,需要在自定义路由中进行配置。关于自定义路由的更多介绍,可参考《小程序页面转场动画》 在路由动画过程中,我们将上一步的共享元素帧回调拿到 begin、end 的值,然后结合动画进度 t 计算得出新页面的位置、缩放比例。 还有根据动画进度,设置页面渐显,与前面的卡片渐隐承接。 [图片] 3、手势搭配 学习过我们前面的文章的同学都知道,自定义路由经常需要结合页面手势,来实现手势返回,关于手势的基础知识可参考《小程序页面转场动画》 [图片] 这里我们希望手势缩小整个当前页面,所以这里手势返回时只在当前页面做手势动画即可。 在页面详情页的最外层,嵌套一个手势组件 pan-gesture-handler,当手势拖动时根据手势的位置改变整个页面(通过 #fake-host 控制)的位置和大小来达到拖动的效果。 [图片] 同样绑定页面驱动动画,通过 applyAnimatedStyle 给 #fake-host 绑定驱动动画,当共享变量 transX、transY 等变化时则自动改变 transform 来驱动 #fake-host 缩小。 [图片] 接着绑定手势事件,根据手势拖动时拿到位置信息改变共享变量 transX、transY 的值。 [图片] 最后我们需要设置背景颜色透明,来达到类似把卡片拖回列表的视觉效果,更好的减少页面切换感~ [图片] 一个自定义路由的页面会有 3 层可以设置到背景色,要做到透明的效果需要将 3 个背景色都设置为透明。更多自定义路由背景色的详情参考官方文档。 [图片] 想要试试卡片转场的无恒效果~扫描 ⬇️ 下方小程序码即可体验。 如果你也想在小程序中实现卡片转场动画,mark 下这个 源码 直接接到到你的小程序吧~ [图片]
2023-08-03 - Skyline|小程序页面转场动画
开发者A:小程序跳转时,页面切换效果可以自定义实现吗? 开发者B:搞个单页自己写呢 开发者A:这个写起来代码量有点大,而且放单页里面代码太复杂了🥹 官方:来啦来啦~小程序页面转场动画可以使用小程序自定义路由来实现,赶紧往下 ⬇️ 看~ 我们先来看下有无自定义路由的效果对比 没有自定义路由:只能使用默认路由切换效果,从右到左推入页面使用自定义路由:支持自定义转场动画,示例下沉式路由效果[图片] 在使用默认路由的时候,只需要调用 wx.navigateTo 即可,而自定义路由,则需要先声明 1、通过 wx.router.addRouteBuilder( 命名,builder 函数 ) 来声明自定义路由 2、页面跳转新增 routeType 参数,值为上一步的命名 [图片] 接着,我们来看下声明自定义路由中 builder 函数,这个函数主要是定义两个动画 定义页面推入动画:结合 推入进度、推入状态 等参数,由开发者自定义计算得来定义页面被推出动画:结合 被推出进度、被推出状态 等参数,由开发者自定义计算得来[图片] 当打开一个新页面的过程中,就会触发自定义路由动画,此时有两个动画 新页面:打开动画旧页面:隐藏动画下图演示在页面 A 打开 页面 B,打开过程的动画效果: 新页面:页面 B 半屏打开旧页面:页面 A 页面下沉[图片] 动画除了上述讲的动画效果之外,还有一个关键的点就是动画曲线,动画曲线可以让动画效果更加丰富。 👇 下面我们可以看出来,相同的动画效果,但是使用不同的动画曲线,展示出来的页面切换效果是很不一样的。worklet 支持了常见的动画缓动函数 Easing,开发者可以根据业务需求实现页面动画切换效果。 [图片] 那么我们就来看一下页面切换动画的代码是怎么实现的~ 页面的切换动画通过 worklet 函数来实现的,worklet 函数运行在 UI 线程,使得小程序可以做到 类原生动画般的体验。 我们先来看下这里设计的新页面打开时的动画,在页面打开的过程中,开发者可以收到一个 primaryAnimation 的参数,这个参数表示当前页面从 0 到 1 打开的进度,此时,我们通过 handlePrimaryAnimation 来实现页面打开过程我们希望页面展示的动画效果,例如:改变页面高度、圆角、与顶部的偏移距离等。 这样,就实现页面展示的动画效果。 // 新页面:页面 B 半屏打开 const handlePrimaryAnimation = () => { 'worklet' // primaryAnimation 为 builder 函数的参数,表示当前页面从 0 - 1 展示动画的进度 // primaryAnimation 为 sharedValue 类型,当 primaryAnimation 变化时,这个函数就会被执行 let t = primaryAnimation.value // 非手势触发时,可以通过动画曲线 easeInToLinear 来改变动画的进度值 // worklet 支持了常见的动画缓动函数,开发者可以根据业务需求实现需要的页面切换动画效果 if (!userGestureInProgress.value) { t = wx.worklet.Easing.bezier(0.35, 0.91, 0.33, 0.97).factory()(t) } const top = 0.12 // 半屏页面距离顶部的距离比例 const selfHeight = (1 - top) * screenHeight // 半屏页面高度 const marginTop = top * screenHeight // 半屏页面距离顶部的距离 const translateY = selfHeight * (1 - t) // 页面动画过程中的纵向偏移值 // 返回 AnimatedStyle,改变页面展示 return { marginTop: `${marginTop}px`, borderRadius: '10px', height: `${selfHeight}px`, transform: `translateY(${translateY}px)`, } } 同样的,页面隐藏跟页面展示是类似的,开发收到的是 secondaryAnimation 参数,表示的是页面从 1 到 0 的关闭进度,这里我们通过 [代码]handleSecondaryAnimation 来实现页面隐藏的效果。[代码] 注意:handleSecondaryAnimation 表示下一页面推入时,当前页面的隐藏动画,在下沉式动画这个案例中,handleSecondaryAnimation 是旧页面的,而 handlePrimaryAnimation 是新页面的。 // 旧页面:页面 A 页面下沉 const handleSecondaryAnimation = () => { 'worklet' // secondaryAnimation 为 builder 函数的参数,表示下一个页面推入时,当前页面从 1 - 0 隐藏动画的进度 // secondaryAnimation 为 sharedValue 类型,当 secondaryAnimation 变化时,这个函数就会被执行 let t = secondaryAnimation.value // 非手势触发时,可以通过动画曲线 fastOutSlowIn 来改变动画的进度值 if (!userGestureInProgress.value) { t = wx.worklet.Easing.bezier(0.4, 0.0, 0.2, 1.0).factory()(t) } const top = 0.1 // 页面距离顶部的距离比例 const scaleRatio = 0.08 // 缩放比例 const translateY = screenHeight * (top - 0.5 * scaleRatio) * t // 页面动画过程中的纵向偏移值 const scale = 1 - scaleRatio * t // 缩放过程中的比例 const radius = 12 * t // 页面圆角 // 返回 AnimatedStyle,改变页面展示 return { borderRadius: `${radius}px`, transform: `translateY(${translateY}px) scale(${scale})`, } } 实现完动画切换的函数之后,我们需要包装到 builder 函数中并注册这个 builder 函数。 // 注册 builder 函数 wx.router.addRouteBuilder("HalfScreenDialog", HalfScreenDialogRouteBuilder) wx.router.addRouteBuilder("ScaleTransition", ScaleTransitionRouteBuilder) // 实现页面 B 的 builder 函数:页面打开时半屏打开 const HalfScreenDialogRouteBuilder = ({ primaryAnimation }) => { return { handlePrimaryAnimation // 页面 B 打开时的动画,上文中实现的 handlePrimaryAnimation 函数 } } // 实现页面 A 的 builder 函数:下一个页面打开时,当前页面下沉 const ScaleTransitionRouteBuilder = ({ primaryAnimation, secondaryAnimation }) => { return { handlePrimaryAnimation, // 页面 A 打开时的动画 handleSecondaryAnimation // 页面 B 隐藏时的动画,上文中实现的 handleSecondaryAnimation 函数 } } 在文章开头我们知道,声明完自定义路由之后,需要在页面跳转时指定路由类型。 到这里,通过页面跳转,返回按钮已经达到我们要的效果了。 // home.js // 首页打开页面 A, wx.navigateTo({ url: 'pageA', routeType: 'ScaleTransition', }) // page.js // 页面 A 打开页面 B wx.navigateTo({ url: 'pageB', routeType: 'HalfScreenDialog', }) 我们在体验原生页面切换时,手势也是顺滑切换的重要组成部分,在上一篇 小程序手势:让半屏弹窗更顺滑 我们已经了解手势的使用,那么我们这里给页面绑定手势,支持向右、向下拖动页面返回。 我们在页面最外层嵌套一个手势组件 horizontal-drag-gesture-handler(横向滑动时触发) 当手势向右滑动时,根据触摸位置改变页面当前的状态 触摸中:页面随手指拖动触摸结束:根据手势速度和位置判断关闭还是打开页面// .wxml // .js // 根据手势状态改变页面展示状态 // this.customRouteContext 中包含当前页面定义路由 builder 时的全部变量 handleHorizontalDrag(gestureEvent) { "worklet"; if (gestureEvent.state === GestureState.BEGIN) { // 触摸开始 const { startUserGesture } = this.customRouteContext; startUserGesture(); } else if (gestureEvent.state === GestureState.ACTIVE) { // 触摸中,实现跟随手指拖动页面效果 const delta = gestureEvent.deltaX / windowWidth; const { primaryAnimation } = this.customRouteContext; const newVal = primaryAnimation.value - delta; primaryAnimation.value = clamp(newVal, 0.0, 1.0); } else if (gestureEvent.state === GestureState.END) { // 触摸结束 const { stopUserGesture, didPop } = this.customRouteContext; ... didPop(); // 退出页面调用 stopUserGesture(); // 结束必须调用 } else if (gestureEvent.state === GestureState.CANCELLED) { // 触摸取消 } } 添加完手势之后,就可以通过手势关闭页面了~ [图片] 除了案例中实现的下沉式半屏效果,自定义路由可以根据开发者需要自行定制动画。 目前,官方提供了几个常用的路由效果供大家使用,mark 这个 代码片段 即可使用。
2023-08-03 - 案例| 《羊了个羊》DAU三天内破2000W,拆解爆火背后的营销逻辑
这两天微信群、微博突然被“羊了个羊”刷屏了!很多人见面第一句话:你过第二关了吗?“羊了个羊”在一夜间爆火,全网热度居高不下,接连两天登顶微博热搜榜单!它凭什么一跃成为全民小游戏?今天小木就来扒一扒“羊了个羊”(以下简称羊)到底是啥?以及都有哪些让人“上头”的营销秘笈。 [图片] 1、“羊了个羊”是个什么小游戏? “羊”是一款微信小游戏,但是从本质来说也属于微信小程序的一种,也是以小程序的形态运行在微信中。作为一款结合简单玩法和高难度的小游戏,游戏内容相当简单,与同类消除类的游戏不同,并没有建立动辄几十关的关卡数量,而是只设立两个关卡。 [图片] 其中第一关作为新手教程关,被玩家戏称闭眼都能过。第二关却徒然把通关难度提高到一个“很离谱”的高度,通过多层堆叠,品种加量等方式将原本简单的排列组合变得随机性极强,让很多玩家大呼:总感觉自己就差一点点运气!越难越上头! 说到这,小木也忍不住玩了下,玩过3局后幡然醒悟:过关是不可能过关的,有这时间不如来研究下“羊”式营销秘笈? 2、“羊了个羊”的营销秘笈 小木纵观“羊”的爆火之路,其营销核心可不就是“社交+玩梗+高难度”?下面来盘点“羊”是怎样成就千万DAU的。 1)互联网营销 大多数玩家首次接触到“羊”,源于它连续三天出现在微博热搜,打响了营销推广的第一炮。随之而来,在抖音等短视频平台,#做梦都在羊了个羊##羊了个羊第二关终于过了#等话题也登上了热搜榜。短短几天时间里,“羊”热度持续走高,游戏服务器甚至在2天内崩了3次。 当我们被勾起了兴趣,再加上这是一款微信小程序小游戏,无需像APP一样再下载安装、注册账户,无门槛的游戏资格这不就有了吗。 2)闯关难度反向营销 正常游戏的过关难度都是一关关小幅递增,“羊”反其道为之,从用户体验上来说,第一关闭眼过,第二关却难到始终无法过关,不仅带来巨大的落差感,还容易激发用户的胜负欲。直接导致不断促使用户主动进行社交分享或浏览广告,在短期内吸引大量用户涌入并不断尝试,就把用户粘在了游戏中,这也就形成了雪球效应。 3)拼多多式社交营销+广告变现 “羊”的游戏内部营销的核心是“拼多多式社交营销+广告变现”,在游戏中植入广告是很常见的操作,但“羊”设置的广告频率其实是偏高的。超高难度让用户频繁失败,如果想要获取道具和复活机会,一局下来最少要分享/播放4次广告(而且分享数次后只能观看广告),这就进入了频繁尝试——频繁分享——频繁广告——频繁失败的无限循环中。 [图片] 以上能看出广告时长可真不短,同时,在闯关失败后,还会收到官方的刺激性语言来“激将”玩家持续玩下去,鼓励玩家分享给好友,让小游戏以用户为中心辐射给周边精准用户群体,形成朋友圈快速裂变,这分享逻辑是不是有拼多多砍一刀那味了? 另一方面,小游戏的日活用户在三天内从500w增到2000w,随着大量玩家涌入和广告观看次数的直线攀升,相信广告收益也非常可观。 4)小程序社交优势凸显 “羊”无疑是成功的,以微信小程序为载体的就是一个正确选择,小程序更贴合起社交游戏的自身定位,玩法基础模型本身并没有那么重要。微信作为国民应用不缺用户流量,同时小程序开发难度较低、即用即走、易分享等特性都让小游戏的社交传播变得简单高效。 [图片] 事实上,除了微信小游戏,小程序的应用广泛性,几乎可以覆盖所有行业。无论是构建线上消费场景,还是延伸线下服务场景,商城小程序能真正做到打通线上与线下,实现全场景营销,全面提升商家的经营效率。
2022-09-19 - 一种新颖的解决IOS虚拟支付的方式系列二
做付费答题,绕不开的一个话题是如何解决IOS用户的小程序虚拟支付 ~ 如何解决IOS虚拟支付问题 之前我写过一篇文章,具体可以点击下面的文字 一种新颖的解决IOS虚拟支付的方式? - 微信开放社区 https://developers.weixin.qq.com/community/develop/article/doc/000a6877834f8033e54d2781f51013 ~ 今天我遇到了一种新的模式,在我的一个小程序中已经上线运营了一段时间,这种方式目前是不是合规的要具体问题具体分析 因为在付费的时候是引导到客服界面了,这个怎么来认定 但是在目前体验了很多小程序都是这种操作,通过一定的话术引导用户到客服,通过其他方式进行支付,比如引导H5、引导到线下支付 ~ [图片] ~ [图片] ~ [图片] 如何解决IOS虚拟支付问题 虚拟支付在IOS端确实是不能支付的,带来了很多的问题,在合规的范畴内完成支付就是一个很值得思考的问题,合规运营是小程序运营的底线 希望各位运营人坚守底线认真做事
2022-08-07 - 列表拖拽排序置顶删除效果
一个简单的拖拽排序置顶效果, 一个简单的拖拽排序置顶效果 一个简单的拖拽排序置顶效果 一个简单的拖拽排序置顶效果 一个简单的拖拽排序置顶效果 一个简单的拖拽排序置顶效果 一个简单的拖拽排序置顶效果 一个简单的拖拽排序置顶效果 一个简单的拖拽排序置顶效果 [图片] https://developers.weixin.qq.com/s/0HZoZUmr7EAQ
2022-07-21 - 如何小程序页面隐藏滚动条问题?
如何隐藏小程序page页面右侧的滚动条?我看好多第三方的小程序就没有,腾讯视频也没有 此方法根本没有用 ::-webkit-scrollbar{ width: 0 !important; height: 0 !important; color: transparent !important; } [图片][图片]
2020-07-01 - 微信小程序页面监听全局变量的改变
app.js------------------------------------------------ watch: function (method) { //监听函数 var obj = this.globalData Object.defineProperty(obj, 'clock', { configurable: true, enumerable: true, set: function (value) { this._name = value; method(value); }, get: function () { return this._name } }) }, globalData: { clock:""//要监听的变量 } ———————————————— index.js------------------------------- onLoad:function(options){ getApp().watch(this.watchBack)//注册监听 }, watchBack: function (value){ //要执行的方法 this.setData({ clock: value }) } 亲测可用。 但是文档上没有不知道为啥,如果有坑请留言哈。
2021-04-28 - 一个奇葩思路实现的瀑布流双列布局
传统的瀑布流布局实现一般关键是去计算每一列的高度,从而判断下一个元素应该插入到哪一列(当然是最短的那列)。 这个奇葩思路没有任何计算,主要思路如下: 在瀑布流容器底部加入一根细线 利用微信小程序的IntersectionObserver,为每一列和细线添加监听 逐个加入要插入的item元素 根据监听相交变化结果判断下一个item应该插入哪一列(简单来说就是插入到当前不与细线相交的那一列,因为比较短) 这个思路实际上就是把计算高度换成了监听判断哪列更高,因此也不必知道每个元素的高度。 目前只能支持两列布局的情况,如果列数更多我没办法不通过计算来知道哪列最短,如果有思路或想法的童鞋欢迎交流~ 实现过程也比较简单,就分享个思路,不贴代码了(问就是懒!) 感兴趣的童鞋可以看代码片段,里面有完整的实现代码: https://developers.weixin.qq.com/s/nH5pg4mE78dG
2019-11-23 - wx.createSelectorQuery()实现瀑布流的问题?
做瀑布流时遇到这么个问题,如图所示,插入前四个时没有问题,但是第五个本来应该放在右边的,却被放在了左边 [图片][图片] 图中箭头所指的是插入第五个前,右边的高度,不知道为什么会高出左边导致插入错误,这里贴出瀑布流代码 [图片] 然后我无意中清理了下微信开发者工具代码,只要首次加载,就会变正常,但是后面继续刷新又会插入错误 [图片][图片] 不知道是不是因为我设置了图片的缩放裁剪模式的问题,我把mode设置成widthFix的原因,如果把mode设置成默认的就不会出现高度错误的问题,但是那样不是我想实现的效果,不知道有没有大神帮忙看看啥情况?
2021-03-30 - 修改小程序swiper组件面板指示点样式
修改当前显示眯的宽度及圆角值,记得加!important;如下所示: .wx-swiper-dot-active{ width:25px !important; border-radius: 5px !important; }
2021-07-16 - 无痛刷新token续接401请求
在小程序开发中,我们都知道小程序是没有cookie的,那么用户身份是如何确定的,后段颁发token,前端每次请求头部附带token。 既然是token,那么肯定有它的过期时间,没有一个token是永久的,永久的token就相当于一串永久的密码,是不安全的, 那么既然有刷新时间,问题就来了 1.前后端交互的过程中token如何存储? 2.token过期时,前端该怎么处理? 3.当用户正在操作时,遇到token过期该怎么办?直接跳回登陆页面? token如何存储? cookie的大小约4k,兼容性在ie6及以上 都兼容,在浏览器和服务器间来回传递,因此它得在服务器的环境下运行,而且可以设定过期时间,默认的过期时间是session会话结束。 localStorage的大小约5M,兼容性在ie7及以上都兼容,有浏览器就可以,不需要在服务器的环境下运行, 会一直存在,除非手动清除 。 答案大致分为2种 存在 [代码]cookie[代码] 中 存在 [代码]localStorage[代码] 中 token过期时,前端该怎么处理? 1.第一种:跳回登陆页面重新登陆 2.第二种:拦截401重新获取token class HttpClient { /** * Create a new instance of HttpClient. */ constructor() { this.interceptors = { request: [], response: [] }; } /** * Sends a single request to server. * * @param {Object} options - Coming soon. */ sendRequest(options) { let requestOptions = options; if (!requestOptions.header) { requestOptions.header = {}; } // 重新设置 Accept 和 Content-Type requestOptions.header = Object.assign( { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json;charset=utf-8' }, requestOptions.header ); this.interceptors.request.forEach((interceptor) => { const request = interceptor(requestOptions); requestOptions = request.options; }); // 将以 Promise 返回数据, 无 success、fail、complete 参数 // let response = uni.request(requestOptions); // 使用Promise包装一下, 以 complete方式来接收接口调用结果 let response = new Promise((resolve, reject) => { requestOptions.complete = (res) => { const { statusCode } = res; const isSuccess = (statusCode >= 200 && statusCode < 300) || statusCode === 304; if(statusCode==401){ //拦截401请求 uni.reLaunch({ //关闭所有页面直接跳到登陆页面 url: '/pages/login/login' }); } if (isSuccess) { if(res.data.code==1){ resolve(res.data); }else{ reject(res); } } else { reject(res); } }; requestOptions.requestId = new Date().getTime(); uni.request(requestOptions); }); this.interceptors.response.forEach((interceptor) => { response = interceptor(response); }); return response; } } export default HttpClient; 这种方法适用余有登陆页面的小程序,但同样存在问题,假如用户在填写表单,填写完毕你却告诉我重新登陆,确定用户不会删掉你的小程序??? 有人说了 异常退出 我会本地缓存填写的表单内容,当然你要是能接受这种我也无话可说!!! 我们要做的是无痛刷新toekn,那么首先要在401拦截的时候去重新登陆获取新的token 继续优化改造 import store from "../store/index.js"; class HttpClient { /** * Create a new instance of HttpClient. */ constructor() { this.interceptors = { request: [], response: [] }; } /** * Sends a single request to server. * * @param {Object} options - Coming soon. */ sendRequest(options) { let requestOptions = options; if (!requestOptions.header) { requestOptions.header = {}; } // 重新设置 Accept 和 Content-Type requestOptions.header = Object.assign( { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json;charset=utf-8' }, requestOptions.header ); this.interceptors.request.forEach((interceptor) => { const request = interceptor(requestOptions); requestOptions = request.options; }); // 将以 Promise 返回数据, 无 success、fail、complete 参数 // let response = uni.request(requestOptions); // 使用Promise包装一下, 以 complete方式来接收接口调用结果 let response = new Promise((resolve, reject) => { requestOptions.complete = (res) => { const { statusCode } = res; const isSuccess = (statusCode >= 200 && statusCode < 300) || statusCode === 304; if(statusCode==401){ //拦截401请求 store .dispatch("auth/login") //调用vueX中的登陆将登陆信息保存到VueX .then(()=>{ //提示用户刚才操作无效重新操作一次 uni.showToast({ title: '请重新操作', duration: 2000, icon: "none", }); }) .catch(()=>{ uni.showToast({ title: '账户异常请重启程序', duration: 2000, icon: "none", }); }) } if (isSuccess) { if(res.data.code==1){ resolve(res.data); }else{ reject(res); } } else { reject(res); } }; requestOptions.requestId = new Date().getTime(); uni.request(requestOptions); }); this.interceptors.response.forEach((interceptor) => { response = interceptor(response); }); return response; } } export default HttpClient; 到此我们实现的在401错误时候去重新登陆获取新的token,且告知用户重新操作一次 到此你会发现一个问题,当页面存在一个请求,目前方案毫无问题,但是当存在两个、三个、四个请求,你会骂娘 失败3个请求会重新调用3次登陆会刷新3次token 那么此时要做的就是保证不多次登陆 思路加一个开关,当在登陆过程中后续错误不再走登陆接口 import store from "../store/index.js"; // 是否正在重新登陆刷新的标记 var loginRefreshing = false class HttpClient { /** * Create a new instance of HttpClient. */ constructor() { this.interceptors = { request: [], response: [] }; } /** * Sends a single request to server. * * @param {Object} options - Coming soon. */ sendRequest(options) { let requestOptions = options; if (!requestOptions.header) { requestOptions.header = {}; } // 重新设置 Accept 和 Content-Type requestOptions.header = Object.assign( { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json;charset=utf-8' }, requestOptions.header ); this.interceptors.request.forEach((interceptor) => { const request = interceptor(requestOptions); requestOptions = request.options; }); // 将以 Promise 返回数据, 无 success、fail、complete 参数 // let response = uni.request(requestOptions); // 使用Promise包装一下, 以 complete方式来接收接口调用结果 let response = new Promise((resolve, reject) => { requestOptions.complete = (res) => { const { statusCode } = res; const isSuccess = (statusCode >= 200 && statusCode < 300) || statusCode === 304; if(statusCode==401){ //拦截401请求 if(!loginRefreshing){//防止重复登陆 loginRefreshing = true store .dispatch("auth/login") //调用vueX中的登陆将登陆信息保存到VueX .then(()=>{ //提示用户刚才操作无效重新操作一次 uni.showToast({ title: '请重新操作', duration: 2000, icon: "none", }); }) .catch(()=>{ uni.showToast({ title: '账户异常请重启程序', duration: 2000, icon: "none", }); }) .finally(()=>{ //销毁 是否正在重新登陆刷新的标记 loginRefreshing = false }); } } if (isSuccess) { if(res.data.code==1){ resolve(res.data); }else{ reject(res); } } else { reject(res); } }; requestOptions.requestId = new Date().getTime(); uni.request(requestOptions); }); this.interceptors.response.forEach((interceptor) => { response = interceptor(response); }); return response; } } export default HttpClient; [图片] 我们可以看到在遇到两个401错误时候并没有请求两次login,只请求一次,到此刷新token算是完成了,但是需要用户配合去重新操作一次,还不是真正的无痛刷线token,做到用户无感知 思路:将请求401的请求缓存起来,在重新登陆完成之后再将缓存中的请求重新发出, 废话不多说直接上代码 import AuthService from "@/services/auth.service"; import store from "../store/index.js"; // 是否正在重新登陆刷新的标记 var loginRefreshing = false // 重试队列,每一项将是一个待执行的函数形式 let requests = [] class HttpClient { /** * Create a new instance of HttpClient. */ constructor() { this.interceptors = { request: [], response: [] }; } /** * Sends a single request to server. * * @param {Object} options - Coming soon. */ sendRequest(options) { let requestOptions = options; if (!requestOptions.header) { requestOptions.header = {}; } // 重新设置 Accept 和 Content-Type requestOptions.header = Object.assign( { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json;charset=utf-8' }, requestOptions.header ); this.interceptors.request.forEach((interceptor) => { const request = interceptor(requestOptions); requestOptions = request.options; }); // 将以 Promise 返回数据, 无 success、fail、complete 参数 // let response = uni.request(requestOptions); // 使用Promise包装一下, 以 complete方式来接收接口调用结果 let response = new Promise((resolve, reject) => { let timeId = setTimeout(()=>{ reject({statusCode:504}); },10000) requestOptions.complete = (res) => { clearTimeout(timeId) const { statusCode } = res; const isSuccess = (statusCode >= 200 && statusCode < 300) || statusCode === 304; if(statusCode==401){ //无痛刷新token if(!loginRefreshing){//防止重复登陆 loginRefreshing = true store.dispatch("auth/logout"); store .dispatch("auth/login") .then(()=>{ //所有存储到对列组中的请求重新执行。 requests.forEach(callback=>{ callback(AuthService.getToken() ? AuthService.getToken() : "") }) //重试队列清空 requests = [] }) .catch(()=>{ uni.showToast({ title: '账户异常请重启程序', duration: 2000, icon: "none", }); }) .finally(()=>{ //销毁 是否正在重新登陆刷新的标记 loginRefreshing = false }); } return new Promise((resolve) => { // 将resolve放进队列,用一个函数形式来保存,等token刷新后直接执行 requests.push((token) => { requestOptions.header.token = token //带着登陆后的新token resolve(uni.request(requestOptions)) }) }) } if (isSuccess) { if(res.data.code==1){ resolve(res.data); }else{ reject(res); } } else { reject(res); } }; requestOptions.requestId = new Date().getTime(); uni.request(requestOptions); }); this.interceptors.response.forEach((interceptor) => { response = interceptor(response); }); return response; } } export default HttpClient; 知识点,在重新获取新的token后要将缓存起来的请求中的token替换为重新登陆后新的token [图片] 到此无痛刷新token续接401请求的方法已经处理完毕,在用户提交表单时候遇到token失效重新获取新的token再续接表单请求,此时用户毫无感知,可能在请求时间上多了延迟,体验好感度99+,哈哈哈哈哈 到此无痛刷新token续接401已经完成请求快去试试吧 提示:有些后端在接口请求做了签名,记得像更换token一样在重新登陆完成之后更换新的时间戳新的签名等字段 附上本请求封装的uniapp基础性脚手架 https://github.com/wkiwi/uni-app-base
2020-09-18 - .wxs文件中使用国内外手机号码正则表达式 示例
/** * 国内外手机号码正则表达式 phoneReg(13700001111,+86) * @param {*String} phoneNo 13700001111,5900001111 * @param {*String} areaCode +86,+852 */ var phoneReg = function (phoneNo,areaCode) { var telReg = { 'zh-CN': getRegExp("^(\+?0?86\-?)?1[345789]\d{9}$"), 'zh-HK': getRegExp("^([5|6|8|9|])\d{7}$"), 'zh-TW': getRegExp("^[0][9]\d{8}$"), 'zh-TW_1': getRegExp("^(\+?886\-?|0)?9\d{8}$"), 'zh-AOMEN': getRegExp("^[6]([8|6])\d{5}$"), 'en-US': getRegExp("^(\+?1)?[2-9]\d{2}[2-9](?!11)\d{6}$"), 'en-HK': getRegExp("^(\+?852\-?)?[569]\d{3}\-?\d{4}$"), 'ar-DZ': getRegExp("^(\+?213|0)(5|6|7)\d{8}$"), 'ar-SY': getRegExp("^(!?(\+?963)|0)?9\d{8}$"), 'ar-SA': getRegExp("^(!?(\+?966)|0)?5\d{8}$"), 'cs-CZ': getRegExp("^(\+?420)? ?[1-9][0-9]{2} ?[0-9]{3} ?[0-9]{3}$"), 'de-DE': getRegExp("^(\+?49[ \.\-])?([\(]{1}[0-9]{1,6}[\)])?([0-9 \.\-\/]{3,20})((x|ext|extension)[ ]?[0-9]{1,4})?$"), 'da-DK': getRegExp("^(\+?45)?(\d{8})$"), 'el-GR': getRegExp("^(\+?30)?(69\d{8})$"), 'en-AU': getRegExp("^(\+?61|0)4\d{8}$"), 'en-GB': getRegExp("^(\+?44|0)7\d{9}$"), 'en-IN': getRegExp("^(\+?91|0)?[789]\d{9}$"), 'en-NZ': getRegExp("^(\+?64|0)2\d{7,9}$"), 'en-ZA': getRegExp("^(\+?27|0)\d{9}$"), 'en-ZM': getRegExp("^(\+?26)?09[567]\d{7}$"), 'es-ES': getRegExp("^(\+?34)?(6\d{1}|7[1234])\d{7}$"), 'fi-FI': getRegExp("^(\+?358|0)\s?(4(0|1|2|4|5)?|50)\s?(\d\s?){4,8}\d$"), 'fr-FR': getRegExp("^(\+?33|0)[67]\d{8}$"), 'he-IL': getRegExp("^(\+972|0)([23489]|5[0248]|77)[1-9]\d{6}"), 'hu-HU': getRegExp("^(\+?36)(20|30|70)\d{7}$"), 'it-IT': getRegExp("^(\+?39)?\s?3\d{2} ?\d{6,7}$"), 'ja-JP': getRegExp("^(\+?81|0)\d{1,4}[ \-]?\d{1,4}[ \-]?\d{4}$"), 'ms-MY': getRegExp("^(\+?6?01){1}(([145]{1}(\-|\s)?\d{7,8})|([236789]{1}(\s|\-)?\d{7}))$"), 'nb-NO': getRegExp("^(\+?47)?[49]\d{7}$"), 'nl-BE': getRegExp("^(\+?32|0)4?\d{8}$"), 'nn-NO': getRegExp("^(\+?47)?[49]\d{7}$"), 'pl-PL': getRegExp("^(\+?48)? ?[5-8]\d ?\d{3} ?\d{2} ?\d{2}$"), 'pt-BR': getRegExp("^(\+?55|0)\-?[1-9]{2}\-?[2-9]{1}\d{3,4}\-?\d{4}$"), 'pt-PT': getRegExp("^(\+?351)?9[1236]\d{7}$"), 'ru-RU': getRegExp("^(\+?7|8)?9\d{9}$"), 'sr-RS': getRegExp("^(\+3816|06)[- \d]{5,9}$"), 'tr-TR': getRegExp("^(\+?90|0)?5\d{9}$"), 'vi-VN': getRegExp("^(\+?84|0)?((1(2([0-9])|6([2-9])|88|99))|(9((?!5)[0-9])))([0-9]{7})$"), }; var tel = areaCode + "" + phoneNo; console.log("tel==>",tel) if(telReg["zh-CN"].test(tel) || telReg["zh-HK"].test(tel) || telReg["zh-TW"].test(tel) || telReg["zh-TW_1"].test(tel) || telReg["zh-AOMEN"].test(tel) || telReg["en-US"].test(tel) || telReg["en-HK"].test(tel) || telReg["ar-DZ"].test(tel) || telReg["ar-SY"].test(tel) || telReg["ar-SA"].test(tel) || telReg["cs-CZ"].test(tel) || telReg["de-DE"].test(tel) || telReg["da-DK"].test(tel) || telReg["el-GR"].test(tel) || telReg["en-AU"].test(tel) || telReg["en-GB"].test(tel) || telReg["en-IN"].test(tel) || telReg["en-NZ"].test(tel) || telReg["en-ZA"].test(tel) || telReg["en-ZM"].test(tel) || telReg["es-ES"].test(tel) || telReg["fi-FI"].test(tel) || telReg["fr-FR"].test(tel) || telReg["he-IL"].test(tel) || telReg["hu-HU"].test(tel) || telReg["it-IT"].test(tel) || telReg["ja-JP"].test(tel) || telReg["ms-MY"].test(tel) || telReg["nb-NO"].test(tel) || telReg["nl-BE"].test(tel) || telReg["nn-NO"].test(tel) || telReg["pl-PL"].test(tel) || telReg["pt-BR"].test(tel) || telReg["pt-PT"].test(tel) || telReg["ru-RU"].test(tel) || telReg["sr-RS"].test(tel) || telReg["tr-TR"].test(tel) || telReg["vi-VN"].test(tel) ){ return 1; } return false; } 附上语言(文化)代码与国家地区对照表: 国家/地区 语言代码 国家/地区 语言代码 简体中文(中国) zh-cn 繁体中文(台湾地区) zh-tw 繁体中文(香港) zh-hk 英语(香港) en-hk 英语(美国) en-us 英语(英国) en-gb 英语(全球) en-ww 英语(加拿大) en-ca 英语(澳大利亚) en-au 英语(爱尔兰) en-ie 英语(芬兰) en-fi 芬兰语(芬兰) fi-fi 英语(丹麦) en-dk 丹麦语(丹麦) da-dk 英语(以色列) en-il 希伯来语(以色列) he-il 英语(南非) en-za 英语(印度) en-in 英语(挪威) en-no 英语(新加坡) en-sg 英语(新西兰) en-nz 英语(印度尼西亚) en-id 英语(菲律宾) en-ph 英语(泰国) en-th 英语(马来西亚) en-my 英语(阿拉伯) en-xa 韩文(韩国) ko-kr 日语(日本) ja-jp 荷兰语(荷兰) nl-nl 荷兰语(比利时) nl-be 葡萄牙语(葡萄牙) pt-pt 葡萄牙语(巴西) pt-br 法语(法国) fr-fr 法语(卢森堡) fr-lu 法语(瑞士) fr-ch 法语(比利时) fr-be 法语(加拿大) fr-ca 西班牙语(拉丁美洲) es-la 西班牙语(西班牙) es-es 西班牙语(阿根廷) es-ar 西班牙语(美国) es-us 西班牙语(墨西哥) es-mx 西班牙语(哥伦比亚) es-co 西班牙语(波多黎各) es-pr 德语(德国) de-de 德语(奥地利) de-at 德语(瑞士) de-ch 俄语(俄罗斯) ru-ru 意大利语(意大利) it-it 希腊语(希腊) el-gr 挪威语(挪威) no-no 匈牙利语(匈牙利) hu-hu 土耳其语(土耳其) tr-tr 捷克语(捷克共和国) cs-cz 斯洛文尼亚语 sl-sl 波兰语(波兰) pl-pl 瑞典语(瑞典) sv-se 西班牙语(智利)
2020-06-12 - scroll-view标签中scroll-x属性似乎没用
https://developers.weixin.qq.com/miniprogram/dev/component/scroll-view.html [图片] 在开发工具里面 ,scroll-x横向滚动失败,感觉这个属性没用。换成scroll-y则可以进行纵向滚动,不知道为什么?
2020-03-23 - 只有三行代码的神奇云函数的功能之三:100%成功获取unionid
这是一个神奇的网站,哦不,神奇的云函数,它只有三行代码:(真的只有三行哦) 云函数:login index.js: const cloud = require('wx-server-sdk') cloud.init() exports.main = async (event) => { return { ...event, ...cloud.getWXContext() } } 神奇功能之三:100%成功获取unionid: 保证100%成功获取unionid,需要用户信息授权。 强调一下:这个100%是指必须绑定了开放平台,那么不管用户是什么情况,不管有没有关注公众号,一定100%能获取到unionid。 依然需要符合unionid机制:第1条。 https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/union-id.html js: getUserInfo: function (e) { app.globalData.userInfo = e.detail.userInfo if (!app.globalData.unionid ) { wx.cloud.callFunction({ name: 'login', data: { weRunData: wx.cloud.CloudID(e.detail.cloudID) } }).then(res => { app.globalData.unionid = res.result.weRunData.data.unionId }) } }, 其他功能: 神奇功能之四:获取电话号码: 还是这三行代码,获取用户的电话号码。 https://developers.weixin.qq.com/community/develop/article/doc/0006a8ec7ac860c94bf90a34f5d813 神奇功能之五:获取群id: 将小程序分享到某群里,可获得该群的群id, https://developers.weixin.qq.com/community/develop/article/doc/000ea802c00f70894cf9fe72556013 神奇功能之一:获取openid: https://developers.weixin.qq.com/community/develop/article/doc/00080c6e3746d8a940f9b43e55fc13 神奇功能之二:不用授权获取unionid: https://developers.weixin.qq.com/community/develop/article/doc/000a0c6b580338e947f9db0c65b813 [图片]
2020-10-25 - 手把手带你使用云开发实现微信小程序支付功能(完整视频讲解)
首先看下效果图 [图片] 下面把完整的讲解视频,免费发给大家。 1~注册企业小程序 [视频] 2~微信支付商户号注册及注意事项 [视频] 3~小程序关联微信支付的商户号 [视频] 4~创建云开发项目 [视频] 5~云开发控制台配置微信支付商户号 [视频] 6~编写支付的云函数 [视频] 7~云开发实现微信支付 [视频] 8~动态修改商品名和价格 [视频] 9~编写商品页面 [视频] 10~单个商品的支付 [视频] 11~商品列表实现购买支付 [视频] 如果你是B站用户,也可以去石头哥B站观看完整的视频讲解: https://www.bilibili.com/video/BV1Lp4y1D7oY/
2020-06-11 - 这种中间弯曲的导航css代码怎么写出来的?
[图片] 常规导航就是一个长方形的条,这个是中间弯曲下去一部分。。 有没有老司机给个css方案!
2019-11-22 - “签到”功能,不是你想象的那么简单
签到功能的存在,不仅能够提高用户活跃,还能够刺激用户完成指定行为。签到的同时伴随的荣誉感、仪式感等精神奖励。 [图片] 如果通过签到可赚取积分、经验值以及兑换奖品,达到激励用户的目的,那么它还有个更高级的名字:游戏化思维。 发现在小程序里以「签到」为基础的游戏化思维策略,已经成为开发者的基本打法。 1、如果你是高频小程序,要做签到,这会进一步提高用户粘性; 2、如果你是低频小程序,更要做签到,这是低频变高频的快速通道。 小程序里的签到到底怎么玩? 放长线钓大鱼 在很多人看来,签到或许只是提高用户留存率的方法,其实签到并不仅限于留存,还有拉新促活。 用优质商品吸引用户签到,每次签到可得积分,积分可换购商品,如果用户想获得更多积分,只能多做任务 这样一来,用签到,便完成了引导用户闭环营销。 免费送“钱”,你要不要? 用户在平台里每天签到1次,都能获得积分 用户拿着积分,唯一能做的事,就是尽情的兑换商品,无需考虑“省钱”,反正除了小程序以外,积分在其他地方也毫无用武之地。 而通过连续的签到行为逐渐增加用户的获利程度,刺激用户养成连续签到习惯。 不提留存的签到,都是耍流氓 虽然有些人说签到不仅是为了留存,但用这一玩法真正受益的还是小程序的留存率。 促进内容生产 签到导致产生内容,可以进一步活跃用户,带动整个社群的气氛,让用户在这个圈子里产生互动。这是留存的力量。 就连最近非常流行的步数类小程序,也离不开签到所带来的“回头率”。 [图片] 签到对于小程序本身,也是一种具有仪式感的功能,如果你使用得当,一定能为你的小程序锦上添花。
2020-02-21 - 小程序流量主运营技巧
前言(写给入坑的小白) 本文不涉及任何需要资质的小程序(如:视频类目)。小程序流量主是个人和小微企业主要变现途径之一,满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 - 小程序eval/Function终极替代方案:eval5
由于小程序内部禁用了eval、Function导致在一些场景下无法动态执行脚本,小程序又只支持JavaScript开发,如果想要在在前端动态执行脚本就得实现对于的脚本解释器。 eval5是完全基于JavaScript编写的JavaScript解释器,支持ECMA5语法 项目地址:https://github.com/bplok20010/eval5 实现原理: 使用acorn或esprima编译器对JavaScript代码进行编译并得到抽象语法树(AST)用JavaScript解析语法树并得到计算结果例如:1+1的解析 一、使用acorn编译后得到的语法树,语法树由不同的节点组成各个节点的type代表着不同的语句或表达式类型: { "type": "Program", "body": [ { "type": "ExpressionStatement", "expression": { "type": "BinaryExpression", "operator": "+", "left": { "type": "Literal", "value": 1, "raw": "1" }, "right": { "type": "Literal", "value": 1, "raw": "1" } } } ], "sourceType": "script" } 二、得到语法树后,我们可以根据不同的节点类型实现不同的处理函数,例如: function handleBinaryExpression(node) { switch( node.operator ) { case '+': return node.left.value + node.right.value; case '-': return node.left.value - node.right.value; } } 如何使用 一、使用npm安装: npm install --save eval5 二、使用打包好的eval5.js 使用示例: // npm install --save eval5 import {Function,evaluate} from 'eval5'; //or 'path/eval5.js' const sum = new Function('a', 'b', 'return a+b'); sum(100,200) evaluate('1+1') eval5基于小程序编写的示例:eval5-wx-demo github地址 [图片]
2020-02-19 - 今天只做一件事:带你玩转微信“一物一码”
欢迎进入微信的“码上世界”! 作者丨Tsai [图片] 最近微信两大动作引爆了话题讨论,一个是前天微信新版上线的“超级浮窗”,另一个是同一天官宣正式开放的“一物一码”能力。 前者,一定程度解决了用户先前在微信内无法进行“多任务操作”的痛点;后者,意图借助小程序开启“连接万物”的时代,并且加速商家全链数字化的进程。 01链接万物 据微信官方介绍,“一物一码,就是让每个商品都有一个自己的专属码。”而且由于“微型码”作为腾讯专利码制,无法被破解和仿制,因此它在某种意义上可以视作商品的入网ID,具有唯一性,并且与商品一一对应。 [图片] 按照这样的理解,“一物一码”似乎只是连接了商品这一维度,称之为开启“连接万物”的时代未免过分夸大了。事情显然没那么简单,通过在商品上“赋码”的方式,“一物一码”可以实现消费者扫码跳转小程序、跳转公众号、参与营销互动等,将“人”与“货”紧密相连。 而这,才是“一物一码”真正令人心动的地方,也是腾讯智慧零售目前正在解决的,解决大部分零售企业目前所处的“人货割裂”状态。 [图片] 当“人”与“货”不再割裂时,商家的可操作空间也就能大大提高。借助“一物一码”,商家不仅可以通过多样的营销玩法与用户进行高效互动、获得流量,还能够利用其大数据支持了解自家的营销效果,及时调整营销策略,真正帮助商家读懂用户。 不过,我们前面这些还停留在理论层面,商家具体该怎么使用,用完又能达到怎样的效果?而这,或许才是商家真正关心的。于是我们带着这些问题,对微信“一物一码”首批合作商之一的“蒙牛”进行案例拆解(蒙牛成为2018年FIFA世界杯全球官方赞助商),为大家尽可能地剖析“一物一码”的应用场景和运营玩法。 [图片] 02 庖丁解牛(蒙牛) 正式拆解前,我们先把“一物一码”和“蒙牛世界杯”的合作效果数据抛出来:此次活动线下扫码参与人次2.2亿,总参与人数7165万人,人均复购频次3次,6.18当天小程序访问量达到顶峰:2072万次,小程序日均PV量85万次。 这样的数据是不是很诱人,是不是很想知道微信“一物一码”究竟是如何给到“蒙牛”支持的?话不多说,下面我们赶紧来看一下: 1)多样化的跑码支持——不止瓶盖码 微信“一物一码”,不止支持瓶盖码,还支持一包一码、箱码、棒签码等,并且每个产品的唯一码都会附上数据信息。 这种多样化的跑码支持,使得蒙牛全品线都能参与此次世界杯活动,并且品牌在整个活动过程中,还能够根据数据对品线搭配进行及时的策略调整。 [图片] 2)丰富的接口能力支持——“一物一码+” “一物一码”的强大还在于微信能够提供的丰富接口能力,当时蒙牛就主打“腾讯云+小程序”的组合拳,运用到卡券、微信运动、自定义组件、红包、扫码、登录、获取用户信息、获取用户GPS等接口功能,丰富了整个小程序的交互体验。 以当时推出的“全民集牛卡”活动为例,消费者通过购买蒙牛世界杯指定包装产品,打开微信,使用“扫一扫”功能扫描包装二维码,即进入蒙牛FIFA世界杯小程序,领取红包的同时获得定制版虚拟牛卡。 [图片] 不仅如此,消费者还可以通过微信运动步数兑换、好友分享索要获得更多牛卡,而这些积攒下来的牛卡都将用于最后的抽奖活动。“一物一码”就像个综合枢纽,它将微信众多的工具能力进行聚合,然后以最合适的组合方式进行搭配发射出去,赋能商家更强的营销互动能力。 3)场景多变的互动玩法——让用户动起来 如果把“一物一码”比作一台计算机,那微信提供的丰富接口能力就是硬件设施,不过再好的硬件也需要软件来支撑,放到商家身上,这里的“软件”就是基于接口能力的运营玩法。 在蒙牛世界杯活动中,除了前面提到的“集牛卡”,蒙牛还推出“全民竞猜 瓜分百万红包”、“优益C-吃货来找茬”、“活力优益C 喝彩FIFA世界杯”等多变的互动活动,通过提高消费者的互动粘性,进而刺激他们参与消费。 [图片] 比如在“优益C-吃货来找茬”活动中,蒙牛为用户设计了一个小游戏:15秒内要躲避餐桌上的食物并且收集优益C,每成功收集一瓶优益C,即可获得10活力值,另外,针对已经购买蒙牛产品的用户,进入游戏即可获得额外的500活力值(目的还是刺激用户消费),当然,这些活力值最后也可以参与抽奖; 还有“活力优益C 喝彩FIFA世界杯”的活动,用户进行对应的活动专区后,可以通过拍照/上传靓照并选择心仪的球队元素服饰,生成专属喝彩海报,并且将活动分享给好友还将获得更多电商优惠(本质还是利诱用户分享进行裂变传播)。 [图片] 4)专业的运营服务支持——你的运营“大管家” 前面我们提到,“一物一码”的微型码是腾讯专利码制,无法被破解和仿制,因此它能够用于消费者进行商品溯源,增强消费者对商品及品牌的信任,这是一方面。 另一方面,由于微信“一物一码”以小程序为活动载体,能够将商家活动数字化,因此非常方便商家追踪多品线、地域和城市的参与情况。 [图片] 这还不是全部,“一物一码”还给蒙牛提供一系列细致的运营服务,比如上线后优化活跃度的活动策划、多维度的数据运营报告、页面优化解决方案等,可以给商家提供消费者参与互动扫码的及时数据,一旦出现活动相关异常波动,系统可以发出告警给到活动相关负责人,及时介入解决。 03全链数字化是未来 可以看到,“一物一码”的开放,能够给予商家极大的营销助力,它以小程序为互动抓手,借助微信卡包、公众号、积分等级等能力,对消费进行持续触达和运营的同时,助力商家构建自主可运营的数字化资产,从而提升复购转化。 谈到微信“一物一码”,其实我们很难绕开“腾讯优码计划”。作为“一物一码”的全新升级,“优码”的目的就是融合腾讯全平台能力,通过为商家提供一站式解决方案,从而帮助商家实现与消费者真正“零距离”。 [图片] 对此,腾讯公司副总裁林璟骅也于今年4月份指出,移动社交对零售业的最大帮助在于让零售回归以人为本。但随着行业向数字化的深水区迈进,线上营销和线下渠道割裂仍然困扰着零售人。 “未来的生意是全链路数字化,品牌必须沉淀自有的数字化资产。”这会是未来,也将会是商家无法逃脱的变革,即使这变革它有点残酷。
2019-07-23 - 如何用小程序实现类原生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