- 微信小程序更新提醒uniapp
微信小程序更新提醒uniapp 简介 在小程序开发中,版本更新至关重要。为确保用户始终使用最新版本,我们建议在每次打开小程序时进行版本检测。具体方案如下: 1. 启动时版本检测: 我们使用[代码]uni-app[代码]提供的API[代码]uni.getUpdateManager()[代码],API返回全局唯一的版本更新管理器对象: updateManager,用于管理小程序更新。 2. 新版本提示与更新: 如果检测到新版本,弹出提示框告知用户有新版本可用。 提供“立即更新”选项。 用户选择“立即更新”后,小程序自动下载更新内容。 3. 重启应用新版本: 更新完成后,提示用户确认重启小程序以应用新版本。 [图片] [图片] 摘要 :在小程序开发中,版本更新至关重要。本方案利用 [代码]uni-app[代码] 的 [代码]uni.getUpdateManager()[代码] API 在启动时检测版本更新,提示用户并提供立即更新选项,自动下载更新内容,并在更新完成后重启小程序以应用新版本。适用于微信小程序,确保用户始终使用最新版本。以下是实现步骤: 实现步骤 1 创建更新方法 [代码]App.vue[代码]创建updateApp方法用于检查小程序是否有新版本。 [代码]<script setup lang="ts"> import { onLaunch } from '@dcloudio/uni-app' import { useAppStore } from './stores/app' import { useUserStore } from './stores/user' const appStore = useAppStore() const { getUser } = useUserStore() // #ifdef MP-WEIXIN const updateApp = () => { const updateManager = uni.getUpdateManager(); updateManager.onCheckForUpdate(function (res) { // 请求完新版本信息的回调 console.log(res.hasUpdate); }); updateManager.onUpdateReady(function (res) { uni.showModal({ title: '更新提示', content: '新版本已经准备好,是否重启应用?', success(res) { if (res.confirm) { // 新的版本已经下载好,调用 applyUpdate 应用新版本并重启 updateManager.applyUpdate(); } else if (res.cancel) { console.log('用户点击取消,不更新'); } } }); }); updateManager.onUpdateFailed(function (res) { // 新的版本下载失败 uni.showModal({ title: '已经有新版本了哟~', content: '新版本已经上线啦~,请您删除当前小程序,重新搜索打开哟~', }) }); } // #endif onLaunch(async () => { await appStore.getConfig() // #ifdef MP-WEIXIN updateApp() // #endif await getUser() }) </script> <style lang="scss"> // </style> [代码] 2 测试 添加编译模式,选择编译设置,选择成功状态进行模拟。 [图片]
10-16 - 个人开发者把小程序发布到 App Store 5个步骤(保姆级教程)
用完即走,小程序已经成为连接用户与服务的重要桥梁,无论是购物、出行还是娱乐,小程序都以其便捷性和高效性赢得了用户的青睐。 那小程序是否有边界,能否拓展到 App? 微信开发者工具的最新升级,为这一问题提供了创新的解答。现在,开发者们可以轻松将已有的小程序代码构建为全功能的 App,而无需从零开始开发,这不仅节省了大量的人力和时间成本,更为小程序开发者们打开了通往 App Store 巨大流量的大门。 在这篇文章中,我们将深入探讨微信开发者工具支持小程序 App 化的五大步骤,分析其潜在价值,并通过实际案例来展示这一过程的效果。 背景:个人开发者,将小程序代码构建为 iOS App,以下为整体流程,适合 iOS 开发 / 上架零基础的同学。 [图片] 缘起 一个周末,突然奇想,我还没有搞过 App,要不搞搞玩玩😄 从 0 开始学还是很慢的,毕竟时间有限,好在了解过提示工程 [代码]《ChatGPT 进阶:提示工程入门 陈颢鹏 李子菡》[代码],问了一下助手 ChatGPT 几个常见的问题。 开发适用于 iPhone 的 App 的流程是什么 注册开发者账号 -> 下载 Xcode -> 学习 Swift -> 设计 + 编码 + 测试 -> TestFlight 内测 -> 准备上架 (准备) -> 提交审核 -> 应用上架 -> 应用更新和维护 很好,请给出可运行的应用实例,完成查询本机 IP 地址 我是一个新手,请问在 XCode 中如何运行你提供的代码 几轮对话后,大约用了 1 个小时,一个 iOS Demo 在模拟器上跑成功了,有点意思😄 不过功能有点简单,几年前用 Vue 写过一个还在跑的网站,同时我知道 ChatGPT 的编程能力,于是我丢了一个问题给他。 [代码]你是一个开发,会 Vue 、iOS 开发(使用 SwiftUI 框架 )两种语言,现在需要你根据 Vue 的代码重写为 iOS 代码,以下是 Vue 代码 [代码] [图片] 笔者是一个运维平台的产品,为了不忘记运维场景和技能,自己维护一个业务场景,开发语言:golang + Vue,部署架构:腾讯云 CLB+TKE+ 服务网格,运营系统:CLS+ 云函数 +Kafka+Doris+Flink+Hadoop+Streamsets。 结果惊奇的发现,真的可以执行,不服不行 👍 [图片] 1. 转折:把小程序代码构建为 iOS App 测试包(1 小时) 如果仅仅只是这样,那么这篇文章标题就该叫“GPT 如何将 Vue 改写为 App”。 万万没想到,过了 2 周从朋友那里获悉 微信开发者工具可以直接将小程序代码构建为 App,就像 Golang 一样,可以通过参数 [代码]GOARCH[代码] 控制构建的程序是在跑在 [代码]amd64[代码],还是 [代码]arm64[代码] CPU 架构上。 [图片] 现实就是这么巧,几年前使用 Vue 开发站点时,同时也开发了同款小程序。 有点意思,参照文档 构建你的第一个应用 花了 1 个小时,在我的 iPhone 上跑了 测试版 的 App. [图片] 此处应该给多端应用的产品和开发点个赞👍🏻 搞到这里,我其实进入了这款的第一个哎哈时刻,确实很爽,因为我不需要花心思用 GPT 来迁移 Vue 程序,直接用微信开发者工具构建为 App 即可,交互完全一致。 另外记录构建过程中遇到的两个问题 问题 1:小程序的图片在 App 中无法渲染 启用 Media SDK 即可 [图片] 问题 2:App 带有 Vconsole 入口 一开始以为在模拟器中才有,最后发现是一个配置,需要自己主动关闭。 [图片] 2. 构建正式包 谁不想在 App Store 能搜到自己的 App 呢,第二步,构建正式包。 2.1 准备苹果开发者账号 在 MacBook Air 或 iPhone 中安装 Apple Developer,然后注册苹果开发者账号 [图片] 一年 688 元会费 [图片] 正常情况下,交完会费后,第二天会收到一封欢迎加入 Apple Developer Program 的邮件,代表苹果开发者账号注册成功。 很遗憾,我注册时提示“未知错误,请再试一次” 找 Apple Developer 客服反馈,最后答复 [代码]由于一个或多个原因,您无法完成 Apple Developer Program 的注册。我们目前无法继续处理您的注册。[代码]。 好吧,估计是被风控命中了,于是找了家人的账号来注册,直接成功😄 2.2 生成 Bundle ID/ 证书 /Profile 生成 App 备案和构建正式包都需要的 Bundle ID/ 证书 /Profile。 生成 Bundle ID Bundle ID 是一个唯一的标识符,用来识别你的应用程序。它通常采用反向域名格式,例如 com.example.myapp。在开发和发布应用程序时,你需要在苹果的开发者账户中注册一个 Bundle ID,这样苹果的服务才能识别出你的应用程序。 参照 文档 生成 Bundle ID。 生成 证书 /Profile 证书(Certificates)用于建立开发者的身份,并确保应用是由已注册的开发者发布的。开发者需要从苹果开发者中心申请证书,用来对应用进行签名,这样 iOS 设备才会信任并运行这个应用程序。 配置文件(Provisioning Profiles)是一个包含证书、应用程序 ID、设备 ID 和其他信息的文件,它告诉 iOS 设备一个应用程序可以被安装和运行。配置文件将应用、开发者和设备联系起来,控制哪些设备可以安装和运行你的应用程序。 参照 文档 生成 iOS 证书和 Provisioning Profile。 [图片] 拓展资料:创建证书签名请求 问题:申请的 iPhone Distribution 证书不受信任 导入 Apple WWDRCA 证书 即可,可能原因:大致是分发的根证书没有导入你的 Mac 上。 更多资料详见 Apple PKI。 [图片] 2.3 备案(10 天 +) App 如果没有备案,在中国大陆将无法上架,这是苹果官方的说明。 中国工业和信息化部(MIIT)要求 App 必须具备有效的互联网信息服务提供者(ICP)备案号,了解更多 [图片] 其实备案比较简单,参照 App 备案 ,使用上一部分申请的 [代码]Bundle ID[代码]、证书(可查看 [代码]公钥[代码]、[代码]签名 MD5 值[代码])即可,不需要把 App 开发完,再来备案。 备案最长需要 20 个工作日,笔者用了 10 个工作日,在一个周五的下午收到了工信部发来的备案通过短信。 2.4 创建移动应用 移动应用是为了让 App 能用上微信的能力(比如分享到朋友圈或发送给朋友、微信登录 / 支付等),在移动应用中同时登记了 Bundle ID 和 Universal Links,这将会传递给下一步的多端框架,这是构建可正式包(采用苹果的分发证书)的必备条件。 先介绍一下 Universal Links。当用户使用 iPhone 手机访问你的网站,同时安装了 App 时,能在网站顶部快速跳转到 App。具体可以看下苹果官方的文档 Supporting associated domains 你需要有一个网站,未来要放 Universal Links 要用到的 [代码]apple-app-site-association[代码] 文件,不过对于我来说,这个功能好像用处不大,我更需要的是当用户用 iPhone 访问网站,引导他去 Apple Store 安装 App. 这里有一个关键信息,如果你不需要微信支付 / 微信登录 / 微信卡券的能力,不需要做开发者认证(开发者认证不能是个人主体) 访问 微信开放平台,创建移动应用,提交审核,几个小时就审核通过了。 [图片] 2.5 绑定多端框架 在 Donut 开发平台 中将 多端应用绑定上一步创建的移动应用,这样可以用到移动应用中登记的 Bundle ID 和 Universal Links,官方这么做比较合理,关键信息必须通过移动应用这关人工审核来起到一定的约束。 [图片] 绑定后,在多端应用中可以看到 Bundle ID 和 Universal Links 了。 [图片] 2.6 准备 App icon 等资料 App Icon 先用工具为你的 App 设计一个 1024px X 1024px 的图标,然后在 App Icon Generator 上生成 iPhone 所有规格的图标,之后在 [代码]project.miniapp.json[代码] 配置。 [图片] 启动图片 App 启动一般需要 2~4 秒,如果没有启动图片是白屏,用户会有点慌,不知道当前 App 是否正在启动,启动图片就是解决这个问题,同时在启动图片中传达 App 的价值主张。 我是直接用 Sketch 设计的,分辨率为 1290px x 2796px,这是兼容性最强的 6.7 寸(iPhone 15 Pro Max/15 Plus/14 Pro Max)手机的分辨率。 考虑到启动图片在不同机型上的兼容性,如果你用 Xcode 开发,苹果官方会推荐使用 Launch Screen Storyboard 隐私信息访问许可描述 小程序虽然没有用到摄像头、麦克风等权限,但多端的 SDK 中有(具体详见 Donut 官方文档 上架应用市场常见问题),所以得提前申明,不然把包通过 [代码]Transporter[代码] 上传后,会收到苹果发出的不合规邮件。 [图片] 以下是根据苹果官方打回的邮件中定义的隐私信息访问许可描述,应该是最基础的了,可以贴到你的 [代码]project.miniapp.json[代码] 文件中(用编辑器打开)。 [代码]{ "privateDescriptions": { "NSBluetoothPeripheralUsageDescription": "为了提供完整的功能,我们的应用程序需要访问蓝牙外设。这将用于与其他设备进行通信和数据交换。我们承诺保护用户隐私和数据安全。", "NSMicrophoneUsageDescription": "为了提供完整的功能,我们的应用程序需要访问麦克风。这将用于录制音频和进行语音交互。我们承诺保护用户隐私和数据安全。", "NSCalendarsUsageDescription": "为了提供完整的功能,我们的应用程序需要访问日历。这将用于提醒和日程管理。我们承诺保护用户隐私和数据安全。", "NSLocationAlwaysAndWhenInUseUsageDescription": "","NSBluetoothAlwaysUsageDescription":" 为了提供完整的功能,我们的应用程序需要始终访问蓝牙外设。这将用于与其他设备进行通信和数据交换。我们承诺保护用户隐私和数据安全。","NSPhotoLibraryUsageDescription":" 为了提供完整的功能,我们的应用程序需要始终访问相册。这将用于 IP 查询时显示 ISP 的图标。我们承诺保护用户隐私和数据安全。","NSCameraUsageDescription":" 为了提供完整的功能,我们的应用程序需要访问摄像头。这将用于录制视频。我们承诺保护用户隐私和数据安全。","NSLocationWhenInUseUsageDescription":" 为了提供完整的功能,我们的应用程序需要在使用时访问位置信息。这将用于提供定位服务和相关功能。我们承诺保护用户隐私和数据安全。" } } [代码] 2.7 构建正式版版本包 参照 打包生成 IPA 生成正式版的版本,注意使用分发证书。 [图片] 报错:file must be in miniprogram project 解决:把 mobileprovision 放在 miniprogram 目录下,因为 profile 不像 App icon 一样会自动上传到 miniprogram/ 目录下。 2.8 使用 Transporter 上传版本 参照 官方文档 上传正式版的 APK 包。 [图片] 遇到问题: Transporter,无法为 App “comxxxx.ipa” 创建临时 .itmsp 软件包。No suitable application records were found. Verify your bundle identifier ‘com.xxxx’ is correct and that you are signed into Xcode with an Apple ID that has access to the app in App Store Connect. [图片] 解决办法:去 App Store Connect 添加 App,绑定 [代码]Bundle id[代码],这样 Transporter 可以验证包在 App Store Connect 中已注册。 3. 使用 TestFlight 测试 在 App Store Connect 的 TestFlight 页面,可以选择内部、外部测试,外部测试版本需要 Apple 官方审核,把 公开链接发给朋友即可。 [图片] 在测试的同时,可以同步准备上架 App Store 的资料了。 4. 准备上架 Apple Store 审核资料 截屏 截屏是用来在 App Store 中显示你的 App 产品介绍页的,具体参照 截屏规范 [图片] 有 [代码]iPhone 15 Plus[代码] 和 [代码]iPhone 8 Plus[代码] 这两款机型就足够了,其他型号的手机能复用,分辨率应该是等比率缩放。 如果你像我一样,没有这两款手机,那用 iOS 模拟器。 Xcode -> 工具栏 Windows -> Devices and Simulators -> Create a new simulator -> Download more simulator runtimes [图片] 在微信开发者工具中运行这两款模拟器,利用模拟器自带截屏工具即可。 隐私政策 找一下常见 App 的隐私政策,在其产品介绍页中可以跳转过去。 如果你有网站就放在网站上,如果没有可以放在腾讯文档上。 [图片] 选择 App 供应的地区范围 哪些地区的用户可以下载你的 App。 [图片] 提交审核 一切准备好了后(包含备案),开启提交审核。 下午 5:35 提交审核,第二天早上 3:40 上架成功。✌🏻 [图片] 5. App Store 的数据 上架后刚好一周,看看最近一周的数据,还不错。 [图片] 这是评分数据 [图片] 6. 引流 二维码引流:草料二维码 通过草料二维码生成 App 的下载链接,放在网站上,引导用户跳转至 App。 Universal Links 参照 Apple 官网文章 Supporting associated domains 准备 Universal Links。 前面已经介绍了这个东东是干嘛的。 准备 [代码]apple-app-site-association[代码] 文件,放在网站的 [代码].well-known[代码] 目录下,完整路径为 [代码]/.well-known/apple-app-site-association[代码] 以下为示例,特别注意的是 [代码]appID[代码] 是由 [代码]团队 ID[代码] + [代码]Bundle ID[代码] 组成。 [代码]{ "applinks":{"apps":[], "details":[ { "appID":"<team_id>.<bundle_id>", "paths":["*"] } ] } } [代码] team_id 从 开发者账户 中获取 [图片] 顶部导航 当用户访问网站时,顶部引导用户跳转到 App 下载页。 等有空了搞搞。 7. 后记 小程序转 App,让个人或企业可以快速拥有 App,获取应用市场的流量,让开发者把精力放在业务逻辑上。 同时在开发小程序的过程中,发现开发者生态会散落在多个地方,比如 github,提供一些小程序模版、组件等能力,无法集中在一个地方比较方便的找到整个开发者生态的能力,和 VSCode 插件生态有点区别。 [图片] 先说 IDE 插件,比如我用 GPT4-Turbo 来写先代码或排查问题会在微信开发者工具和 Web 间跳转,操作流不太顺,如果能在微信开发者工具的插件入口中找到对应的 AI 代码助手,用起来应该很爽。 一旦平台的开放能力放出来,这些能力将源源不断的涌入到这个市场中,而不是作为平台方来集成这些能力,毕竟精力有限,同时还不一定做的最好,用插件可以让用户有更多的选择。 再说说 小程序组件,以大模型为例,目前市场有备案的大模型基座模型有好几家,在小程序开发过程中其实比较缺整体组件(UI + 背后的 API),有点像商场一样,平台方构建开放的能力,引导各个供应商提供开箱即用的能力,让用户可以快速上手,赶上这波大模型的技术趋势。 比如我自己在设计开放能力时的思考,平台专注骨架功能的开发,让开发者能参与到平台的建设中来,把生态盘活起来,最终提升大家研发运营的效率。 最后就是管理后端比较分散,比如 开放平台、donut、we 分析、云测、云托管,云开发,产品矩阵看不清,不容易知道整体的能力,缺少一个集中的控制台。 最后希望小程序越来越好 😄
01-30 - 岁寒之松柏:小程序skyline渲染引擎初尝试
小程序架构介绍 我们都知道小程序本质上是运行在安卓端,苹果端的混合APP,只是微信提供了一套JSBridge,方便用户对一些原生功能和微信相关的功能的进行调用。而微信为了安全和性能的需要,一改以往网络架构中的单线程架构,改为小程序的双线程架构。分别是AppServie 和 Webview 两个线程,我们在小程序中编写的JS代码就是运行在AppService线程的JSCore引擎(类似V8 引擎,一个Js解释器)中,而我们的Wxml和Wxss则会依赖WebView线程进行渲染。 [图片] 目前架构存在的问题 这样的架构虽然已经极大了提高了webview的渲染性能,但是依然会存在一些问题比如: 当页面节点数目过多,很容易发生卡顿 当我们新建一个页面,就要新建一个Webview进行渲染 页面之间共享资源,需要使用Native进行通信,就会消耗更多性能 当AppService(逻辑层)与Webview(视图层)通信也需要依赖Native 所以为了解决这些问题小程序推出Skyline渲染引擎 Skyline引擎介绍 在Skyline环境中,Skyline 会创建了一条渲染线程来负责 Layout, Composite 和 Paint 等渲染任务,并在 AppService 中划出一个独立的上下文,来运行之前 WebView 承担的 JS 逻辑、DOM 树创建等逻辑。说白了就是之前的样式计算是放到渲染线程来处理,现在把和样式相关的逻辑也放到AppService线程中处理,个人猜测这个渲染线程很有可能很有可能就是flutter,这样的架构就极大减少内存的消耗,和线程上通信时间的消耗。原本wxs中的逻辑,也可以移到Appservice线程中运行 [图片] 使用Skyline引擎的使用步骤 在app.json 文件添加 [代码]"lazyCodeLoading": "requiredComponents"[代码] 属性,这是因为Skyline 依赖按需注入的特性。 [代码] { "pages": [ "pages/index/index", "pages/logs/logs", "pages/test/test" ], "window": { "backgroundTextStyle": "light", "navigationBarBackgroundColor": "#fff", "navigationBarTitleText": "Weixin", "navigationBarTextStyle": "black" }, "sitemapLocation": "sitemap.json", // 在 app.json 文件添加 "lazyCodeLoading": "requiredComponents" } [代码] 在全局或页面配置中声明为 Skyline 渲染,即 app.json 或 page.json 配上[代码]"renderer": "skyline"[代码] Skyline 不支持页面全局滚动,需在页面配置项加上 [代码]"disableScroll": true[代码],在需要滚动的区域使用scroll-view 实现 Skyline 不支持原生导航栏,需在页面配置项加上 [代码]"navigationStyle": "custom"[代码],并自行实现自定义导航栏 [代码] { "usingComponents": {}, // 在 app.json 文件添加或者page页面的json中添加 "disableScroll": true, "navigationStyle": "custom" //也可以放在App.json文件中 "renderer": "skyline" } [代码] 组件适配,参考 Skyline 基础组件支持与差异 WXSS 适配,参考 Skyline WXSS 样式支持与差异 在本地设置中勾选Skyline渲染调试,如果看不到这个选项框,看一下是否在app.json中配置了[代码]"renderer": "skyline"[代码] [图片] Skyline的 worklet 动画介绍 小程序采用双线程架构,渲染线程(UI 线程)和逻辑线程(JS 线程)分离。[代码]JS[代码] 线程不会影响 [代码]UI[代码] 线程的动画表现,如滚动效果。但引入的问题是,[代码]UI[代码] 线程的事件发生后,需跨线程传递到 [代码]JS[代码] 线程,进而触发开发者回调,当做交互动画(如拖动元素)时,这种异步性会带来较大的延迟和不稳定,[代码]worklet[代码] 动画正是为解决这类问题而诞生的,使得小程序可以做到类原生动画般的体验 worklet函数定义 [代码]function helloWorklet() { 'worklet'; //'worklet'声明该函数为work函数,可以在js线程和UI线程中调用 console.log('hello worklet'); } Page({ onLoad(options) { helloWorklet('hello') // print: hello wx.worklet.runOnUI(helloWorklet)() }, }) [代码] 在小程序控制台可以看到如下输出 [图片] 如果看见SkylineGlobal is not defined错误看看是否开启了Skyline渲染调试 [图片] worklet函数间的相互调用 [代码]function slave() { 'worklet'; return "I am slave" } function master() { 'worklet'; const value = slave() console.log(value); } [代码] 从 UI 线程调回到 JS 线程 [代码]const {runOnUI ,runOnJS} = wx.worklet function jsFun(message) { // 普通函数不需要声明为worklet console.log(message) } function uiFun() { 'worklet'; runOnJS(jsFun)('I am from UI') } [代码] 使用shared共享数据 由worklet函数捕获的静态变量,会在编译期间序列化后生成在UI线程的拷贝环境之中,这就导致我们在JS线程中后续更新了变量,但是在UI线程中时得不到最新的数值的。 [代码]const obj = { name: 'skyline'} function someWorklet() { 'worklet' console.log(obj.name) // 输出的仍旧是 skyline } obj.name = 'change name' wx.worklet.runOnUI(someWorklet)() [代码] 因此shyline使用shared来实现线程之间数据的共享 [代码]const { shared, runOnUI } = wx.worklet const offset = shared(0) function someWorklet() { 'worklet' console.log(offset.value) // 输出的是新值 1 } offset.value = 1 runOnUI(someWorklet)() [代码] 简单案例–实现探探的卡片功能 注意:编辑器版本:1.06.2303162 基础库版本:2.30.2 先看效果 [图片] 代码如下 <br> wxml 代码 [代码]<navigation-bar title="探探" /> <view class="page"> <block wx:for="{{containers}}" wx:key="*this"> <pan-gesture-handler data-id="container-{{index}}" onGestureEvent="handlePan"> <view id="container-{{index}}" class="container" style="z-index: {{zIdnexes[index]}};background-image: url({{partContentList[index]}});"> </view> </pan-gesture-handler> </block> </view> [代码] scss代码 [代码].page{ display: flex; justify-content: center; align-items: center; height: 100vh; width: 100vw; position: relative; .container{ height: 80vh; width: 95vw; background-color: burlywood; position: absolute; border-radius: 16rpx; display: flex; justify-content: center; align-items: center; background-size: cover; .image{ display: block; height: 1067rpx; width: 712rpx; margin: 0 0; } } } [代码] 核心逻辑 [代码]import { useAnimation, setAni, Animation, GestureState } from "./method" Page<{ pos: Animation }, any>({ /** * 页面的初始数据 */ data: { containers: [ "burlywood", "blue", "cyan", "black" ], zIdnexes:[], current:0, partContentList:[] }, /** * 生命周期函数--监听页面加载 */ onLoad() { this.initNode() // 当前node的下标 this.active = wx.worklet.shared(0) // 当前contentList的下标 this.current = wx.worklet.shared(0) this.zIndex = 100000 }, initNode() { // 用与保存shared值 this.Nodes = {} // 图片文件 this.contentList = [ "https://i.hexuexiao.cn/up/ca/63/4a/a32912fc26b8445797c8095ab74a63ca.jpg", "https://th.bing.com/th/id/OIP.kSrrRGx6nqOgWzbaEvVD9AHaNK?pid=ImgDet&rs=1", "https://img.zmtc.com/2019/0806/20190806061552744.jpg", "https://img.zmtc.com/2019/0806/20190806061000600.jpg", "https://img.ratoo.net/uploads/allimg/190523/7-1Z5231J058.jpg", "https://th.bing.com/th/id/R.47de9dfcc25d579d84850d4575d24a6a?rik=%2fGkmrewzIEY4Iw&riu=http%3a%2f%2fimg3.redocn.com%2ftupian%2f20150930%2fqizhimeinvlisheyingtu_5034226.jpg&ehk=rG9Ks2QRzj81mZl38gVGmWVAgCHVLWppoDezpfwdxjo%3d&risl=&pid=ImgRaw&r=0", "https://th.bing.com/th/id/R.95f8e6f6bd5b660ae3ad4f3e0d712276?rik=ELKcha%2bE5ryuiw&riu=http%3a%2f%2f222.186.12.239%3a10010%2fwlp_180123%2f003.jpg&ehk=mVN7AzIRR%2fmVPJYWrWOFbEiher3QWtwSdH%2f%2fe4lE7n8%3d&risl=&pid=ImgRaw&r=0" ] this.data.containers.forEach((_: string, index: number) => { if (index == 0) { this.Nodes[`#container-${index}`] = useAnimation(`#container-${index}`, { x: 0, y: 0 }, this) this.setData({ [`zIdnexes[${index}]`]:100000-index, [`partContentList[${index}]`]:this.contentList[index] }) } else { console.log("10123") this.Nodes[`#container-${index}`] = useAnimation(`#container-${index}`, { x: 0, y: 20, scale: 0.95 }, this) this.setData({ [`zIdnexes[${index}]`]:100000-index, [`partContentList[${index}]`]:this.contentList[index] }) } }); }, handlePan(evt: any) { "worklet"; console.log(evt) const now = this.Nodes[`#container-${this.active.value}`] as Animation const next = this.Nodes[`#container-${(this.active.value+1)%4}`] as Animation if (evt.state == GestureState.ACTIVE) { // 滑动激活状态 // 设置当前的滑动块 now.x.value += evt.deltaX now.y.value += evt.deltaY now.rotate.value = now.x.value * 10 / 360 // 设置下一个滑动块 let rate = Math.abs(now.x.value) / 150 rate = rate > 1 ? 1 : rate next.y.value = (20 - rate * 20) < 0 ? 0 : (20 - rate * 20) next.scale.value = 0.95 + rate * 0.05 } if (evt.state == GestureState.END) { // 滑动结束 if (Math.abs(now.x.value) < 150) { // 判断是否超过界限值 setAni(now.x, 0) setAni(now.y, 0) setAni(now.rotate, 0) } else if (now.x.value < 0) { // 判断判断左划还是右划 setAni(now.x, -2000) setAni(now.y, -2000) setAni(now.rotate, 0) // 通知js线程进行数据的更新 wx.worklet.runOnJS(this.toNext.bind(this))() } else if (now.x.value > 0) { setAni(now.x, 2000) setAni(now.y, -2000) setAni(now.rotate, 0) wx.worklet.runOnJS(this.toNext.bind(this))() } } }, // 将当前序号的跳转到下一个 toNext(){ const current = this.current.value+1 this.active.value = current%4 this.current.value = current this.setData({ current }) if(current-2>=0){ wx.worklet.runOnUI(this.toReset)((current-2)%4) this.setData({ [`zIdnexes[${(current-2)%4}]`]:99998-current, [`partContentList[${(current-2)%4}]`]:this.contentList[current+2] }) } }, // 将动画归位 toReset(index:number){ "worklet"; const reset = this.Nodes[`#container-${index}`] as Animation setAni(reset.x, 0,0) setAni(reset.y, 20,0) setAni(reset.rotate, 0,0) setAni(reset.scale, 0.95,0) } }) [代码] 参考 skyline worklet 动画
2023-03-20 - 小程序瀑布流的一种实现
在小程序中由于图片组件为固定宽高,所以无法像网页中一样简单的实现瀑布流布局 但是image组件可以设置mode为widthFix,为图片设置固定高度后,可以自适应高度 如果是两栏瀑布流布局 就先定义两个view,让其float: left(高度不会随父组件高度) 获取到新数据后,循环新数据,通过 wx.createSelectorQuery() 获取两个view的高度,将数据中的一项push入矮的一项 不断循环添加子项就能够达到瀑布流的效果 注意事项: 在瀑布流最后的外面要清除浮动,防止影响后面的布局 [图片] 在设置单项时,要在setData的回调方法中再去执行下一项,保证能够让下次获取的高度正确 [图片] 最后效果如下 [图片][图片] 代码片段:https://developers.weixin.qq.com/s/d9MnuWmw7WGm
2023-03-24 - WeUI组件库中Slideview扩展如何同一时间只显示一个?
Slideview如何同一时间只显示一个删除,即左滑第二个删除时,第一个删除自动收回。 [图片] [图片]
2021-05-11 - weUi中使用多个Slideview组件,是否支持关闭已滑动开的组件?
想实现微信一样左滑删除,每次滑动只打开一个滑动模块。之前自己手写一个左滑删除用scroll-view,ios手机下用左滑抖动。现在发现weUi提供有左滑组件,滑动前关闭其他已滑动开的模块,是否支持这个操作,哪个大佬知道怎么实现。
2019-09-06 - 通过WXS实现回弹的平滑滚动容器
前言 最近在愉快的开发微信小程序的时候碰到了一个体验需求,需要在 Android 侧的滚动也需要带回弹效果,类似于在 Web 端可以使用的 better-scroll,查阅微信小程序内置组件 [代码]scroll-view[代码] 无法满足这种场景,没办法,需求得做呀,只能自己动手撸了! 在微信小程序中,我们可以通过 WXS响应事件 来替代逻辑层处理从而有效的提高交互流畅度,其中使用到的 WXS语法 也是非常类似我们非常熟悉 JavaScript,不过很多的 JavaScript 高级语法在 WXS 模块中不能使用,具体可以点击链接进入微信小程序提供的文档。 思路 以横向滚动为例,内容的宽度大于容器的宽度时可以发生滚动,如图 [图片] 接着通过监听三个触摸事件[代码]touchstart[代码]、[代码]touchmove[代码]、[代码]touchend[代码]来实时的改变 content 的 CSS translate,从而从视觉上达到滚动的目的。 WXS 示例 我们先从一个简单的 WXS 使用示例来了解回顾一下使用方式,WXS 的模块系统类似 CommomJS 规范,使用每个模块内置的 [代码]module[代码] 对象中的 [代码]exports[代码] 属性进行变量、函数导出: [代码]// helper.wxs module.exports = { // 注意 WXS 模块中不支持函数简写 touchstart: function touchstart() { console.log('touchstart called') } } [代码] [代码]<!-- index.wmxl --> <!-- module 为模块名,可按规范任意取名 --> <wxs src="./helper.wxs" module="helper" /> <!-- 与普通的逻辑层事件不同,这里需要加上 {{}} --> <view bind:touchstart="{{ helper.touchstart }}">view</view> [代码] 这样就给 [代码]view[代码] 绑定了一个 [代码]touchstart[代码] 事件,在事件触发后,会在控制台打印出字符串 "touchstart called" 好了,现在正式进入滚动容器的逻辑实现 开工 新建 [代码]scroll.wxml[代码] 文件,准备符合上图中结构的 WXML 内容来构造出一个正确的可以滚动条件 [代码]<!-- scroll.wxml --> <!-- 即图中的 container --> <view class="container" style="width: 100vw;"> <!-- 即图中的 content --> <view class="content" style="display: inline-block; white-space: nowrap;"> <view wx:for="{{ 10 }}" wx:key="index" style="width: 200rpx; height: 300rpx; border: 1px solid; display: inline-block;">{{ item }}</view> </view> </view> [代码] 新建 [代码]scroll.wxs[代码] 文件,里边用于存放我们实现滚动的所有逻辑 接下来进行初始化操作,首先需要获取到 container 和 content 组件实例,在上一节 “WXS 示例” 中我们知道可以通过在组件中触发一个事件来调用 WXS 模块中的方法,但有没有什么方式可以不用等到用户来触发事件就可以执行吗? 通过阅读 WXS 响应事件 文档,可以了解到,另外一种调用 WXS 模块方法就是可以通过 [代码]change:[prop][代码] 监听某一个组件的 Prop 的改变来执行 WXS 模块中指定的方法,且这个方法会立即执行一次,如下面一个示例 [代码]// helper.wxs module.exports = { setup: function setup() { console.log('setup') } } [代码] [代码]<!-- index.wxml --> <wxs src="./helper.wxs" module="helper"></wxs> <!-- 例如我们指定一个 prop 为 prop1,值为 {{ prop1Data }} --> <!-- 通过 change:prop1 语法对这个 prop 的变化进行监听 --> <view prop1="{{ prop1Data }}" change:prop1="{{ helper.setup }}"></view> [代码] [代码]// index.js Page({ data: { prop1Data: {} } }) [代码] 上面示例中,在页面初始化或 [代码]prop1Data[代码] 发生改变时(准确来说是在逻辑层对 [代码]prop1Data[代码] 调用了 [代码]setData[代码] 方法后,即使 [代码]prop1Data[代码] 的内容不变化),都会调用 [代码]hepler.wxs[代码] 模块中的 setup 方法。 现在我们可以通过 [代码]change:prop[代码] 会立即执行一次的特点,来对我们的滚动逻辑进行一次初始化操作 [代码]// scroll.wxs var exports = module.exports // 页面实例 var ownerInstance // container BoundingClientRect var containerRect // content 实例,通过此实例设置 CSS 属性 var slidingContainerInstance // content BoundingClientRect var slidingContainerRect // X方向的最小、最大滚动距离。如 -200 至 0(手势往右时,元素左移,translateX 为负值) var minTranslateX var maxTranslateX = 0 /** * @param newValue 最新的属性值 * @param oldValue 旧的属性值 * @param ownerInstance 页面所在的实例 * @param instance 触发事件的组件实例 */ exports.setup = function setup(newValue, oldValue, _ownerInstance, instance) { ownerInstance = _ownerInstance containerRect = instance.getBoundingClientRect() slidingContainerInstance = ownerInstance.selectComponent('.content') slidingContainerRect = slidingContainerInstance.getBoundingClientRect() minTranslateX = (slidingContainerRect.width - containerRect.width) * -1 } [代码] [代码]<!-- scroll.wxml --> <wxs src="./scroll.wxs" module="scroll" /> <!-- 因本案例只利用 change:[prop] 首次执行的机制,传递的给 _ 的参数是个对象字面量 --> <view class="container" style="width: 100vw;" _="{{ { k: '' } }}" change:_="{{ scroll.setup }}" bind:touchstart="{{ scroll.touchstart }}" bind:touchmove="{{ scroll.touchmove }}" bind:touchend="{{ scroll.touchend }}" > <view class="content" style="display: inline-block; white-space: nowrap;"> <view wx:for="{{ 10 }}" wx:key="index" style="width: 200rpx; height: 300rpx; border: 1px solid; display: inline-block;">{{ item }}</view> </view> </view> [代码] 完成基本的跟随手指移动 [代码]// scroll.wxs var exports = module.exports // 页面实例 var ownerInstance // container BoundingClientRect var containerRect // content 实例,通过此实例设置 CSS 属性 var slidingContainerInstance // content BoundingClientRect var slidingContainerRect // X方向的最小、最大滚动距离。如 -200 至 0(手势往右时,元素左移,translateX 为负值) var minTranslateX var maxTranslateX = 0 /** * @param newValue 最新的属性值 * @param oldValue 旧的属性值 * @param ownerInstance 页面所在的实例 * @param instance 触发事件的组件实例 */ exports.setup = function setup(newValue, oldValue, _ownerInstance, instance) { ownerInstance = _ownerInstance containerRect = instance.getBoundingClientRect() slidingContainerInstance = ownerInstance.selectComponent('.content') slidingContainerRect = slidingContainerInstance.getBoundingClientRect() minTranslateX = (slidingContainerRect.width - containerRect.width) * -1 } // 实时记录 content 位置 var pos = { x: 0 } // 记录每次触摸事件开始时,content 的位置,后续的移动都是基于此值增加或减少 var startPos = { x: 0 } // 记录触摸开始时,手指的位置,后续需要通过比较此值来计算出移动量 var startTouch = { clientX: 0 } function setTranslate(pos0) { slidingContainerInstance.setStyle({ transform: 'translateX(' + pos0.x + 'px)' }) pos.x = pos0.x } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x } exports.touchmove = function touchmove(event) { var deltaX = event.changedTouches[0].clientX - startTouch.clientX var x = startPos.x + deltaX setTranslate({ x: x }) } exports.touchend = function touchend() {} [代码] 效果图: [图片] 处理松手后移动超出的情况,需要对其归位: 添加 clamp 工具方法 [代码]// 给出最小、最大、当前值,返回一个在最下-最大范围之间的结果 // 如: -100, 0, -101 => -100 function clamp(min, max, val) { return Math.max(min, Math.min(max, val)) } [代码] 在 touchend 事件中,添加位置校验的逻辑 [代码]// scroll.wxs exports.touchend = function touchend() { setTranslate({ x: clamp(minTranslateX, maxTranslateX, pos.x) }) } [代码] 看看效果: [图片] 回去是能回去了,有点生硬~ 加上松手回弹动画 其中动画可以使用两种实现方式 CSS Transition:在松手后,给 content 元素设置一个 [代码]transition[代码],然后调整 [代码]translateX[代码] 值归位 JS 帧动画:在松手后,利用动画函数不断调整 [代码]translateX[代码] 来进行归位 两种方式通过给相同的动画函数可以达到一样的体验,但 CSS Transition 在我的理解中不太好处理中止的情况,如在动画过程中,又有了新的触摸事件,这里就会产生抖动或未预期到的结果,但 JS 动画可以很简单的应对 因此后续的动画部分打算采用 JS 动画实现,先准备一些动画函数 [代码]// scroll.wxs // 下面内容通过 better-scroll 借鉴 ~ // 可以理解为入参是一个 [0, 1] 的值,返回也是一个 [0, 1] 的值,用来表示进度 var timings = { v1: function (t) { return 1 + --t * t * t * t * t }, v2: function(t) { return t * (2 - t) }, v3: function(t) { return 1 - --t * t * t * t } } [代码] 定义 [代码]moveFromTo[代码] 方法来实现从一个点通过指定的动画函数运动到另一点 [代码]// scroll.wxs /** * @param fromX 起始点xx * @param toX 目标点 x * @param duration 持续时长 * @param timing 动画函数 */ function moveFromTo(fromX, toX, duration, timing) { if (duration === 0) { setTranslate({ x: fromX }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) } } ownerInstance.requestAnimationFrame(rAFHandler) } } [代码] 调整 touchend 事件处理逻辑,添加归位的动画效果 [代码]// scroll.wxs exports.touchend = function touchend() { moveFromTo( pos.x, clamp(minTranslateX, maxTranslateX, pos.x), 800, timings.v1 ) } [代码] 看看效果: [图片] 看起来达到了目的,再优化一下,在滑动超出边界后,需要给一些阻力,不能滑的“太简单了” 给超边界的滚动加阻力 [代码]// scroll.wxs exports.touchmove = function touchmove(event) { var deltaX = event.changedTouches[0].clientX - startTouch.clientX var x = startPos.x + deltaX // 阻尼因子 var damping = 0.3 if (x > maxTranslateX) { // 手指右滑导致元素左侧超出,超出部分添加阻尼行为 x = maxTranslateX + damping * (x - maxTranslateX) } else if (x < minTranslateX) { // 手指左滑导致元素右侧超出,超出部分添加阻尼行为 x = minTranslateX + damping * (x - minTranslateX) } setTranslate({ x: x }) } [代码] 瞅瞅: [图片] 效果达到了,手指都划出屏幕了,才移动了这么一点距离 到现在已经完成了一个带回弹效果的滚动容器,但还没有做到“平滑”,即在滑动一段距离松手后,需要给 content 一些“惯性”来继续移动一些距离,体验起来就不会那么生硬 加滑动惯性 在这之前,还有一些准备工作需要做 [代码]// scroll.wxs // 记录触摸开始的时间戳 + var startTimeStamp = 0 // 增加动画完成回调 + function moveFromTo(fromX, toX, duration, timing, onComplete) { if (duration === 0) { setTranslate({ x: fromX }) + ownerInstance.requestAnimationFrame(function() { + onComplete && onComplete() + }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) + } else { + onComplete && onComplete() + } } ownerInstance.requestAnimationFrame(rAFHandler) } } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x + startTimeStamp = event.timeStamp } [代码] 因为是在松手后加动量,所以继续处理 touchend [代码]// scroll.wxs exports.touchend = function touchend(event) { // 记录这一轮触摸动作持续的时间 var eventDuration = event.timeStamp - startTimeStamp var finalPos = { x: pos.x } var duration = 0 var timing = timings.v1 var deceleration = 0.0015 // 计算动量,以下计算方式“借鉴”于 better-scroll,有知道使用什么公式的朋友告知以下~ var calculateMomentum = function calculateMomentum(start, end) { var distance = Math.abs(start - end) var speed = distance / eventDuration var dir = end - start > 0 ? 1 : -1 var duration = Math.min(1800, (speed * 2) / deceleration) var delta = Math.pow(speed, 2) / deceleration * dir return { duration: duration, delta: delta } } // 此次滑动目的地还在边界中,可以进行动量动画 if (finalPos.x === clamp(minTranslateX, maxTranslateX, finalPos.x)) { var result = calculateMomentum(startPos.x, pos.x) duration = result.duration finalPos.x += result.delta // 加上动量后,超出了边界,加速运动到目的地,然后触发回弹效果 if (finalPos.x > maxTranslateX || finalPos.x < minTranslateX) { duration = 400 timing = timings.v2 var beyondDis = containerRect.width / 6 if (finalPos.x > maxTranslateX) { finalPos.x = maxTranslateX + beyondDis } else { finalPos.x = minTranslateX + beyondDis * -1 } } } moveFromTo(pos.x, finalPos.x, duration, timing, function () { // 若动量动画导致超出了边界,需要进行位置修正,也就是回弹动画 var correctedPos = { x: clamp(minTranslateX, maxTranslateX, pos.x) } if (correctedPos.x !== pos.x) { moveFromTo( pos.x, correctedPos.x, 800, timings.v1 ) } }) } [代码] 继续看看效果: [图片] 有了有了 只是现在的滚动容器还很“脆弱”,在进行动量动画、回弹动画时,如果手指继续开始一轮新的触摸,就会出现问题,也就是最开始我们在选择 CSS 过渡和 JS 动画考虑到的问题 解决连续触摸滑动问题 在 [代码]moveFromTo[代码] 方法中,添加强制中止的逻辑 [代码]// scroll.wxs + var effect = null function moveFromTo(fromX, toX, duration, timing, onComplete) { + var aborted = false if (duration === 0) { setTranslate({ x: fromX }) ownerInstance.requestAnimationFrame(function () { onComplete && onComplete() }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { + if (aborted) return var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) } else { onComplete && onComplete() } } ownerInstance.requestAnimationFrame(rAFHandler) } + if (effect) effect() + effect = function abort() { + if (!aborted) aborted = true + } } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x startTimeStamp = event.timeStamp + if (effect) { + effect() + effect = null + } } [代码] 体验一下: [图片] 这样一个带回弹的平滑滚动容器就处理的可以使用啦,有问题的地方欢迎大家指出讨论 结尾 完整源码托管在 Github 中:weapp-scroll 其中功能、逻辑更为完善,并同时支持横向、竖向方向的滚动,适合在 Android、PC 场景的使用(毕竟 IOS 侧可以直接使用微信内置组件 [代码]scroll-view[代码]~)。若有帮到希望可以给个星星~ 完~
2023-07-07 - 如何从零实现上拉无限加载瀑布流组件
代码已优化请查看另外一篇文章 https://developers.weixin.qq.com/community/develop/article/doc/00026c521ece40c2d2db97f7156013 小程序瀑布流组件 前言:为了实现这个组件也花费了些时间,以前也做过瀑布流的功能,不过是利用 js 去 计算图片的高度,然后通过 css 的绝对定位去改变位置。不过这种要提前加载完一个列 表的图片,然后通过排列的算法生成排序的数组。总之就是太复杂了,后来在网上也看到 纯 css 实现,比如 flex 两列布局,columns 等,不做过多的阐述,下面分享下自己项 目中实现的瀑布流过程。 Css Grid 布局 Css3 变量属性 Js 动态修改 css 变量属性 Wxs 小程序脚本语言 Wxml 节点 Api Component 自定义组件 效果图 代码片段 [图片] Css Grid 网格布局实现多列多行布局 [代码]<view class="c-waterfall"> <view wx:for="{{ 10 }}" wx:key="item" class="view-container" > {{ item }} </view> </view> [代码] [代码].c-waterfall { display: grid; grid-template-columns: repeat(2, 1fr); grid-auto-flow: row dense; grid-auto-rows: 10px; grid-gap: 10px; } .view-container { width: 100%; grid-row: auto / span 20; } [代码] Css3 变量,可以通过[代码]js动态[代码]改变 [代码].c-waterfall { --grid-span: 10; --grid-column: 2; --grid-gap: 10px; --grid-rows: 10px; width: 100%; display: grid; grid-template-columns: repeat(var(--grid-column), 1fr); grid-auto-flow: row dense; grid-auto-rows: var(--grid-rows); grid-gap: var(--grid-gap); } .view-container { width: 100%; grid-row: auto / span var(--grid-span); } [代码] 动态修改 css 变量,实现遍历的节点都有独立的样式 [代码]<view class="c-waterfall" style="{{ style }}"> <view wx:for="{{ 10 }}" wx:key="item" class="view-container style="grid-row: auto / span var(--grid-row-{{ index }})" > {{ item }} </view> </view> [代码] [代码]Page({ data: { span: 20, style: '' }, onReady() { this.setData({ style: '--grid-row-0: 10;--grid-row-1: 10;' // 0-9... }) } }) [代码] 显然通过这种方式去修改emmm,有点不尽人意,当view渲染的时候,通过[代码]index[代码]下标给每个view都设置独立的[代码]grid-row[代码]样式,然后在修改view父级的style,将[代码]--grid-row-xxx[代码]变量写进去实现子类继承,虽然比直接去修改每个view的样式要优雅些,但是一旦views的节点多了,100个、1000个、没上限呢,那这个父级的style真的惨不忍睹。。比如100个view,那么style将会是下面这样,所以需要换个思路还是得单独去设置view的样式。 [代码]const views = [...99].map((v, k) => `--grid-row-${k}: 10;`) console.log(views) // ["--grid-row-0: 10;", "--grid-row-1: 10;", ... "--grid-row-2: 10;", "--grid-row-3: 10;", "--grid-row-98: 10;", "--grid-row-99: 10;"] [代码] 通过Wxs脚本语言来修改view的样式,相比较通过[代码]setData[代码]去修改view的样式,wxs的性能绝对比js强。 WXS 不依赖于运行时的基础库版本,可以在所有版本的小程序中运行。 WXS 与 JavaScript 是不同的语言,有自己的语法,并不和 JavaScript 一致。 WXS 的运行环境和其他 JavaScript 代码是隔离的,WXS 中不能调用其他 JavaScript 文件中定义的函数,也不能调用小程序提供的API。 WXS 函数不能作为组件的事件回调。 由于运行环境的差异,在 iOS 设备上小程序内的 WXS 会比 JavaScript 代码快 2 ~ 20 倍。在 android 设备上二者运行效率无差异。 一般在对wxs的使用场景上大多数用来做[代码]computed[代码]计算,因为在[代码]wxml[代码]模板语法里只能进行简单的三元运算,所以一些复杂的运算、逻辑判断等都会放到wxs里面去处理,然后返回给wxml。 [代码]// index.wxs var format = function(string) { return string + 'px' } module.exports = { format: format } [代码] [代码]<!-- index.wxml --> <wxs src="./index.wxs" module="wxs"></wxs> <view>{{ wxs.format('100') }}</view> <view>{{ wxs.format(span) }}</view> <button bind:tap="modifySpan">修改span的值</button> [代码] [代码]// index.js page({ data: { span }, modifySpan() { this.setData({ span: '200' }) } }) [代码] 通过WXS响应事件来修改视图层[代码]Webview[代码],跳过逻辑层[代码]App Service[代码],减少性能开销,比如一些频繁响应的事件监听,滚动条位置,手指滑动位置等,通过wxs来做视图层的修改,大大提升了流畅度。 通过wxs响应原生组件的事件,[代码]image[代码]组件的[代码]bind:load[代码]事件 [代码]<!-- index.html --> <wxs src="./index.wxs" module="wxs"></wxs> <image class="image" src="https://hbimg.huabanimg.com/ccf4a904deaebc25990a47471c61ea1c765694f82633b-71iPZs_/fw/480/format/webp" bind:load="{{ wxs.loadImg }}" /> [代码] [代码]// index.wxs var loadImg = function(event, ownerInstance) { // image组件load加载完返回图片的信息 var image = event.detail // 获取image的实例 var imageDom = ownerInstance.selectComponent('.image') // 设置image的样式 imageDom.setStyle({ height: image.height + 'px', background: 'red' // ... }) // 给image添加class imageDom.addClass('.loaded') // 更多的功能请参考文档 // https://developers.weixin.qq.com/miniprogram/dev/framework/view/interactive-animation.html } module.exports = { loadImg: loadImg } [代码] wxs监听data的值 [代码]<!-- index.html --> <wxs src="./index.wxs" module="wxs"></wxs> <view class="container"> <view change:text="{{ wxs.changeText }}" text="{{ text }}" class="text" data-options="{{ options }}" > {{ text }} </view> <view class="child-node"> this is childNode </view> <!-- 某个自定义组件 --> <test-component class="other-node" /> </view> [代码] [代码]// index.wxs var changeText = function(newValue, oldValue, ownerInstance, instance) { // 获取修改后的text var text = newValue // 获取data-options var options = instance.getDataset() // 获取当前页面的任意节点实例 var childNode = instance.selectComponent('.container .child-node') // 修改childNode样式 childNode.setStyle({ color: 'gree' }) // 获取页面的自定义组件 var otherNode = instance.selectComponent('.container .other-node') // 获取自定义组件内的节点实例 // 通过css选择器 > var otherChildNode = instance.selectComponent('.container .other-node >>> .other-child-node') // 获取自定义组件内部节点的样式 var style = otherChildNode.getComputedStyle(['width', 'height']) // 更多功能看文档 } module.exports = { changeText: changeText } [代码] 通过[代码]createSelectorQuery[代码]获取节点的信息,用来后续计算[代码]grid-row[代码]的参数 [代码]Page({ onReady() { wx.createSelectorQuery(this) .select('.view-container') .fields({size: true}) .exec((res) => { console.log(res) // [{width: 375, height: 390}] }) } }) [代码] 创建waterfall自定义组件 waterfall组件的职责,做成组件有什么好处,不做成组件又有什么好处,以及通过抽象节点来实现多组件复用。 prop的基本设置参数 [代码]Component({ properties: { views: Array, // 需要渲染的瀑布流视图列表 options: { // 瀑布流的参数定义 type: Object, default: { span: 20, // 节点高度比 column: 2, // 显示几列 gap: [10, 10], // xy轴边距,单位px rows: 2, // 网格的高度,单位px }, } } }) [代码] 组件内部默认的样式 [代码].c-waterfall { --grid-span: 10; --grid-column: 2; --grid-gap: 10px; --grid-rows: 10px; width: 100%; display: grid; grid-template-columns: repeat(var(--grid-column), 1fr); grid-auto-flow: row dense; grid-auto-rows: var(--grid-rows); grid-gap: var(--grid-gap); } .view-container { width: 100%; grid-row: auto / span var(--grid-span); } [代码] 组件的骨架 [代码]<wxs src="./index.wxs" module="wx" ></wxs> <!-- 样式承载节点 --> <view class="c-waterfall" change:loadStatus="{{ wx.load }}" loadStatus="{{ childNode }}" data-options="{{ options }}" style="{{ wx.setStyle(options) }}" > <!-- 抽象节点 --> <selectable class="view-container" id="view-{{ index }}" wx:for="{{ views }}" wx:key="item" value="{{ item }}" index="{{ index }}" bind:load="load" > </selectable> </view> [代码] 抽象节点 [代码]{ "component": true, "usingComponents": {}, "componentGenerics": { "selectable": true } } [代码] 抽象节点应该遵循什么 [代码]Component({ properties: { value: Object, // 组件自身需要的数据 index: Number, // 下标值 }, methods: { load(event) { // load节点响应事件 this.triggerEvent('load', { ...this.data, // value必填参数 {width,height} value: { ...event.detail }, }) }, }, }) [代码] 组件wxs响应事件 [代码].c-waterfall[代码]样式承载节点,主要是设置options传入的参数 [代码] var _getGap = function (gaps) { return gaps .map(function (v) { return v + 'px' }) .join(' ') } var setStyle = function (options) { if (!options) return var style = [ '--grid-span: ' + options.span || 10, '--grid-column: ' + options.column || 2, '--grid-gap: ' + _getGap(options.gap || [10, 10]), '--grid-rows: ' + (options.rows || 10) + 'px', ] return style.join(';') } [代码] 获取瀑布流样式承载节点实例 [代码] var _getWaterfall = function (dom) { var waterfallDom = dom.selectComponent('.c-waterfall') return { dom: waterfallDom, options: waterfallDom.getDataset().options, } } [代码] 获取事件触发的节点实例 [代码] var _getView = function (index, dom) { var viewDom = dom.selectComponent('.c-waterfall >>> #view-' + index) return { dom: viewDom, style: viewDom.getComputedStyle(['width', 'height']), } } [代码] 获取虚拟节点自定义组件load节点实例,初始化渲染时,节点是未知的,比如image组件,图片的宽高是未知的,需要等到image加载完成才会知道宽高,该节点用于存放异步视图展示,然后通过事件回调计算出节点高度。 [代码] var _getLoadView = function (index, dom) { return { dom: dom.selectComponent( '.c-waterfall >>> #view-' + index + '>>>.waterfall-load-node' ), } } [代码] 获取虚拟节点自定义组件other节点实例,初始化渲染就存在节点,比如一些文字就放在该节点,具体由组件的创造者去自定义。 [代码] var _getOtherView = function (index, dom) { var other = dom.selectComponent( '.c-waterfall >>> #view-' + index + '>>> .waterfall-load-other' ) return { dom: other, style: other.getComputedStyle(['height', 'width']), } } [代码] 已知瀑布流样式承载节点的宽度,等load节点异步视图回调时,获取到load节点的实际高度,比如一张400*800的图片,如果要显示在一个宽度180px的视图里,注意:[代码]image[代码]组件会有默认高度240px,或者用户自己设置了高度。如果要实现瀑布流,还是需要通过计算图片的宽高比例得到图片在视图中宽高,然后再通过计算grid布局的span值实现填充。 [代码] var fix = function (string) { if (typeof string === 'number') return string return Number(string.replace('px', '')) } var computedContainerHeight = function (node, view) { var vW = fix(view.width) var nW = fix(node.width) var nH = fix(node.height) var scale = nW / vW return { width: vW, height: nH / scale, } } [代码] 通过公式计算span的值,这个公式也是花了我不少时间去研究的,对grid布局使用也不多,很多潜在用法并不知道,所以通过大量的随机数据对比查找规律所在。[代码]gap为数组[x, y][代码],我们要取y计算,已知gap、rows求视图中节点高度[代码](gap[y] + rows) * span - gap[y] = height[代码],有了求height的公式,那么求span就简单了,[代码](height + gap[y]) / (gap[y] + rows) = span[代码],最终视图里的高度会跟计算出来的结果几个像素的误差,因为[代码]grid-row[代码]设置span不能为小数,只能为整数,而我们瀑布流的高度是未知的,通过计算有多位浮点数,所以只能向上取整了导致有几个像素的误差。 [代码] var computedSpan = function (height, options) { var rows = options.rows var gap = options.gap[1] var span = Math.ceil((height + gap) / (gap + rows)) return span } [代码] 最后我们能得到[代码]span[代码]的值了,只需要将[代码]load完成的视图修改样式即可[代码] [代码] var load = function (node, oldNode, dom) { if (!node.value) return false var index = node.index var waterfall = _getWaterfall(dom) // 获取虚拟组件,通过index下标确认是哪个,获取宽度高度 var view = _getView(index, dom) var otherView = _getOtherView(index, dom) var otherViewHeight = fix(otherView.style.height) // 计算虚拟组件的高度,其实就是计算图片在当前视图节点里的宽高比例 // image组件的mode="widthFix"也是这样计算的额 var virtualStyle = computedContainerHeight(node.value, view.style) // span取值,此处计算的高度应该是整个虚拟节点视图的高度 // load事件回调里,我们只传了load视图节点的宽高 // 后续通过selectComponent获取到了other视图节点的高度 var span = computedSpan( otherViewHeight + virtualStyle.height, waterfall.options ) // 设置虚拟组件的样式 view.dom.setStyle({ 'grid-row': 'auto / span ' + span, }) // 获取重新渲染后的虚拟组件高度 var viewHeight = view.dom.getComputedStyle(['width', 'height']) viewHeight = fix(viewHeight.height) // 上面说了因为浮点数的计算会导致有几个像素的误差 // 为了视图美观,我们将load视图节点的高度设置成虚拟视图节点的总高度减去静态节点的高度 var loadView = _getLoadView(index, dom) loadView.dom.setStyle({ width: virtualStyle.width + 'px', height: parseInt(viewHeight - otherViewHeight) + 'px', opacity: 1, visibility: 'visible', }) return false } module.exports = { load: load, setStyle: setStyle, } [代码] 抽离成虚拟节点自定义组件的利弊 利: 符合观察者模式的设计模式 降低代码耦合度 扩展性强 代码清晰 弊: 节点增加,如果视图节点过多会造成小程序性能警告 样式编写不便捷,需要写过多的判断代码去实现外部样式覆盖 wxs只能监听原生组件的事件,所以image的load事件触发时本可以直接去修改页面视图节点样式,不需要传回给父组件,然后父组件setData下标,wxs监听事件触发在去修改视图样式,多了一次setData的开销。 合: 时间有限没有扩展样式覆盖了,可以开启自定义组件的外部样式引入 节点过多的问题,在我自己电脑上,开发工具插入100个组件时,出现了卡顿,样式错乱,真机上目前还没发现上限。 后续想实现长列表功能,有回收机制,这样视图内的节点有限了,降低了性能开销,因为之前版本的长列表组件是通过[代码]createSelectorQuery[代码]获取节点信息,然后记录高度,通过创建[代码]createIntersectionObserver[代码]监听视图节点是否在视图来判断是否渲染。但是瀑布流有异步视图,初次渲染的高度跟异步加载完的高度是不一样,所以创建监听事件高度会不准确,若等到load完再创建监听事件,父级容器的高度又要经过计算,因为子节点会去填充空白区域实现瀑布流,目前项目中为了避免节点过大造成性能警告,加了item的个数限制,如果超过100或者1000个就清空数组,类似分页的功能。不过上面总结的思路可以去试试。 等把功能完善了,发布npm依赖包安装。 后续有时间会将项目里比较实用的组件抽离出来。。 自定义tabbar 自定义navbar 长列表 下拉刷新 上拉加载 购物车sku … Demo page调用页面 [代码]<view class="container"> <waterfall wx:if="{{ _type === 0 }}" generic:selectable="test-view" views="{{ views }}" options="{{ options }}" /> <waterfall wx:else generic:selectable="image-view" views="{{ images }}" options="{{ options }}" /> </view> <view class="btns"> <button bind:tap="loadView">模拟节点</button> <button bind:tap="loadImage">远程图片</button> </view> [代码] [代码]Page({ data: { views: [], loading: false, options: { span: 30, column: 2, gap: [10, 10], rows: 2, }, images: [], _page: 1, _type: 0, }, onLoad() { // 生成随机数据 // this.generateViews() // this.getHuaBanList() }, loadView() { this.data._page = 1 this.setData({ images: [], _type: 0 }) this.generateViews() }, loadImage() { this.data._type = 1 this.setData({ views: [], _type: 1 }) this.getHuaBanList() }, getHuaBanList() { let { images, _page } = this.data wx.request({ url: `https://huaban.com/search/?q=随机&page=${_page}&per_page=10&wfl=1`, header: { accept: 'application/json', 'accept-language': 'zh-CN,zh;q=0.9', 'x-request': 'JSON', 'x-requested-with': 'XMLHttpRequest', }, success: (res) => { res.data.pins.map((v) => { images.push({ url: `https://hbimg.huabanimg.com/${v.file.key}_/fw/480/format/webp`, title: v.raw_text, }) }) this.setData({ images, _page: ++_page }) wx.hideLoading() }, }) }, generateViews() { const { views } = this.data for (let i = 0; i < 10; i++) { views.push({ width: this._randomNum(150, 500) + 'px', height: this._randomNum(200, 600) + 'px', }) } this.setData({ views, }) }, _randomNum(minNum, maxNum) { switch (arguments.length) { case 1: return parseInt(String(Math.random() * minNum + 1), 10) break case 2: return parseInt(Math.random() * (maxNum - minNum + 1) + minNum, 10) break default: return 0 break } }, onReachBottom() { let { loading, _type } = this.data if (!loading) { wx.showLoading({ title: 'loading...', }) loading = true setTimeout(() => { _type === 0 ? this.generateViews() : this.getHuaBanList() wx.hideLoading() loading = false }, 1000) } }, }) [代码] [代码]{ "usingComponents": { "waterfall": "/components/waterfall/index", "test-view": "/components/test-view/index", "image-view": "/components/image-view/index" } } [代码] 模拟load异步的自定义组件 [代码]<view class="c-test-view"> <view class="waterfall-load-node"> {{value.width}}*{{value.height}} </view> <view class="waterfall-load-other">模拟加载图片</view> </view> [代码] [代码]Component({ properties: { value: Object, index: Number, }, lifetimes: { ready() { const { index } = this.data const timer = 1000 + 300 * String(index).charAt(index.length - 1) setTimeout(() => this.load(), timer) }, }, methods: { load() { this.triggerEvent('load', { ...this.data, }) }, }, }) [代码] [代码].c-test-view { width: 100%; height: 100%; display: flex; flex-flow: column; justify-content: center; align-items: center; background: white; } .c-test-view .waterfall-load-node { height: 50%; flex-grow: 1; transition: all 0.3s; display: inline-flex; flex-flow: column; justify-content: center; align-items: center; background: #eeeeee; width: 100%; opacity: 0; } .c-test-view .waterfall-load-other { width: 100%; height: 80rpx; display: inline-flex; justify-content: center; align-items: center; background: cornflowerblue; color: white; } [代码] 随机获取花瓣网图片的自定义组件 [代码]<view class="c-image-view"> <view class="waterfall-load-node"> <image class="load-image" src="{{ value.url }}" bind:load="load" /> </view> <view class="waterfall-load-other">{{ value.title }}</view> </view> [代码] [代码]Component({ properties: { value: Object, index: Number, }, lifetimes: { ready() {}, }, methods: { load(event) { this.triggerEvent('load', { ...this.data, value: { ...event.detail }, }) }, }, }) [代码] [代码].c-image-view { width: 100%; display: inline-flex; flex-flow: column; background: white; border-radius: 10px; overflow: hidden; height: 100%; } .c-image-view .waterfall-load-node { width: 100%; height: 50%; display: inline-flex; flex-grow: 1; background: gainsboro; transition: opacity 0.3s; opacity: 0; overflow: hidden; visibility: hidden; } .c-image-view .waterfall-load-node .load-image { width: 100%; height: 100%; overflow: hidden; } .c-image-view .waterfall-load-other { font-size: 30rpx; background: white; min-height: 60rpx; padding: 10px; display: flex; align-items: center; } [代码] 代码片段 https://developers.weixin.qq.com/s/Q02FETmW7ind
2021-03-19 - 你想要的微信小程序瀑布流组件库:me-waterfall
介绍 me-waterfall 是一个微信小程序瀑布流组件库,实现简单,侵入性小,贴近 web 端的效果。 线上体验 扫描下方的小程序二维码,体验使用效果: [图片] 安装 方式一:使用 npm 安装(推荐) [代码]npm install me-waterfall [代码] 方式二:下载源码 将源码下载到本地,然后将 [代码]lib[代码] 目录拷贝到自己的项目中。 使用方法 在页面的 [代码]json[代码] 文件或 [代码]app.json[代码] 中引入组件: [代码]{ "usingComponents": { "me-waterfall": "/path/to/me-waterfall/waterfall/index", "me-waterfall-item": "/path/to/me-waterfall/waterfall-item/index" } } [代码] 然后就可以在 [代码]wxml[代码] 中直接使用了: [代码]<me-waterfall> <me-waterfall-item wx:for="{{list}}" wx:key="{{index}}"> <image src="{{item.src}}" style="height:{{item.height}}px;width:100%"/> </me-waterfall-item> </me-waterfall> [代码] API waterfall 组件 props 参数 说明 类型 默认值 是否必须 width 容器宽度,传入后将优先使用此值,呈现速度更快 Number - 否 column 列数 Number 2 否 gap 列与列之间的间距 Number 15 否 methods reflow 重新排列元素,在某些情形下,你可能希望在完成某些操作后对瀑布流进行重新排列,此时可以调用此方法: [代码]const waterfallInstance = this.selectComponent("#waterfall"); waterfallInstance.reflow(); [代码] 外部样式类 参数 说明 类型 默认值 是否必须 custom-class 外部样式类 String - 否 waterfall-item 组件 外部样式类 参数 说明 类型 默认值 是否必须 custom-class 外部样式类 String - 否 关于性能 首先,够用; 其次,由于微信小程序中获取元素尺寸的 api 为回调形式,因此排列内部元素时需要延迟,即等到容器宽度取到之后再进行排列,这会使得瀑布流呈现速度减慢;如果改为传入 [代码]width[代码],呈现速度会更快。 捐赠 如果这个库有帮助,请 Star 这个仓库,让更多人发现它。 当然,也可以鼓励我一下: [图片] 开源协议 本项目基于 MIT 协议。
2022-07-25 - 小程序性能优化实践
小程序性能优化课程基于实际开发场景,由资深开发者分享小程序性能优化的各项能力及应用实践,提升小程序性能表现,满足用户体验。
10-09 - fiddler调试jssdk、小程序抓包
使用fiddler调试jssdk下载地址 https://www.telerik.com/download/fiddler-everywhere 操作流程fiddler配置 [图片] [图片] [图片] 点击options,按图示勾选配置,端口号默认8888 [图片] 右侧面板 AutoResponder 按图示勾选,新增一条规则 // 此处域名为可调试jssdk的地址, 可自定义xxx.edu.dev.faisco.com.cn regex:^http://wx.edu.dev.faisco.com.cn/(?.+)?$ // 此处域名为本机开启的服务地址 http://172.17.1.35:8080/${name} 使用powershell或者cmd,输入ipconfig获取电脑端ip地址 [图片] 手机端设置连接公司wifi,确保手机能直接访问http://172.17.1.35:8080这种本机开启的域名点开wifi设置,新增代理,主机为上一步获取的电脑ip,端口为fiddler设置的端口,默认8888 [图片] 手机访问http://172.17.1.35:8888, 下载fiddler证书并安装 [图片] [图片] 输入第2步正则的域名访问(如:wx.edu.dev.faisco.com.cn),现在访问就相当于访问http://172.17.1.35:8080,可以调试jssdk了 使用fiddler抓包配置信任证书 [图片] 配置网络代理 [图片] 打开电脑版微信小程序,即可抓包 [图片]
2022-06-13 - 添加npm包时无法添加node_modules的问题解决
1.问题: 昨天添加npm包时一直遇到个问题,npm init 和npm i 之后,只生成package.json,不会自动生成node_modules,也就无法构建npm。 2.尝试: 在网上查找各种方案,多次尝试,清缓存,修改config 中的global为false等,一晚上没成功,今天再次调试,终于,现将解决步骤写出,以免下次遇到又懵了。 3.步骤: 第一步 npm初始化,npm init, 然后会生成一个package.json,此时,在project.config.js里面修改如下:"packNpmManually": true, "packNpmRelationList": [ { "packageJsonPath": "./package.json", "miniprogramNpmDistDir": "./" } ] 第二步 安装想要的组件包npm install weui-miniprogram npm i @vant/weapp -S --production 此时,就会出现node_modules第三步 在工具中找到构建npm,点击完成npm构建
2022-06-09 - 小程序scroll-view翻转后 scroll-into-view的替代方案
背景 腾讯云医小程序有医患聊天会话的场景,由于会话场景存在查询历史消息的场景,小程序中按照常规思路加载历史消息时会出现跳动的问题;跳动的原因是由于在’顶部’插入dom,会使得后面的dom被往后面推,然后重新设置scroll-top或者scrol-into-view从而导页面出现跳动;我们尝试采用【 前端开发中聊天场景的体验优化】文章中的方案处理跳动的场景。该文章的核心观点将scroll-view元素通过设置css样式 transform: rotateX(180deg); 进行翻转,这样将历史消对应的dom结构放在尾部,当添加更多的历史消息(dom)时,由于dom是添加在尾部很优雅的绕过了插入历史消息跳动的场景。但是当我们按照这种方式实现后,发现scroll-view元素提供的scroll-into-view属性不好使了。因此有了本文通过计算scrollTop值设置scrollTop来达到相同目的。 复现该问题的小程序代码片段:代码片段 目前已经反馈给官方(官方已确认是内部组件实现暂不支持翻转的场景 基础知识介绍 计算scrollTop涉及到一些web和小程序的基础知识,后面针对这些基础点进行简单介绍 .scroll-into-view 微信小程序提供的scroll-view元素提供了属性 scroll-into-view,该属性的作用是可以将指定dom滚动到scroll-view可见区域内 [图片] 关于boundingClientRect 下图是MDN解释该属性时提供的,从下图中可以看到top/bottom/left/right的值是元素的左上角和右下角相对于视口左上角的水/垂直距离 [图片] 为了更深入理解这些值。给出了一个简易的demo(代码片段),获取实例元素的的boundingClientRect的值后,可以看到这些值是根据元素的border边界进行计算的 [图片] [图片] [图片] 值得注意的是,当元素处于一个滚动区域内部,left/top值是考虑滚动操作的即包含滚动距离的(参考MDN 另外,当我们把容器元素又或者元素自身设置 transform: rotateX/Y(180deg):不会导致top和bottom的值互换(left与right的值互换); 总会有这样的结论,当dom元素的宽度和高度不为0时,top值一定小于bottom值,left值一定小于right值 关于scrollTop 当一个容器的内容的高度大于其容器高度时,overflow不为visible/hidden时,则会出现滚动条。出现滚动条后,内容区域则可以滚动,此时scrollTop的值是容器可视区域的顶部到内容区域顶部的距离,见下面示意图。 [图片] 值得注意的是,滚动条出现在盒模型中的content区域,见下图滚动条不会覆盖padding/border部分。因此上面说到内容区域高度超过容器高度并不严谨,严谨的说法应该是超过容器的content区域的高度。 [图片] 如果此时给容器设置css样式: transform: rotateX(180deg); 即沿垂直方向翻转180度,scrollTop的值会发生变化吗。 下面我们看下实际的对比效果,为了方便查看滚动条的效果,给滚动条轨道(红色部分)以及滑块(黑色部分)添加了背景色,发现整个元素包括滚动条在内一并进行了翻转。 正常情况(左侧),应用翻转css样式(右侧) [图片] [图片] 翻转后的scrollTop值示意图 [图片] 通过计算scrollTop值来模拟scroll-into-view效果(针对scroll-view翻转的场景j) 由于boundingClinetRect的值是包含border边界的,因此当数据项包含padding,border等区域不会影响这里的计算过程,可以认为下面示意图中的数据项部分的边界是border边界; 由于滚动条是出现在content区域,因此容器元素的的border-top/padding-top不为0时,会影响计算流程,因此这里分为两种情况进行介绍: 2.1 假设scroll-view元素的的border-top/padding-top为0 2.2 假设scroll-view元素的的border-top/padding-top不为0 border-top/padding-top为0的情况 为了方便说明计算过程,我定义三种状态,初始态、中间态、最终态 示意图中的区域说明 白色背景的为视口, 绿色背景的是容器(scroll-view)的可视区域, 灰色区域是内容区域,并且内容区域的高度超过了容器的高度, 红色区域是一个数据项 [图片] 现在的目标是将数据项从初始态滚动到最终态即scroll-into-view的效果:border的上边界与可视区域上边界对齐 第一步:从初始态达到中间态 根据上面关于scrollTop的描述,这里如果scrollTop的值是targetDistance即数据项的底部到内容区域的底部的距离,就可以达到中间态,因此现在的目标是求targetDistance 初始状态的已知变量 初始状态下的的scrollTop值:currentScrollTop (由于容器发生翻转,所以scrollTop视觉上指向容器下方) 数据项的boundingClientRect.bottom为 itemBottom 容器的boundingClientRect.bottom为 contianerBottom 通过示意图很容易得出 [代码]targetDistance = currentScrollTop + (containerBottom - itemBottom) [代码] 第二步:从中间到达最终态 已知变量:容器高度:containerHeight、数据项高度:itemHeight 最终态是数据项的顶部距离容器顶部,从示意图中看到中间态到最终态的scrollTop是减少了的,减少的值其实就是cotainerHeight - itemHeight 经过第一步和第二步我们就可以得到scrollTop的计算公式 [代码]let itemScrollTop = currentScrollTop + containerBottom - itemBottom itemScrollTop -= (containerHeight - itemHeight) => itemScrollTop = currentScrollTop + containerBottom - itemBottom - (containerHeight - itemHeight) [代码] border-top/padding-top不为0的情况 [图片] 根据上面第一种情况的介绍的思路,很容易得到下面结果,不再赘述(X 就是容器padding-top + border-top的值) [代码]let itemScrollTop = currentScrollTop + containerBottom - itemBottom - X itemScrollTop -= (containerHeight - itemHeight - X) => itemScrollTop = currentScrollTop + containerBottom - itemBottom - X - (containerHeight - itemHeight - X) => itemScrollTop = currentScrollTop + containerBottom - itemBottom - (containerHeight - itemHeight) [代码] 【结论】两种情况最终的计算过程是一样的,因此在实现的过程中不需要进行区分 代码实现 代码片段见:https://developers.weixin.qq.com/s/y1X11dmr7AqC 视图层代码 [代码]{{item.content}} #scroll-view { position: absolute; top: 50px; bottom: 50px; width: 100%; background-color: rgba(0, 0, 0, 0.1); // 关键case transform: rotateX(180deg); } [代码] 逻辑层核心代码 [代码]scrollTo () { const itemId = '#item_id_50' const containerId = '#scroll-view' Promise.all([this._queryBoundingClient(itemId), this._getScrollInfo(containerId)]) .then((res = [[[{}]], {}]) => { const [[[ { bottom: itemBottom, height: itemHeight }]], { bottom: containerBottom, scrollTop, height: containerHeight }] = res let itemScrollTop = containerBottom - itemBottom + scrollTop itemScrollTop -= (containerHeight - itemHeight) this.setData({ scrollTop: itemScrollTop }) }) }, _queryBoundingClient (selector) { // 获取目标dom的相关位置/尺寸信息 return new Promise(resolve => { const query = this.createSelectorQuery(); query.selectAll(selector).boundingClientRect(); query.exec(resolve); }) }, _getScrollInfo (idSelector) { // 用来获取容器层相关位置/尺寸信息 return new Promise(resolve => { const query = this.createSelectorQuery() query.select(idSelector).boundingClientRect() query.select(idSelector).scrollOffset() query.exec((res = [{}, {}]) => { const [{ top, bottom, height }, { scrollHeight, scrollTop }] = res const scrollInfo = { scrollTop, scrollHeight, top, bottom, height } resolve(scrollInfo) }) }) } [代码]
2023-03-23 - 开发工具官方TypeScript-基础模板如何构建npm安装包?
开发工具官方TypeScript-基础模板如何构建npm安装包 步骤一 [图片] 步骤二 使用命令 npm i tdesign-miniprogram -S --production 测试。 [图片] 步骤三、 根据错误提示,修改 project.config.json 文件下的两个参数 "packNpmManually": true, "packNpmRelationList": [ { "packageJsonPath": "package.json", "miniprogramNpmDistDir": "./miniprogram" } ] 再次尝试构建,操作成功。 [图片]
2022-04-29 - 小程序隐藏API - onAppRoute(eventListener)
在小程序切换页面或打开页面时会触发onAppRoute 事件,小程序框架通过wx.onAppRoute 可以注册页面切换时的处理程序,一般开发放在app.js的onLunch生命周期中全局注册一次即可,可用于监听页面切换。 onLaunch() { wx.onAppRoute((route) => { console.log(route); }); } 通过对查看route,个人总结如下 [图片] 注:场景值
2022-05-13 - 怎么区分主包还是分包页面呢?
现需要添加页面标识,区分主包还是分包页面,大家有什么好办法呢
2022-04-01 - 云开发如何通过机器人向企业微信发送消息
需求描述 在日常工作中,我们可能希望将一些小程序的消息通知发送到企业微信当中去,以实现消息的及时推送和分发。 解决方案 1. 创建企业微信群机器人 在企业微信中,我们可以在群内添加群机器人,用于消息的通知,对于需要进行通知的我们来说,就需要实现相同的功能。 首先,你需要将需要接受消息通知的人拉入一个新的群内(当然,你也可以复用之前的群。如果一个消息你只希望自己可以收到,那么可以先把几个人拉入群内,在没有说话之前,踢出所有的人,这样就可以避免他们也会收到消息,同时还可以保留这个群,用于你接收消息)。 [图片] 其次,在列表中找到你用于接受消息的群,在其上右击,选择添加群机器人,然后设置机器人的名字、头像等信息。 [图片] 再次,点击群成员中机器人的头像,在弹出的窗口中,复制 WebHook 的连接。 2. 编写云函数 在上一环节中,我们获取到了刚刚创建的云函数的 WebHook 地址,接下来,我们就可以向企业微信群内发送具体的消息。 这里你需要阅读企业微信关于机器人的文档,这里我们只做简单消息的发送演示。 首先,我们创建一个云函数,命名为sendToWeChatWork,意为发送消息到企业微信。 然后,你需要先在云函数上右击,选择使用终端打开,然后在命令行中输入如下命令,安装依赖 [代码]npm install got --save [代码] 随后,使用微信开发者工具打开云函数,输入如下代码 [代码]// 云函数入口文件 const got = require('got'); const robotUrl = '你获得的连接' // 填入你的机器人连接 // 云函数入口函数 exports.main = async (event, context) => { return await got(robotUrl,{ headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ "msgtype": "text", "text": { "content": `你有新的订单 ${event.orderId}` } }) }) } [代码] 然后保存文件,并上传部署函数。 3. 触发发送企业微信消息 在完成了云函数的编写后,接下来,我们可以在小程序中触发事件,发送消息到企业微信。 [代码]let orderId = 'this is a orderId' wx.cloud.callFunction({ name: "sendToWeChatWork", data:{ orderId: orderId, } }) [代码] 这样,我们就可以在企业微信中看到我们刚刚发送的消息。 总结 除了短信、微信,也有一些人在使用企业微信办公,又或是我们需要将消息发在一个群内,从这个角度来看,企业微信再合适不过了。 此外,企业微信除了做简单的文本消息,还可以发送更多其他类型的消息,相关的连接,你可以在企业微信的文档中找到
2019-11-21 - 如何彻底解决小程序滚动穿透问题
背景 俗话说,产品有三宝:弹窗、浮层加引导,足以见弹窗在产品同学心目中的地位。对任意一个刚入门的前端同学来说,实现一个模态框基本都可以达到信手拈来的地步,但是,当模态框里边的内容滚动起来以后,就会出现各种各样的让人摸不着头脑的问题,其中,最出名的想必就是滚动穿透。 什么是滚动穿透? 滚动穿透的定义:指我们滑动顶层的弹窗,但效果上却滑动了底层的内容。 具体解决方案分析如下: 改变顶层:从穿透的思路考虑,如果顶层不会穿透过去,那么问题就解决了,所以我们尝试给蒙层加catchtouchmove,但是发现部分场景无效果,那么就不再赘述了。 改变底层:既然是顶层影响了底层,要是底层不会滚动,那就没这个问题了。 如何改变底层解决该问题呢? 不成熟方案: 底部页面最外层view设置position: fixed;页面不可滚动,但是这个时候会导致页面回到顶部。 滚动时监听滚动距离,弹窗时记录滚动位置,关闭弹窗后使用wx.pageScrollTo回滚到记录的位置。 成熟方案 使用page-meta组件,通过该组件我们可以操作Page的style样式,类似于h5里body设置overflow: hidden; 控制页面不可滚动。文档地址:https://developers.weixin.qq.com/miniprogram/dev/component/page-meta.html 使用wx.setPageStyle设置overflow: hidden, 也可以实现给Page组件设置样式。) page-meta组件: 通过该组件我们可以直接操作[代码]Page[代码]组件 ,我们给它的wxss样式overflow动态设置[代码]hidden[代码]or[代码]visible[代码]or[代码]auto[代码] 就可以控制整个页面是否可以滚动。 [图片] wx.setPageStyle方法: 调用这个api,动态设置它为hidden/auto,用于控制页面是否可滚动,主要用于页面组件内使用,比如封装好的弹窗组件,就不用单独写page-meta组件了。。 [代码]wx.setPageStyle({ style: { overflow: 'hidden' // ‘auto’ } }) [代码] 老规矩,结尾放代码片段: https://developers.weixin.qq.com/s/U6ItgQmP7upQ 拓展 支付宝小程序虽然存在page-meta组件,但是由于内核为69版本,给page设置overflow: hidden 也无法控制底部元素不可滚动,目前已联系支付宝的底层开发同学提供API控制页面disableScroll,目前正在封装Appx,近期开放。
08-06 - sticky吸顶组件在小米手机下无法触发吸顶效果
使用手机小米8(小米10、1加手机)在小程序“小程序示例”下,吸顶组件不触发吸顶效果,效果如图 [图片] [图片] 手机型号:MI 8\系统版本Android 10\MIUI12.0.3稳定版 [图片] 微信版本7.0.20 [图片]
2020-11-13 - 已解决。小程序获取手机号时,checkSession通过但是获取手机号解密失败
一开始我的处理方式是在页面直接用checkSession,我的session_key是在index.js登录的时候保存到storage,这里check回调的是“success”。 但是把此时storage里面的session_key结合授权按钮的参数去进行解密是失败的,需要在当前的Page再登陆一次才能成功。 不推荐把session_key存放在缓存。所以以上做法直接跳过。 最后参考了一个朋友的做法,在Page onLoad的时候执行一次wx.login(),然后拿到新的session_key,再用此时的新key去解密就通了。或者改为请求解密之前执行一次登录,据说出问题的概率还是很大 结尾补充:最后一种方法还有个问题要考虑,就是最好执行获取手机号之前再checkSession一下(尽管没啥用)。 问题源头,由于这个函数在校验session_key的时候,无论是过期的key还是新的key都是success,所以有了之后一些列的问题,session_key的状态没法把控 [代码]Page({ data: { currentSessionKey: null }, onLoad: function(options) { /* do something*/ const here = this; // 执行登录确保session_key在线 wx.login({ success(res) { if (res.code) { // call()是我自己基于wx.request封装的一个请求函数工具,这里通过后端发送登录请求获得openid const data = call(userLogin, { code: res.code }); data.then(obj => { if (!obj.error) { here.setData({ currentSessionKey: obj.result.session_key }) } }); } }, fail(error) { throw error; } }); }, // 点击按钮获取手机号权限并解析<button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber" bindtap='doMyAction'>获取手机号</button> getPhoneNumber: function (e) { const { encryptedData, iv } = e.detail; const options = { encryptedData: encryptedData, iv: iv, sessionKey: this.data.currentSessionKey }; here.doGetPhone(options); }, doMyAction: function() { // 还可以做一些事情 }, doGetPhone: function (options) { const { sessionKey, encryptedData, iv } = options; const here = this; // 向服务器请求解密 wx.request({ // 这里是解密用的接口 url: 'https://xxx.com/python/decrypt', method: 'POST', data: { sessionKey: sessionKey, encryptedData: encryptedData, iv: iv }, success(res) { // 最终获取到用户数据,国家代号前缀、不带前缀的手机号。默认是不带前缀 const { countryCode, purePhoneNumber } = res.data; here.pageForward(countryCode, purePhoneNumber); }, fail(error) { console.log(error); here.pageForward(); } }) }, pageForward: function(countryCode, purePhoneNumber) { // 获取成功后我是跳转到另一个页面 wx.navigateTo({ url: `/pages/person/index?phone=${purePhoneNumber}` }) } }) [代码]
2020-09-15 - 如何自己在小程序内做埋点数据统计
如何在自己小程序内做数据埋点 小程序后台已经有了较为完善的数据统计和基础的分析,但是功能还是比较基础的,通常我们对数据分析有较高的要求时,就需要自己做数据收集了。那小程序内如何做自动的数据埋点和手动埋点呢。 自动埋点 启动时间 (onLaunch) 系统信息 (systemInfo) 停留时长 (Page onHide - Page onShow) 来源 (query 参数) PV (Page onShow) UV (结合用户筛选PV) 预设点击数据收集 (onTap 等) 手动埋点 自定义点击收集数据 改造小程序生命周期 自动埋点是需要集成到底层内,不能对业务进行侵入,所以,我们需要改造小程序生命周期,在不同的生命周期内进行预设收集数据的功能。 对 [代码]App[代码] 进行重写 [代码]const oldApp = App; // 我们需要重写的方法 const appFn = ['onLaunch', 'onShow', 'onHide']; App = function (options) { let oldFuncs = {}; appFn.forEach((item) => { oldFuncs[item] = options[item] }) appFn.forEach((item) => { options[item] = function (options) { // todo 做各类数据收集 oldFuncs[item].apply(this, arguments) } }) oldApp.apply(this, arguments); }; [代码] 对 [代码]Page[代码] 重写 [代码]const oldPage = Page; const pageFn = ['onLoad', 'onShow', 'onHide', 'onUnload', 'onShareAppMessage', 'onAddToFavorites'] Page = function (options) { let oldFuncs = {}; pageFn.forEach((item) => { if (options[item]) { oldFuncs[item] = options[item] } }) pageFn.forEach((item) => { if (options[item]) { options[item] = function () { console.log('Page', item, ); // 收集各类数据 oldFuncs[item].apply(this, arguments) } } }) // 以下代码则是对除生命周期类的方法进行重写,做预设点击事件收集数据 const methods = getMethods(options); if (!!methods) { for (var i = 0, len = methods.length; i < len; i++) { clickProxy(options, methods[i]); } } oldPage.apply(this, arguments); } [代码] 对 [代码]Component[代码] 重写 [代码]const oldComponent = Component; Component = function (options) { // 对组建内 methods 进行重写预设点击事件埋点收集 Object.keys(options.methods).forEach((method) => { clickProxy(options.methods, method) }) oldComponent.apply(this, arguments); } [代码] 以上对 [代码]App[代码] [代码]Page[代码] [代码]Component[代码] 进行重写之后,在必要的地方,加入自己的上报代码. 以下完整代码 [代码] const mpHook = { data: 1, onLoad: 1, onShow: 1, onReady: 1, onPullDownRefresh: 1, onReachBottom: 1, onShareAppMessage: 1, onPageScroll: 1, onResize: 1, onTabItemTap: 1, onHide: 1, onUnload: 1, }; const oldApp = App; const oldPage = Page; const oldComponent = Component; const appFn = ['onLaunch', 'onShow', 'onHide'] App = function (options) { let oldFuncs = {}; appFn.forEach((item) => { oldFuncs[item] = options[item] }) appFn.forEach((item) => { options[item] = function (options) { console.log('App', item); // 收集各类数据 oldFuncs[item].apply(this, arguments) } }) oldApp.apply(this, arguments); }; const pageFn = ['onLoad', 'onShow', 'onHide', 'onUnload', 'onShareAppMessage', 'onAddToFavorites'] Page = function (options) { let oldFuncs = {}; pageFn.forEach((item) => { if (options[item]) { oldFuncs[item] = options[item] } }) pageFn.forEach((item) => { if (options[item]) { options[item] = function () { console.log('Page', item, ); // 收集各类数据 oldFuncs[item].apply(this, arguments) } } }) const methods = getMethods(options); if (!!methods) { for (var i = 0, len = methods.length; i < len; i++) { clickProxy(options, methods[i]); } } oldPage.apply(this, arguments); } Component = function (options) { Object.keys(options.methods).forEach((method) => { clickProxy(options.methods, method) }) oldComponent.apply(this, arguments); } function clickProxy(options, method) { const oldFunc = options[method]; options[method] = function () { const pages = getCurrentPages(); const currentPage = pages[pages.length - 1]; const pageQuery = currentPage.options || {}; const pagePath = currentPage.route; const res = oldFunc.apply(this, arguments); let prop = {}, type = ""; if (isObject(arguments[0])) { const current_target = arguments[0].currentTarget || {}; const dataset = current_target.dataset || {}; type = arguments[0]["type"]; prop["$event_type"] = type; prop["$event_timestamp"] = Date.now(); prop["$element_id"] = current_target.id; prop["$element_type"] = dataset["type"]; prop["$element_content"] = dataset["content"]; prop["$element_name"] = dataset["name"]; prop["$page_path"] = pagePath; prop["$page_quey"] = pageQuery; if (isObject(arguments[0].event_prop)) { prop = Object.assign(prop, arguments[0].event_prop); } } console.log('type', type) if (type) { // 可以对不同事件类型进行筛选是否需要收集 post(prop) } console.log(res); return res; }; } const getMethods = function (options) { let methods = []; for (let m in options) { if (typeof options[m] === "function" && !mpHook[m]) { methods.push(m); } } return methods; }; const isObject = function (obj) { if (obj === undefined || obj === null) { return false; } else { return toString.call(obj) == "[object Object]"; } }; const post = function (data) { console.log('data', data) // 提交数据时,可以在组合下 systeminfo 用户信息等相关信息 wx.request({ url: 'https://www.example.php', method: 'post', data }) } // 手动埋点的部分,自己在需要收集的地方调用相关方法收集数据 [代码]
2021-06-24 - 电脑版微信内置浏览器打不开公众号问题解决
我这边遇到的问题是PC端微信内置浏览器打开任何链接、小程序显示白屏,经反复测试,得出以下两种现象: 1、白屏的链接里面的图片可以正常托出(盲托)到桌面 2、白屏的链接文字内容可以通过CTRL+A(盲选)全选复制粘贴到记事本 由此可以推测出白屏的链接,小程序,网页其实已经打开,只是呈现的方式是白屏。 最后妥协的解决方式是设置运行微信的兼容模式为Windows XP (sp3)得以临时解决。希望能帮助大家,同时也希望有大神能找到彻底解决的方法。[图片]
2021-06-10 - 小程序内嵌二维码长按识别内测QA
小程序内嵌二维码长按识别内测QA Q1:支持识别的码类型与场景如何? A1:小程序内一直支持小程序码的长按识别,公众号二维码仅在小程序内嵌公众号文章场景下识别。 此次放开内测识别的码包括:微信个人码、企业微信个人码、普通群码与互通群码,支持的场景包括: 调用previewImage接口后,长按图片出现菜单:iOS 8.0.6&安卓8.0.3以上版本支持调用previewMedia接口后,长按图片出现菜单:iOS 8.0.6&安卓8.0.3以上版本支持<image>组件将 show-menu-by-longpress属性设置为true后,长按图片出现菜单:iOS 8.0.8&安卓8.0.7以上版本支持(未发布)<web-view>组件中长按图片出现菜单:iOS 8.0.6&安卓8.0.3以上版本支持 Q2:使用该能力时需要注意什么? A2:请勿使用利诱等方式诱导用户添加好友或者加入群聊,页面内容需要遵循小程序运营规范,若发现违反规范的行为将封禁识别能力。 Q3:为什么有些图片长按没有弹出菜单? A3:在小程序中<image>组件需要将 show-menu-by-longpress属性设置为true后才可以直接长按出现菜单。 同时<image>支持识别微信个人码、企微个人码、普通群码、互通群码的能力目前在iOS下存在问题需要客户端进行修复(预计8.0.8版本);安卓8.0.3版本未在此场景下支持,预计8.0.7版本完成支持。 Q4:为什么有些图片长按会出现菜单,也会出二维码的跳转入口,但是点击后不跳转? A4:此问题已知,是iOS的跳转出现了问题,将在8.0.8版本修复 Q5:为什么企业微信群码有时可以识别有时无法识别? A5:请确认是否为企业微信活码,企业微信活码不支持识别,暂无放开计划 Q6:为什么H5中的图片长按不出现菜单,反而出现一个系统的共享/添加到“照片”/拷贝菜单? A6:此处是iOS WebView的特性,可参考此链接进行禁用:https://developers.weixin.qq.com/community/develop/doc/000a20560c89a8f7555a0b16051400
2021-06-09 - 如何在 Nuxt.js 中配合 Element UI 使用微信开放标签
微信把服务号的模板消息改成了订阅通知,而订阅通知是用开放标签 [代码]wx-open-subscribe[代码] 来订阅,而不管是在官方文档,还是在社区的帖子中,都只有普通 Vue 项目的用法,所以今天专门记录一下如何在 Nuxt.js 项目中,跟原有的 Element UI 一起搭配使用微信开放标签。 跟着官方文档的使用步骤走,因为开放标签使用步骤与微信 JS-SDK 类似,所以很多工作都不需要做,像 [代码]JS接口安全域名[代码] 之前已经配置过。然后引入 JS 文件,之前我用的是 1.4.0 版本的,现在换成 1.6.0 版本,这个 JS 文件我是在 nuxt.config.js 文件中引入的。 [代码]module.exports = { head: { script: [ {src: 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'} ] } } [代码] 然后下一步,通过 config 接口注入权限验证配置并申请所需开放标签。这个因为之前要用 jsApi,所以也做过,只需要在里面加上需要的开放标签即可。我这里需要跳转小程序和服务号订阅通知 2 个开放标签。 [代码]wx.config({ debug: true, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印 appId: '', // 必填,公众号的唯一标识 timestamp: , // 必填,生成签名的时间戳 nonceStr: '', // 必填,生成签名的随机串 signature: '',// 必填,签名 jsApiList: [ 'updateAppMessageShareData', 'updateTimelineShareData' ], // 必填,需要使用的JS接口列表 openTagList: [ 'wx-open-launch-weapp', 'wx-open-subscribe' ] // 可选,需要使用的开放标签列表 }); [代码] 这个时候如果你去页面中使用开放标签,控制台会报这个错误:[代码]Unknown custom element: <wx-open-subscribe> - did you register the component correctly?[代码]。因为开放标签属于自定义标签,Vue 和 Nuxt.js 会给予未知标签的警告。在普通的 Vue 项目中,可以在 main.js 中配置 [代码]Vue.config.ignoredElements = ['wx-open-launch-weapp', 'wx-open-subscribe'][代码]。而在 Nuxt.js 项目中,需要在 nuxt.config.js 中配置。(查看 nuxtjs 官方文档) [代码]module.exports = { vue: { config: { ignoredElements: ['wx-open-launch-weapp','wx-open-subscribe'] } } } [代码] 现在就可以在页面中去使用开放标签了。先提醒一句,因为订阅通知只有已认证的服务号才可以使用,所以代码写好之后,需要部署到生产环境,在手机真机上进行验证调试,在测试环境以及开发者工具都是没办法验证的。一般我们在开发时,会使用 vconsole 进行调试,而在生产环境下,没有 vconsole,最好的工具就是 Chrome 浏览器的 inspect 功能,可以用来调试 Android 手机中的 H5 页面,不知道的可以了解一下。在官方文档的使用说明中有提到,对于Vue 等视图框架,为了避免 template 标签冲突的问题,可使用 [代码]<script type="text/wxtag-template"></script>[代码] 代替 [代码]<template></template>[代码],来包裹插槽模版和样式。所以如果你的开放标签不显示,请先检查是不是这个有问题。在 vue 页面的 [代码]<template></template>[代码] 中添加 [代码]wx-open-subscribe[代码]。 [代码]<template> <wx-open-subscribe template="QhsDLahvIjb8RAB8iC23-hTbsMtnhIgKNrcgXERTxk0" id="subscribe-btn"> <script type="text/wxtag-template"> <style>.btn { margin:10px; padding: 5px }</style> <button class="btn">订阅通知</button> </script> </wx-open-subscribe> </template> [代码] 这里 wx-open-subscribe 的 template 属性是模板的 id,订阅通知的模板需要先在公众号后台申请。将上述代码部署到生产环境之后,就可以在手机上看到按钮了,同时按钮点击后,也能弹出申请通知的弹框。 [图片] [图片] 既然已经可以正常显示按钮和响应按钮点击事件了,接下来要考虑的就是怎么和现有的项目融合。首先,我觉得不应该专门写个按钮让用户订阅通知,而是应该当用户点击之前已有的按钮时,可以先弹出这个申请通知的弹框,然后用户允许或取消后,再走下面的流程。举个例子,我之前有个[代码]购买[代码]按钮,用户购买之后我想给用户发条购买成功的通知,那么我就应该把订阅通知的功能加到我这个[代码]购买[代码]按钮上,而不是再加个按钮让用户专门订阅这条通知。同时,我可能会在任何一个组件上(不仅仅是 Button,可能是一张图片),当用户点击的时候,申请发送某个通知, 所以这个订阅通知标签应该是和其他组件分离的,可以搭配任何组件使用,只是当用户点击其他组件的时候,我要触发这个订阅功能,弹出这个弹窗。所以样式这块就比较头疼,我项目中用的 UI 框架是 Element UI,我不希望在加这个订阅通知的时候,还要对我的 Element UI 做改动,那太不合理了,所以最好的处理方法就是把开放标签和需要添加订阅功能的那个组件作为兄弟节点,放在同一个父元素 div 里,然后利用子绝父相,把开放标签的 position 写为 absolute,然后父元素的 position 写为 relative,让开放标签覆盖需要添加订阅功能的那个组件,这样看着像是点击的那个组件,其实点击的是开放标签,就可以正常申请通知了,然后把那个组件的点击事件交给开放标签处理就可以了。总结下来,就是原先的按钮实现样式,开放标签实现申请通知,并处理点击事件。 [代码]<div style=""="position: relative;"> <el-button size="medium" type="primary" round style="width:100%;height:100%;">去购买</el-button> <div class="" style="position:absolute;width:100%;height:100%;top:0px;"> <wx-open-subscribe template="LRN2-ntyTNKhUCtbOlhTfhvLFEXVZHp1zeeftcbc2Q4" id="subscribe-btn" style="position:absolute;width:100%;height:100%;left:0px;top:0px;overflow:hidden;" @success="openSubscribeSuccess" @error="openSubscribeError"> <script type="text/wxtag-template"> <style>.btn { width: 100%; height: 500px;}</style> <div class="btn"></div> </script> </wx-open-subscribe> </div> </div> [代码] 可以看到,在开放标签里面的样式中,我把 height 写成了 500px,因为插槽中模版的样式是和页面隔离的,所以写成 100% 是不行的,必须得有固定的高度,在社区中,名为猛的这位伙伴是获取的实际高度,我发现没必要,只要我在 wx-open-subscribe 里面,设置了 overflow,让内部超出的部分隐藏,就可以正常使用。要注意的是,这里的高度必须大于等于需要添加订阅功能的组件高度,这里我假设的是需要添加订阅功能的组件高度不超过 500px,否则就不够了,还得改大一点。然后 wx-open-subscribe 有 success 和 error 两个事件,我们像 click 点击事件一样,调用方法就可以了。 [图片] [图片] 到了这里,开放标签 wx-open-subscribe 的使用差不多就成功了。但是如果以后很多地方都需要订阅不同的通知,难道每次都要把这一段代码复制一遍吗,这就不太合适了,所以最好的方案是把每个开放标签都封装成一个 Vue 组件,然后在每个用到的地方直接引入组件使用就可以了,非常 nice。下面是我 open-subscribe.vue 的内容。 [代码]<template> <div class="wx-open-subscribe-container" style="position:absolute;width:100%;height:100%;top:0px;"> <wx-open-subscribe :template="templateId" id="subscribe-btn" style="position:absolute;width:100%;height:100%;left:0px;top:0px;overflow:hidden;" @success="openSubscribeSuccess" @error="openSubscribeError"> <script type="text/wxtag-template"> <style>.btn { width: 100%; height: 500px;}</style> <div class="btn"></div> </script> </wx-open-subscribe> </div> </template> <script> export default { props: { templateId: { type: String, default() { return '' } } }, methods: { openSubscribeSuccess(e) { this.$emit('open-subscribe-success', e.detail) }, openSubscribeError(e) { this.$emit('open-subscribe-error', e.detail) } } } </script> [代码] [代码]wx-open-subscribe[代码] 有一个属性 template,所以将其作为 prop,从父组件传值过来。[代码]wx-open-subscribe[代码] 有 2 个事件 success 和 error,所以使用 [代码]$emit[代码],将值传给父组件,让父组件处理事件。下面是这个子组件在父组件中的使用。 [代码]<template> <div class="" style="position:relative;"> <el-button type="success" plain icon="el-icon-share" circle /> <open-subscribe template-id="9KjxFrQpgHpDpvkw3Krk6N8URgPNn7j6inHUeNF0sQg" @open-subscribe-success="openSubscribeSuccess" @open-subscribe-error="openSubscribeError" /> </div> </template> <script> import openSubscribe from '@/components/open-tags/open-subscribe.vue' export default { components: { openSubscribe }, methods: { openSubscribeSuccess(e) { }, openSubscribeError(e) { } } } </script> [代码] 可以看到,父组件在使用订阅通知子组件时,把模板 id 传进去,然后监听 [代码]open-subscribe-success[代码] 和 [代码]open-subscribe-error[代码] 两个事件,自己实现两个时间的处理即可。 至于其他的开放标签,原理差不多,下面为跳转小程序的开放标签封装的子组件 open-launch-weapp.vue。 [代码]<template> <div class="wx-open-launch-weapp-container" style="position:absolute;width:100%;height:100%;top:0px;"> <wx-open-launch-weapp :username="username" :path="path" id="launch-btn" style="position:absolute;width:100%;height:100%;left:0px;top:0px;overflow:hidden;" @launch="openWeappLaunch" @error="openWeappError"> <script type="text/wxtag-template"> <style>.btn { width: 100%; height: 500px;}</style> <div class="btn"></div> </script> </wx-open-launch-weapp> </div> </template> <script> export default { props: { username: { type: String, default() { return '' } }, path: { type: String, default() { return '' } } }, methods: { openWeappLaunch(e) { this.$emit('open-weapp-launch') }, openWeappError(e) { this.$emit('open-weapp-error') } } } </script> [代码] 到底为止,就实现了在 Nuxt.js 中配合原有的 Element UI 使用微信开放标签,对之前的东西没有什么大的破坏,还算比较合理的,希望可以帮到你。
2021-04-08 - Url link问题?
如果需要百万个以上商品每个都不一样的二维码,且长期有效,用urllink生成二维码是都可以实现?
2021-05-26 - 关于微信安卓端网页字体适配的通知
为了提供给用户更好的阅读体验,微信安卓版 7.0.10 版本起,网页的字体会跟随微信设置里的字体大小更改而变化。 若调整字体变大或变小后,部分未适配网页的排版会出现显示错乱,建议未进行适配的开发者尽快完成对“ 字体大小” 的适配。 查看网页在字体不同大小下展示效果的方法: 方法1:"设置">"通用">“字体大小">进行字体大小修改后查看对应网页显示效果。 方法2:在微信内访问对应网页右上角”…">底部菜单栏选择调整字体">进行字体大小修改后查看对应网页显示效果。 另外,对于现有的显示问题,我们提供以下方案让开发者临时将字体还原标准大小。同时,开发者可以在页面中提示用户在右上角”…”更多菜单中修改字体到合适的大小。 下列方案可以将字体还原标准大小,但我们仍然建议后续做字体适配来提高用户的阅读体验。 『字体还原标准大小』方案: 我们提供了一个 JSAPI 用于设置字体大小,只需将字体大小等级设置为 2 (标准)即可,代码示例如下: document.addEventListener("WeixinJSBridgeReady", function () { WeixinJSBridge.invoke("setFontSizeCallback", { fontSize: '2' }); }, false); 此外,若页面是用 rem 单位进行排版的(目前该做法更容易导致页面不可用),可以反向重置 font-size 的数值达到还原字体标准大小的目的,此方法在效果上也比较理想。代码示例如下: // 以下代码思路来源网络。同时代码放在 body 标签开头位置效果最佳 var $dom = document.createElement('div'); $dom.style = 'font-size: 10px'; document.body.appendChild($dom); // 计算出放大后的字体 var scaledFontSize = parseInt(window.getComputedStyle($dom, null).getPropertyValue('font-size')); document.body.appendChild($dom); // 计算原字体和放大后字体的比例 var scaleFactor = 10 / scaledFontSize; // 取 html 元素的字体大小 var originRootFontSize = parseInt(window.getComputedStyle(document.documentElement, null).getPropertyValue('font-size')); // 由于设置 font-size 后实际会变大,故 font-size 需设置为更小一级 document.documentElement.style.fontSize = originRootFontSize * scaleFactor * scaleFactor + 'px';
2020-01-14 - app.onLaunch与page.onLoad异步问题
问题:相信很多人都遇到过这个问题,通常我们会在应用启动app.onLaunch() 去发起静默登录,同时我们需要在加载页面的时候,去调用一个需要登录态的后端 API 。由于两者都是异步,往往page.onload()调用API的时候,app.onLaunch() 内调用的静态登录过程还没有完成,从而导致请求失败。 解决方案:1. 通过回调函数// on app.js App({ onLaunch() { login() // 把hasLogin设置为 true .then(() => { this.globalData.hasLogin = true; if (this.checkLoginReadyCallback) { this.checkLoginReadyCallback(); } }) // 把hasLogin设置为 false .catch(() => { this.globalData.hasLogin = false; }); }, }); // on page.js Page({ onLoad() { if (getApp().globalData.hasLogin) { // 登录已完成 fn() // do something } else { getApp().checkLoginReadyCallback = () => { fn() } } }, }); ⚠️注意:这个方法有一定的缺陷(如果启动页中有多个组件需要判断登录情况,就会产生多个异步回调,过程冗余),不建议采用。 2. 通过Object.defineProperty监听globalData中的hasLogin值 // on app.js App({ onLaunch() { login() // 把hasLogin设置为 true .then(() => { this.globalData.hasLogin = true; }) // 把hasLogin设置为 false .catch(() => { this.globalData.hasLogin = false; }); }, // 监听hasLogin属性 watch: function (fn) { var obj = this.globalData Object.defineProperty(obj, 'hasLogin', { configurable: true, enumerable: true, set: function (value) { this._hasLogin = value; fn(value); }, get: function () { return this._hasLogin } }) }, }); // on page.js Page({ onLoad() { if (getApp().globalData.hasLogin) { // 登录已完成 fn() // do something } else { getApp().watch(() => fn()) } }, }); 3. 通过beautywe的状态机插件(项目中使用该方法) // on app.js import { BtApp } from '@beautywe/core/index.js'; import status from '@beautywe/plugin-status/index.js'; import event from '@beautywe/plugin-event/index.js'; const app = new BtApp({ onLaunch() { // 发起静默登录调用 login() // 把状态机设置为 success .then(() => this.status.get('login').success()) // 把状态机设置为 fail .catch(() => this.status.get('login').fail()); }, }); // status 插件依赖于 beautywe-plugin-event app.use(event()); // 使用 status 插件 app.use(status({ statuses: [ 'login' ], })); // 使用原生的 App 方法 App(app); // on page.js Page({ onLoad() { // must 里面会进行状态的判断,例如登录中就等待,登录成功就直接返回,登录失败抛出等。 getApp().status.get('login').must().then(() => { // 进行一些需要登录态的操作... }) }, }); 具体实现 具体实现可以参考我的商城小程序项目 项目体验地址:体验 代码:代码
2021-05-20 - 企业微信内H5页面能使用 wx-open-app、wx-open-launch-weapp 标签 ?
这边尝试在企业微信内添加h5 页面,wx-open-launch-weapp 、wx-open-launch-app 按钮图标没有展示,不能跳转
2021-03-30 - 小程序1rpx边框不完美解决方案
在小程序开发中,1rpx边框随处可见, 像上图UI给的设计稿,如果只是简单使用[代码]border: 1rpx solid red;[代码]的话,在不同的机型上会有不同的表现 [图片] 表现IOS 机型上[图片] Android机型上[图片] 由图片可以看出, IOS机型上会有边框缺失(然而经常出现缺不能稳定复现), 而Android机型上边框比较粗 原因上面这两种表现形式很难联系到一起 首先先看IOS边框缺失的问题,借鉴网络上前辈们的经验 当父元素的高度为奇数,容易出现上下边框缺失,同理宽度为奇数,容易出现左右边框缺失解决办法是在边框内部添加一个1rpx的元素或者伪元素, 撑开内部使父元素的宽高是偶数。 然而我们发现这种方案在Iphone 6等2倍屏可以生效, 但放在如Iphone X等3倍屏下面就很飘了, 还是经常会出现边框缺失的情况, 这种情况下再去把父元素改为2和3共同的倍数就非常不现实了。 再回过头看导致边框缺失的具体原因是啥。 在这之前需要了解下高分屏的物理像素和虚拟像素的概念 简单来说物理像素是设备的实际像素 虚拟像素是设备的坐标点, 可以简单理解为css像素 而rpx类似rem,渲染后实际转换成px之后可能存在小数,在不同的设备上多多少少会存在渲染的问题。而1rpx的问题就更加明显,因为不足1个物理像素的话,在IOS会进行四舍五入,而安卓好像统一向上取整,这也是上面两种设备表现不同的原因。 解决方法我们采用的方法是采用translate:scale(0.5)的方法对边框进行缩放 具体的代码如下 .border1rpx, .border1rpx_before{ position: relative; border-width: 0rpx !important; padding: 0.5rpx; z-index: 0; } .border1rpx::after, .border1rpx_before::before{ content: ""; border-style: inherit; border-color: inherit; border-radius: inherit; box-sizing: border-box !important; position: absolute; border-width: 2rpx !important; left: 0; top: 0; width: 200% !important; height: 200% !important; transform-origin: 0 0; transform: scale(0.5) !important; z-index: -1; } .border1rpx-full { margin: -1rpx; } 给.border1rpx的元素设置边框宽度为0给::after伪元素宽高为两倍,边框设置2rpx,边框其他样式继承元素的设置然后再缩放0.5来达到边框为1rpx的效果 用法基础用法给相应的元素添加border1rpx的class即可, (.borde1rpx说:我们不生产边框,只是边框的搬运工,要显示边框样式的话还需要在元素上自行设置) 圆角边框圆角边框需要自行设置相应伪元素::before 或 ::after的border-raduis值为预期的2倍, 如原本想要设置10rpx的圆角,需要设置[代码].xxx::after{border-raduis: 20rpx;}[代码] 边框内部填充由于设计原因,目标元素会留1rpx的padding用于显示伪元素的边框,如果内部元素是填充的,正常会看到填充元素和目标元素有小部分间隙,此时需要给填充元素添加.border1rpx_full来解决 注意点此方案默认使用::after伪元素实现边框,如果目标元素的after被占用(如iconfont),请使用[代码].border1rpx_before[代码]如单独设置边框(如上边框), [代码]border: 1rpx solid red;border-width: 1rpx 0 0 0;[代码]不能被正确继承,请使用简写[代码]border-top: 1rpx solid red;[代码]由于设计原因,目标元素请最少设置1rpx的padding用于显示边框,(上面的样式已经有了默认的padding,不写也可以, 只是不要用padding:0覆盖)请自行测试点击功能是否正常,防止层级关系导致元素区域被伪元素覆盖
2020-07-23 - Coolui Scroll v2.0基于小程序原生组件scroll-view的上拉加载下拉刷新
Coolui Scroll v2.0 上拉加载下拉刷新 v2.0 版 上传至npm包可安装下载并npm构建 修改参数配置使组件使用更便捷 增加加载插槽可以自定义加载更多样式 前言 基于小程序原生组件scroll-view的扩展与封装,实现简单的上拉加载下拉刷新 扩展下拉刷新动画,有灵感的朋友可以丰富更多下拉动画 组件持续更新,请关注github 在线征集 在线征集下拉刷新动画创意,你可以发草图,或者psd,AE等文件到邮箱:1003418012@qq.com. 只要想法合理立马安排demo~ 组件持续更新,请关注Github https://github.com/wzs28150/coolui-scroller 演示Demo https://developers.weixin.qq.com/s/KmYdwMmX7hjV npm 安装 安装之后开发者工具点击npm构建 [代码]npm i coolui-scroller --production [代码] 引入 在[代码]app.json[代码]或[代码]index.json[代码]中引入组件 [代码]"usingComponents": { "coolui-scroll": "coolui-scroller/index", } [代码] 示例 基础用法 [图片] 升级用法 [图片] 天猫动画背景 [图片] 京东下拉 [图片] 弹射火箭 [图片] 端午安康 [图片] 天气 [图片] 基础用法代码演示 页面结构 [代码]<coolui-scroll scrollOption="{{scroll}}" bindrefresh="refresh" bindloadMore="loadMore" background="#fff"> <view class="list-inner" slot="inner"> <view class="item" wx:for="{{list}}" wx:key="unique"> 第{{index + 1}}条内容 </view> </view> </coolui-scroll> [代码] 配置 详见api [代码]// data 中配置 scroll: { // 设置分页信息 pagination: { page: 1, totalPage: 10, limit: 10, length: 100 }, // 设置数据为空时的图片 empty: { img: 'http://coolui.coolwl.cn/assets/mescroll-empty.png' }, // 设置下拉刷新 refresh: { type: 'default', style: 'black', background: "#000" }, // 设置上拉加载 loadmore: { type: 'default', icon: 'http://upload-images.jianshu.io/upload_images/5726812-95bd7570a25bd4ee.gif', background: '#f2f2f2', // backgroundImage: 'http://coolui.coolwl.cn/assets/bg.jpg', title: { show: true, text: '加载中', color: "#999", shadow: 5 } } }, [代码] 事件 详见api [代码]// 加载数据 getData:function (type, page) { // 可走后台接口 if (type == 'refresh') { // 刷新时执行 }else{ // 加载时执行 } }, // 下拉 刷新 页数设置1 refresh: function () { this.getData('refresh', 1) }, // 上拉 加载 页数设置+1 loadMore: function () { this.getData('loadMore', this.data.scroll.pagination.page + 1) }, // 自定义下拉刷新时执行 插槽下拉 返回的下拉进度p refreshPulling: function (e) { p = e.detail.p }, [代码] API Props background 下拉刷新背景颜色 (如:#fff) tip: 在写组件的时候遇到了bug 本来该设置应该放在 scrollOption.refresh 中的 不知为何出现了 下拉刷新直接穿位置到页面底部,有知道为什么的么? 目前还没有解决。 scrollOption 滚动设置 分页设置 pagination 参数 说明 类型 默认值 版本 page 页码 Number [代码]1[代码] - totalPage 总页码数 Number [代码]0[代码] - limit 每页显示个数 Number [代码]0[代码] - length 总个数(个数为0是,页面显示空样式) Number [代码]0[代码] - 空设置 empty 参数 说明 类型 默认值 版本 img 数据为空时显示的图片 String [代码]http://coolui.coolwl.cn/assets/mescroll-empty.png[代码] - 下拉刷新设置 refresh 参数 说明 类型 默认值 版本 type 下拉样式类型,小程序默认样式或自定义 支持 [代码]default | diy[代码] String [代码]default[代码] - style 默认模式下样式有深色和浅色 支持 [代码]black | white[代码] String [代码]black[代码] - diyLevel 自定义等级,简单设置:1,插槽自定义:2 支持 [代码]1 | 2[代码] Number - p 自定义等级2时,下拉的百分比方便自定义动画,设置0即可 Number [代码]0[代码] - refreshthreshold 自定义下拉高度 Number - backgroundImage 自定义下拉背景图片 String - title 自定义下拉文字 可设置 [代码]show[代码]: 是否显示, [代码]text[代码]: 文字内容, [代码]color[代码]: 文字颜色, [代码]shadow[代码]: 文字阴影范围(0时不显示) Obj - 上拉加载设置 loadmore 参数 说明 类型 默认值 版本 type 上拉样式类型,默认样式或插槽自定义 支持 [代码]default | diy[代码] String [代码]default[代码] - icon 默认样式时设置图标 String - title 默认样式时设文字 可设置 [代码]show[代码]: 是否显示, [代码]text[代码]: 文字内容, [代码]color[代码]: 文字颜色, [代码]shadow[代码]: 文字阴影范围(0时不显示) Obj - Slots 名称 说明 inner 加载列表内容区域 refresh 下拉自定义结构 loadmore 上拉自定义结构 Events 事件名 说明 参数 bind:refresh 下拉刷新成功时触发 - bind:loadMore 上拉加载成功时触发 event.detail: 当前输入值 bind:refreshPulling 下拉时触发 event.detail.p: 下拉进度 从0开始到1, 可根据p实现一些动画效果
2020-09-20 - 微信开放标签 wx-open-launch-app 样式设置技巧
微信7.0.12开始,增加了 [代码]wx-open-launch-app[代码]标签,用于打开App,感觉又高大上了。 前期相关配置需严格对照文档,戳 微信内网页跳转App功能 和 开发标签说明 按照官方demo加各种配置踩坑,终于弹出了唤起App的弹窗以及打开了App,不容易啊~,现在要开始真正的写需求了 对照设计稿,有一个button,带背景色和圆角,点击要唤起App,按照文档的方式,样式写在了 template中的style里,样式还算简单,终于撸出来了,但标签内样式无法使用vw(这次妥协下,就不适配了) 后面又来了个需求,是个复杂的列表,需求点击每个item都是唤起App操作,一看这个,想着等于要在这个标签内完成原生的样式,而且还有数据的渲染,想想都可怕(React/Vue都出来多少年了~)。想了下,干脆用absolute定位,将内部空元素覆盖在外面不就可以了,直接开干,但最后悲剧了,没有任何效果,在社区一搜发现样式不能设置position [图片] 休息下,重点马上到来~~ 试来试去发现 [代码]wx-open-launch-app[代码]这个标签本身是可以加样式的,那还等什么,直接设置个[代码]postition: absolute[代码],然后外面样式该怎么写就怎么写,外层设置个[代码]position: relative[代码]就好,template内部样式就放一个空div,设置[代码]width:100%, height: 100%[代码],见证奇迹的时刻到了 你以为这样就结束了,但打开发现内部根本没有高度,因此必须设置真实高度,代码大致如下(React) [代码]export const getWxOpenAppHtml = (extinfo: string, height: number) => { const id = 'launch-btn' + getRandomId(); return { id, html: ` <wx-open-launch-app id=${id} style="position:absolute;top:0;left:0;right:0;bottom:0;" extinfo=${extinfo} appid="appid" > <template> <style> .wx-btn{ width:100%; height:${getRealSize(height, isPad)}px; } </style> <div class="wx-btn"></div> </template> </wx-open-launch-app> `, }; }; <div className="open-app-btn"> <Button className="btn">打开APP</Button> <div onClick={() => { openAppError(); // 用于处理标签未生效的情况 }} dangerouslySetInnerHTML={{ __html: openBtnAppHtml }} ></div> </div> [代码] 上面的[代码]getRealSize[代码]我这边主要是为了处理传入的高度,并将其按vw的形式进行适配,返回适配后的px 总结 配置需严格按照文档,wx.config调用时,jsApiList必须传入至少一项Api,不能只传openTagList 外部样式无法影响标签内样式,因为template下生成了document-fragment单独的doc,样式已于外界隔离 template样式无法使用position 标签本身可以设置样式,因此对其设置absolute,外部按正常方式写,只是样式多一个relative即可 内部高度无法使用百分比,需要传入真实高度,因此不适用不知道外部高度的场景 弹窗唤起后,不报错的情况下,点击允许和取消都会进入launch事件,无法区分,见 https://developers.weixin.qq.com/community/develop/doc/000a0e72ba07207ffd9a115025b400
2020-07-29 - 开放标签wx-open-launch-weapp的一些问题认知
先上代码: 普通HTML版本为:(由于使用代码选项,保存无法显示,所以只能文本显示了) <wx-open-launch-weapp id="launch-btn" username="gh_xxxxxxxx" path="pages/home/index?user=123&action=abc" > <script type="text/wxtag-template"> <style>.btn { padding: 12px }</style> <button class="btn">打开小程序</button> </script> </wx-open-launch-weapp> <script> var btn = document.getElementById('launch-btn'); btn.addEventListener('launch', function (e) { console.log('success'); }); btn.addEventListener('error', function (e) { console.log('fail', e.detail); }); </script> 如果是使用框架,则为: <div class="test-position" <wx-open-launch-weapp id="launch-btn" username="gh_** *" path="pages/index/index.html?user=123&action=abc"> <template> <style>.btn { padding: 12px; height: 100px; width: 120px; }</style> <button class="btn">打开小程序-测试方法二</button> </template> </wx-open-launch-weapp> </div> 如果框架写上还不行,可以试试在文件main.js中,写上: Vue.config.ignoredElements = ['wx-open-launch-app', 'wx-open-launch-weapp']; 代码就写完了,说说需要注意的几点吧: 1、在开放标签中,<template>或者<script>里面的写样式,千万不要使用定位position,如果非要用就在最外层的div里面写,例如我这里的class=‘test-position’这里定位; 2、如果你觉得里面写样式不好写,可以在里面样式style写opacity:0;,这样的话开放标签只是用来填充,大小自己控制就行; 3、如果你跳转之后显示页面不存在,请检查下path的路径结尾是否写上了.html; 4、开发工具是无法测试的,只能使用手机测试,如果你在手机分享功能正常,说明你初始化授权没问题,记得要在初始化授权写上开放标签openTagList: ['wx-open-launch-weapp'];如果手机发现按钮不见了,初始化授权是ok的,只是标签写法出来问题;请查看其他注意点; 微信版本要求为:7.0.12及以上。 系统版本要求为:iOS 10.3及以上、Android 5.0及以上。 5、如果还不行,然后初始化授权也是成功的,请质疑一下后台初始化授权信息的jssdk中,APPID是否你想要的公众号,眼见为实; 6、如果还不行,请移步到微信官网查看是否有其他问题:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_Open_Tag.html
2021-05-18 - sticky三个应用场景
1. 吸顶导航 <view class="item_title" bindtap="toBack">返回上一页</view> <view class="item" wx:for='{{10}}' wx:key='index'>{{item+1}}</view> .item_title { width: 700rpx; height: 90rpx; line-height: 90rpx; text-align: center; background-color: #ccc; border-radius: 8rpx; position: sticky; top: 6rpx; margin: 25rpx; } .item{ height: 200rpx; border-bottom: 2rpx solid #ccc; line-height: 200rpx; text-align: center; } [图片] 2. 标题推开 <block wx:for='{{list}}' wx:key='index'> <view class="title">{{item.title}}</view> <view class="content" wx:for='{{item.content}}' wx:key='index2' wx:for-item='item2' wx:for-index='index2'>{{item2}}</view> </block> .title { line-height: 80rpx; font-size: 38rpx; position: sticky; top: 0; background-color: #ccc; } .content { line-height: 60rpx; } [图片] 3. 相对父级固定定位 <view class='page'> <view class="box"> <view class="box_title">砍价规则</view> <view class="box_cotent" wx:for="{{rule}}" wx:key='index'>{{item}}</view> <button class="box_btn" type="primary">已了解</button> </view> <view class="body">占位符</view> </view> .box{ height: 500rpx; border: 2rpx solid #ccc; margin: 10rpx; overflow: scroll; padding: 0 20rpx; } .box_title{ text-align: center; line-height: 80rpx; font-size: 38rpx; position: sticky; left: 0; top: 0; background-color: #fff; } .box_cotent{ line-height: 60rpx; color: #333; padding-bottom: 10rpx; } .body{ line-height: 1500rpx; text-align: center; } .box_btn{ position: sticky; left: 0; bottom: 10rpx; } [图片] 代码片段: https://developers.weixin.qq.com/s/uTR93LmD7cfM
2020-03-11 - 从源码角度理解 wx.reLaunch 执行过程
正文稍显啰嗦,可直接拖到文末看总结的执行流程图以及在开发过程中需要注意的一些坑点。 相信老司机们看到这个标题就笑了,[代码]wx.reLaunch[代码]有啥好解释的,顾名思义就是:「重新启动小程序」。重启这个词,对于工程师最熟悉不过了,很多问题一出现,往往第一反应就是“重启”一下就能解决掉,能重启解决的问题,那都不是“大问题”。 wx.reLaunch === 重启? 那这样来理解[代码]wx.reLaunch[代码]的话,那就应该是“微信会 kill 掉当前这个小程序进程,然后像第一次加载小程序那样,重新打开小程序”。 如果是这样的话,那[代码]bug[代码]应该离你就不远了。事实并非如此。 微信文档怎么说 进一步,那不是这样的话,那我们查查微信文档怎么说的?wx.reLaunch(Object object) 关闭所有页面,打开到应用内的某个页面 看到这里,老司机们都拍了拍大腿,原来是这样:清空所有路由,并打开新的页面。我知道了,也很简单嘛。ok,理解了的话,那我再追问几个问题吧: 关闭所有页面会执行什么操作? 打开新页面的过程中,除了新页面 Page 的各生命周期外,全局 js 会重新执行吗(加载时即执行)? 如果 reLaunch 的目标页是分包内的页面呢?执行过程又是怎样的 写段代码测试下 事实胜于雄辩,依赖文档来编程是合理的,但是如果我们要继续深入来探讨代码的执行,还是远远不够的。 我们先来写段代码片段测试一下,链接:https://developers.weixin.qq.com/s/tis8tVmX7ShS(需要真机调试需要填写 appId)。 有 p0 - p4 共 5 个页面,我们的测试路径是这样的,打开小程序-p0-p1-p2,然后 reLaunch 到 p3,看控制台输出: [图片] 这里我们能看到,“关闭所有页面”,对应会依次会把当前页面栈的页面出栈,并触发相应的 [代码]unUnload[代码]回调。 就不耽误大家的时间,经过反复测试,最终得出在 [代码]wx.reLaunch[代码]的执行过程如下: [图片] 追溯源码 其实到了这一步,相信大家对 [代码]wx.reLaunch[代码]有了进一步的认识,而不是像开始那样觉得就是单纯的重启了。但是到这里就结束了吗?如果说有一样东西能够 100% 让人信服,单纯测试是不够的,总可能会有遗漏的测试用例,无法保证结论的正确性。so… Talk is cheap, show me your code. 我们下一步打算从源码层去看一看 [代码]wx.reLaunch[代码]到底做了些什么。 什么?你不知道源码怎么看… (嗯,网上其实有文章,介绍怎么去找到小程序框架层的源码) 我的方法是这样的:在控制台输入[代码]wx.reLaunch()[代码]并执行,我们看到会抛出一个错误,我们点开错误堆栈: [图片] WAService.js 就是小程序框架的源码了,cmd+f,点进去输入关键词[代码]reLaunch[代码]搜索,我们会找到关键代码,也就是搜索结果的 9/40 和 40/40,代码如下: 9/40 [代码]b = function(e) { var t; "active" === a.default.runningStatus || "ios" !== c.PLATFORM ? Object(u.beforeInvoke)("reLaunch", e, { url: "" }) && (!v("reLaunch", e) || (t = g("reLaunch", e, !1)) && (e.url = t, e.url = Object(i.encodeUrlQuery)(e.url), h("reLaunch", e.url, e) && (a.default.navigatorLock = !0, Object(u.invokeMethod)("reLaunch", e, { afterFail: function() { a.default.navigatorLock = !1 } }), p.emit({ type: "reLaunch", start: Date.now() })))) : Object(u.beforeInvokeFail)("reLaunch", e, "can not invoke reLaunch in background") } [代码] 40/40 [代码]"reLaunch" === r || "autoReLaunch" === r ? function(t, e, n, r, o, i) { __appServiceSDK__.traceBeginEvent("Framework", "onReLaunch"); var a = !1; for ("reLaunch" === o && (a = !0); 0 < lt.length;) { var s = !0, a = a && (s = !1); Pt(lt[lt.length - 1], t, !1, s) } Object.keys(st).forEach(function(e) { Pt(st[e], t, !1, !0) }), le(t), jt(t, e, n, r, { isMainTabBarPage: xt({ route: t }), initialRenderingCacheData: i }), __appServiceSDK__.traceEndEvent() } (e, C, t, n, r, i) [代码] 然后,我们开启漫长的 debug 的过程,因为我们对其内部实现并不熟悉,所以最开始我们找到入口,然后逐步调试。 我们第一步其实就有发现: [代码]"active" === a.default.runningStatus || "ios" !== c.PLATFORM ? ... : Object(u.beforeInvokeFail)("reLaunch", e, "can not invoke reLaunch in background") [代码] 这段代码其实一眼看上去就能猜测到,[代码]wx.reLaunch[代码]在非 iOS 设备下,如果小程序不在前台,那执行会报错。我们看社区里反馈了很多类似的问题,最常见的一个就是「在微信支付后为什么页面没有跳转」,其实都可以从这行代码找到问题的原因(https://developers.weixin.qq.com/community/develop/doc/000caaf5e9cf486e710aaf18751800)。 这里其实我们会想为什么微信会有这个限制,我猜测可能和 [代码]wx.reLaunch[代码]执行过程中,有一些操作在非 iOS 设备下是无法完成的。(在使用小程序过程中,我们会发现安卓下的小程序和微信进程是相互独立的,但是在 iOS 下,小程序进程和微信进程是同一个。参见小程序运行环境) 我们继续往下,又发现一个关键信息: [图片] 这就验证了如果 reLaunch 目标页是分包代码代码时, 会先加载分包代码的结论了。而且事实上继续往下单步执行,在控制台也会先执行 p4/index.js 中 Page 外面写的 console 语句。 接下来,我们先把我们之前发现的第 2 段关键代码打上断点,因为发现如果不断的话会直接执行掉,这里应该是被另外一层函数封装了,没进到里面的代码。我们接下来分析[代码]wx.reLaunch[代码]定义的核心代码会执行什么逻辑: 代码结构是非常清晰的,是一个 IIFE,函数主体被包裹在了 begin 和 end 中,赞一个。 [代码]"reLaunch" === r || "autoReLaunch" === r ? function(t, e, n, r, o, i) { __appServiceSDK__.traceBeginEvent("Framework", "onReLaunch"); // ... __appServiceSDK__.traceEndEvent() } (e, C, t, n, r, i) [代码] 入参的 6 个变量值分别是: [图片] 我们分为 5 句代码: 第一句: [代码]var a = !1; [代码] 这里定义了一个局部变量 a,值为 false 第二句: [代码]for ("reLaunch" === o && (a = !0); 0 < lt.length;) { var s = !0, a = a && (s = !1); Pt(lt[lt.length - 1], t, !1, s) } [代码] 看到这里,我们会看到有 3 个变量,我们没办法直接看出分别代表什么: s,局部变量,初始值为 true,如果 a 为 true 的话,s 会变成 false。s 作为第 4 个变量传入 Pt 函数中 lt,通过 watch,猜测 lt 即为页面栈 [图片] Pt,直接通过 watch,没办法看出来这个函数是什么,不过我们可以点击下面的 [[FunctionLocation]] 查看函数定义的位置。 [图片] [代码]function Pt(e, t, n, r) { __appServiceSDK__.traceBeginEvent("Framework", "unloadPage"), Ze(e.webviewId), e.page.__toRoute__ = t, e.page.__isBack__ = n, e.page.__notReportHide__ = r, e.page.__callPageLifeTime__("onUnload"), e.node && Y.destroy(e.page), Object(L.isDevTools)() && (delete __wxAppData[e.route], __appServiceSDK__.publishUpdateAppData()), delete st[e.webviewId], (lt = lt.slice(0, lt.length - 1)).length ? gt(lt[lt.length - 1].route) : gt(""), ze("pageUnload", e.page), ze("leavePage", e.page), __appServiceSDK__.traceEndEvent(), __appServiceSDK__.uploadUserLogOnHide(e.route) } [代码] 这里,我们就能知道了,Pt 函数做的事情就是对传入的页面执行其 [代码]onUnload[代码]生命周期函数。 这段代码是**对页面栈进行遍历,依次触发页面的[代码]onUnload[代码]**回调。 第三句: [代码]Object.keys(st).forEach(function(e) { Pt(st[e], t, !1, !0) }), [代码] 我们先看看 st 是什么? [图片] 发现 st 是以 webviewId 为 key,以 page 对象及一些其他带有 page 特性的标识字段组合 为 value 的结构。 所以这段代码仍然是遍历页面(这里我理解是无序的,因为 Object.keys 首先就不能保证有序,参考Object.keys(…)对象属性的顺序?),依次触发其 [代码]onUnload[代码]回调。 这里先抛出两个问题: 第 2 句代码执行后,这段代码还有什么作用? 重复触发 [代码]onUnload[代码],不出意外,框架内部会根据 webviewId 来判断能否执行。即 [代码]__callPageLifeTime__[代码]函数内部逻辑。 暂时先跳过,回到主流程。 第四句: [代码]le(t) [代码] 同样的方式,我们找到 le 函数的源码: [代码]// line 94280 var ue = !1; function le(e) { var t = __appServiceSDK__.isIsolatedSubpackage(e); // 是否独立分包 !ue && t ? ge() : ue && !t && he(), ue = t } function he(t) { t = t || pe, __appServiceSDK__.emitIsloatedAppShow(t), de.forEach(function(e) { e.preventOnShow || e.app.onShow(t), e.preventOnShow = !1 }), pe = t } function ge(t) { __appServiceSDK__.emitIsloatedAppHide(), de.forEach(function(e) { 7 === __wxConfig.appType ? e.app.onHide(t) : e.app.onHide() }) } [代码] 我们找到微信文档对于独立分包的定义:独立分包。从独立分包中页面进入小程序时,不需要下载主包,可以很大程度上提升分包页面的启动速度。 配置方式是在分包配置里加上[代码]independent[代码]字段。因为我们这里没考虑到这种情况,感觉这里不会影响主流程,先跳过。 第五句: [代码]jt(t, e, n, r, { isMainTabBarPage: xt({ route: t }), initialRenderingCacheData: i }) [代码] 通过前面,我们不难推测出,这段代码应该是“打开目标页面”。通过参数发现,目标页面对于 tab 页和非 tab 页有区别对待,继续找到 jt 函数: [代码]function jt(e, t, n, r, o) { var i = 4 < arguments.length && void 0 !== o ? o: {}; __appServiceSDK__.traceBeginEvent("Framework", "openNewPage"), se(te); var a = ot; ot = void 0; var s = null; Ye.call(ct, e) ? s = ct[e] : console.info('Page "' + e + '" has not been registered yet.'); // 检查是否注册 var c = bt(e); wt.newPageTime = Date.now(), gt(e); var u = c ? Y.create(n, e) : q.create(n, e, s || {}), // 初始化 page l = u.page, d = b(r); (nt = { // nt,新页面对象 page: l, webviewId: n, route: e, rawPath: t, lastRoute: nt ? nt.route: "", lastQuery: nt ? nt.page.options: {}, node: u.node }).isTabBarPage = xt(nt), // 是否 tabBar nt.isMainTabBarPage = i.isMainTabBarPage || !1, // 是否主 tabBar lt.push(nt), // 页面栈 l.__exitState__ = a; var f, p, h = u.node, g = {}, v = !1; Object.keys(d).forEach(function(e) { exparser.Component.hasProperty(h, e) && (g[e] = decodeURIComponent(d[e]), v = !0) }), v && h.setData(g), __virtualDOM__.attachView(n), // attachView /^__wx__\//.test(e) && (f = {}, /^__wx__\/open-api-redirecting-page/.test(e) ? f = me || {}: !/^__wx__\/functional-page/.test(e) || (p = _e()) && p.functionalPage && (f = Object.assign({}, p.functionalPage, { accountInfo: __wxConfig.accountInfo })), l.setData(f)), l.options = d, Lt(nt, n, wt.newPageTime, void 0, !1), // Lt 函数 Object(L.isDevTools)() && (__wxAppData[e] = l.data, __wxAppData[e].__webviewId__ = n, __appServiceSDK__.publishUpdateAppData()), i.initialRenderingCacheData && l.setData(i.initialRenderingCacheData); var _ = __appServiceSDK__._getOpenerEventChannel(); _ && (nt.eventChannel = _), l.__callPageLifeTime__("onLoad", r), // 1.触发 onLoad l.__callPageLifeTime__("onShow"), // 2.触发 onShow st[n] = { page: l, route: e, rawPath: t, webviewId: n, statesData: null, node: c ? u.node: void 0 }, // webviewId 为 key 的特殊结构 ze("pageLoad", l), ze("enterPage", l), vt("appRoute2newPage", wt.appRouteTime, wt.newPageTime), __appServiceSDK__.traceEndEvent() } [代码] 我们在里面发现了我们熟悉的 lt、st 变量,并且在函数内被初始化。其中有一个 Lt 函数,我们找到其函数定义。 [代码]var Lt = _(function(e, t, n, r, o) { __appServiceSDK__.traceBeginEvent("Framework", "publishInitData"), j("Update view with init data"); var i = e.page, a = {}; a.wechatLibVersion = ("undefined" != typeof __libVersionInfo__ ? __libVersionInfo__.version: "") || "", a.webviewId = t, a.enablePullUpRefresh = _t(i, "onReachBottom"), a.enablePageScroll = _t(i, "onPageScroll"), a.onReachBottomDistance = function(e) { try { if ("number" == typeof __wxConfig.page[e + ".html"].window.onReachBottomDistance) return __wxConfig.page[e + ".html"].window.onReachBottomDistance } catch(e) { return $.DEFAULT_ON_REACH_BOTTOM_DISTANCE } return $.DEFAULT_ON_REACH_BOTTOM_DISTANCE } (i.__route__), a.statesData = r, a.scene = rt, a.route = i.__route__, a.query = i.options, a.lastRoute = e.lastRoute, a.lastQuery = e.lastQuery, a.wxConfig = { accountInfo: __wxConfig && __wxConfig.accountInfo || {}, appContactInfo: __wxConfig && __wxConfig.appContactInfo || {}, appLaunchInfo: __wxConfig && __wxConfig.appLaunchInfo || {}, plugins: __wxConfig && __wxConfig.plugins || {} }, a.windowConfig = __wxConfig && __wxConfig.page && __wxConfig.page[i.__route__ + ".html"] && __wxConfig.page[i.__route__ + ".html"].window || {}, a.debug = __wxConfig && __wxConfig.debug, a.appId = __wxConfig && __wxConfig.accountInfo && __wxConfig.accountInfo.appId, a.appLaunchTime = wt.appLaunchTime, a.appFgTime = wt.appFgTime, a.isTabBarPage = e.isTabBarPage, a.isMainTabBarPage = e.isMainTabBarPage, a.navigationStyle = __wxConfig && __wxConfig.global && __wxConfig.global.window && __wxConfig.global.window.navigationStyle, a.packageType = function(e) { if (__wxConfig && __wxConfig.subPackages && __wxConfig.subPackages.length) { for (var t = 0; t < __wxConfig.subPackages.length; t++) { var n = __wxConfig.subPackages[t]; if (0 === e.indexOf(n.root)) return n.independent ? "independent": "normal" } return "main" } return "none" } (i.__route__), a.needGetSubjectInfo = !At.isInit, a.subPackages = __wxConfig.subPackages, a.perfData = (ie[ne] = Date.now(), ie[X] = ae ? 1 : 0, ie[oe] = __wxConfig.isSubContext ? 1 : 0, ie[Q] = __wxConfig.onReadyStart, ie[re] = __wxConfig.onReadyEnd || 0, ae = !1, ie), a.isReload = o, a.adInfo = { preloadVideoAdUnitIds: __appServiceSDK__.getPreloadVideoAdUnitIds() }, a.permissionBytes = __appServiceSDK__.getPermissionBytes(), a.fontFaceRecords = __appServiceSDK__.fontFaceRecords, a.fontSizeSetting = Object(L.getCachedSystemInfo)().fontSizeSetting; var s = { ext: a, options: { firstRender: !0, timestamp: n, path: i.__route__ } }; if (r) { var c = JSON.stringify(s), u = c.length; if (262144 < u) { for (var l = [], d = 0; d < u;) l.push(c.substr(d, 262144)), d += 262144; for (var f = ++$e, p = 0, h = l.length; p < h; p++) qe.emit({ isSplitData: !0, splitInfo: { id: f, index: p + 1, total: h, data: l[p] } }, t); return ze("pageReady", i), void __appServiceSDK__.traceEndEvent() } } qe.emit(s, t), // qe 函数,s 是组装 page 信息的对象 ze("pageReady", i), __appServiceSDK__.traceEndEvent() }) [代码] qe 函数: [代码]var qe = function() { function e() { Object(g. default)(this, e) } return Object(r. default)(e, null, [{ key: "emit", value: function(e, t, n) { __appServiceSDK__.invokeWebviewMethod("appDataChange", e, [t], n) } }]), e } (); [代码] 搜索 [代码]appDataChange[代码],找不到其他地方定义。我们找到 [代码]__appServiceSDK__.invokeWebviewMethod[代码] 的定义: [代码]function(e, t, n) { n.r(t), n.d(t, "invokeWebviewMethod", function() { return r }); var c = n(0), u = n(3), l = 0, d = [], r = function(e, t, n, r) { var o = 1 < arguments.length && void 0 !== t ? t: {}, i = 2 < arguments.length ? n: void 0, a = 3 < arguments.length ? r: void 0, s = l++; d[s] = a, Object(c.publish)("invokeWebviewMethod", { // 发布 name: e, args: o, callbackId: s }, void 0 === i ? [u. default.currentWebviewId]: i) }; Object(c.subscribe)("callbackWebviewMethod", // 订阅 function(e) { var t = e.res, n = e.callbackId, r = d[n]; delete d[n], r && r(t) // 执行 }) } [代码] 最终发现以下代码会被执行: [代码]__appServiceSDK__.onWebviewEvent(_(function(e) { __appServiceSDK__.traceBeginEvent("Framework", "onWebviewEvent"); var t = e.webviewId, n = e.eventName, r = e.data, o = function(e, t, n, r) { if (Ye.call(st, e)) { var o = st[e], i = o.page; if (n === $.DOM_READY_EVENT) return wt.pageReadyTime = Date.now(), j("Invoke event onReady in page: " + o.route), i.__callPageLifeTime__("onReady"), // 3.触发 onReady void vt("newPage2pageReady", wt.newPageTime, wt.pageReadyTime); if (r._requireActive) { var a = lt[lt.length - 1]; if (!a || a.webviewId !== e) return } if (r._relatedInfo && F.DisplayReporter.setEventRelatedInfo(r._relatedInfo), t) { var s = __virtualDOM__.getNodeById(t, e); if (!s) return; var c = exparser.Element.getMethodCaller(s); return j("Invoke event " + n + " in component: " + s.is), _t(c, n) ? tt(c, n, r) : void x("事件警告", "Do not have " + n + " handler in component: " + s.is + ". Please make sure that " + n + " handler has been defined in " + s.is + ".") } if (j("Invoke event " + n + " in page: " + o.route), _t(i, n)) return tt(i, n, r); x("事件警告", "Do not have " + n + " handler in current page: " + o.route + ". Please make sure that " + n + " handler has been defined in " + o.route + ", or " + o.route + " has been added into app.json") } } (t, e.nodeId, n, r); return __appServiceSDK__.traceEndEvent(), o }, "onWebviewEvent")) [代码] 说实话,这段代码没太看懂,对着 Page 的 生命周期 一起看理解起来会更清晰点,这里还是回到最开始,看传入的 [代码]isMainTabBarPage[代码]字段做了什么操作。 我们回到 jt 函数,发现关键语句: [代码]var u = c ? Y.create(n, e) : q.create(n, e, s || {}) // 初始化 page [代码] Y: [代码]K = ["onLoad", "onReady", "onShow", "onRouteEnd", "onHide", "onUnload", "onResize"], J = __appServiceSDK__.getLogManager(), Y = function() { function e() { Object(g. default)(this, e) } return Object(r. default)(e, null, [{ key: "create", value: function(d, e) { var f = __virtualDOM__.addView(d, e), p = exparser.Element.getMethodCaller(f), u = __virtualDOM__.getOwnerPluginAppId(p); if (p.__wxWebviewId__ = d, p.__route__ = e, p.route = e, p.__displayReporter = new F.DisplayReporter(e, 2), f.__customConstructor__ === __virtualDOM__.Page) { var t = f.getRootBehavior().methods, n = p.__freeData__; for (var r in t) p[r] = t[r].bind(p); for (var o in n) p[o] = b(n[o]) } var h = __appServiceSDK__.getSystemInfoSync().deviceOrientation; p.__callPageLifeTime__ = function(e) { var t = this[e] || I; Reporter.__route__ = this.__route__, Reporter.__method__ = e; for (var n, r, o, i, a, s = arguments.length, c = new Array(1 < s ? s - 1 : 0), u = 1; u < s; u++) c[u - 1] = arguments[u]; "onLoad" === e && (n = p.__displayReporter).setQuery.apply(n, c), "onShow" === e ? (p.__displayReporter.reportShowPage(), Object(F.checkWebviewAlive)(d)) : "onReady" === e ? p.__displayReporter.setReadyTime(Date.now()) : "onHide" === e || "onUnload" === e ? (r = this.__toRoute__, o = this.__isBack__, i = this.__notReportHide__, delete this.__toRoute__, delete this.__isBack__, delete this.__notReportHide__, i || p.__displayReporter.reportHidePage(r, o), Object(F.stopCheckWebviewAlive)(d)) : "onResize" === e && (a = c[0] || {}, h !== a.deviceOrientation && (h = a.deviceOrientation, p.__displayReporter.addOrientationChangeCount())), "onShow" === e ? f.triggerPageLifeTime("show", c) : "onHide" === e ? f.triggerPageLifeTime("hide", c) : "onResize" === e && f.triggerPageLifeTime("resize", c), j(this.__route__ + ": " + e + " have been invoked"), __appServiceSDK__.traceBeginEvent("LifeCycle", "Page." + e); var l = t.apply(this, c); return __appServiceSDK__.traceEndEvent(), Reporter.__route__ = Reporter.__method__ = "", l }, K.forEach(function(s) { var c = p[s]; p[s] = function() { var e, t = c || I; try { for (var n = Date.now(), r = arguments.length, o = new Array(r), i = 0; i < r; i++) o[i] = arguments[i]; e = t.apply(this, o); var a = Date.now() - n; 1e3 < a && Reporter.slowReport({ key: "pageInvoke", cost: a, extend: 'at "' + this.__route__ + '" page lifeCycleMethod ' + s + " function" }), J && J.logApiInvoke && J.log("page " + this.__route__ + " " + s + " have been invoked"), __appServiceSDK__.nativeConsole.info("component page " + this.__route__ + " " + s + " have been invoked") } catch(e) { Reporter.thirdErrorReport({ source: u, error: e, extend: 'at "' + this.__route__ + '" page lifeCycleMethod ' + s + " function" }) } return e }.bind(p) }); var i = "function" == typeof p.onShareAppMessage; i && __appServiceSDK__.showShareMenu(); var a = "function" == typeof p.onShareTimeline; return i && a && __appServiceSDK__.showShareTimelineMenu(), { page: p, node: f } } }, { key: "destroy", value: function(e) { __virtualDOM__.removeView(e.__wxWebviewId__) } }]), e } () [代码] q: [代码]var B = Object.assign, L = n(4), F = n(5), W = ["onLoad", "onReady", "onShow", "onRouteEnd", "onHide", "onUnload", "onResize"], U = function(e) { for (var t = 0; t < W.length; ++t) if (W[t] === e) return ! 0; return "data" === e }, V = ["__wxWebviewId__", "__route__"], G = ["route"], z = function(e) { return - 1 !== V.indexOf(e) }, H = __appServiceSDK__.getLogManager(), q = function() { function h() { var t = this, c = 0 < arguments.length && void 0 !== arguments[0] ? arguments[0] : {}, d = 1 < arguments.length ? arguments[1] : void 0, e = 2 < arguments.length ? arguments[2] : void 0; Object(g. default)(this, h); var n = { __wxWebviewId__: d, __route__: e }; V.forEach(function(e) { Object.defineProperty(t, e, { set: function() { x("关键字保护", "should not change the protected attribute " + e) }, get: function() { return n[e] } }) }); var r = __virtualDOM__.addView(d, e), o = exparser.Element.getMethodCaller(r), u = __virtualDOM__.getOwnerPluginAppId(o); this.__wxExparserNode__ = r, this.__wxComponentInst__ = o, exparser.Element.setMethodCaller(r, this), c.data = c.data || {}, T(c.data) || A("Page data error", "Page.data must be an object"); var i = JSON.stringify(c.data); this.data = JSON.parse(i), this.__viewData__ = JSON.parse(i), this.__displayReporter = new F.DisplayReporter(e, 1); var f = __appServiceSDK__.getSystemInfoSync().deviceOrientation; this.__callPageLifeTime__ = function(e) { var t = (this[e] || I).bind(this); Reporter.__route__ = this.__route__, Reporter.__method__ = e; for (var n, r, o, i, a, s = arguments.length, c = new Array(1 < s ? s - 1 : 0), u = 1; u < s; u++) c[u - 1] = arguments[u]; "onLoad" === e && (n = this.__displayReporter).setQuery.apply(n, c), "onShow" === e ? (this.__displayReporter.reportShowPage(), Object(F.checkWebviewAlive)(d)) : "onReady" === e ? this.__displayReporter.setReadyTime(Date.now()) : "onHide" === e || "onUnload" === e ? (r = this.__toRoute__, o = this.__isBack__, i = this.__notReportHide__, delete this.__toRoute__, delete this.__isBack__, delete this.__notReportHide__, i || this.__displayReporter.reportHidePage(r, o), Object(F.stopCheckWebviewAlive)(d)) : "onResize" === e && (a = c[0] || {}, f !== a.deviceOrientation && (f = a.deviceOrientation, this.__displayReporter.addOrientationChangeCount())), j(this.__route__ + ": " + e + " have been invoked"), __appServiceSDK__.traceBeginEvent("LifeCycle", "Page." + e); var l = t.apply(this, c); return __appServiceSDK__.traceEndEvent(), Reporter.__route__ = Reporter.__method__ = "", l }, W.forEach(function(s) { t[s] = function() { var e, t = (c[s] || I).bind(this); try { for (var n = Date.now(), r = arguments.length, o = new Array(r), i = 0; i < r; i++) o[i] = arguments[i]; e = t.apply(this, o); var a = Date.now() - n; 1e3 < a && Reporter.slowReport({ key: "pageInvoke", cost: a, extend: "at " + this.__route__ + " page lifeCycleMethod " + s + " function" }), H && H.logApiInvoke && H.log("page " + this.__route__ + " " + s + " have been invoked"), __appServiceSDK__.nativeConsole.info("page " + this.__route__ + " " + s + " have been invoked") } catch(e) { Reporter.thirdErrorReport({ source: u, error: e, extend: "at " + this.__route__ + " page lifeCycleMethod " + s + " function" }) } return e }.bind(t) }); for (var a in c) ! function(a) { z(a) ? x("关键字保护", "Page's " + a + " is write-protected") : U(a) || ("Function" === k(c[a]) ? t[a] = function() { var e; Reporter.__route__ = this.__route__, Reporter.__method__ = a, __appServiceSDK__.traceBeginEvent("User Script", "Page." + a); try { for (var t = Date.now(), n = arguments.length, r = new Array(n), o = 0; o < n; o++) r[o] = arguments[o]; e = c[a].apply(this, r); var i = Date.now() - t; 1e3 < i && Reporter.slowReport({ key: "pageInvoke", cost: i, extend: "at " + this.__route__ + " page " + a + " function" }) } catch(e) { Reporter.thirdErrorReport({ source: u, error: e, extend: "at " + this.__route__ + " page " + a + " function" }) } return __appServiceSDK__.traceEndEvent(), Reporter.__route__ = Reporter.__method__ = "", e }.bind(t) : t[a] = b(c[a])) } (a); var s = { route: e }; G.forEach(function(e) { Object.prototype.hasOwnProperty.call(t, e) || (t[e] = s[e]) }); var l = "function" == typeof c.onShareAppMessage; l && __appServiceSDK__.showShareMenu(); var p = "function" == typeof c.onShareTimeline; l && p && __appServiceSDK__.showShareTimelineMenu() } return Object(r. default)(h, null, [{ key: "create", value: function(e, t, n) { var r = new h(n, e, t), o = r.__wxExparserNode__; return delete r.__wxExparserNode__, { page: r, node: o } } }, { key: "destroy", value: function(e) { __virtualDOM__.removeView(e.__wxWebviewId__) } }]), Object(r. default)(h, [{ key: "setData", value: function(c, e) { var u = this; try { var t = k(c); if ("Object" !== t) return void A("类型错误", "setData accepts an Object rather than some " + t); Object.keys(c).forEach(function(e) { void 0 === c[e] && A("Page setData warning", 'Setting data field "' + e + '" to undefined is invalid.'); var t, n, r, o = M(e), i = R(u.data, o), a = i.obj, s = i.key; a && (a[s] = b(c[e])), void 0 !== c[e] && (n = (t = R(u.__viewData__, o)).obj, r = t.key, n && (n[r] = b(c[e]))) }), __appServiceSDK__.traceBeginEvent("Framework", "DataEmitter::emit"), this.__wxComponentInst__.setData(JSON.parse(JSON.stringify(c)), e), __appServiceSDK__.traceEndEvent() } catch(e) { v(e) } } }, { key: "pageScrollTo", value: function(e) { __appServiceSDK__.publishPageScrollTo(e, [this.__wxWebviewId__]) } }]), h } () [代码] 仍然没有发现 [代码]isMainTabBarPage[代码]关键字。 以上,第五步虽然没找到我们想找到的逻辑,但是发现其实 jt 函数其实和其他路由函数执行的是同一段逻辑,不影响我们对 [代码]wx.reLaunch[代码]执行过程的分析。 结论 最终,结合源码,我们得出[代码]wx.reLaunch[代码]的最终执行过程如下图所示: [图片] 关于 [代码]wx.reLaunch[代码]的执行过程,额外提出几个点需要注意: [代码]wx.reLaunch[代码]真正的逻辑是清空路由,再打开新页面,并不是传统意义上的“重启”; [代码]wx.reLaunch[代码]只会影响小程序各生命周期(回调)的执行,全局 js 代码在小程序加载时执行,分包中的全局 js 代码在分包加载时执行; [代码]wx.reLaunch[代码]的目标页在分包内,且分包未加载过时,会先加载分包代码,再执行后续逻辑(unUnload + openNewPage); [代码]wx.reLaunch[代码]在非 iOS 设备中,如果小程序不在前台时,执行会报错,导致无法跳转。 除此之外,再额外说几个在 debug 过程中的总结的小 tips: 1、如果在代码里不太好看一些变量或表达式的值,可以复制下来贴在 watch 里。 2、发现一些 function 执行了,但是不知道函数定义的位置位置,可以先将其放进 watch 里,等其有值的时候点开会有一个路径点击开就到了函数定义的位置了。 3、不要无脑 debug,带着疑问,先在脑子里假设出你推测或者认为的一些结论,用 debug 去验证,否则中间很难发现关键信息。其实本文的成因并非是我真的想去深入了解下 [代码]wx.reLaunch[代码]的执行过程,而是因为对他的理解有偏差,在排查线上 bug 时产生了一些自己无法理解的现象。
2020-06-12 - 一个小小的优化,能让你的小程序瘦身10%
我司一直专注于微信小程序(以下简称小程序)开发,可以说是重仓押注在小程序上。 但由于小程序的大小有严格的限制(单个分包/主包大小不能超过2M)。 而我们的业务又相对比较复杂,因此常常会突破小程序的大小限制。因此,我们就不得不思考如何优化小程序的大小。 暴力方式 要优化小程序的大小,最好(最暴力)的方式就是删页面。 这样来,即高效执行起来也简单:统计下所有页面的PV、UV,将一些不活跃的页面移除就完事了。 但是,本文并不是要讲如何移除页面,因为这没什么好讲的。 分析 讲本文的优化方式之前,先分析一下小程序一般都由哪些文件组成的。 一般都是由以下几种文件组成: [代码].js[代码] 逻辑文件 [代码].wxml[代码] 页面结构文件 [代码].wxss[代码] 样式文件 [代码].json[代码] 配置文件 也许你会将一些image放在小程序里,一般建议放较小且少量的image,其他都使用网络图片 其中,由于[代码]JavaScript[代码]有一定的兼容问题需要处理,因此在打包和上传小程序时,开发者工具会对[代码]JavaScript[代码]进行[代码]babel[代码]编译处理,故这块可优化的空间比较有限。 而[代码]JSON[代码]的大小都比较小,且格式较为固定,也没什么可优化的地方。 接下来就是本文要重点说到的[代码]WXML[代码]了,一般[代码]WXSS[代码]都是和[代码]WXML[代码]配套使用的。这两者占小程序的大小比例也比较高,可优化空间非常大,可优化的思路也非常多。本文先讲一下[代码]WXML[代码]的一个优化技巧。 试验 其实,小程序最终的执行都是以WEB的形式完成的。因此[代码]WXML[代码]可以理解成类似于[代码]VUE[代码]的语法糖,最终都是要编译成[代码]HTML[代码]的。 所以,想要压缩[代码]WXML[代码]代码,就可以参考[代码]HTML[代码]的压缩方式。比如移除多余的空格。 我立马做了个试验,将[代码]WXML[代码]中的部分的空格移除之后,再使用开发者工具上传,发现小程序的大小真的发生了变化,变得更小了。因此可以得出结论,移除[代码]WXML[代码]中的空格是可行的压缩思路。 自动化 既然移除空格是可以减小小程序代码体积的,那么如何实现自动化移除的。 首先我想到的是,利用巨人的肩膀:[代码]htmlparser2[代码]。通过语法分析器,识别[代码]WXML[代码]的空格,并一举歼灭。 绝大多数情况下,这个做法是可行的。但是有一种情况,会导致[代码]parser[代码]识别出错:[代码]WXML[代码]中出现[代码]{{ }}[代码],且使用了[代码]<[代码]。 因此需要特制一个识别[代码]WXML[代码]语法的[代码]parser[代码]。 由于这样的parser比较简单,因此我就自己上手写了一个:wxml-parser 实践 通过上述我写的parser,写了一个简单的minifier:wxml-minifier 安装 [代码]npm i -D wxml-minifier [代码] 使用 [代码]let minifier = require('wxml-minifier') let fs = require('fs') let resource = fs.readSync('./app.wxml') // 假设输入为:<view class="home" ></view> <!-- test --> let result = minifier(resource) console.log(result) // <view class="home"></view> [代码] 总结 通过将[代码]WXML[代码]中多余的空格移除,可以将小程序的代码减小大概10%。 其实,从这个角度可以发现,开发者工具在上传[代码]WXML[代码]时,是没有做任何处理的。因此对于HTML的任何压缩方式都可以在[代码]WXML[代码]上使用。当然这也是后续我的[代码]wxml-parser[代码]持续更新迭代的方向。 不知道为什么微信官方在开发者工具上传代码时,不进行简单的简化处理。如果你有答案的话,欢迎在评论中给我回复! 如果觉得对你有用,希望给我一个star,感谢!
2020-01-21 - 你不知道的小程序系列之生命周期执行顺序
再次开始之前先问几个问题: 你是否知道[代码]Page[代码]生命周期 与 [代码]pagelifetimes[代码] 生命周期执行顺序? 你是否知道[代码]behaviors[代码]中的生命周期与组件生命周期执行顺序? 你是否知道[代码]Page[代码]生命周期 与 组件[代码]pagelifetimes[代码]生命周期执行顺序? 要回答上面的问题,首先我们看看小程序生命周期有哪些: App onLaunch onShow onHide Page onLoad onShow onReady onHide onUnload Component created attached ready moved detached 想一下加载一个页面(包含组件)的加载顺序,按照直觉小程序加载顺序应该是这样的加载顺序(以下列子中[代码]Component[代码]都是同步组件): App(onLaunch) -> Page(onLoad) -> Component(created) 但其实并不然,小程序的加载顺序是这样的: 首先执行 [代码]App.onLaunch[代码] -> [代码]App.onShow[代码] 其次执行 [代码]Component.created[代码] -> [代码]Component.attached[代码] 再执行 [代码]Page.onLoad[代码] -> [代码]Page.onShow[代码] 最后 执行 [代码]Component.ready[代码] -> [代码]Page.onReady[代码] 其实也不难理解微信这么设计背后的逻辑,我们先看下官方的的生命周期: [图片] 可以看到,在页面[代码]onLoad[代码]之前会有页面[代码]create[代码]阶段,这其中就包含了组件的初始化,等组件初始化完成之后,才会执行页面的[代码]onLoad[代码], 之后页面[代码]ready[代码]事件也是在组件[代码]ready[代码]之后才触发的。 下面我们来看看 [代码]Behavior[代码], [代码]Behavior[代码] 与 [代码]Vue[代码]中的 [代码]mixin[代码] 类似,猜想下其中的执行顺序: Behavior.created => Component.created 测试下来和预期相符,其实在[代码]Vue[代码]的文档中有一段这样的描述: 另外,混入对象的钩子将在组件自身钩子之前调用。 这样的设计和主流设计保持一致。接下来我们看看 [代码]pageLifetimes[代码],有[代码]show[代码]和[代码]hide[代码]生命周期对应页面的展示与隐藏,预期的执行顺序: pageLifetime.show => Page.onShow 测试下来也和预期相符,那么我们可以推断出如下的结论: 当页面中包含组件时,组件的生命周期(包括pageLifetimes)总是优先于页面,[代码]Behaviors[代码]生命周期优先于组件的生命周期。但其实有个例外:页面退出堆栈,当页面[代码]unload[代码]时会执行如下顺序: Page.onUnload => Component.detached 看了以上的分析你应该知道了答案,最后做个总结(demo): [图片] 最后的最后布置个作业 异步组件(异步渲染的组件,通常是通过if条件判断是否渲染)的生命周期执行顺序是怎样的,pagelifetimes会不会执行?
2020-01-10 - 微信小程序使用GoEasy实现websocket实时通讯
不需要下载安装,便可以在微信好友、微信群之间快速的转发,用户只需要扫码或者在微信里点击,就可以立即运行,有着近似APP的用户体验,使得微信小程序成为全民热爱的好东西~ 同时因为微信小程序使用的是Javascript语法,对前端开发人员而言,几乎是没有学习成本和技术门槛的。对于大部分场景,都可以使用小程序快速开发实现,不论是开发周期还是开发成本都低的让人笑哭,所以受到了技术开发团队的各种追捧~ 但如果要在小程序里快速的实现一个即时通讯功能,就有点尴尬,因为微信官方提供的只是一个底层的websocket api,要在项目中直接使用,还需要做很多额外的工作,比如首先就需要搭建自己的websocket服务~ 那有没有简单的方式呢? 当然是有的! 今天小编就手把手的教您用GoEasy在微信小程序里,最短的时间快速实现一个websocket即时通讯Demo。 [图片] 本demo已经完成了真机下的小程序的测试,完整源代码开源到oschina的码云上,clone后,只需要将代码里的appkey换成自己的common key,就可以体验了, 源码网址:https://gitee.com/goeasy-io/GoEasyDemo-wxapp-Helloworld 1、获取appkey GoEasy官网(https://www.goeasy.io/)上注册账号,创建一个应用,拿到您的appkey。 [图片] GoEasy提供了两种类型的appkey: Common key: 即可以接收消息,也可以发送消息,与Subscribe Key最大的区别就是有写权限,可以发消息。适用于有消息发送需求的客户端和服务端开发。 Subscribe key: 只能接收消息,不可以发送消息,与Common Key最大的区别就是没有写权限,只能收消息。可以用于一些没有发送需求的客户端。 2、获取GoEasy SDK 下载 https://cdn.goeasy.io/download/goeasy-1.0.11.js [代码]import GoEasy from './goeasy-1.0.11'; [代码] 3、初始化GoEasy对象 [代码]var self = this; this.goeasy = GoEasy({ host: 'hangzhou.goeasy.io', appkey: "您的appkey", onConnected: function () { console.log("GoEasy connect successfully."); self.unshiftMessage("连接成功."); }, onDisconnected: function () { console.log("GoEasy disconnected.") self.unshiftMessage("连接已断开."); }, onConnectFailed: function (error) { console.log(error); self.unshiftMessage("连接失败,请检查您的appkey和host配置"); } }) [代码] 根据您在GoEasy后台创建应用时选择的区域,来传入不同的Host,如果您创建GoEasy应用时,选择了杭州,那么host:“hangzhou.goeasy.io”。选择了新加坡,host:“singapore.goeasy.io”。 如果您的大部分用户都是在国内,创建应用时,记得选择杭州,以便获得更快的通讯速度。 4、小程序端接收消息 [代码]var self = this; this.goeasy.subscribe({ channel: "my_channel", onMessage: function (message) { self.unshiftMessage(message.content); }, onSuccess: function () { self.unshiftMessage('订阅成功.'); } }); [代码] 很多朋友会问channel从哪里来,如何创建,应该传入什么呢? 根据您的业务需求来设定,channel可以为任意字符串,除了不能包含空格,和不建议使用中文外,没有任何限制,只需要和消息的发送端保持一致,就可以收到消息。channel可以是您直播间的uuid,也可以是一个用户的唯一表示符,可以任意定义,channel不需要创建,可以随用随弃。 5、小程序端发送消息: 发送时,需要注意channel一定要和subscribe的channel完全一致,否则无法收到。 [代码]this.goeasy.publish({ channel: "my_channel", message: self.data.message, onSuccess: function () { self.setData({ message: '' }); //清空发送消息内容 console.log("send message success"); }, onFailed: function (error) { self.unshiftMessage('发送失败,请检查您的appkey和host配置.'); } }); [代码] 本代码源码下载:https://gitee.com/goeasy-io/GoEasyDemo-wxapp-Helloworld 特别强调: 在运行之前,一定要在微信公众号平台配置socket合法域名,否则无法建立连接。具体步骤: 访问https://mp.weixin.qq.com,进入微信公众平台|小程序 -> 设置 -> 开发设置 -> 服务器域名 socket合法域名-> 添加GoEasy的地址: wx-hangzhou.goeasy.io(记得wx-开头) 若您创建GoEasy应用时选择了新加坡区域则添加地址:wx-singapore.goeasy.io 答疑时间: 1、我的服务器端可以给小程序发送消息吗?都支持些哪些语言? 当然可以,任何语言都可以通过调用GoEasy的Rest API发送消息,同时为了大家方便,GoEasy的官方文档里,也准备了Java, C#,NodeJS,PHP,Ruby和Python等常见语言调用REST API的代码,这里获取更多详情:https://www.goeasy.io/cn/doc/server/publish.html 2、GoEasy可以发送图片,语音和视频吗? 当然可以,您可以通过推送文件路径的方式来实现文件的发送。 按照行业惯例,不论MSN,微信,QQ对于图片和视频,通常的做法都是,只推送文件路径,而不会推送文件本身。你如果有注意的话,当您接受图片和视频的时候,收到消息后,等一会儿才能看,就是因为发送的时候,只发送了路径。 3、GoEasy和微信小程序官方的websocket API有什么区别和优势? 小程序官方的websocket API主要是用来与您的websocket服务通讯,所以使用小程序websocket的前提是,首先要搭建好您自己的websocket服务,然后与之通讯。这是一个纯技术的API,在建立网络连接后,还有很多的工作需要自己来完成,比如: 需要自己实现心跳机制,来维护网络连接,来判断客户端的网络连接状态; 需要自己实现断网自动重连; 需要自己维护消息列表,确保遇到断网重连后,消息能够补发; 需要自己维护一个客户端列表; 等等很多细致而繁杂的工作,比如websocket的安全机制和性能优化; 此之外服务端也有很多工作需要自己完成,有兴趣自己搭建websocket的话,可以参考这篇技术分享《搭建websocket消息推送服务,必须要考虑的几个问题》 而GoEasy是一个成熟稳定的企业级websocket PAAS服务平台,开发人员不需要考虑websocket服务端的搭建,只需要几行代码,就可以轻松实现客户端与客户端之间,服务器与客户端之间的的websocket通信,不需要考虑性能,安全,高可用集群的问题,只需要全力专注于开发自己的业务功能就好了。 GoEasy已经内置websocket中必备的心跳,断网重连,消息补发,历史消息和客户端上下线提醒等特性,开发人员也不需要自己搭建websocket服务处理集群高可用,安全和性能问题。GoEasy已经稳定运行了5年,支持千万级并发,成功支撑过很多知名企业的重要活动,安全性和可靠性都是久经考验。 4、GoEasy在小程序的开发中主要用在那些场景呢? 从应用场景上来说,所有需要websocket通信的场景,GoEasy都可以完美支持: 聊天,IM,直播弹幕,用户上下线提醒, 在线用户列表 扫码点菜,扫码登录, 扫码支付, 扫码签到, 扫码打印 事件提醒,工单,订单实时提醒 在线拍卖, 在线点餐,在线选座 实时数据展示,实时监控大屏, 金融实时行情显示,设备监控系统 实时位置跟踪,外卖实时跟踪,物流实时跟踪 远程画板,远程医疗,游戏,远程在线授课 5、GoEasy的文档为什么这么简单?简单到我都不知道如何使用 简单还不好吗?GoEasy从研发的第一天,就把追求API的极简作为我们的工作重点。严格控制接口的数量,就是是为了降低开发人员的学习成本,其实就是为了让您爽啊!但这并不影响GoEasy完美支持所有的websocket即时通讯需求。 GoEasy官网:https://www.goeasy.io GoEasy系列教程: 搭建websocket消息推送服务,必须要考虑的几个问题 websocket IM聊天教程-教你用GoEasy快速实现IM聊天 Websocket直播间聊天室教程-GoEasy快速实现聊天室 微信小程序使用GoEasy实现websocket实时通讯 Uniapp使用GoEasy实现websocket实时通讯 IM聊天教程:发送图片/视频/语音/表情
2020-05-21 - 复杂瀑布流长列表页踩坑记录,内存不足问题【2】
第二期来啦,带来了新的方案和代码片段~ 第一期点此查看 上期问题 经过一系列的实践,上期的方案有些问题,其中最麻烦的就是,需要对外传递一个当前index,然后控制前后数据展示;这里对于每个用到[代码]skeleton[代码]组件的页面来说,都要重复的写一个方法来承接这个index,然后渲染页面对应的数据。 优化 依然是监听[代码]skeleton[代码]曝光,这里监听的方案变为出现在屏幕上下[代码]n[代码]屏的内容块进行展示,此范围外的内容块就卸载掉。 核心代码 [代码] // 修改了监听是否显示内容的方法,改为前后showNum屏高度渲染 // 监听进入屏幕的范围relativeToViewport({top: xxx, bottom: xxx}) let info = SystemInfo.getInfo() //获取系统信息 let { windowHeight = 667 } = info.source.system let showNum = 2 //超过屏幕的数量,目前这个设置是上下2屏 let listItemContainer = this.createIntersectionObserver() listItemContainer.relativeToViewport({ top: showNum * windowHeight, bottom: showNum * windowHeight }) .observe(`#list-item-${this.data.skeletonId}`, (res) => { // 此处来控制slot展示,详见代码片段 }) [代码] 干货 话不多说,干货放后面,点击获取代码片段
2019-12-05 - 模板消息与订阅消息对比分析
[图片] 订阅消息流程: 小程序管理后台添加及申请消息模板 前端通过button组件点击后调授权弹框,授权是针对于消息模板下发权限(单次授权最多支持3个模板Id) 后端 (通过openId和模板ID) 调用subscribeMessage.send发送订阅消息,未授权或拒绝推送的会返回错误信息 与模板消息对比: 相同点: 1. 都需要button或支付触发; 2. 一次性订阅和模板消息一样,都是点一次订阅一条 不同点: 订阅消息会有授权弹框, 而模板消息无弹框,只收集formId,用户是无感的; 订阅消息针对单个或多个消息模板授权以获取下发权限,而模板消息只要有formId就可以发送任意后台已选用的模板消息; 订阅消息授权模板A后,没有过期时间,突破了模板消息7天有效期的限制,而模板消息获取到的formId只有7天有效期,过期了就无法推送; 订阅消息支持长期订阅,但只针对于部分医疗、民生、交通、教育之类的线下服务开放,而模板消息不支持长期,都是一次性的。 使用方式差别: 1. 模板消息。因为只需要formId,所以解决思路是尽可能多的收集。早期的做法是利用js事件穿透,嵌套很多层的form/button,但这个方法后来被微信屏蔽了。现在普遍的做法是在页面中埋点,将链接、跳转、tab之类的通通用from/button替代。 2. 订阅消息。需要调用 wx.requestSubscribeMessage 对指定消息模板授权,如果用户不点击上图中的"总是保持以上选择,不再询问",就会一直弹弹弹,弹得你满脸鱼尾纹(因为这功能愁的)。 补充说明: 关于长期订阅,很多人都会眼前一亮,但尝试下来又开始 "祝福" 微信小程序团队。那几个大类下面还有很多二级分类,只有指定的分类才会支持长期,以我知道的举例,教育>培训机构 是可以的。但是呢,就算你刚好在这个类目里,以为自己可以用长期订阅了,里面没有长期模板,还不提供申请入口,你能怎么办?不过官方说了,也不是没有,他们只在论坛这类渠道收集用户反馈,再评估要不要加到模板库。我只想说,开发申请入口,审核严格一些,难道不会更好? 看过很多帖子,都提到一个问题,教育>培训机构是需要上传办学证明的,但非文化素质(像语文数学)的培训,比如舞蹈,是不需要办理办学证明的,相关机构也不给办,这是有国家规定的,但申请教育>培训机构这个类目时是必须你提供这个证明的,我自己也发帖反馈了好几次,微信团队理都不理,理了也总是那来回几句话,让提供相关机构证明文件或批复。 最好抛出一个棘手的问题,大家看看有没有好的解决方案? 模板消息不会弹框,用户无感,很方便去埋点,但一次性订阅就没那么好埋点了,毕竟是会弹窗的,而且也不能在A页面去引导授权C页面功能才需要的推送,难啊。本来用模板消息可以实现的逻辑,现在有点不知所措了。
2019-11-06 - 微信小程序setData源码分析
背景 setData 是小程序开发中使用最频繁的接口,也是最容易引发性能问题的接口。详见官网描述 常见的 setData 操作错误 频繁的去 setData 每次 setData 都传递大量新数据 后台态页面进行 setData 针对第二点官网给出意见是,其中 key 可以以数据路径的形式给出,支持改变数组中的某一项或对象的某个属性,如 array[2].message,a.b.c.d,并且不需要在 this.data 中预先定义 下面通过源码深入分析的方式了解小程序是怎么针对数据路径进行组装和构造数据 小程序逻辑层框架源码 微信小程序运行在三端:iOS(iPhone/iPad)、Android 和 用于调试的开发者工具。在开发工具上,小程序逻辑层的 javascript 代码是运行在 NW.js 中,视图层是由 Chromium 60 Webview 来渲染的。这里简单点就直接通过开发者工具来查找源码。 在微信开发者工具中,编译运行你的小程序项目,然后打开控制台,输入 document 并回车,就可以看到小程序运行时,WebView 加载的完整的 WAPageFrame.html,如下图: [图片] 可以看到[代码]./__dev__/WAService.js[代码]这个库就小程序逻辑层基础库,提供逻辑层基础的 API 能力 查找WAService.js源码 在微信小程序 IDE 控制台输入 openVendor 命令,可以打开微信小程序开发工具的资源目录 [图片] 我们可以看到小程序各版本的运行时包 .wxvpkg。.wxvpkg 文件可以使用 wechat-app-unpack 解开,解开后里面就是[代码]WAService.js[代码] 和 [代码]WAWebView.js[代码] 等代码 [图片] 另外也可以只直接通过开发者工具的Sources面板查找到WAService.js的源码 [图片] 分析setData源码 在WAService.js中全局查找setData方法,找到定义此方法的地方,如下 [图片] 源代码使用了大量的逗号运算符,逗号运算符的优先级是最低的,比条件选择符还低 大量使用void 0 表示undefined setData函数定义中添加了关键的注释如下: [代码]function(c, e) { // 保存闭包内的this对象,即常用的that var u = this; // 官网定义 Page.prototype.setData(Object data, Function callback), // 即 c: Object对象,e: Function界面更新渲染完毕后的回调函数 try { // 返回 [object Object] 中的Object var t = v(c); if ("Object" !== t) return void E("类型错误", "setData accepts an Object rather than some " + t); Object.keys(c).forEach(function(e) { // e: 可枚举属性的键值, void 0 表示undefined (https://github.com/lessfish/underscore-analysis/issues/1) void 0 === c[e] && E("Page setData warning", 'Setting data field "' + e + '" to undefined is invalid.'); // t为包含子对象属性名的属性数组, u.data和u.__viewData__都是page.data的深拷贝副本 var t = N(e) , n = j(u.data, t) , r = n.obj , o = n.key; if (r && (r[o] = y(c[e])), void 0 !== c[e]) { var i = j(u.__viewData__, t) , a = i.obj , s = i.key; a && (a[s] = y(c[e])) } }), __appServiceSDK__.traceBeginEvent("Framework", "DataEmitter::emit"), this.__wxComponentInst__.setData(JSON.parse(JSON.stringify(c)), e), __appServiceSDK__.traceEndEvent() } catch (e) { k(e) } } [代码] 关键函数N(e),解析属性名(包含.和[]等数据路径符号),返回相应的层级数组,如 [代码]{abc: 1}中abc属性名 => [abc], {a.b.c: 1}中'a.b.c'属性 => [a,b,c], {"array[0].text": 1} => [array, 0, text][代码] 关键的注释如下 [代码]function N(e) { // 如果属性名不是String字符串就抛出异常 if ("String" !== v(e)) throw E("数据路径错误", "Path must be a string"), new M("Path must be a string"); for (var t = e.length, n = [], r = "", o = 0, i = !1, a = !1, s = 0; s < t; s++) { var c = e[s]; if ("\\" === c) // 如果属性名中包含\\. \\[ \\] 三个转义属性字符就将. [ ]三个字符单独拼接到字符串r中保存,否则就拼接\\ s + 1 < t && ("." === e[s + 1] || "[" === e[s + 1] || "]" === e[s + 1]) ? (r += e[s + 1], s++) : r += "\\"; else if ("." === c) // 遇到.字符并且r字符串非空时,就将r保存到n数组中并清空r; 目的是将{ a.b.c.d: 1 }中的链式属性名分开,保存到数组n中,如[a,b,c,] r && (n.push(r), r = ""); else if ("[" === c) { // 遇到[字符并且r字符串非空时,就将r保存到n数组中并清空r;目的是将{ array[11]: 1 }中的数组属性名保存到数组n中,如[array,] // 如果此时[为属性名的第一个字符就报错,也就是说属性名不能直接为访问器, 如{ [11]: 1} if (r && (n.push(r), r = ""), 0 === n.length) throw E("数据路径错误", "Path can not start with []: " + e), new M("Path can not start with []: " + e); // a赋值为true, i赋值为false i = !(a = !0) } else if ("]" === c) { if (!i) throw E("数据路径错误", "Must have number in []: " + e), new M("Must have number in []: " + e); // 遍历到{ array[11]: 1 }中的']'的时候,就将a赋值为false, 并将o保存到数组n中,如[array,11,] a = !1, n.push(o), o = 0 } else if (a) { if (c < "0" || "9" < c) throw E("数据路径错误", "Only number 0-9 could inside []: " + e), new M("Only number 0-9 could inside []: " + e); // 遍历到{ array[11]: 1 }中的'11'的时候,就将i赋值为true, 并将string类型的数字计算成Number类型保存到o中 i = !0, o = 10 * o + c.charCodeAt(0) - 48 } else r += c // 普通类型的字符就直接拼接到r中 } // 将普通的字符串属性名,.和]后面剩余的字符串保存到数组n中,如{abc: 1} => [abc], {a.b.c: 1} => [a,b,c], {array[0].text: 1} => [array, 0, text] if (r && n.push(r),0 === n.length) throw E("数据路径错误", "Path can not be empty"), new M("Path can not be empty"); return n } [代码] 关键函数j(e, t),解析出属性最终对应的子对象的属性名,以及对应的子对象 [代码]var x = Object.prototype.toString; function _(e) { return "[object Object]" === x.call(e) } function j(e, t) { // e: page.data的深拷贝副本, t为包含子对象属性名的属性数组 /* - 遍历属性数组[a,b], e={a: {b: 1}} 1. i=0, 此时o为Object类型时, n = a, r = {a: {b: 1}}, o = {b: 1}; 2. i=1, 此时o为Object类型时, n = b, r = {b: 1}, o = 1; retrun { obj: {b: 1}, key: b} - 遍历属性数组[a,0,b], e={a: [{b: 1}]} 1. i=0, 此时t[i]=a, o为Object类型时, n = a, r = {a: [{b: 1}]}, o = [{b: 1}]; 2. i=1, 此时t[i]=0, o为Array类型时, n = 0, r = [{b: 1}], o = {b: 1}; 3. i=2, 此时t[i]=b, o为Object类型时, n = b, r = {b: 1}, o = 1; retrun { obj: {b: 1}, key: b} */ for (var n, r = {}, o = e, i = 0; i < t.length; i++) Number(t[i]) === t[i] && t[i] % 1 == 0 ? // t[i]是否为有效的Number Array.isArray(o) || (r[n] = [], o = r[n]) : _(o) || (r[n] = {}, o = r[n]), n = t[i], o = (r = o)[t[i]]; //注意由于逗号分隔符的优先级是最低的,所以这一行会在前面的条件运算符执行完,再执行 return { obj: r, key: n } } [代码] 最后通过[代码]r && (r[o] = y(c[e]))[代码]的方式将新的值赋给匹配出的子对象的属性,这里j(e,t)函数内部是通过引用的方式向外传递出[代码]r[代码],所以这里改变[代码]r[o][代码]的值也会将[代码]u.data[代码]内部的值相应修改,完成局部刷新 由于不同的版本解包后,里面压缩之后的方法名称可能跟上面的对不上,但是大体的结构都是一样的 总结 官方提供的array[2].message,a.b.c.d方式就是通过解析成[array,2,message]和[a,b,c,d],找到相应的子结构进行复制操作,到达减少数据量的目的; 分页加载的时候,为了避免将整个list数据重新传输,就可以利用数据路径的方式只追加新的数据 [代码]假设原数组长度 length 为 10,新数组 newList 长度为 3 this.setData{ 'list[10]': newList[0], 'list[11]': newList[1], 'list[12]': newList[2], } [代码] 参考资料 微信小程序技术原理分析 小程序开发指南
2019-08-24 - 集成官方地铁插件,打开地铁图,后台报错,请问什么情况?
插件包 地铁图appId: wx6aaf93c4435fa1c1,版本1.02,官方文档https://lbs.qq.com/miniprogram_plugin/subway.html-----------以下为本地小程序配置------------------------ ------------app.json---- //引入插件包 "plugins": { "subway": { "version": "1.0.2", "provider": "wx6aaf93c4435fa1c1" } }, //授权 "permission": { "scope.userLocation": { "desc": "你的位置信息将用于小程序定位" } }, -----------------------index.js------------------------- //事件处理函数 contact: function(){ let plugin = requirePlugin("subway"); let key = 'M7TBZ-C6T3U-I4VVT-2AQJT-6BJYF-ADBPJ';//使用在腾讯位置服务申请的key; let referer = 'miniWeb'; //调用插件的app的名称 wx.navigateTo({ url: 'plugin://subway/index?key=' + key + '&referer=' + referer }) },-------------console输出---------------- VM452:1 thirdScriptError Page is not a function; [Component] Event Handler Error @ pages/index/index#bound contact TypeError: Page is not a function at http://127.0.0.1:28436/appservice/__onlineplugin__/wx6aaf93c4435fa1c1/1.0.2/appservice.js:1450:56 at h (http://127.0.0.1:28436/appservice/__dev__/WAService.js:19:5341) at http://127.0.0.1:28436/appservice/__dev__/WAService.js:19:5477 at http://127.0.0.1:28436/appservice/__onlineplugin__/wx6aaf93c4435fa1c1/1.0.2/appservice.js:1452:2 at g (http://127.0.0.1:28436/appservice/__dev__/WAService.js:19:5573) at _ (http://127.0.0.1:28436/appservice/__dev__/WAService.js:19:5924) at r.contact (http://127.0.0.1:28436/appservice/pages/index/index.js:24:18) at Object.r.safeCallback (http://127.0.0.1:28436/appservice/__dev__/WAService.js:14:10521) at http://127.0.0.1:28436/appservice/__dev__/WAService.js:16:19796 at d (http://127.0.0.1:28436/appservice/__dev__/WAService.js:16:22199)
2019-08-10 - 小程序实现列表拖拽排序
小程序列表拖拽排序 [图片] wxml [代码]<view class='listbox'> <view class='list kelong' hidden='{{!showkelong}}' style='top:{{kelong.top}}px'> <view class='index'>?</view> <image src='{{kelong.xt}}' class='xt'></image> <view class='info'> <view class="name">{{kelong.name}}</view> <view class='sub-name'>{{kelong.subname}}</view> </view> <image src='/images/sl_36.png' class='more'></image> </view> <view class='list' wx:for="{{optionList}}" wx:key=""> <view class='index'>{{index+1}}</view> <image src='{{item.xt}}' class='xt'></image> <view class='info'> <view class="name">{{item.name}}</view> <view class='sub-name'>{{item.subname}}</view> </view> <image src='/images/sl_36.png' class='more'></image> <view class='moreiconpl' data-index='{{index}}' catchtouchstart='dragStart' catchtouchmove='dragMove' catchtouchend='dragEnd'></view> </view> </view> [代码] wxss [代码].map-list .list { position: relative; height: 120rpx; } .map-list .list::after { content: ''; width: 660rpx; height: 2rpx; background-color: #eee; position: absolute; right: 0; bottom: 0; } .map-list .list .xt { display: block; width: 95rpx; height: 77rpx; position: absolute; left: 93rpx; top: 20rpx; } .map-list .list .more { display: block; width: 48rpx; height: 38rpx; position: absolute; right: 30rpx; top: 40rpx; } .map-list .list .info { display: block; width: 380rpx; height: 80rpx; position: absolute; left: 220rpx; top: 20rpx; font-size: 30rpx; } .map-list .list .info .sub-name { font-size: 28rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #646567; } .map-list .list .index { color: #e4463b; font-size: 32rpx; font-weight: bold; position: absolute; left: 35rpx; top: 40rpx; } [代码] js [代码]data:{ kelong: { top: 0, xt: '', name: '', subname: '' }, replace: { xt: '', name: '', subname: '' }, }, dragStart: function(e) { var that = this var kelong = that.data.kelong var i = e.currentTarget.dataset.index kelong.xt = this.data.optionList[i].xt kelong.name = this.data.optionList[i].name kelong.subname = this.data.optionList[i].subname var query = wx.createSelectorQuery(); //选择id query.select('.listbox').boundingClientRect(function(rect) { // console.log(rect.top) kelong.top = e.changedTouches[0].clientY - rect.top - 30 that.setData({ kelong: kelong, showkelong: true }) }).exec(); }, dragMove: function(e) { var that = this var i = e.currentTarget.dataset.index var query = wx.createSelectorQuery(); var kelong = that.data.kelong var listnum = that.data.optionList.length var optionList = that.data.optionList query.select('.listbox').boundingClientRect(function(rect) { kelong.top = e.changedTouches[0].clientY - rect.top - 30 if(kelong.top < -60) { kelong.top = -60 } else if (kelong.top > rect.height) { kelong.top = rect.height - 60 } that.setData({ kelong: kelong, }) }).exec(); }, dragEnd: function(e) { var that = this var i = e.currentTarget.dataset.index var query = wx.createSelectorQuery(); var kelong = that.data.kelong var listnum = that.data.optionList.length var optionList = that.data.optionList query.select('.listbox').boundingClientRect(function (rect) { kelong.top = e.changedTouches[0].clientY - rect.top - 30 if(kelong.top<-20){ wx.showModal({ title: '删除提示', content: '确定要删除此条记录?', confirmColor:'#e4463b' }) } var target = parseInt(kelong.top / 60) var replace = that.data.replace if (target >= 0) { replace.xt = optionList[target].xt replace.name = optionList[target].name replace.subname = optionList[target].subname optionList[target].xt = optionList[i].xt optionList[target].name = optionList[i].name optionList[target].subname = optionList[i].subname optionList[i].xt = replace.xt optionList[i].name = replace.name optionList[i].subname = replace.subname } that.setData({ optionList: optionList, showkelong:false }) }).exec(); }, [代码]
2019-07-28 - 小程序rich-text组件,怎么解析不出我html的代码,求各位大神们看下
[图片] [图片] 这个bug我要怎么解决....
2019-07-31 - scroll-view的bindscroll获取到的scrollTop不为0
scroll-view的bindscroll监听滑动事件,经常会有这样的问题:scrollview惯性滑动到顶部后,最后的事件中的scrollTop不为0!!!!toupper事件也没有收到!!!
2018-10-08 - 分享一个固定头和列的 table 组件的简单实现
本案案例基于 WePY 实现,大家可根据自身需要进行更改扩展。 代码地址>> 演示 [图片] 演示视频地址>> 实现原理 [图片] 橙色和紫色区域组成了横向滚动的 [代码]scroll-view[代码]。 红色虚线区域是纵向滚动的 [代码]scroll-view[代码]。但由于绿色区域设置了 [代码]pointer-events: none;[代码],即实际只能触摸橙色区域。通过在橙色区域绑定的 [代码]scroll[代码] 事件(纵向),实时设置绿色虚线区域的 [代码]scrollTop[代码]。 紫色区域是固定头部,绿色区域是固定列。左上角的绿色区域是横向与纵向共同固定的区域。 实现要点 绑定了 [代码]scroll[代码] 事件的 [代码]scroll-view[代码] 要指定 [代码]throttle: false[代码],否则回调函数有可能取不到最终位置的 [代码]scrollTop[代码] 值。官方文档目前未提及此属性,参考资料>>。 固定列需要设置 [代码]pointer-events: none;[代码],实现点击穿透。使得 [代码]tbody[代码] 能触发 [代码]scroll[代码] 事件,而不是为固定列也绑定 [代码]scroll[代码] 事件。 找出每列的最大单元格作为该列的宽度,当然你也可以显示设置。 peace out!👋 小程序 Bug 2019.09.03 更新 当将该组件至于 Popup 弹框,且该弹框通过 [代码]visibility: hidden/visible[代码] 切换,那么在 iOS 中,会使固定列([代码].table__fixed-columns[代码])的 [代码]pointer-events: none[代码] 失效。
2019-09-03 - 小程序自定义单页面、全局导航栏
摘要: 小程序开发技巧。 作者:小白 原文:小程序自定义单页面、全局导航栏 Fundebug经授权转载,版权归原作者所有。 需求 产品说小程序返回到首页不太方便,想添加返回首页按钮,UI说导航栏能不能设置背景图片,因为那样设计挺好看的。 [图片] 需求分析并制定方案 这产品和UI都提需求了,咱也不能反驳哈,所以开始调研,分析可行性方案;1、可以添加悬浮按钮。2、自定义导航栏。 添加悬浮按钮,是看起来是比较简单哈,但是感觉不太优雅,会占据页面的空间,体验也不太好。所以想了下第二种方案,自定义导航栏既可以实现产品的需求还可以满足UI的设计美感,在顶部空白处加上返回首页的按钮,这样和返回按钮还对称(最终如图所示,顶部导航栏是个背景图片,分两块组合起来)。 实现方案 一、实现的前提 1、首先查看文档,看文档里关于自定义导航栏是怎么规定的,有哪些限制;还有小程序自定义导航栏全局配置和单页面配置的微信版本和调试库的最低支持版本。 2、在app.json window 增加 navigationStyle:custom ,顶部导航栏就会消失,只保留右上角胶囊状的按钮,如何修改胶囊的颜色呢;胶囊体目前只支持黑色和白色两种颜色 在app.josn window 加上 “navigationBarTextStyle”:“white/black” 3、还要考虑加返回按钮和返回首页的按钮,适配不同的机型 先说下两种配置方法: ①全局配置navigationStyle: 调试基础库>=1.9.0 微信客户端>=6.6.0 app.json [代码]{ "usingComponents": { "navigationBar": "/components/navigationBar/navigationBar" }, "window": { "navigationStyle": "custom" } } [代码] ②单页面配置navigationStyle 调试基础库>=2.4.3 微信客户端版本>=7.0.0 自定义的页面.json [代码]{ "window": { "navigationStyle": "default" } } { "navigationStyle": "custom", "usingComponents": { "navigationBar": "/components/navigationBar/navigationBar" } } [代码] 两者的区别就是,全局配置放在app.json文件里,单页面配置放在自定义页面配置文件里。 二、实现的步骤 以下说下几个要点: 1、自定义导航栏文本,是否显示返回,是否显示返回首页,导航栏高度 2、statusBarHeight,用来获取手机状态栏的高度,这个需要在全局app.js中的onLaunch,调用wx.getSystemInfo获取,navigationBarHeight+默认的高度,这个是设定整个导航栏的高度, 3、还有注意的,在写样式距离和大小时建议都用px,因小程序右边的胶囊也是用的px,不是rpx。 4、因为自定义导航栏每个页面都要写,所以把导航栏封装了公共组件,这样只需要在每个页面引入即可。 如下是封装的导航栏组件: wxml [代码]<view class="navbar" style="{{'height: ' + navigationBarHeight}}"> <view style="{{'height: ' + statusBarHeight}}"></view> <view class='title-container'> <view class='capsule' wx:if="{{ back || home }}"> <view bindtap='back' wx:if="{{back}}"> <image src='/images/back.png'></image> </view> <view bindtap='backHome' wx:if="{{home}}"> <image src='/images/home.png'></image> </view> </view> <view class='title'>{{text}}</view> </view> </view> <view style="{{'height: ' + navigationBarHeight}};background: white;"></view> [代码] 这里有个需注意的问题,就是一般会出现自定义导航栏,下拉页面,导航栏也随着会下拉,这种问题是因为设置fixed后页面元素整体上移了navigationBarHeight,所以在此组件里设置一个空白view元素占用最上面的navigationBarHeight这块高度 wxss [代码].navbar { width: 100%; background-color: #1797eb; position: fixed; top: 0; left: 0; z-index: 999; } .title-container { height: 40px; display: flex; align-items: center; position: relative; } .capsule { margin-left: 10px; height: 30px; background: rgba(255, 255, 255, 0.6); border: 1px solid #fff; border-radius: 16px; display: flex; align-items: center; } .capsule > view { width: 45px; height: 60%; position: relative; .capsule > view:nth-child(2) { border-left: 1px solid #fff; } .capsule image { width: 50%; height: 100%; position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); } .title { color: white; position: absolute; top: 6px; left: 104px; right: 104px; height: 30px; line-height: 30px; font-size: 14px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } [代码] js [代码]const app = getApp() Component({ properties: { text: { type: String, value: 'Wechat' }, back: { type: Boolean, value: false }, home: { type: Boolean, value: false } }, data: { statusBarHeight: app.globalData.statusBarHeight + 'px', navigationBarHeight: (app.globalData.statusBarHeight + 44) + 'px' }, methods: { backHome: function () { let pages = getCurrentPages() wx.navigateBack({ delta: pages.length }) }, back: function () { wx.navigateBack({ delta: 1 }) } } }) [代码] json [代码]{ "component": true, "usingComponents": {} } [代码] 最终还需要考虑下版本兼容的问题,毕竟还有一些用户,微信版本并没有更新到最新版本。 首先可以在app.js里面获取下当前用户的微信版本,做下版本比较,如果小于这个版本,设置个全局变量,也可以在组件写个方法,在不同的页面打开显示不同的顶部导航栏,或者可以控制是否显示导航栏,这里就不详细说了。 亲自试了下,在低于7.0版本的微信中,如果采用单页面自定义导航栏,会出现两个导航栏,这时候通过判断版本号不要再渲染自定义的导航栏组件了,在页面的配置文件里写上title名,还有相应的背景色,这样就会显示自带的导航栏了。 总结 小程序开发是有些坑的地方,从不支持自定义导航栏,到支持全局自定义导航栏,再到现在的支持单页面配置,可以看出在慢慢完善。还有底部tabbar,可自己选择配置的太少了,虽然也支持自定义,但是发现自定义写的底部导航组件体验并不好,每次打开页面都会重新渲染底部的按钮,如果全部写成在一个页面里的tab切换,虽然按钮每次不用重新加载了,但是业务多肯定不行,写到一个单页面里东西也太多了。 希望微信能够多添加或放开一些功能,让开发者更好的服务于产品,给用户更好的体验。
2019-06-22 - 吸顶效果求解
吸顶demo在开发工具上不会延迟,在真机上,需要等滚动结束才会出现吸顶效果,demo链接: https://developers.weixin.qq.com/s/Y07Qg4mX7J9s 请问如何做吸顶效果,才会流畅,求官方帮忙解答下,或者官方出个吸顶的demo [图片]
2019-06-12 - DatePicker 年月日时分秒 任你选
DatePicker 微信上的时间选择,有的时候你会发现,你不能同时选择日期和时间,而且时间不能选到秒。DatePicker让你想选什么选什么… Mode DatePicker分为四个mode:YMDhms(年月日时分秒)、YMD(年月日)、MD(月日)、hm(时分)。 我自己觉得用起来很爽快。 效果图 mode:YMDhms (年月日时分秒) [图片] mode:YMD(年月日) [图片] mode:MD (月日) [图片] mode:hm (时分) [图片] gitHub地址
2019-05-11 - 发送短信验证码后60秒倒计时
微信小程序发送短信验证码后60秒倒计时功能,效果图: [图片] 完整代码 index.wxml [代码]<!--index.wxml-->[代码][代码]<view class=[代码][代码]"container"[代码][代码]>[代码][代码] [代码][代码]<view class=[代码][代码]"section"[代码][代码]>[代码][代码] [代码][代码]<text>手机号码:</text>[代码][代码] [代码][代码]<input placeholder=[代码][代码]"请输入手机号码"[代码] [代码]type=[代码][代码]"number"[代码] [代码]maxlength=[代码][代码]"11"[代码] [代码]bindinput=[代码][代码]"inputPhoneNum"[代码] [代码]auto-focus />[代码][代码] [代码][代码]<text wx:if=[代码][代码]"{{send}}"[代码] [代码]class=[代码][代码]"sendMsg"[代码] [代码]bindtap=[代码][代码]"sendMsg"[代码][代码]>发送</text>[代码][代码] [代码][代码]<text wx:if=[代码][代码]"{{alreadySend}}"[代码] [代码]class=[代码][代码]"sendMsg"[代码] [代码]>{{second+[代码][代码]"s"[代码][代码]}}</text>[代码][代码] [代码][代码]</view>[代码][代码]</view>[代码] index.wxss [代码]/**index.wxss**/[代码][代码].userinfo {[代码][代码] [代码][代码]display[代码][代码]: flex;[代码][代码] [代码][代码]flex-[代码][代码]direction[代码][代码]: column;[代码][代码] [代码][代码]align-items: [代码][代码]center[代码][代码];[代码][代码]}[代码][代码].section {[代码][代码]display[代码][代码]: flex;[代码][代码]margin[代码][代码]: [代码][代码]16[代码][代码]rpx;[代码][代码]padding[代码][代码]: [代码][代码]16[代码][代码]rpx;[代码][代码]border-bottom[代码][代码]: [代码][代码]1[代码][代码]rpx [代码][代码]solid[代码] [代码]#CFD8DC[代码][代码];[代码][代码]}[代码][代码] [代码] [代码]text {[代码][代码] [代码][代码]width[代码][代码]: [代码][代码]200[代码][代码]rpx;[代码][代码]}[代码][代码] [代码] [代码]button {[代码][代码] [代码][代码]margin[代码][代码]: [代码][代码]16[代码][代码]rpx;[代码][代码]}[代码][代码] [代码] [代码].sendMsg {[代码][代码] [代码][代码]font-size[代码][代码]: [代码][代码]12[代码][代码];[代码][代码] [代码][代码]margin-right[代码][代码]: [代码][代码]0[代码][代码];[代码][代码] [代码][代码]padding[代码][代码]: [代码][代码]0[代码][代码];[代码][代码] [代码][代码]height[代码][代码]: inherit;[代码][代码] [代码][代码]width[代码][代码]: [代码][代码]80[代码][代码]rpx;[代码][代码]}[代码]index.js [代码]//index.js[代码][代码]//获取应用实例[代码][代码]const app = getApp()[代码][代码] [代码] [代码]Page({[代码][代码] [代码][代码]data: {[代码][代码] [代码][代码]send: true,[代码][代码] [代码][代码]alreadySend: false,[代码][代码] [代码][代码]second: [代码][代码]60[代码][代码],[代码][代码] [代码][代码]disabled: true,[代码][代码] [代码][代码]phoneNum: [代码][代码]''[代码][代码] [代码][代码]},[代码][代码] [代码][代码]// 手机号部分[代码][代码] [代码][代码]inputPhoneNum: function (e) {[代码][代码] [代码][代码]let phoneNum = e.detail.value[代码][代码] [代码][代码]this.setData({[代码][代码] [代码][代码]phoneNum: phoneNum[代码][代码] [代码][代码]})[代码][代码] [代码][代码]},[代码][代码] [代码][代码]sendMsg: function () {[代码][代码] [代码][代码]var phoneNum = this.data.phoneNum;[代码][代码] [代码][代码]if(phoneNum == [代码][代码]''[代码][代码]){[代码][代码] [代码][代码]wx.showToast({[代码][代码] [代码][代码]title: [代码][代码]'请输入手机号码'[代码][代码],[代码][代码] [代码][代码]icon: [代码][代码]'none'[代码][代码],[代码][代码] [代码][代码]duration: [代码][代码]2000[代码][代码] [代码][代码]})[代码][代码] [代码][代码]return ;[代码][代码] [代码][代码]}[代码][代码] [代码][代码]//此处省略发送短信验证码功能[代码][代码] [代码][代码]this.setData({[代码][代码] [代码][代码]alreadySend: true,[代码][代码] [代码][代码]send: false[代码][代码] [代码][代码]})[代码][代码] [代码][代码]this.timer()[代码][代码] [代码][代码]},[代码][代码] [代码][代码]showSendMsg: function () {[代码][代码] [代码][代码]if (!this.data.alreadySend) {[代码][代码] [代码][代码]this.setData({[代码][代码] [代码][代码]send: true[代码][代码] [代码][代码]})[代码][代码] [代码][代码]}[代码][代码] [代码][代码]},[代码][代码] [代码][代码]hideSendMsg: function () {[代码][代码] [代码][代码]this.setData({[代码][代码] [代码][代码]send: false,[代码][代码] [代码][代码]disabled: true,[代码][代码] [代码][代码]buttonType: [代码][代码]'default'[代码][代码] [代码][代码]})[代码][代码] [代码][代码]},[代码][代码] [代码][代码]timer: function () {[代码][代码] [代码][代码]let promise = new Promise((resolve, reject) => {[代码][代码] [代码][代码]let setTimer = setInterval([代码][代码] [代码][代码]() => {[代码][代码] [代码][代码]this.setData({[代码][代码] [代码][代码]second: this.data.second - [代码][代码]1[代码][代码] [代码][代码]})[代码][代码] [代码][代码]if (this.data.second <= [代码][代码]0[代码][代码]) {[代码][代码] [代码][代码]this.setData({[代码][代码] [代码][代码]second: [代码][代码]60[代码][代码],[代码][代码] [代码][代码]alreadySend: false,[代码][代码] [代码][代码]send: true[代码][代码] [代码][代码]})[代码][代码] [代码][代码]resolve(setTimer)[代码][代码] [代码][代码]}[代码][代码] [代码][代码]}[代码][代码] [代码][代码], [代码][代码]1000[代码][代码])[代码][代码] [代码][代码]})[代码][代码] [代码][代码]promise.then((setTimer) => {[代码][代码] [代码][代码]clearInterval(setTimer)[代码][代码] [代码][代码]})[代码][代码] [代码][代码]},[代码][代码]})[代码]完整的短信验证码登录实例参考: https://blog.csdn.net/zuoliangzhu/article/details/81219900
2019-04-17 - setData与渲染
1、问题与猜想 setData的流程是什么? 一次setData调用必定会引起一次渲染? 1.1、setData的流程 ? 通信过程:jscore -> native -> webview,native以队列处理消息,最终的结果可以大致理解为插了一段 eval 代码到对应webview中。 jscore -> native 的时间可以基本忽略。 数据量比较大的时候 native -> webview 比较耗时 native -> webview 的通信,在ios上会抢占CPU资源,影响页面渲染 eval编译代码也比较耗时,抢占渲染线程 1.2、一次setData调用必定会引起一次渲染 ? 一次setData并不会引起一次渲染,如果在一次渲染周期内,收到了多次setData,那么只会有一次渲染。setData会在当前的js执行栈中插入一段js代码,如果刚好赶上了当前的渲染周期,那多次setData也只会引起一次渲染。 2、验证测试 (demo测试) 2.1、同一页面连续setData 前少后多:一般是赶不上第一次的渲染周期,会触发两次 前多后少:只有一次 2.2、不同页面的setData 前少后多:ios上影响当前页面渲染,android没有影响 前多后少:基本影响,后台页面set的数据比较少的时候,用的资源基本可以忽略 后台页面setData首先会抢占通信通道,其次会抢占CPU资源,ios上如果后台页面set的数据多,当前页面渲染会有很明显延迟,android上无影响。 2.3、页面和组件同时setData 前少后多:只有一次 前多后少:只有一次 2.4、同一组件连续setData 前少后多:一般是赶不上第一次的渲染周期,会触发两次 前多后少:只有一次 2.5、不同组件同时setData 前少后多:一次渲染 前多后少:只有一次 当多个组件同时setData时,感觉引擎层会搜集所有改变,一次渲染,这样也比较合理,某个属性改变,引起多个组件同时渲染的场景还是挺多的。 3、结论 多用组件,因为组件是局部patch、render,页面是全部 同一页面,同一组件的同时setData要合并,特别是页面 后台页面最好不要setData,会抢占通信通道和CPU资源,影响前台页面的渲染,如果控制好set的量级和次数,可以接受。 如果父组件、子组件同时setData,不用考虑合并成一次 多个组件同时setData可以不用合并,引擎层直接合并了,只会触发一次渲染 以上只是个人的一点思考,并用demo验证了下,有错误的地方 欢迎讨论
2018-12-04 - 前端加载优化及实践
大家都知道产品体验的重要性,而其中最重要的就是加载速度,一个产品如果打开都很慢,可能也就没有后面更多的事情了。这篇文章是我最近项目中的一些加载优化总结,欢迎大家一起讨论交流。 内容包括: 性能指标及数据采集 性能分析方法 性能优化方法 性能优化具体实践 第一部分:性能指标及数据采集 要优化性能首先需要有一套用来评估性能的指标,这套指标应该是是可度量、可线上精确采集分析的。现在来一起看看如何选择性能指标吧。 1. 性能指标 加载的过程是一个用户的感知变化的过程。所以我们的页面性能指标也是要以用户感知为中心的。下面是google定义了几个以用户感知为中心的性能指标。 1.1 以用户感知为中心的性能指标 首先确定页面视觉的变化传递给用户的感知变化关键点: 感知点 说明 发生了吗? 浏览是否成功。 有用了吗? 是否有足够的内容呈现给用户。 可用了吗? 用户是否可以和页面交互了。 好用吗? 用户和应用交互是否流畅自然。 我这里讲的是加载优化,所以第四点暂时不讨论。下面是感知点相关的性能指标。 First paint(FP) and first contentful paint(FCP) FP: Webview跳转到应用的首次渲染时间。 FCP:Webview首次渲染内容的时间:文本,图像(包括背景图像),非白色画布或SVG。这是用户第一次消费内容的时间。 Chrome支持用Paint Timing API获取这两个值: [代码] performance.getEntriesByType("paint") [代码] First meaningful paint(FMP) 首次绘制有效内容的时间,用来表明这个应用是否绘制了有效内容。比如天气应用可以看到天气了,商品列表可以看到商品了。 Time to Interactive(TTI) 应用可交互时间,这时应用渲染完成且可以响应用户输入的时间。这种情况下JS已经加载完成且主线程处于空闲状态。 Speed index 速度指标:代表填充页面内容的速度。要想降低速度指标分数,您需要让加载速度从视觉上显得更快,也就是渐进式展示。 上面指标对应的感知点如下: 感知点 说明 发生了吗? FP/FCP 有用了吗? FMP 可用了吗? TTI Speed index是个整体效果指标所以没有对应上面的任何一个,但也同时对应任何一个。 对于实际项目中我们选取指标要便于采集,下面是针对我的实际项目(APP内的单页面应用)选取的性能指标。 1.2 实际项目选取的性能指标 Webview加载时间 反应Webview性能。这样就可以更真实的知道我们应用的加载情况。 页面下载时间 反应浏览成功时间。 应用启动时间 反应应用启动完成时间,这个时候页面初始化完成,是JS首次执行完成的时间,应用所需异步请求都已经发出去了。 首次有效绘制内容时间 已经有足够的内容呈现给用户,是首屏所需重要接口返回且DOM渲染完成的时间,这个时间由程序员自行判断。 应用加载完成时间 应用完整的呈现给了用户,这个时候页面中所有资源都已经下载好,包括图片等资源。 这里我们的性能指标确定了,下面看看这些数据怎么采集吧。 2. 数据采集 performance.timing为我们提供页面加载每个过程的精确时间,如下图: [图片] 是不是很完美,这足够了?还不够,我们还需要加上原生APP为我们提供的点击我们应用的时间和我们自己确定的FMP才够完美。 下面是每个指标的获取方法: 公用代码部分 [代码]let performance = window.performance || window.msPerformance || window.webkitPerformance; if (performance && performance.timing) { let t = performance.timing; let navigationStart = t.navigationStart; //跳转开始时间 let enterTime = ""; //app提供的用户点击应用的时间,需要和app沟通传递方式 //... 性能指标部分 } [代码] Webview加载时间 [代码] let webviewLoaded = navigationStart - enterTime; [代码] 注意:enterTime应该是客户端ms时间戳,不是服务器时间。 页面下载时间 [代码] let pageDownLoadedTime = t.responseEnd - navigationStart; [代码] 应用启动时间 [代码]let appStartTime = t.domContentLoadedEventStart - navigationStart; [代码] 首次有效绘制内容时间 这里我们需要在有效绘制后调用 [代码]window._fmpTime = +(new Date())[代码]获取当前时间戳。 [代码]let fmpTime = window._fmpTime - navigationStart; [代码] 应用加载完成时间 [代码]let domCompleteTime = t.domComplete - navigationStart; [代码] 最后在document load以后使用上面代码就可以收集到性能数据了,然后就可以上报给后台了。 [代码]if (document.readyState == 'complete') { _report(); } else { window.addEventListener("load", _report, false); } [代码] 这样就封装了一个简单性能数据采集上报组件,这是非常通用的可以用在类似项目中使用只要按照标准提供enterTime和window._fmpTime就可以。 3. 数据分析 有了上面的原始数据,我们需要一些统计方法来观察性能效果和变化趋势,所以我们选取下面一些统计指标。 平均值 注意在平均值计算的时候要设置一个取值范围比如:0~10s以防脏数据污染。 平均值的趋势用折线图展示: [图片] 分布占比 可以清晰的看到用户访问时间的分布,这样你就可以知道有多少用户是秒开的了。 分布占比可以使用折线图、堆积图、饼状图展示: [图片] [图片] [图片] 第二部分:性能分析方法 上面有了性能指标和性能数据,现在我们来学习一下性能分析的一些方法,这样我们才能知道性能到底哪里不行、为什么不行。 1. 影响性能的外部因素 分析性能最重要的一点要确定外部因素。经常会有这种情况,有人反应页面打开速度很慢,而你打开速度很快,其实可能并不是页面性能不好,只是外部因素不同而已。 所以做好性能优化不能只考虑外部因素好的情况,也要让用户能在恶劣条件(如弱网络情况)下也有满足预期的表现。下面看看影响性能的外部因素主要有哪些。 1.1 网络 网络可以说是最影响页面性能最重要的外部因素了,网络的主要指标有: 带宽:表示通信线路传送数据的能力,即在单位时间内通过网络中某一点的最高数据率,单位有bps(b/s)、Kbps(kb/s)、Mbps(mb/s)等。常说的百兆带宽100M就是100Mbps,理论下载最大速度12.5MB/s。 时延:Delay,指数据从网络的一端传送到另一端所需的时间,反应的网络畅通程度。 往返时间RTT:Round-Trip Time,是指从发送端发送数据到接收端接受到确认的总时间。我们经常用的ping命令就是用这个指标表明我们和目标主机的网络顺畅程度。比如我们要对比几个翻墙代理哪里个好,我们就可以ping一下,看看这几个代理哪个RTT低来作出选择。 [图片] 这三个主要指标中后面两个类似,在Chrome中模拟网络主要用设置带宽和网络延迟(往返时间RTT出现最小延迟)来模拟网络。我们电脑一般用的是WI-FI(百兆),那么我们模拟网络,主要模拟常见3G(1兆)、4G(10兆)网络就好,这样我们就覆盖了三个级别的网络情况了。 可以在Chrome的NetWork面板直接选取Chrome模拟好的网络,这个项目network-emulation-conditions中有默认模拟网络的速度。 [图片] 如果默认不满足,你也可以自己配置网络参数,在设置面板的Throttling。 [图片] 上面设置的3G接近100KB/s,4G 0.5MB/s。你可以根据自己的需要来调整这个值,这两个值的差异应该能很好两种不同的网络情况了。设置模拟网络只要能覆盖不同的带宽情况就好,也不用那么真实因为真实情况很复杂。网络部分就介绍完了,接着看其他因素。 1.2 用户机器性能 经常会有这种情况,一个应用在别人手机上打开速度那么快、那么流畅,为啥到我这里就不行了呢?原因很简单人家手机好,自然有更好的配置、更多的资源让程序运行的更快。 Chrome现在非常强大你可以通过performance面板来模拟cpu性能。也可以让你看到应用在低性能机器上的表现。 [图片] 1.3 用户访问次:首次访问、2次访问、发版本访问 用户访问次数也是分析性能的重要外部因素,当用户第一次访问要请求所有资源,后面在访问因为有些资源缓存了访问速度也会不同。当我们开发者又发版本,会更新部分资源,这样访问速度又会跟着变。因为缓存的效果存在,所以这三种情况要分开分析。同时也要注意我们是否要支持用户离线访问。 通过在Chrome中的Network面板中选中Disable cache就可以强制不缓存了,来模拟首次访问。 [图片] 1.4 因素对选取 上面的外部因素虽然只有3种但相乘也有不少情况,为了简化我们性能分析,要选取代表性的因素去分析我们的性能。下面是指导因素对: 网络:WIFI 3G 4G 用户访问状态:首次 2次 这样有6种情况不算特别多,也能很好反应我们应用在不同情况下的性能。 2. devtools具体分析性能 通过devtools可以观察在不同外部因素下代码具体加载执行情况,这个工具是我们性能分析中最重要的工具,加载优化这里我们主要关注两个面板:Network、Performance。 先看Network面板的列表页: [图片] 这是网络请求的列表,右击表头可以增删属性列,根据自己需要作出调整。 下面我介绍网络列表中的几个重点属性: Protocol:网络协议,h2说明你的请求是http2协议的了。 Initiator:可以查到这个资源是哪里引用的。 Status:网络状态码。 Waterfall:资源加载瀑布流。 下面在看看Network面板中单个请求的详情页: [图片] 这里可以看到具体的请求情况,Timing面板是用来观察这次网络的请求时间占用的具体情况,对我们性能分析非常重要。具体每个时间段介绍可以点击Explanation。 虽然Network面板可以让我看到了网络请求的整体和单个请求的具体情况,但Network面板整体请求情况看着并不友好,而且也只有加载情况没有浏览器线程的执行情况。下面看看强大的Performance面板的吧。 [图片] 这里可以清晰看到浏览器如何加载资源如何解析html、解析css、执行js和渲染绘制的。 Performance简直太强大了,所以请你务必要掌握它的使用,这里篇幅有限,只能介绍了个大概,建议到google网站仔细学习一下。 3. Lighthouse整体分析性能 使用Lighthouse可以对应用做整体性能分析评分,并且会给我们专业的指导建议。我们可以安装Lighthouse插件或者安装Lighthouse npm包来使用它。 检测结果中可以看到很多性能指标的分值和建议。你也可以去测试下你的应用表现。 4. 线上用户统计分析性能 虽然使用devtools和Lighthouse可以知道页面的性能情况,但我们还要观察用户的真实访问情况,这才能真实反映我们应用的性能。线上数据采集分析,第一步部分已经介绍过了,这里就不在多说了。优化完看看自己对线上数据到底造成了什么影响。 上面介绍了性能分析的方法,可以很好帮你去分析性能,有了性能分析的基础,下面我们在来看看怎么做性能优化吧。 第三部分:性能优化方法 1. 微观:优化单次网络请求时间 在性能分析知道Network面板可以看到单次网络请求的详情 [图片] 从图可以看出请求包括:DNS时间、TCP时间、SSL时间(https)、TTFB时间(服务器处理时间)、ContentLoaded内容下载时间,所以有下面公式: [代码]requestTime = DNS + TCP + SSL+ TTFB +ContentLoaded [代码] 所以只要我们降低这里面任意一个值就可以降低单次网络请求的时间了。 2. 宏观:优化整体加载过程 加载过程的优化就是不断让第一部分的性能指标感知点提前的过程。通过关键路径优化、渐进式展示、内容效率优化手段,来优化资源调度。 2.1 加载过程 在介绍页面加载过程,先看看渲染绘制过程: [图片] Javascript:操作DOM和CSSOM。 样式计算:根据选择器应用规则并计算每个元素的最终样式。 布局:浏览器计算它要占据的空间大小及其在屏幕的位置。 绘制:绘制是填充像素的过程。 合成。由于页面的各部分可能被绘制到多层,合成是将他们按正确顺序绘制到屏幕上,正确渲染页面。 渲染其实是很复杂的过程这里只简单了解一下,想深入了解可以看看这篇文章。 了解了渲染绘制过程,在学习加载过程的时候就可以把它当作黑盒了,黑盒只包括渲染过程从样式计算开始,因为上面的Javascript主要是用来输入DOM、CSSOM。 浏览器加载过程: Webview加载 下载HTML 解析HTML:根据资源优先级加载资源并构建DOM树 遇到加载同步JS资源暂停DOM构建,等待CSSOM树构建 CSS返回构建CSSOM树 用已经构建的DOM、CSSOM树进行渲染绘制 JS返回执行继续构建DOM树,进行渲染绘制 当HTML中的JS执行完成,DOM树第一次完整构建完成触发:domContentLoaded 当所有异步接口返回后渲染制完成,并且外部加载完成触发:onload 注意点: CSSOM未构建好页面不会进行任何渲染 脚本在文档的何处插入,就在何处执行 脚本会阻塞DOM构建 脚本执行要等待CSSOM构建完成后执行 下面看看如何在加载过程提前感知点。 2.2 优化关键路径 把关键路径定义为:从页面请求到应用启动完成这个过程,也就是到JS执行完domContentLoaded触发的过程。 主要指标有: 关键资源: 影响应用启动完成的资源。 关键资源的数量:这个过程中加载的资源数据。 关键路径长度:关键资源请求的串行长度。 关键字节的数量:关键资源大小总和。 [图片] 上图关键资源有:html、css、3个js。关键资源数量:5个。关键字节的数量:5个资源的总大小。关键路径长度:2,html+剩余其他资源。 关键优化路径优化,就是要降低关键路径长度、关键字节的数量,在http1时代还要降低关键资源的数量,现在http2资源数不用关心。 2.3 优化内容效率 主要是关注的应用加载完成这个时间点,由首页加载完成所需的资源量决定。我们要尽量减少加载资源的大小,避免不必要加载的资源,比如做一些图片压缩懒加载尽快让应用加载完成。 主要指标有: 应用加载完成字节数:应用加载完成,所需的资源大小。 这个指标可以从Chrome上观察到,不过要剔除prefetch的资源。这个指标一般不太稳定,因为页面展示的内容不太相同,所以最好在相同内容相同情况下对比。 2.4 渐进式展示 从上面的加载过程中,可以知道渲染是多次的。那样我们可以先让用户看到一个Loading提示、先展示首屏内容。Loading主要优化的是FP/FCP这两个指标,先展示首屏主要是优化FMP。 3. 缓存:优化多次访问 缓存重点强调的是二次访问、发版访问、离线访问情况下的优化。 通过缓存有效减少二次访问、发版访问所要加载资源,甚至可以让应用支持离线访问,而且是对弱网络环境是最有效的手段,一定要善于使用缓存这是你性能优化的利器。 4. 优化手段 优化手段我归纳为5类:small(更小)、pre(更早)、delay(更晚)、concurrent(并发)、cache(缓存)。性能优化就是将这5种手段应用于上面的优化点:网络请求优化、关键路径优化、内容效率优化、多次访问优化。 5. 构建自己可动态改变的优化方法表和检查表 Checklist包括两部分,一个优化方法表,另外一个优化方法检查表。优化方法表是让我们对我们的性能优化方法有个评估和认识,优化方法检查表的好处是,可以清晰的知道你的项目用了哪些优化方法,还有哪些可以尝试做进一步优化,同时作为一个新项目的指导。 优化名:优化方法的名字。 优化介绍:对优化方法做简单的介绍。 优化点:网络请求优化、关键路径优化、内容效率优化、多次访问优化。 优化手段:small、pre、delay、concurrent、cache。 本地效果:选取合适的因素对,进行效果分析,确定预期作用大小。 线上效果:线上效果对比,确定这个优化方案的有效性及实际作用大小。 这样我们就能大概了解了这个效果的好处。我们新引入了一种优化方法都要按这张表的方法进行操作。 优化方法表: 名称 内容 优化名 JS压缩 优化介绍 压缩JS 优化点 关键路径优化 优化手段 small 本地效果 具体本地效果对比 线上效果 线上数据效果 上面是以JS压缩为例的优化方法表。 优化方法检查表: 分类 优化点 是否使用 不适用 问题说明 small JS压缩 √ pre preload/prefetch √ 不需要 通过这张表就能看出我们使用了哪些方法,还有哪些没使用,哪些方法不适用我们。可以很方便的应用于任何一个新项目。 第四部分:性能优化具体实践 现在就看看我在项目中的具体实践吧,项目中使用的技术栈是:Webpack3+Babel7+Vue2,下面我按照优化手段介绍: 1. small(更小) scope-hoisting scope-hoisting(作用域提升):Webpack分析出模块之间的依赖关系,把可以合并到一起模块合并到一起,但不造成冗余,因此只有被一个地方引用的代码可以合并到一起。这样做函数声明会变少,可以让代码更小、执行更快。 这个功能从Webpack3开始引入,依赖于ES2015模块的静态分析,所以要把Babel的preset要设置成[代码]"modules": false[代码]: [代码] ... [ "@babel/preset-env", { "modules": false ... [代码] Webpack3要引入ModuleConcatenationPlugin插件,Webpack4 product模式已经预置该插件: [代码]... new webpack.optimize.ModuleConcatenationPlugin(), ... [代码] [图片] 如上图,不压缩的JS中可以文件中看到CONCATENATED MODULE这就说明生效了。 tree-shaking 摇树:通常用于描述移除JavaScript上下文中的未引用代码,在webpack2中开始内置。依赖于ES2105模块的静态分析,所以我们使用babel同样要设置成 [代码]"modules": false[代码]。 [图片] 如上图,不压缩的JS中可以文件中看到unused harmony这就说明摇树成功了。 code-splitting(按需加载) 代码分片,将代码分离到不同的js中,进行并行加载和按需加载。 代码分片主要有两种: 按需加载:动态导入 vendor提取:业务代码和公共库分离 这里只介绍按需加载部分,动态导入Webpack提供了两个类似的技术。1. Webpack特定的动态导入require.ensure。2.ECMAScript提案[代码]import()[代码]。这里我只介绍我使用的[代码]import()[代码]这种方法。因为是推荐方法。 代码如下: Babel配置支持动态导入语法: [代码]... "@babel/plugin-syntax-dynamic-import", ... [代码] 代码中使用: [代码]... if(isDevtools()){ import(/* webpackChunkName: "devtools" */'./comm/devtools').then((devtools)=>{ let initDevtools = devtools.default; initDevtools(); }); } ... [代码] polyfill按需加载 我们代码是ES2015以上版本的要真正能在浏览器上能使用要通过babel进行编译转化,还要使用polyfill来支持新的对象方法,如:Promise、Array.from等。对于不同环境来说需要polyfill的对象方法是不一样的,所以到了Babel7支持了按需加载polyfill。 下面是我项目中的配置,看完以后我会介绍一下几个关键点: [代码]module.exports = function (api) { api.cache(true); const sourceType = "unambiguous"; const presets = [ [ "@babel/preset-env", { "modules": false, "useBuiltIns": "usage", // "debug": true, "targets": { "browsers": ["Android >= 4.0", "ios >= 8"] } } ] ]; const plugins= [ "@babel/plugin-syntax-dynamic-import", "@babel/plugin-transform-strict-mode", "@babel/plugin-proposal-object-rest-spread", [ "@babel/plugin-transform-runtime", { "corejs": false, "helpers": true, "regenerator": false, "useESModules": false } ] ]; return { sourceType, presets, plugins } } [代码] @babel/preset-env preset是预置的语法转化插件的集合。原来有很多preset如:@babel/preset-es2015。直到出现了@babel/preset-env,它可以根据目标环境来动态的选择语法转化插件和polyfill,统一了preset众多的局面。 [代码]targets[代码]:是我们用来设置环境的,我的应用支持移动端所以设置了上面那样,这样就可以只加载这个环境需要的插件了。如果不设置[代码]targets[代码]通过@babel/preset-env引入的插件是 @babel/preset-es2015、@babel/preset-es2016和@babel/preset-es2017插件的集合。 [代码]"useBuiltIns": "usage"[代码]:将useBuiltIns设置为usage就会根据执行环境和代码按需加载polyfill。 @babel/plugin-transform-runtime 和polyfill不同,@babel/plugin-transform-runtime可以在不污染全局变量的情况下,使用新的对象和方法,并且可以移除内联的Babel语法转化时候的辅助函数。 我们这里只用它来移除辅助函数,不需要它来帮我处理其他对象方法,因为我们在开发应用不是做组件不怕全局污染。 sourceType:“unambiguous” 一个文件混用了ES2015模块导入导出和CJS模块导入导出。需要设置[代码]sourceType:"unambiguous"[代码],需要让babel自己猜测类型。如果你的代码都很合规不用加这个的。 压缩:js、css js、css压缩应该最基本的了。我在项目中使用的是[代码]UglifyJsPlugin[代码]和[代码]optimize-css-assets-webpack-plugin[代码],这里不做过多介绍。 压缩图片 通过对图片压缩来进行内容效率优化,可以极大的提前应用加载完成时间,我在项目中做了下面两件事。 广告图片,限制大小50K以内。原来基本会上传超过100K的广告图。 项目中图片使用的[代码]img-loader[代码]对图片进行压缩。 HTTP2支持,去掉css中base64图片 先看看HTTP1.1中的问题: 同一域名浏览器做了TCP连接数的限制,如:Chrome中只能有6个。 一个TCP连接只能同时处理一个请求响应。 在看看HTTP2的优势: 二进制分帧:HTTP2的性能增强的核心在于新的二进制分帧层。帧是最小传输单位,帧组成消息,数据以消息形式发送。 多路复用:所有请求在一个连接上完成,可以支持多数据流混合传输,在接收端拼接。 头部压缩:使用HPACK对头部压缩,网络中可以传递更少的数据。 服务端推送:服务端可以主动向客户端推送资源。 有了HTTP2我们在也不用担心资源数量,不用在考虑减少请求了。像:base64图片打到css、合并js、域名分片、精灵图都不要去做了。 这里我把原来base64压缩图片从css中去除了。 2. pre(更早) preload prefetch preload:将资源加载和执行分离,你可以根据你的需要指定要强制加载的资源,比如后面css要用到一个字体文件就可以在preload中指定加载,这样提高了页面展示效果。建议把首页展示必须的资源指定到preload中。 prefetch:用来告诉浏览器我将来会用到什么资源,这样浏览器会在空闲的时候加载。比如我在列表页将详情页js设置成prefetch,这样在进入详情页的时候速度就会快很多,因为我提前加载好了。 这里我用的是来使用[代码]preload-webpack-plugin[代码]preload和prefetch的。 代码: [代码]... const PreloadWebpackPlugin = require('preload-webpack-plugin'); ... new PreloadWebpackPlugin({ rel: 'prefetch', include: ['devtools','detail','VideoPlayer'] }), ... [代码] dns-prefetch preconnect dns-prefetch:在页面中请求该域名下资源前提前进行dns解析。preconnect:比dns-prefetch更近一步连TCP和SSL都为我们处理好了。 使用注意点:1. 考虑到兼容性问题,我们对一个域名两个都设置 2. 对于应用中不一定会使用的域名我们设置dns-prefetch就好以防占用资源。 代码如下: [代码]... <link rel="preconnect" href="//game.gtimg.cn"> ... <link rel="dns-prefetch" href="//game.gtimg.cn"> ... [代码] 3. delay(更晚) lazyload 对图片进行懒加载,我使用的是[代码]vue-lazyload[代码]。 代码如下: [代码]... import VueLazyload from 'vue-lazyload' ... Vue.use(VueLazyload, { preLoad: 1.3, error: '...', loading: '...', attempt: 1 }); ... <div class='v-fullpage' v-lazy:background-image="item.roomPic" :key="item.roomPic"></div> ... [代码] 这里的:key特别注意,如果你的列表数据是动态变化的一定要设置,否则图片是最开始一次的。 code-splitting(按需加载) code-splitting(按需加载)前面已经介绍过这里只是强调下它的delay作用,不使用的部分先不加载。 4. concurrent(并发) HTTP2 HTTP2前面已经应用在了css体积减少,这里主要强调它的多路复用。需要大家看看自己的项目是否升级到HTTP2,是否所有资源都是HTTP2的,如果不是的,需要推进升级。 code-splitting(vendor提取) vendor提取是把业务代码和公共库分离并发加载,这样有两个好处: 下次发版本这部分不用在加载(缓存的作用)。 JS并发加载:让先到并在前面的部分先编译执行,让加载和执行并发。 Webpack配置: [代码] ... entry:{ "bundle":["./src/index.js"], "vendor":["vue","vue-router","vuex","url","fastclick","axios","qs","vue-lazyload"] }, ... new webpack.optimize.CommonsChunkPlugin({ name: "vendor", minChunks: Infinity }), new webpack.optimize.CommonsChunkPlugin({ name: 'manifest' }), ... [代码] 5. cache(缓存) HTTP缓存 HTTP缓存对我们来说是非常有用的。 下面介绍下HTTP缓存的重点: Last-Modified/ETag:用来让服务器判断文件是否过期。 Cache-Control:用来控制缓存行为。 max-age: 当请求头设置max-age=deta-time,如果上次请求和这次请求时间小于deta-time服务端直接返回304。当响应头设置max-age=deta-time,客户端在小于deta-time使用客户端缓存。 强制缓存:这主要把不经常变化的文件设置强制缓存,这样就不需要在发起HTTP请求了。通过设置响应头Cache-Control的max-age设置。 如果像缓存很久设置一个很大的值,如果不想缓存设置成:Cache-Control:no-cahce。 协商缓存:如果没有走强制缓存就要走协商缓存,服务器根据Last-Modified/ETag来判断文件是否变动,如果没变动就直接返回304。 这里我们做的就是让运维调整资源的强制缓存时间,前端在结合文件hash命名就可以进行资源更新了。 ServiceWorker ServiceWorker是Web应用和浏览器之间的代理服务器,可以用来拦截网络来进行资源缓存、离线体验,还可以进行推送通知和后台同步。功能非常强大,我们这里使用的是资源缓存功能,看看和HTTP缓存比有什么优势: 功能多:支持离线访问、资源缓存、推送通知、后台同步。 控制力更强:缓存操作+络拦截功能都由开发者控制,可以做出很多你想做的事情比如动态缓存。 仅HTTPS下可用,更安全。 看看我在项目中的使用: js使用HTTP缓存和ServiceWorker双重缓存在cacheid变化后依然可以缓存。 不得对service-worker.js缓存,因为我们要用这个更新应用。在Chrome中看到请求的cache-control被默认设置了no-cache。 我们项目中使是Google的Workbox,Webpack中插件是 workbox-webpack-plugin。 [代码]... const WorkboxPlugin = require('workbox-webpack-plugin'); ... new WorkboxPlugin.GenerateSW({ cacheId: 'sw-wzzs-v1', // 缓存id skipWaiting: true, clientsClaim: true, swDest: './html/service-worker.js', include: [/\.js(.*)$/,/\.css$/], importsDirectory:'./swmainfest', importWorkboxFrom: 'local', ignoreUrlParametersMatching: [/./] }), ... [代码] localStorage localStorage项目中主要做接口数据缓存。通常localStorage是没有缓存时间的我们将其封装成了有时间的缓存,并且在应用启动的时候对过期的缓存清理。 code-splitting(vendor提取) 这里在提vendor提取主要是说明它发版本时候的缓存价值,前面介绍过了。 6. 整体优化效果评价 经过上面的优化,看看效果提升吧。 主要增长点来源: 关键路径资源:698.6K降低到538.6K降低22.9% 内容效率提升:广告图由原来的基本100K以上降低到现在50K以下,页面内图片全部走强制缓存。 缓存加快多次访问速度:js+css强制缓存加ServiceWorker。 线上数据效果: 页面下载时间: 平均值下降:25.74%左右 应用启动完成时间: 平均值下降:33.45%左右 秒开占比提高:23.42%左右 应用加载完成时间: 平均值下降:48.02%左右 第六部分:总结 以上就是我在加载优化方面的一些总结,希望对您有所帮助,个人理解有限,欢迎一起讨论交流。
2019-03-11 - 自定义导航栏所有机型的适配方案
写在前面的话 大家看到这个文章时一定会感觉这是在炒剩饭,社区中已经有那么多分享自定义导航适配的文章了,为什么我还要再写一个呢? 主要原因就是,社区中大部分的适配方案中给出的大小是不精确的,并不能完美适配各种场景。 社区中大部分文章给到的值是 iOS -> 44px , Android -> 48px 思路 正常来讲,iOS和Android下的胶囊按钮的位置以及大小都是相同且不变的,我们可以通过胶囊按钮的位置和大小再配合 wx.getSystemInfo 或者 wx.getSystemInfoSync 中得到的 [代码]statusBarHeight[代码] 来计算出导航栏的位置和大小。 小程序提供了一个获取菜单按钮(右上角胶囊按钮)的布局位置信息的API,可以通过这个API获取到胶囊按钮的位置信息,但是经过实际测试,这个接口目前存在BUG,得到的值经常是错误的(通过特殊手段可以偶尔拿到正确的值),这个接口目前是无法使用的,等待官方修复吧。 下面是我经过实际测试得到的准确数据: 真机和开发者工具模拟器上的胶囊按钮不一样 [代码]# iOS top 4px right 7px width 87px height 32px # Android top 8px right 10px width 95px height 32px # 开发者工具模拟器(iOS) top 6px right 10px width 87px height 32px # 开发者工具模拟器(Android) top 8px right 10px width 87px height 32px [代码] [代码]top[代码] 的值是从 [代码]statusBarHeight[代码] 作为原点开始计算的。 使用上面数据中胶囊按钮的高度加 [代码]top[代码] * 2 上再加上 [代码]statusBarHeight[代码] 的高度就可以得到整个导航栏的高度了。 为什么 [代码]top[代码] * 2 ?因为胶囊按钮是垂直居中在 title 那一栏中的,上下都要有边距。 扩展 通过胶囊按钮的 [代码]right[代码] 可以准确的算出自定义导航的 [代码]左边距[代码]。 通过胶囊按钮的 [代码]right[代码] + [代码]width[代码] 可以准确的算出自定义导航的 [代码]右边距[代码] 。 通过 wx.getSystemInfo 或者 wx.getSystemInfoSync 中得到的 [代码]windowWidth[代码] - 胶囊按钮的 [代码]right[代码] + [代码]width[代码] 可以准确的算出自定义导航的 [代码]width[代码] 。 再扩展 wx.getSystemInfo 或者 wx.getSystemInfoSync 中得到的 [代码]statusBarHeight[代码] 每个机型都不一样,刘海屏得到的数据也是准确的。 如果是自定义整个页面,iPhone X系列的刘海屏,底部要留 [代码]68px[代码] ,不要问我为什么! 代码片段 https://developers.weixin.qq.com/s/Q79g6kmo7w5J
2019-02-25 - 【优化】解决swiper渲染很多图片时的卡顿
相信各位在开发的时候应该有遇到这样一个场景,比如商品的图片浏览,有时图片的浏览会很大,多的时候达几百张或上千张,这样就需要swiper里需要很多swiper-item,如此一来渲染的时候就会很消耗性能,渲染时会有一大段的空白时间,有时还会造成卡顿,体验非常差,下面给大家介绍一下我的解决方案。 首先是wxml结构: [图片] js: [图片] [图片] 主要是利用current属性,swiper里面只放3个swiper-item,要显示的图片放在第二,第一和第三放的是加载的动画背景,步骤如下: 将请求到的数据存入一个数组picListAll内,这里不需要setData,只需要在data外面定义一个变量就行了,以减少渲染性能; 把要显示的图片路径赋值给picUrl; 切换的时候根据bindchange获取current属性,当current改变时判断当前图片在picListAll的index,根据index拿到图片再赋值给picUrl; 主要实现步骤就是以上3 步,比较简单,要注意的是当切换到第一张和最后一张的时候要判断一下,把loding动画去掉,请求的时候还可以传入index参数以显示不同的图片,方便从前一页点击图片进入到此页面时能定位到该图片,例子里我是自己mock数据的,只是为了展示,如果你有服务器的话可以弄几百张看看效果,对比直接渲染和用以上方式渲染的差异。当然,这只是我的解决方案,如果各位有更好的方案欢迎一起讨论,一起进步。 完整代码:https://github.com/HaveYuan/swiper
2019-02-20