- 微信小程序使用自定义目录(文件路径)进行下载/保存 案例(fail permission denied 解决方案)
场景描述 最近项目中有一个需要把网络文件下载下来保存到本地,然后对下载的文件进行读取,待文件不再使用后把文件进行删除的需求。当然也类似的需求还有很多,比如把小程序中的临时图片/文件永久保存下来等等,都是对文件操作的典型场景。 常见问题 在以上场景的实现过程中可能会遇到各式各样的问题,以下是比较常见的几个: 不清楚文件应该保存到哪个目录下。 fail permission denied 文件权限问题。 使用同步函数不清楚怎么获取执行结果。 API提炼 提到文件操作我们会自然而然地想到了API中的FileSystemManager相关的API,我这里用到的函数有以下几个: 下载函数 wx.downloadFile(Object object) 异步函数: FileSystemManager.access(Object object) FileSystemManager.mkdir(Object object) 同步函数: FileSystemManager.accessSync(string path) FileSystemManager.mkdirSync(string dirPath, boolean recursive) 我对同样的业务逻辑分别分别尝试了异步和同步的两种不同的方案,下面我们用一个最简单的下载保存到本地的案例来切入正题。 案例实践 一:获取正确的文件目录路径 当然在保存文件之前我们先要解决一个小问题,那就是我们要保存到哪里?也就是我们自定义的目录。这里我们简单命名其为 [代码]//自定义缓存文件根路径 var rootPath = "......"; [代码] (当然你也可以命名成其他名字) 变量名字可以随便写,不过自定义路径可不能随便写,也不可以在下载的时候直接给一个path路径,否者就会抛出没有权限或找不到文件的异常。所以开发者并不是可以随意的决定自定义文件的路径,这里就不卖关子了,小程序API中有一个很容易被忽略的API, wx.env.USER_DATA_PATH是专门获取文件系统中的用户目录路径的常量值。 这就是我们在小程序中合法的可操作文件的根目录路径: [代码]rootPath = wx.env.USER_DATA_PATH; [代码] 好了到目前为止我们已经知道了我们该往里存储文件了。 定义一下我们下载文件的缓存目录 [代码]var cachePath = rootPath+"/cache"; [代码] 也就是说之后我们下载的文件都会保存到 /cache 目录下,我们在之后的代码中用到的目录路径均为此路径。 二:异步函数实现方案 我们先来用异步函数来实现一下下载保存的流程。 1 下载文件之前我们首先要判断当前目录是否存在,如果目录不存在我们就直接下载文件到该目录下就会抛出 fail permission denied [图片] 这是判断目录存在的代码 [代码] access() { return new Promise(function(resolve, reject) { let fm = wx.getFileSystemManager(); fm.access({ path: cachePath, success: function(res) { resolve(); }, fail: function(err) { resolve(err); } }); }); }, [代码] 2 如果目录真实存在那我们当然可以直接使用,如果目录不存在则需要开发者自己创建目录。 [代码] mkdir(){ return new Promise(function(resolve, reject) { let fm = wx.getFileSystemManager(); fm.mkdir({ dirPath: cachePath, recursive: true, success: function(res) { resolve(); }, fail: function(err) { resolve(err); } }); }); }, [代码] 代码执行完之后我可以验证一下目录是否如我们所愿被创建出来。 开发工具右上角的详情–>基本信息–>文件系统–>当前小程序文件系统根目录 [图片] 点击usr文件夹进入根目录 [图片] 执行完上面代码之后我们可以看一下 cache 文件夹确实已经存在了 [图片] 3 目录也存在了,万事具备只欠下载了 [代码] downloadFile() { let fileUrl = 'https://res.wx.qq.com/wxdoc/dist/assets/img/0.4cb08bb4.jpg'; wx.downloadFile({ url: fileUrl, filePath: cachePath + '/temp.png', success: function(res) { console.log('downloadFile success', res); }, fail: function(err) { console.log('downloadFile fail', err); } }); }, [代码] 那执行完下载的代码之后我们再来看看cache目录 [图片] 这就是我们刚刚下载的图片。 ok,异步的整个下载和文件创建流程就走完了。接下来我们来瞅瞅同步流程中有哪些需要我们注意的。 三: 同步方案 同步方案和异步方案的流程大体一致,都是先判断文件目录是否存在,若不存在则创建目录,存在则执行下载逻辑。 使用同步函数需要特别注意的是怎么去判断函数的执行结果,由于 FileSystemManager.accessSync(string path) FileSystemManager.mkdirSync(string dirPath, boolean recursive) 这两个同步函数没有直接给出任何的返回结果。那我们怎么知道目录是否存在、目录是否被创建成功了呢? 这里我们需要剑走偏锋一下,即利用同步函数抛出的异常来判断结果。 我们直接来看代码 [代码] accessSync() { return new Promise(function(resolve, reject) { let fm = wx.getFileSystemManager(); try { fm.accessSync(cachePath); resolve(); } catch (err) { resolve(err); } }); }, mkdirSync() { return new Promise(function(resolve, reject) { let fm = wx.getFileSystemManager(); try { fm.mkdirSync(cachePath, true); resolve(); } catch (err) { resolve(err); } }); }, [代码] 可以看到我们的代码中多了 try catch 的代码结构,因为同步方法中没有直接返回给我们可用的信息,那我们可以认为同步函数正常执行完的结果为true或success,而进入 catch 后则结果为false或fail亦或根据具体异常具体处理。我们利用同步函数来走一遍下载保存的流程。 [代码] // 同步函数流程 this.accessSync().then(function (err) { if (err) { return that.mkdirSync(); } }).then(function (err) { if (!err) { that.downloadFile() } }); [代码] 可以看到我们利用同步函数下载的图片。 [图片] 总结时刻 我们分别使用异步和同步函数完成了目录的创建和文件的下载等流程。在这个过程中我们特别需要注意几点: 操作文件的根目录是以 wx.env.USER_DATA_PATH 开头的。 使用自定义目录时一定主要不可直接使用,需要增加 判断目录存在、创建目录 两个步骤。 使用同步函数时的执行结果是通过抓取同步函数抛出的异常来进行判断的。在没有给出直接结果的时候要学会利用异常信息来达到目的。 在创建目录时如果该目录存在同样会抛出异常(fail file already exists),这时按照success逻辑继续往下执行即可。 异步方案中介绍了目录查看的步骤和方法,可自行验证。其中usr目录是开发者自定义目录根节点,tmp目录是小程序默认的缓存根节点。 结尾 叙述若有不对或不严谨之处还请不吝指正。
2019-10-31 - 【微信小程序】wx.request简易仿Axios封装与日期格式化函数
概要 wx.request()方法不支持Promise风格的调用方式,且无法使用拦截器、baseUrl等功能,我根据自己的使用习惯,对其进行简单封装。 封装代码: [代码]const http = { defaults: { baseUrl: 'http://localhost:8081/micronews/v1', timeout: 60000, method: 'GET', data: null, header: { 'Content-type': 'application/json' } }, interceptors: { request: config => { wx.showLoading({ title: '数据加载中。。。' }) return config }, response: res => { wx.hideLoading() console.log(res) if (!res.isSuccess) { wx.showToast({ title: '网络错误', icon: 'error' }) return res } switch (res.data.code) { case '0': break default: wx.showToast({ title: '错误码:' + res.data.code, icon: 'error' }) throw new Error('错误') } return res.data } }, request(opt) { let opts = { ...this.defaults, ...opt } opts.url = opts.baseUrl + opts.url opts = this.interceptors.request(opts) return new Promise((rs, rj) => { wx.request({ ...opts, success: res => { const rres = this.interceptors.response({ ...res, config: opts, isSuccess: true }) return rs(rres) }, fail: res => { const rres = this.interceptors.response({ ...res, config: opts, isSuccess: false }) return rj(rres) } }) }) }, get(url, data = {}, config = {}) { return this.request({ url, data, method: 'GET', ...config }) }, post(url, data = {}, config = { header: { 'Content-type': 'application/x-www-form-urlencoded' } }) { return this.request({ url, data, method: 'POST', ...config }) } } wx.$apis = { getNewsList: () => http.get('/news'), getNews: id => http.get('/news/' + id), getMyNews: id => http.get('/news/user/' + id), login: data => http.post('/user/login', data), signup: data => http.post('/user/signup', data), publish: data => http.post('/news/publish', data), changeName: data => http.post('/user/1', data) } const formatTime = date => { const year = date.getFullYear() const month = date.getMonth() + 1 const day = date.getDate() const hour = date.getHours() const minute = date.getMinutes() const second = date.getSeconds() return `${[year, month, day].map(formatNumber).join('-')} ${[hour, minute].map(formatNumber).join(':')}` } const formatNumber = n => { n = n.toString() return n[1] ? n : `0${n}` } wx.formatTime = formatTime [代码] 技术细节 提示:注意,这里没有使用模块化暴露的方式进行封装,而是直接将apis加载到wx全局对象上,这样,只需要在app.js里引入一次,即可全局使用,更加便捷。使用同样的方式,也可以把app.js中的全局数据直接挂载到wx全局对象上。app.js的代码如下: [代码]// app.js // import "./utils/axios" import './utils/http' // import './utils/util' App({ onLaunch() { // 获取登录状态 const user = wx.getStorageSync('user') if (user) { this.globalData.logined = true this.globalData.user = user } wx.$app = this wx.$data = this.globalData wx.$user = wx.$data.user }, globalData: { user: null, logined: false, newsList: [] } }) [代码] 可以看到我们挂载了formatTime、$apis、$app、$data等全局数据,一次挂载,全局使用
2024-08-03 - video-swiper-短视频轮播,解决方案及部分思路
这是一个官方的组件,但是不符合自己业务逻辑,几经修改,勉强适合使用。 本插件适合有限视频轮播,先说说组件思路: 1、组件是使用微信组件swiper来做动画切换; 2、video标签,官方提示尽量不要超过3个标签(同一个界面),虽然我不知道为什么,但是肯定和性能有关; 链接地址:https://developers.weixin.qq.com/community/develop/doc/000e4ef22583d8961919efb6b56009[图片] 3、视频轮播逻辑规律是:组件是使用三个swiper-item一直做无限滚动(好处就是不会造成过多的节点,性能好),看图:[图片]图的意思是滑动轮播的规律:箭头表示当前swiper滑动的位置,然后显示对应的视频,这样轮动排放的逻辑可以知道进可攻,退可守,不会出现上下滑播放顺序就乱了。 图片我们也可以发现,当视频数量刚好是3的倍数,不会有任何排放问题,但是如果是3的余数是1或者2时,那就出坑了,因为轮播swrper-item是固定三个的,所以修改的组件就是解决这个余数问题。 解决的思路是:当余数为1时,我们把当前swiper-item轮播变成4个轮播,当余数为2时,我们把当前swiper-item轮播变成2个轮播;这样就可以解决余数不为0的时候,但是有个特殊情况就是当视频刚好4个,应该直接全部swiper-item展示,无需其它逻辑处理。 // 下面的代码是官方没有的,自增核心代码 // 手势向上时处理逻辑 if ((total % 3) === 1 && nextQueue.length === 0) { let timers = new Date(); let addItem = JSON.parse(JSON.stringify(add)); addItem.idxKey = timers.getTime(); curQueue[3] = addItem; } else if ((total % 3) === 2 && nextQueue.length === 0) { let _pop = curQueue.pop(); this.setData({ _pop: _pop }) } // 手势向下时处理逻辑 if ((total % 3) === 1 && curQueue.length === 4) { curQueue.pop(); } else if ((total % 3) === 2 && nextQueue.length === 0) { curQueue.push(this.data._pop); } 这里有个坑就是视频自动播放,如果元素有两个渲染key是相同时,会造成视频点击两次才能播放;出现此问题主要发生在总数余1时会出现(即总是大于4以上,且总数余1,时,swiper-item出现四个时候会渲染两个相同的视频,导致key渲染相同)。 解决此问题:主要保证渲染的key是唯一的即可; if ((total % 3) === 1 && nextQueue.length === 0) { let timers = new Date(); let addItem = JSON.parse(JSON.stringify(add)); addItem.idxKey = timers.getTime(); // 此语句就是保证添加四个swiper-item时,key是不同的 curQueue[3] = addItem; } else if ((total % 3) === 2 && nextQueue.length === 0) { let _pop = curQueue.pop(); this.setData({ _pop: _pop }) } 如果不能理解我讲的,请下载测试代码片段自己测试并验证,有问题可以评论留意,相互学习。(使用在小程序名称为:iTOP-智能名片,需要进去后左滑,进入视频专辑,随便点击一个视频查看即可查看视频滑动效果) 这里的部分核心代码参考此文章(有修改点):https://developers.weixin.qq.com/community/develop/article/doc/0006ecd75fce608033ba9348d51413 官方源码组件地址(没修改过的代码):https://developers.weixin.qq.com/miniprogram/dev/extended/component-plus/video-swiper.html 本文章的代码片段:https://developers.weixin.qq.com/s/1M3qTFmg7ZmA
2020-12-17 - 运用小程序Skyline技术构建无缝用户体验 —— 同程旅行酒店最佳实践分享
[图片] 动效衔接设计与小程序渲染框架 1、什么是动效衔接设计? 随着互联网技术和设计理念的不断发展,动效设计成为现代 UI 设计中不可或缺的一部分。其中,动效衔接是非常重要的一环。动效衔接设计是指通过巧妙的动效设计,将不同的 UI 元素在动画过程中自然、流畅地衔接起来,从而增强用户的交互体验和视觉感受。在实际应用中,动效衔接设计主要应用于界面转场、信息提示、状态变化等方面,通过顺畅的衔接,降低用户因白屏等待而产生的焦虑。 2、动效衔接设计的意义 (1)极大提高用户体验,让用户感受到界面的流畅和自然,从而增加用户对产品的好感度。 (2)降低用户的操作认知成本,帮助用户更好地理解执行操作后所带来的结果,从而减少用户对产品的困惑。 (3)强化视觉层,让用户更好地区分不同的信息和元素,从而增强视觉层次感。 (4)增加界面的美感度,让界面更加生动有趣,从而提升整体的美感和设计价值。 (5)提升品牌的认知度,让产品更加具有特色和独特性,从而提高品牌的认知度和市场竞争力。 3、什么是小程序渲染框架Skyline? 为了进一步优化小程序性能,小程序在原 webview 渲染引擎之外最新推出 小程序渲染框架Skyline,其使用更精简高效的渲染管线,并拥有诸多增强特性,让它拥有更接近原生渲染的性能体验。新的增强特性有 worklet 动画系统、手势系统、自定义路由、共享元素动画,而且许多常用的组件如 scroll-view、swiper 都有了更高性能的实现。 [图片] [图片] 实践理念和场景拆解 1、动效衔接设计的核心原则 简单而清晰的动效设计,需要遵守以下几个原则: (1)一致性:动效衔接应该与整体设计风格保持一致。包括颜色、字体、动画速度等方面。 (2)可预测性:用户能够感知动画元素的变化关联性,从而增加用户对产品的掌控感和对界面的理解。 (3)反馈性:动效衔接与用户操作相响应,从而帮助用户理解他们的操作所带来的结果。 (4)视觉层次:动效衔接遵循视觉层次原则,让用户区分页面中的上下关系以及三维物理世界的关系层次,给用户清晰的层级区分感知,提高用户体验。 (5)自然性:动效衔接符合物理规律,例如重力、加速度等,从而增强动画的真实感和用户体验。 2、理念孵化与使用场景拆解 以提炼的动态感受为出发点,理性的层面给予了我们大致的产品体验感知,为我们动效理念的建成提供了框架。对此我们将继续从感性层面出发,找寻可传递真实感受的运动现象并加以组合提炼。 本次 同程旅行小程序 以酒店预订链路中核心的相册页面进行应用场景,在用户操作图片的过程中运用小程序渲染框架承接。 (1)将整个过程进行了拆解,首先为了退出时行动的路径更加清晰,做了一个响应设计,当界面向右滑动退出的过程中,相册图片进行缩小,在缩放过程中,会根据位移距离控制缩放的比例,同时蒙层的透明度以及毛玻璃效果也跟随手指移动变化,和相册列表页在视觉上呈现 XY 轴以及上下层级的空间关系,在缩小到一定的比例时,触发震动效果,松手退出到相册列表页。 (2)在交互结束时,图片退回相册列表页原始位置,在返回路径的过程中,根据交互结束时的定位点,来判断运动的方向和距离,计算运动加速度,以及模拟运动加速度带来的惯性回弹的方向和角度变化,加强与模拟真实物理世界的运动定律,和视觉上的动态感知。 结合自然世界的运动规律来看,把页面进入的元素比作是行驶的汽车,用户当作是正在斑马线上行驶的人,将马路作为页面空间。若汽车采用的是缓入运动(加速)的话,马路上的行人则看到的是一辆不断加速向他行驶过来的车辆。因为担心车辆高速的逼近导致刹车不及时的情况,行人便会本能的作出躲闪的反应。其实页面也是一个道理,进入的元素使用加速运动出现过冲的运动感知会让用户体验时产生不适。 [图片] 小程序渲染框架技术开发实践过程剖析 1、开发自定义路由实现此交互,需要 自定义路由动画,因为小程序渲染框架的页面支持自定义跳转动画。当使用自定义路由后,页面跳转时指定路由类型,就会触发自定义路由动画,而不再是默认的从右往左的动画,此处的实现可以使得页面跳转时,没有默认的路由动画,页面将直接以透明的方式渲染在屏幕上,由开发者自己控制页面内元素的动画展示方式,具体实现如下: (1)在图片查看页面配置文件 index.json 中声明 { "backgroundColor": "#00000000", "backgroundColorContent": "#00000000", // 设置客户端页面背景为透明 "navigationStyle": "custom", "renderer": "skyline", // skyline渲染引擎 "disableScroll": true, "usingComponents": { } } (2)在 wxss 中,设置图片查看页面的 page 节点为透明背景 page { background: transparent; } (3)在 js 中,使用 wx.router.addRouteBuilder(routeType, fn) 来声明自定义路由动画 wx.router.addRouteBuilder('myCustomRoute', function (params) { const handlePrimaryAnimation = () => { 'worklet'; return { // 可在此处,根据 params.primaryAnimation.value 的值,来设置页面的动画效果 backgroundColor: `rgba(0,0,0,${ params.primaryAnimation.value })` }; }; return { opaque: false, handlePrimaryAnimation, barrierColor: '', barrierDismissible: false, transitionDuration: 320, reverseTransitionDuration: 250, canTransitionTo: true, canTransitionFrom: false }; }) (4)在图片列表页面中,使用 x.navigateTo 来跳转页面,并且设置 routeType 为 myCustomRoute wx.navigateTo({ url: '/pages/skyline-image-viewer/index?index=0', routeType: 'myCustomRoute' }) [图片] 需配置页面的渲染引擎为 Skyline,并且在跳转时使用 routeType 就可以实现让页面在跳转时没有默认的路由动画。 2、共享元素穿越在连续的页面跳转时,页面间 key 相同的 share-element 节点将产生飞跃特效,还可自定义插值方式和动画曲线,通常作用于图片。为保证动画效果,前后页面的 share-element 子节点结构应该尽量保持一致 <share-element key="share-key"> <view> you code here </view> <!-- 需要注意,share-element 内要求只有一个根节点 --> </share-element> [图片] 这时,界面的表现像上面视频一样,是一个连续的动画状态,这完全是由 share-element 来控制的,share-element 的动画原理如下图所示: [图片] 3、接入手势组件,实现图片放大、缩小、平移在图片查看页面有如下结构: <scale-gesture-handle worklet:ongesture="onScaleGestureHandle"> <share-element key="{{ shareKey }}" class="current-item"> <image src="{{ src }}"/> </share-element> </scale-gesture-handle> 这里,我们使用小程序渲染框架提供的 手势组件 <scale-gesture-handle>,来实现图片的放大、缩小、平移等手势交互。 注意,所有声明为 worklet 指令的方法它们运行在UI线程,不要在方法中修改普通的变量,因为跨线程的关系,只能修改使用 wx.worklet.shared 声明的变量。 const GestureState = { POSSIBLE: 0, // 此时手势未识别 BEGIN: 1, // 手势已识别 ACTIVE: 2, // 连续手势活跃状态 END: 3, // 手势终止 CANCELLED: 4 // 手势取消 }; Component({ attached() { this.shareX = wx.worklet.shared(0); this.shareY = wx.worklet.shared(0); this.sharScale = wx.worklet.shared(1); // 声明共享变量,并且给需要变化的dom,绑定动画 this.applyAnimatedStyle('.current-item', () => { 'worklet'; return { transform: `translate3d(${this.shareX.value}px, ${this.shareY.value}px, 0) scale(${this.sharScale.value})` } }); // 页面所需的数据,需要在 attached 事件里初始化完毕,使其可以参与首帧渲染 this.setData({ src: '...', shareKey: '...' }); }, methods: { // 当手势组件识别到手势时,触发此回调 onScaleGestureHandle(e) { 'worklet'; const { state } = e; // 在worklet函数里,不要使用 const {} = this 对this解构 const shareX = this.shareX; const shareY = this.shareY; const sharScale = this.sharScale; if (state === GestureState.BEGIN) { // 手势已经识别,此时,可以获取到手势的初始值 } else if (state === GestureState.ACTIVE) { // 手势活跃状态,此时,可以获取到手势的变化值,如平移的距离、缩放的比例等 // 将当前变化的值,设置到 `shared` 变量,就可以改变元素的样式,类似于vue3的数据驱动 shareX.value += e.focalDeltaX; shareY.value += e.focalDeltaY; sharScale.value = e.scale; } else if (state === GestureState.END || state === GestureState.CANCELLED) { // 手势终止或取消,此时,可以获取到手势的最终值 } } } }) [图片] 4、手势协商(解决手势冲突) 上面的 demo 简单演示如何使用手势组件来做图片交互,但是在图片查看页面中,我们还有其他的手势交互,如图片的左右滑动切换等,一般我们会使用 <swiper> 组件来实现,但是 <swiper>组件的内部实现和 <scale-gesture-handle> 组件,都会监听手势事件,手势组件的事件不支持冒泡的,就会导致下面结构横时: <scale-gesture-handle worklet:ongesture="onScaleGestureHandle"> <swiper> <swiper-item wx:for="{{ imgs }}"> <share-element key="{{ item.shareKey }}" class="current-item"> <image src="{{ item.src }}"/> </share-element> </swiper-item> </swiper> </scale-gesture-handle> 使用手势横向滑动时,会优先触发 swiper 的横向切换事件,而无法触发 <scale-gesture-handle> 的手势事件了,这在图片放大时的图片横向移动产生了冲突。此时就需要使用手势协商来解决手势冲突。 什么是手势协商? 手势协商指的是:当页面同时有多个手势交互时,需通过一定的约定来决定哪些手势事件应该被执行,哪些需要被忽略。 小程序渲染框架解决手势冲突的方式,主要是通过手势组件的 tag、simultaneous-handlers、native-view 和 should-response-on-move 来实现 tag:手势组件的标识,用于区分不同的手势组件simultaneous-handlers:手势组件的协商者,表示需要同时触发事件的手势组件的标识should-response-on-move:参与手势时间的派发过程,返回 false时,表示该手势时间不会继续派发native-view:用当前手势组件来代理原生组件内部的手势事件,如<swiper>组件内部的手势事件<swiper> 的内部也是使用了 <horizontal-drag-gesture-handler>手势组件,但是我们不能直接在<swiper>上设置tag来使其参与手势协商,需要用相同的手势组件通过native-view=swiper将其内部的事件代理出来,使其可以参与协商<!-- <scale-gesture-handle> 缩放手势 --> <!-- <horizontal-drag-gesture-handler> 横向拖动手势 --> <!-- 通过 simultaneous-handlers=tag 来声明多个手势应该同时触发 --> <scale-gesture-handle tag="scale" simultaneous-handlers="{{['swiper']}}" worklet:ongesture="onScaleGestureHandle"> <!-- 此处使用 native-view=swiper 代理内部的手势组件 --> <!-- 通过 should-response-on-move=fn 来参与`事件派发`过程,决定手势的事件是否应该派发 --> <horizontal-drag-gesture-handler tag="swiper" native-view="swiper" simultaneous-handlers="{{['scale']}}" worklet:should-response-on-move="shouldResponseOnMove"> <swiper> <swiper-item wx:for="{{ imgs }}"> <share-element key="{{ item.shareKey }}" class="current-item"> <image src="{{ item.src }}"/> </share-element> </swiper-item> </swiper> </horizontal-drag-gesture-handler> </scale-gesture-handle> const GuestureMode = { INIT: 0, SCALE: 1, SWIPE: 2, MOVE: 3 // ... }; Component({ attached() { this.GuestureModeShared = wx.worklet.shared(GuestureMode.INIT); this.shareX = wx.worklet.shared(0); this.shareY = wx.worklet.shared(0); this.shareScale = wx.worklet.shared(1); // 声明共享变量,并且给需要变化的dom,绑定动画 this.applyAnimatedStyle('.current-item', () => { 'worklet'; return { transform: `translate3d(${this.shareX.value}px, ${this.shareY.value}px, 0) scale(${this.shareScale.value})` } }); // ... }, methods: { onScaleGestureHandle(e) { 'worklet'; const { state } = e; if (state === GestureState.BEGIN) { this.GuestureModeShared.value = GuestureMode.INIT; } else if (state === GestureState.ACTIVE) { if(this.GuestureModeShared.value === GuestureMode.INIT) { this.gestureBefore(e); // 手势类型未知时,判断手势类型 } else { this.gestureHandle(e); // 手势类型已知时,处理手势事件 } } else if (state === GestureState.END || state === GestureState.CANCELLED) { this.GuestureModeShared.value = GuestureMode.INIT; } }, // 判断手势类型 gestureBefore(e) { 'worklet'; const { focalDeltaX, focalDeltaY, scale } = e; if (Math.abs(focalDeltaX) > Math.abs(focalDeltaY)) { this.GuestureModeShared.value = GuestureMode.SWIPE; } else if (scale > 1) { this.GuestureModeShared.value = GuestureMode.SCALE; } else { this.GuestureModeShared.value = GuestureMode.MOVE; } }, // 处理手势事件 gestureHandle(e) { 'worklet'; if (this.GuestureModeShared.value === GuestureMode.SCALE) { this.shareScale.value = e.scale; } else if (this.GuestureModeShared.value === GuestureMode.SWIPE) { // swiper 切换模式时,这里什么都不用做 } else if (this.GuestureModeShared.value === GuestureMode.MOVE) { this.shareX.value += e.focalDeltaX; this.shareY.value += e.focalDeltaY; } }, // 用于判断手势事件是否应该派发 shouldResponseOnMove(e) { 'worklet'; return this.GuestureModeShared.value === GuestureMode.SWIPE; // 当模式为SWIPE时,才响应手势事件 } } }) [图片] 通过上面的代码,我们实现了手势协商,当用户在图片上进行滑动的操作时,总是会触发 <scale-gesture-handler> 的手势事件,通过对图片当前状态的判断来决定应该触发哪种手势,我们通过此种协商让 <horizontal-drag-gesture-handle> 手势在合适的时机触发,以此避免手势冲突。 5、使用小程序渲染框架时需要注意的一些地方作为一款新的渲染优化方式,开发者使用小程序渲染框架需要注意以下内容,以保证渲染的效果和性能。 (1)自定义路由时首帧渲染&首帧性能优化 小程序渲染框架的首帧渲染对共享元素动画非常重要,若共享元素节点的key 错过首帧设置的话,可能会丢失飞跃动画,所以在使用小程序渲染框架时,共享元素的 key 应该尽量在 attached 中或之前设置到页面,并且在首帧渲染时,应尽可能的减少 UI 层的渲染工作 如下: 1)所需要的数据应尽可能使用提前计算好,避免构建页面时等待太久影响响应速度 2)首次设置的数据应该尽可能的少,避免首次渲染时,页面上的元素过多,导致首帧渲染时间过长,导致动画卡顿(如:不要同时初始化太多的 <swiper-item>) 3)确保首帧渲染时,共享元素的 key 正确的设置,避免在首帧渲染时,由于找不到对应的共享元素,导致动画丢失,看不到飞跃动画 4)由于手势事件触发频繁,应尽量避免大量需要的计算的逻辑高频执行,容易导致机器发烫,或者导致动画卡顿 **worklet 函数的使用** worklet 函数的使用有一些限制,主要是由于它是在 UI 线程执行的,所以 worklet 函数中的 this 并非是页面的 this 实例, 里面所使用到的变量也是通过特殊的 babel 插件转换到UI线程的,需要与逻辑层共用的变量都需要用 wx.worklet.shared 将它声明成共享变量,在 UI 线程调用逻辑层的函数需要使用 wx.worklet.runOnJS (2)与 web 规范的差异 虽然小程序渲染框架尽可能的与 web 规范保持一致,但是由底层渲染引擎的限制,还是有一些差异,如: 1)display: flex 的默认朝向是 column,而不是 row,这需要开发者注意,官方后续会支持 block 布局方式 2)暂不支持 css 伪元素,如 ::after、::before,官方正在支持中 3)position 仅支持 absolute、relative,不支持 sticky,实现滚动吸附的效果需用 sticky-* 组件来配合 scroll-view 实现 ** <share-element> 在非小程序渲染框架运行环境里的表现是什么** 在非小程序渲染框架的运行环境内,<share-element> 组件会被视为一个 <view> 组件,需要做好布局的兼容 6、何时使用小程序渲染框架开发时,请确保小程序开发者工具版本是 最新版 nightly,sdk 版本在 2.30.2+,具体限制可参考 文档。 这些新特性的引入,使得小程序渲染框架在小程序开发中的优势更加明显,开发者可以更加便捷地实现各种复杂的交互效果,并且达到接近原生APP的体验。 [图片] 未来展望 1、个性化产品形态:将会根据不同的用户需求和场景,设计出更加符合用户喜好和习惯的动效衔接,进行组件化调用。 2、更加自然和真实的动效衔接:动效衔接将会更加贴近自然规律和真实物理效应,从而增强动画的真实感和用户体验。 3、更加智能化和自适应的动效衔接:动效衔接将会根据用户的操作行为和使用习惯,自适应调整动画效果,从而提高用户体验和产品效果。 4、扩大产品、设计与开发的协作效应:设计对动效的把控、产品对用户的洞察以及开发对新技术的应用,才可以发挥最大化的协作效应。 附1:本文作者 同程旅行研发工程师 同程旅行体验设计师 同程旅行产品经理 附2:代码片段 相册小程序代码片段(请使用 PC 端浏览器打开):https://developers.weixin.qq.com/s/E979jCmP7oHG 附3:UE标注 [图片] 附4:AB 实验效果 AB 实验显著win0.23% [图片]
2023-04-28 - Skyline|探秘下拉二楼,打造更丰富的内容展示
下拉二楼是一种常见的交互设计,可以为应用中的内容展示提供更多的可能性。 通过下拉操作,开发者可以在二楼展示更丰富、更多样化的内容,从而增加用户的点击量和留存率,例如宣传视频、精选商品、走心故事等等。 在小程序中,下拉二楼一直是一种难以实现的交互设计,即使部分小程序实现了,但效果和性能都很差。 为了丰富小程序的内容展示,提高用户的使用体验,小程序官方近期推出了下拉二楼的能力,方便小程序开发者使用。 效果展示 让我们来看看小程序 scroll-view 实现下拉效果的效果~ [图片] 实现步骤 接下来,我们来看下如何使用 scroll-view 实现下拉二楼 1、配置下拉相关属性 scroll-view 新增了以下接口供开发者配置下拉二楼的能力,开发者可以根据业务需要配置相关的属性 属性 说明 refresher-two-level-enabled 开启下拉二级能力,配置开启需同时配置 refresher-two-level-triggered 设置打开/关闭二级 refresher-two-level-threshold 下拉二级阈值 refresher-two-level-close-threshold 滑动返回时关闭二级的阈值 refresher-two-level-scroll-enabled 处于二级状态时是否可滑动 refresher-ballistic-refresh-enabled 惯性滚动是否触发下拉刷新 refresher-two-level-pinned 即将打开二级时否定住 [代码]<scroll-view type="list" scroll-y // 开启下拉刷新(下拉二级必须开启下拉刷新) refresher-enabled="{{true}}" // 开启下拉二级能力 refresher-two-level-enabled="{{true}}" // 处于二级状态是否可滑动 refresher-two-level-scroll-enabled="{{true}}" > ... </scroll-view> [代码] 2、实现二楼内容 配置完下拉二楼属性之后,接着就是将我们的二楼实现在 scroll-view 中。 在 scroll-view 放置一个子节点,声明 slot=“refresher”,该节点中的内容即为下拉二楼的内容。 [代码]<scroll-view ... > <view slot="refresher"> 这里是二楼的内容 </view> </scroll-view> [代码] 3、根据下拉状态回调进行个性化处理 接着我们需要根据业务小程序自身的诉求,根据下拉状态的回调进行个性化的处理,例如:下来完成跳转页面等。 在 scroll-view 绑定 bind:refresherstatuschange 监听下拉状态,下拉状态有以下几种 属性 说明 Idle 空闲 CanRefresh 超过下拉刷新阈值 Refreshing 下拉刷新 Completed 下拉刷新完成 Failed 下拉刷新失败 CanTwoLevel 超过下拉二级阈值 TwoLevelOpening 开始打开二级 TwoLeveling 打开二级 TwoLevelClosing 开始关闭二级 [代码]<scroll-view bind:refresherstatuschange="onStatusChange" ... > <view slot="refresher"></view> ... </scroll-view> // .js onStatusChange(e) { const status: RefreshStatus = e.detail.status if (status === RefreshStatus.TwoLeveling) { const that = this // 当打开二级之后,跳转到新的页面 wx.navigateTo({ url: '../goods/index', events: { nextPageRouteDone: function(data) { // 新页面打开之后,关闭下拉二楼 that.scrollContext.closeTwoLevel({ duration: 1 }) } } }) } } [代码] 我们来演示一下松手立即跳转(图左)、完全打开二楼后跳转(图右) [图片] 丰富小程序展示内容和形式,欢迎大家使用小程序下拉二楼,为小程序的内容展示提供更多的可能性和创意发挥的空间。 通过下拉二楼,可以展示更丰富、更多样化的内容,也为小程序的发展带来了更多的机会和挑战~ 赶紧 mark 下这个 代码片段 来接入使用吧~
2023-08-03 - 怎么才能不频繁setData?
我这边用户列表是服务器实时刷新的, 用户列表高峰期有2K人, 如果我要改变某个用户的状态,需要调用下setData, 这样的话会频繁调用setData ,内存会狂涨~ 如果我要操作用户列表,是不是只能放到Page 外面才可以~ 还是起个时钟,比如5分钟同步一次?
2017-07-17 - 小程序页面吸顶效果、右下角悬浮按钮等隐藏显示切换时不卡顿的实现方法
使用的api及页面方法 api:wx.createSelectorQuery、wx.createIntersectionObserver 页面方法:onPageScroll 为什么使用以上方法? wx.createSelectorQuery:主要解决页面渲染后保证所涉及的元素能百分百渲染到屏幕上,这里打包一个异步方法。 [代码]getElement(elm, component) { const _this = this; return new Promise((resolve, reject) => { let ss = setInterval(function() { let Qy = component ? _this.createSelectorQuery() : wx.createSelectorQuery(); let a = Qy.select(elm).boundingClientRect(function(res) { if (res) { clearInterval(ss) resolve(res) } }).exec(); }, 50); }); } [代码] wx.createIntersectionObserver与onPageScroll的作用: 单纯使用onPageScroll切换隐藏显示状态必然会高频率使用setData导致页面卡顿。如果只是在wx.createIntersectionObserver与onPageScroll中隐藏或者显示,即确保每个方法中只setData一次,那么卡顿的现象就不会出现。 以下wx.createIntersectionObserver仅作显示元素 [代码]onCreateIntersectionObserver(component,elm) { const _this = this; this.getElement(elm||".tr-fixed", component).then(res => { _this.setData({ fixed_top: res.top //记录一直显示的元素的top值 }); _this.IntersectionObserver = component ? _this.createIntersectionObserver() : wx.createIntersectionObserver() _this.IntersectionObserver.relativeTo(".top-transparent", { bottom: 2 }).observe(elm||'.tr-fixed', (res) => { //显示吸顶 const { fixed_show } = _this.data; if (fixed_show === false) { _this.setData({ fixed_show: true }); } //显示吸顶 }) }); } [代码] 上面代码中: .top-transparent是自定义参照区域。 .tr-fixed或elm为切换隐藏与显示的元素(事先写好顶部浮动,隐藏起来,这里并没有css仅作为监听对象) wxml基本代码: [代码]<view class="top-transparent">页面顶部透明参照元素</view> <view class="tr-fixed">一直显示的部分(滚动的scrollTop小于此元素的top值则隐藏,如果监测到与透明的参照元素交叉则显示)</view> <view class="fixed-view" wx:if="{{fixed_show}}">隐藏的部分(与一直显示的部分一模一样)</view> [代码] [代码].top-transparent{ position: fixed; top: 0; left: 0; right: 0; height: 20px; background: transparent;//透明 pointer-events: none; //保证此元素所有点击事件无效,即点击事件都穿透到比它层级低的元素上 } [代码] 以下onPageScroll仅作隐藏元素 [代码]onPageScroll(e) { const { fixed_top, fixed_show } = this.data // 隐藏吸顶头部 if (fixed_top != undefined && fixed_show) { if (e.scrollTop <= fixed_top) { this.setData({ fixed_show: false }) } } // 隐藏吸顶头部 }, [代码] 代码片段: https://developers.weixin.qq.com/s/oUhsfCmP76au
2019-08-14 - setData 调用优化
[视频] 你好,我是李艺。 上节课我们主要学习了脚本优化技巧,这节课学习set Data方法在调用时的优化技巧。 首先看一下问题,在第6.6讲我们介绍过,当小程序切换至后台以后,setData便不需要调用了,这样可以节省逻辑层与视图层之间的调用消耗,还可以节省页面的渲染消耗。除了这个优化点以外,结合小程序的双线程运行机制和重渲染机制,还有其他的一些优化技巧,这节课我们就一起来看一下。 首先看实践一。 不要多次分开调用setData,尽量要合并调用,在主页的JS文件里,在dealWithListData这个方法里有三处调用setData的代码,这个代码是可以优化的,三处调用显然可以合并为一处,在一个函数内,如果调用一次setData可以达到目的就不需要分开多次调用,甚至在不同的if else分支语句里边调用了setData,适当用一些编程技巧也可以在if语句外面合并成一次调用,如我们屏幕上展示的是优化后的JS的调用代码,小程序页面没有一个beforeRender周期函数,如果有的话,所有需要setData的数据在不同地方的一个数据更新倒可以积攒起来,在这个周期函数里面集中进行更新。 下面我们看代码演示。 首先我们在主页的JS文件里面找到dealWithListData这个方法,这个方法里面我们看一下,目前我们有:这是第一个setData的调用,然后下面还有一个setData,在后面这个地方也有一个setData,一共有三处setData的调用,这三处调用它都处于同样的一个函数体代码里面是可以合并的。首先我们将这个给它移下来,移到这个位置,然后后面这个调用也可以移上来,把它放在一起,这个就删掉,同样我们后面还有一个newList,newList它其实不涉及到数据更新,所以我们是可以直接进行一个赋值的,这个地方它不需要调用setData,newList目前在我们这个里面,它其实目前是在data数据对象里面,它也不需要在data数据对象里面,它可以直接放在我们的Page对象下面,这样的话可以减少对这个视图更新的一个触发,位置改完以后我们还需要将所有的对它的调用代码,进行统一的一个查找,我们可以查this.data.newList,这个地方this.data.newList改成this.newList,再看一下其他地方,已经没有了 这个地方已经可以了,改完以后我们重新单击一下编译按钮,这个page我们再看一下,目前我们page是已经在我们Page对象下面,是没有问题的。page目前直接这样赋值也是可以的,它也不涉及到数据的视图的一个更新,所以直接这样赋值也是没有问题的,它和下面的newList一样的,也是可以通过这样的方式更新,项目已经刷新了,我们现在看一下表现,看一下我们的调试区没有错误,说明我们变动是可以的,没有问题,这个代码演示我们就说到这里。 下面看实践二。 不准备渲染的数据不要放在data数据对象里边,在主页的JS文件里边数据allList表示所有列表数据,目前它位于data数据对象里面,如我们屏幕上所示,我们可以将它直接放在当前的页面对象下,直接放在当前的页面对象上,也可以通过this关键字直接进行取用,一般方法里面this它就是指代我们当前的页面对象。 下面我们进行代码演示。 首先我们要查一下当前的allList目前位于我们data数据对象里面,前面说过所有的只有需要触发视图更新的数据才需要放在data对象里面,不需要触发的,完全可以放在外面,这是不影响的,所以我们将这个给它拷贝出来,另外我们在这个页面里面查一下所有的this.data.allList查一下这个引用,将相关的代码给它修改一下,把中间的data给它去掉,这个地方也是给它去掉,已经没有了 所有的都修改过来了,然后我们再单击编译按钮进行测试,调试区没有问题,然后看页面的表现也没有问题,这个代码演示就到这里。 下面我们看实践三,通过index局部更新长列表数据。 下面我们尝试给项目添加一个新的功能,在单击列表中的列表项标题的时候,在标题文本的末尾就压一个字符,每单击一次就追加一次,这个需求要求在主页JS文件里面的onTapRecycleItem这个方法里面进行实现,在列表数据中的某一项数据发生变化的时候,没有必要更新整个数据列表,只需要使用计算属性、使用索引局部更新的方法,更新列表中对应的某一条数据就可以了,要实现局部更新,对recycle-view组件有两种方法:第一种方法是如我们屏幕上所展示的,先改变data数据,再使用forceUpdate方法进行更新,在示例源码里面,我们在调用setData方法改变标题文本的时候,展示的便是更新某一条数据的方法,对于recycle-view recycleList是它当前真正渲染的数据,但是仅仅更新这个数据还不能完成页面的更新,还需要额外调用渲染上下文对象的forceUpdate方法,才能强制长列表进行重新渲染,对于recycle-view组件还有另外一种更新某一个列表项标题的方法,直接调用长列表组件上下文渲染对象的update方法,这种方法更简单,如我们屏幕上现在展示的,在示例源码中,item是引用对象,修改它的字段以后不需要再做其他的任何更新了,只需要再调用一下长列表组件渲染上下文对象的update方法就可以了。 下面我们看代码演示。 首先打开我们的最终的源码,在主页的JS文件里面我们找到onTapRecycleItem这个方法,在这个方法里面注意前面已经拿到了相关的一些数据,首先第一步,我们要用forceUpdate这种方法进行更新,将这个代码给它拷贝一下,然后在我们目前的项目里面找到onTapRecycleItem这个方法,前面的数据已经拿到了。我们将新代码给它粘进来,把这些代码给它反注释,我们看一下这个地方是直接将item它title属性直接在尾部加了一个加号,当然其他字符也可以,然后我们再拿到这个id,这个id其实前面已经有了,所以这个地方不再需要了,把这个给它去掉。然后我们再拿到recycleList 这是一个数据,在这个数据里面我们要找到id它所对应的需要更新的列表项,找到它以后,然后在这个地方我们用了计算属性,然后去更改它的title数据,这个数据就是item.title,就是我们前面已经更改的数据设置为这样的一种数据结果,最后再调用这个forceUpdate它其实是一个ctx,ctx是我们当前的recycle-view组件的一个上下文渲染对象,其实是它,然后在它上面调用forceUpdate方法,这个代码已经写完了,我们单击编译测试一下 看它的一个表现,单击可以看到标题后面它每次单击都会添加一个加号,这是第一种更新的方式,下面我们看第二种方式,第二种方式是更简单,直接用update这种方法将前面给它先注掉,然后使用后面这种方法,首先是改变数据属性,然后加一个加号,后面我们就直接调用渲染上下文对象的update方法,同时将index也就是我们当前的列表项里面索引,然后传给它,第二个参数是我们要更新的数据,我们需要更新哪一项数据就把这个数据传给它就可以了,注意这个地方其实它的参数类型它是一个数组,我们再单击测试看一下表现,我们看到标题后面加号仍然可以正常的追加,也没有问题,这个代码演示我们就说到这里。 下面看实践四,创建examples/pages/index页面。 按索引更新data数据,在扩展示例页面里边如我们屏幕上展示的,我们需要实现单击某一个组,将其他的组自动折叠起来这样的一个效果,折叠与否是通过一个名称为open的子属性进行控制的,在对这个属性的改变过程当中,我们就可以使用索引法进行局部更新,主要的代码如屏幕上所展示的这样,其中这个open是准备更新的数据属性,然后需要更新的数据准备好以后,就可以使用索引法调用setData方法统一进行更新了,这种更新方式具有统一性,在任何项目里面都可以使用,因为这个页面里边包含的数据量不大,修改带来的正面效果可能很小,但它也展示了如何使用索引进行局部更新的方法,当数据量很大的时候,这种更新方式的优势会更加明显 下面我们看代码演示。 首先我们看一下当前的源码在examples/pages目录下面,找到index.js文件,在这里面有一个kindToggle,这是这个方法它在我们单击每一组的时候会触发的,我们可以在wxml这个标签文件里面看到,它是有一个type属性type事件进行触发的,触发以后在js里面我们先取到id,然后这地方有一个循环,它会循环列表的每一项,同时进行检查,如果是这一项的id与我们当前传进来id相同,那就把它的open属性进行一个切换,同时其他的我们给它置为false,最后我们再通过setData重新设置一下list数据,这种方式在我们list数据很小的情况下其实没有什么问题,也看不出什么差别来,但是如果我们list数据量很大,这种方式其实它是非常耗费资源的,下面我们看我们要优化的代码是怎么写的。 首先找到我们最终的源码,找到同样的文件,也是kindToggle这个方法。第一步我们要创建一个tempData这样的一个临时对象,接下来是我们对for循环的一个改造,在这个里面我们要在属性的设置这个地方,我们要加这样一个代码,把它放在它的前面,另外还有一个这个地方也有一个,把这个代码也给它放在这个地方前面。注意这个地方,我们在往tempData数据对象里面添加这个计算属性的时候为什么要加if判断,这两个判断它本质上是为了让我们少一点往tempData对象里面,加一些不需要更新的数据。如果原来本质上它open属性没有变化的话,我们就没有必要更新,这种情况下我们就不需要去设置了。只有当它属性有变化的时候我们才需要去往里写,tempData准备好了以后,接下来我们就调set。这是set方法,通过set方法去设置,我们设置数据,这个就不要了,这个地方我们可以看一眼,打印我们可以把它放开,稍后我们会看到它的内部的数据表现。 这是原来的代码,把它注掉,然后现在是改用这样的一种方式,这个文件测试的主页目前我们还没有一个入口,下面我们给它加一个小入口,找到examples,这是一个独立的分包,然后分包里面这是它的一个主页,找到我们的app.json,找到关于分包的设置,这个地方已经存在了,所以我们也不需要添加了,接下来我们只需要改编译模式,将我们这个测试主页作为启动页面进行测试,已经启动了,现在我们单击任意组,单击以后我们可以看到这个地方,首先这个界面上它已经发生了切换,然后我们可以看一下tempData的打印情况,在这个里面我们看一下它里面它写法,是list[1].open,这是它的一个属性名称,后面这个true是它的一个值,然后上面这个也是list[0].open等于false,这是它的,而且数据更新量非常小,只有这一条数据 它这个数据量是非常小的,这是关于按索引法局部更新数据对象的一种写法,我们直接这样写也可以,但是我们如果是照着我们这个代码里面刚才看到的这种方式,就这样一种方式这样去写可能会更加清晰一点,先创建一个临时对象,然后往临时对象里边去准备我们要更新的数据属性、准备要更新的数据,所有的都准备好以后再统一调用setData,然后进行一个设置,这就是局部更新的方法,这个代码演示我们就说到这里。 在使用setData更新数据列表的时候,优先选择计算属性进行局部更新,如果有两条以上的数据需要更新,可以并排写多条计算属性,这节课我们就讲到这里。 点击查看:recycle-view 上面的网址是本课涉及的文档地址。 这节课我们主要学习了有关setData调用相关的优化技巧,下节课我们学习网络请求相关的优化技巧。 最后看一下思考题,这里有个问题请你思考一下,我们知道小程序为了保证整体上所有程序的流畅运行限制了网络请求的并发数最大为10,如果同时请求数达到了这个数字wx.request的请求将无法继续发出,后来在小程序基础库版本1.40更新以后,将这个地方进行优化了,超出的请求它不再被直接拒绝,而是放入了一个队列中排队,稍后等设备资源允许了以后,它会重新发起,只要我们使用的小程序基础库版本大于1.40便不会有10个最大限制这个问题,这样一来貌似关于并发限制这个问题便不是问题了。但是这里仍然有另外的一个问题,程序里面的并发请求它们的优先级往往并不是一样的,例如对后端数据接口的调用这类请求的优先级往往都比较高,而对于打点 日志的接口调用这类请求的优先级就比较低,那么有没有办法在网络请求发出的时候就给请求操作安排一个优先级,让高优先级的网络请求先执行呢?下节课我们就一起来深入探讨一下这个问题。
2022-07-15 - 2022-03-04
- svgaplayer-weapp 发布,支持在微信小程序播放 SVGA 动画。
具体请参阅 GitHub 仓库 https://github.com/svga/svgaplayer-weapp 示例代码片段 https://developers.weixin.qq.com/s/u2JBSOmy7rqU
2021-06-05 - 使用web-view组件加载网页,禁止页面下拉显示“网页由xxx.com提供”
在小程序开发时,总有免不了使用web-view加载网页的时候,但是如果你的网页实现了overflow-y: auto;使网页可滑动查看长内容,此时在小程序页面就会出现:手指滑动页面的时候,没有触发网页内部的滚动,而是出现了整个html页面被下拉,而显示出“网页由xxx.com提供”的字样! 无论是不想暴漏域名还是不想影响网页内部的滑动功能,此时都需要禁止这种情况 下面说下我得解决方法,需要在你的网页里设置(不是在小程序页面设置): 1)html,body{height:100%;overflow:hidden;} 2).warp-cont{ position: fixed; position: -ms-device-fixed; top: 0px; left: 0px; right: 0px; bottom: 0px; } 其中.warp-cont元素是我整个页面的所有内容的父元素,设置了以上两个css之后,页面就不能滑动了 对了,如果设置了以上内容还不起作用的话,可能是我的页面还设置了meta,可以同时设置一下 <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1" />
2021-06-09 - 小程序TabBar动画技巧
小程序实现TabBar创意动画(文末附完整源代码) 小程序日益增多的情况下,UI风格显得越来越重要,在页面中如果能让[代码]TabBar[代码]个性化一点,加一些小交互,用户体验会大大提升。由于小程序对[代码]svg[代码]不太友好,所以我们尽量使用[代码]css[代码]动画进行实现。之前文章小程序开发技巧中提到过[代码]TabBar[代码]自定义方案,感兴趣的可以了解一下。下面就分享一下今天写的几个交互效果,文末也会分享源代码。记得点赞+关注+收藏! NO.1 这种效果主要使用了[代码]transform[代码]和[代码]opacity[代码]来实现。文字默认隐藏并缩小,点击后[代码]icon[代码]图标[代码]transform[代码]的[代码]y轴[代码]方向上移,同时控制文字的[代码]opacity[代码]。圆形块根据点击的[代码]index[代码]去动态计算[代码]x轴[代码]的偏移位置即可。 [图片] 核心css代码(完整代码见文末): [代码] .tabbar .item .text{ position: absolute; width: 100%; bottom: 10rpx; text-align: center; font-size: 22rpx; opacity: 0; transition: all .8s; transform: scale(0.8); width: 100%; } .tabbar .item.active .text{ opacity: 1; transform: scale(1); } .tabbar .item.active .icon{ color: #3561f5; transform: translateY(-55rpx); } .tabbar .item .icon{ font-size: 50rpx!important; text-align: center; transition: all .8s; } [代码] NO.2 这个效果用到一个css动画工具库:bouncejs,它可以在线生成css动画,然后复制到项目中使用即可。下方效果采用跳跃式切换,整体看上去非常有活力。使用了[代码]animation[代码]动画。由于css动画代码过多,想看完整代码见文末[代码]github[代码]地址。 [图片] NO.3 下方这个效果还是用bouncejs在线编辑,编辑完成后只需要点击后给相应的元素添加类名即可。 [图片] 结尾 如需源代码可以移步github。 👉欢迎关注+收藏+点赞,感谢支持~
2021-06-17 - web-view组件中网页向小程序传输数据案例
H5页面示例代码: <!DOCTYPE html> <html> <body> <div><p>微信公众平台007</p></div> </body> <script type=“text/javascript” src=“https://res.wx.qq.com/open/js/jweixin-1.3.2.js”></script> <script> wx.miniProgram.postMessage({ data: ‘foo’}); wx.miniProgram.navigateTo({url: ‘/pages/index/index’}); </script> </html> 小程序示例代码: <web-view src=“https://wx.xxxxx.com/wxgzpt.html” bindmessage=“gettest”></web-view> gettestx(e) { console.log(e.detail); }, bindmessage在网页向小程序 postMessage 时,会在特定时机(小程序后退、组件销毁、分享)触发并收到消息。 ** 如果还有其他问题,请留言!!! 收到后会马上回复!!!**
2021-06-24 - 情侣券-选中卡片翻转动画
效果 [图片] 代码 [代码].animt { animation: turn 1.2s; } @keyframes turn { 0% { transform: perspective(150px) rotateY(0deg); } 50% { transform: perspective(150px) rotateY(0deg); } 100% { transform: perspective(150px) rotateY(179.9deg); } } [代码] 总结 情侣券随机券三篇动画完结了。 回顾一下顺序: 用户进入页面卡片洗牌:效果实现 用户点击卡片进行翻转(本篇) 翻转之后显示卡片内容:效果实现
2020-11-12 - 推荐一个自定义导航栏开源库
前言大家都知道官方提供的小程序导航栏相对有限,那么我们如何应对产品大大无限的需求呢? 那么肯定就需要自定义导航栏,而今天我要给大家推荐一款很棒的自定义导航栏开源项目。 少啰嗦,看效果。⬇️ 看效果[图片] [图片][图片] [图片] [图片] [图片] [图片] 看属性[图片] 如何使用?配置 app.json 中的 navigationStyle 和 usingComponents { "window": { "backgroundTextStyle": "light", "navigationBarBackgroundColor": "#fff", "navigationBarTitleText": "自定义导航栏", "navigationBarTextStyle": "black", "navigationStyle": "custom" }, "usingComponents": { "navBar": "/components/navBar/navBar" }, "sitemapLocation": "sitemap.json" } 页面代码: 上地址地址:https://github.com/lingxiaoyi/navigation-bar
2020-08-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,近期开放。 [代码] my.setPageScrollable({ scrollable: true, success: res => { console.log(res); }, fail: err => { console.log(err); }, complete: res => { console.log(res); } }) [代码] 20250618. API已开放,支付宝小程序测试时发现bug,安卓设置禁止滚动后,弹窗内的可滚动区域也会被禁止,IOS正常,且该问题暂时无法解决。 原因: 由于系统实现层面的差异,安卓与 iOS 对于滚动禁止的层级控制存在区别: 安卓端采用 Webview 级滚动限制(全页面锁定),生效时界面及所有弹层均不可滚动; iOS 端采用组件级滚动限制(局部锁定),当弹层激活时会智能区分层级,仅限制底层页面滚动而保持弹层可滚动。
06-18 - 小程序的各种炫酷样式、炫酷动画
收集了上百种的小程序样式,拿来即用,源码公开 各种各样的样式都有,只有你想不到,没有做不到的 什么样的场景都有 源码公开,自行下载 可通过扫描二维码查看样式效果 ## 1、初衷就是收集,后续也会一直的收集并更新下去 *** ## 2、项目只在小程序上测试过,其他平台还需自行测试使用 *** ## 3、如果你有好的样式,可以发送到我的邮箱 1228742150@qq.com *** ## 4、部分样式是从别的地方拿过来的,我也有注明出处,如果你发布到其他的地方,也请尊重别人的劳动成果,注明出处。 *** ## 5、如有任何冒犯的地方,可联系作者 [图片]
2021-07-24 - 2021-03-31
- mina-lazy-image: 图片懒加载自定义组件
Github: https://github.com/alexayan/mina-lazy-image 功能 图片在视口中出现才进行加载显示,优化页面性能 使用方法 安装组件 [代码]npm install --save mina-lazy-image [代码] 在页面的 json 配置文件中添加 mina-lazy-image 使用此组件需要依赖小程序基础库 2.2.2 版本,同时依赖开发者工具的 npm 构建。具体详情可查阅官方 npm 文档。 [代码]{ "usingComponents": { "mina-lazy-image": "mina-lazy-image/index" } } [代码] WXML 文件中引用 mina-lazy-image [代码]<mina-lazy-image src="{{src}}" mode="widthFIx" image-class="custom-class-name"/> [代码] mina-lazy-image 的属性介绍如下: 字段名 类型 必填 描述 src String 是 图片链接 placeholder String 否 占位图片链接 mode String 否 请参考 image 组件 mode 属性 webp Number 否 请参考 image 组件 webp 属性 showMenuByLongpress Boolean 否 请参考 image 组件 show-menu-by-longpress 属性 styles String 否 设置图片样式 viewport Object 否 默认为 {bottom: 0},配置图片显示区域 mina-lazy-image 外部样式类 [代码]image-class[代码], [代码]image-container-class[代码]
2020-01-09 - 聊聊如何给一个小程序历史老项目“减压”
前言 在日常的工作中,由于业务或者工作安排的需要,有时候需要我们参与到一些曾经没有接触过却 历史悠久 的项目当中,如果这个项目创建初期,创建者有很好的 前瞻性,并且严格遵循 code preview 等项目开发工作流,那代码看上去就会像是同一个人写出来一样,十分规范;否则,将会逐渐沦为一个 茅坑代码集合。 接下来我想分享一下近期对公司的一个小程序项目做的一些优化工作,会分别从以下几个方面进行阐述: 项目现状 项目拆解 搭建工具平台 项目地址 项目现状 1. 子模块分包不完全,存在子包内文件相互引用的情况 2. 个别没有用到的图片等静态资源文件没有及时删除,导致包体积过大,无法生成预览码 3. 测试同学反馈小程序测试流程过于繁琐,复杂,加大测试工作量和小程序的出错率 项目拆解 因此,需要针对以上提到的三个问题对这个项目进行初步的基于项目目录结构层面上的优化(不涉及到项目里面的业务代码,组件等冗余代码的优化) 其实也很容易理解,当你刚接手一个项目的时候,想必是先对这个项目的目录结构有一个总体初步的认识。 一、 “子模块分包不完全,存在子包内文件相互引用的情况” 1. 分析 如果是微信小程序项目,我们可以通过以下两种方法去快速了解一个项目的模块分包情况: 打开根目录下的 [代码]app.json[代码] 文件,找到 [代码]subPackages[代码] 字段,这就是当前项目的所有子模块数组集合;(缺点:人工肉眼查找,不智能) [代码]// app.json { "pages": [], "subPackages": [ { "root": "A", "pages": [ "pages/A-a", "pages/A-b", .... ] }, { "root": "B", "pages": [ "pages/B-a", "pages/B-b", .... ] } ] } [代码] 通过微信提供的 cli 命令行工具,查看当前的分包情况;(优点:不仅智能,还能查看每个子模块压缩后的包大小) [代码]cli preview --project f://workspace/rainbow-mp/xinyu [代码] 效果如下: [图片] 通过cli工具分析出来的结果,我们可以很明显看出当前项目总共分了哪几个子模块,以及这些子模块经过微信压缩工具(实则微信开发者工具编译)之后的大小,由此得出的当前项目存在的问题有如下几点: 主包([代码]main[代码])体积已经超过了微信规定的 [代码]2MB[代码]最大值,无法生成预览码用于移动端测试(问题很严重); 子包分包不合理,将子包的子目录作为分包的入口(如:[代码]/daojia/pages[代码], [代码]/deptstore/pages[代码], [代码]/index/pages/userMaterial[代码], [代码]/shopping/pages[代码]),而不是将子包根目录本身作为拆包的入口,导致其余目录下的文件统一打包到了主包中,造成主包体积变大; 2. 细分拆包 换句话说,如果我们把子包的分包不合理的问题给解决了,主包([代码]main[代码])的体积过大的问题自然而然也就解决了。 定位到问题就相当于解决了一半。 接下来就是想办法把子包根目录更改为模块打包的入口,这时候有人会说了,把 [代码]app.json[代码]文件下的[代码]subPackages[代码]模块数组字段的每个模块的[代码]root[代码]的值都改成子模块的根目录不就完事了吗? 没毛病,做法就是这样 但是,在修改之前得保证拆出来子包根目录下的其余子目录下的文件并没有被别的模块引用,否则就会出现文件引用错误的bug。 因此,大致有以下两种做法可以参考一下,我采用的是第一种: 对当前拆解子包外的其余模块(包括主包[代码]main[代码]) 进行全文件扫描,通过正则的方式过滤出 [代码]require[代码]引用到的文件路径,进而分析是否有子包下的文件被别的模块引用; 复写 [代码]require[代码] 方法(因为我们项目中文件引入的方式是[代码]require[代码]方式); 这里简单说明一下我不采用第二种方法的原因: [代码]require[代码]方法没有挂载在[代码]global[代码]全局下(因为接下来我需要写脚本在node环境下运行),因此需要重写一个如[代码]myRequire[代码]的自定义函数,然后挂载到[代码]global[代码]对象下,接着全局匹配所有文件的[代码]require[代码]字符替换为[代码]myRequire[代码]; [代码]require[代码]是动态引入,也就是说,可以在[代码]js[代码]文件的任意处进行引入,写在了小程序的业务代码中,因为接下来的脚本文件是运行在微信开发者工具以外的环境,缺失了微信小程序需要的模块包,会导致编写的脚本分析文件报错; 3. 编写脚本 接下来就正式步入编码阶段了,其实思路比较简单,我大致从以下几点进行这次脚本文件的编写: 1. 获取当前项目的所有一级目录:除去当前需要拆解的子包以外的所有一级目录都需要进行全局文件扫描 [代码] * 获取当前路径下的第一层目录 * @param {*} path 项目路径 * @param {*} targetDir 子包的目录名 */ const getOwnDirectorys = async(path, targetDir) => { const dir = await fs.promises.opendir(path) const result = [] for await (const dirent of dir) { const isDir = await isDirectory(`${path}/${dirent.name}`) // 也就是说,除去子包以外的目录都需要进行全局文件扫描 if (isDir && dirent.name !== targetDir) { result.push(`${path}/${dirent.name}`) } } return result } [代码] 2.过滤出每个一级目录下所有js和json文件 读取到目录了,那接下来自然就是遍历这些一级目录,然后获取到这些目录下的所有资源文件,那为什么只是过滤其中的[代码]js[代码]和[代码]json[代码]文件出来呢? 经过一段时间的接触之后,我发现: 子包的组件存在被别的模块包引用的情况,而小程序的组件引入主要是通过[代码]json[代码]文件的[代码]usingComponents[代码]字段; 子包的[代码]js[代码]文件也存在被别的模块包引用的情况,多数发生在一些工具函数,接口调用文件上; 因此,为了减少扫描文件的数量和提高效率,先针对项目中每个模块的[代码]js[代码]和[代码]json[代码]文件进行扫描匹配。 [代码]const filterJsAndJsonFiles = async (dirItem, filterDirs) => { const subDir = await fs.promises.opendir(dirItem) const jsFiles = [] const jsonFiles = [] for await (const dirent of subDir) { // 不需要分析的目录直接跳过 if (!filterDirs.includes(dirent.name)) continue const currentFiles = getAllFiles(`${PROJECT_NAME}${dirItem}/${dirent.name}`) // 过滤若干不同类型的文件数组 currentFiles.forEach(fileItem => { const extname = path.extname(fileItem) if (extname === '.json') { jsonFiles.push(fileItem) } if (extname === '.js') { jsFiles.push(fileItem) } }) } return { jsFiles, jsonFiles, } } [代码] 3. 文件查找 & 匹配 到这里,我们已经拿到了每个模块对应下的所有[代码]js[代码],[代码]json[代码]文件,接下来就需要针对这些文件进行分析了,大致思路分为以下两点: [代码]json[代码]文件分析:读取文件内容,将[代码]json[代码]字符串转为[代码]json[代码]对象格式,过滤出[代码]usingComponents[代码]字段,查找匹配出拆解子包的组件; [代码]{ "usingComponents": { "a": "./A/a", "b": "../B/b", "c": "../C/c" } } [代码] [代码]js[代码]文件分析:读取文件内容,通过[代码]正则表达式[代码]过滤出[代码]require[代码]引入的文件字符数组,从中查找匹配出拆解子包内的文件引用; [代码]const a = require('../../a.js') const b = require('./b.js') .... [代码] 脚本编写: json文件组件引入分析: [代码]/** * 统计json文件引入到的组件数组 * @param {*} jsonFile */ const listComponents = (jsonFile) => { if (!jsonFile) return const jsonDataStr = fs.readFileSync(jsonFile) const jsonData = JSON.parse(jsonDataStr) const componentList = [] if (jsonData) { const { usingComponents } = jsonData for (let key in usingComponents) { componentList.push({ name: key, path: usingComponents[key], filePath: jsonFile, }) } } return componentList } [代码] js文件[代码]require[代码]引入分析: [代码]const lineReg = /require\s*\([\'\"][\w\W]*[\'\"]\)/g // 子模块初始化 moduleResultMap[dirKey] = { componentImport: [], fileImport: {}, } jsFiles.forEach(filePath => { const fileContent = fs.readFileSync(filePath, 'utf8') // 为了避免无用查找,只针对前30行文本进行内容分析 const lines = fileContent.split(/\r?\n/).splice(0, 30) // 初始化子包目录文件名 moduleResultMap[dirKey]['fileImport'][filePath] = lines.reduce((acc, current) => { const matchArr = current.match(lineReg) return matchArr && matchArr.length > 0 && matchArr[0].indexOf('/daojia/') > -1 ? [...acc, matchArr[0]] : acc} , []) }) [代码] 4. 效果展示 最后,我是将分析出来的结果导出到了[代码]csv[代码]文件中,以便于为我接下来的拆包提供一份相对有保障的可视化的支持: [图片] 因为我这次主要是针对项目中[代码]daojia[代码]这个子模块进行一个拆包,因此分析的也是针对项目中其余子模块对该模块文件的一个引用情况做一个分析,表格中的每个字段所代表的意思我也大概说明一下: [代码]interface Table { module: string //子模块 type: string // 分析的文件类型 name: string // 分析的文件名 import: string // 引用的组件 || 引用的文件 filePath: string // 分析的文件路径 } [代码] 5. 终极展示 我们再回过头来看这幅图: [图片] 当我们成功地都将以下几个子包根目录从项目中剥离抽身之后,才会真的有底气地说:把[代码]app.json[代码]文件下的[代码]subPackages[代码]改下就好了 [代码]/daojia/pages -> /daojia /deptstore/pages -> /deptstore /index/pages/userMaterial -> /index /shopping/pages -> /shopping [代码] 再来看看现在的模块包分析表: [图片] 结论:经过合理化的分包之后,优化后的主包体积比优化前整整减少了35% 二、“个别没有用到的图片等静态资源文件没有及时删除,导致包体积过大,无法生成预览码” 1. 分析 在上一节里,我的做法概括起来:拆解子包,合理化模块打包 由于各种原因,一是在当前项目里面存在了过多的活动图片,重复的icon等等,但是当活动下架之后,这些图片并没有得到及时移除;二是组件引用混乱,相同组件的代码会同时出现在各个子模块里面; 这些无疑都是导致 项目体积过大 和造成 项目难以维护 的主要原因; 所以,在这一节里,也可以概括一句话:剔除无用资源,减少项目文件 2. 思路 总体来说,我也是通过写脚本来分析这些资源文件,思路如下: 无用图片资源查找 ① 根据不同模块配置信息,依次读取当前模块图片目录下的所有图片文件,过滤出图片文件名,存储在一个数组内; ② 然后全扫描这个项目内的所有文件,通过[代码]fs[代码]模块读取到文件的字符串内容,遍历图片数组,根据字符串匹配[代码]indexOf[代码],如果存在,则标记图片的引用路径;文件全扫描之后,如果找不到,则在路径一栏标记为“没有用到”; ③ 又或者匹配到的图片,则从数组内剔除出去,当扫描完所有的文件之后,剩下的就是没有引用到的图片文件了;(以上方法很蠢,但是胜在简单粗暴,希望有更好方法的朋友可以给我留言,不胜感激。) 组件引用分析 ① 根据不同模块的配置信息,依次读取当前模块内[代码]pages[代码]和[代码]components[代码]目录下的[代码]json[代码]文件(组件引入的入口),实则一个JSON字符串的转成JSON对象;[代码]JSON.parse(jsonstring)[代码] [代码]{ "usingComponents": { "a": "./A/a", "b": "../B/b", "c": "../C/c" } } [代码] ② 然后获取其相同文件名的[代码]js[代码]文件(页面或者组件的主体文件),通过[代码]fs[代码]模块读取文件内容,注意,这时候是得将这些富文本字符串转为DOM节点树结构对象,然后遍历节点对象,去匹配解析出对应的[代码]json[代码]组件引入入口文件下的json对象,然后分析出引用到的组件,实际就是节点标签名的匹配过程。 3. 脚本编写 这里就把一些核心代码贴出来就好,大家看看就好,不做过多阐述了 无用图片资源查找脚本 [代码] // 需要分析的图片目录地址 const imgDirPath = path.resolve(__dirname + '/../..' + imagesEntry); const imgFiles = getAllFiles(imgDirPath) if (imgFiles.length === 0) return // 只保留图片的文件名数组 const allImageFiles = imgFiles.map(imgItem => path.basename(imgItem)) // 查找所有的wxml, js文件 const allWxmlFiles = targetEntrys.reduce((acc, targetEntry) => { const targetDirPath = path.resolve(__dirname + '/../..' + targetEntry) const targetAllFiles = getAllFiles(targetDirPath, true) const allWxmlFiles = targetAllFiles.filter(filePath => { const extname = path.extname(filePath) return ['.wxml', '.js'].indexOf(extname) > -1 }) return [...acc, ...allWxmlFiles] }, []) // 遍历图片集数组,查找文件是否有引入 const result = allImageFiles.reduce((acc, imgName) => { const rowItems = allWxmlFiles.reduce((childAcc, filePath) => { const fileStr = fs.readFileSync(filePath, 'utf8') return fileStr.indexOf(imgName) === -1 ? childAcc : [...childAcc, { image: imgName, existPath: filePath, }] }, []) // 如果查找完毕数组为空,则说明没有引入到该图片 return rowItems.length === 0 ? [...acc, { image: imgName, existPath: '没有用到' }] : [...acc, ...rowItems] }, []) // 导出csv文件 const csv = new ObjectsToCsv(result) const exportPath = `${__dirname}${'/../..'}${BASE_EXPORT_IMG}/${imageReportFile}` await csv.toDisk(exportPath) [代码] 组件引用分析脚本 [代码] // 解析入口目录 const entryDir = path.resolve(__dirname + '/../..' + entry) const allFiles = getAllFiles(entryDir) if (allFiles.length === 0) return const filterFiles = getFilterFiles(allFiles, ['wxml', 'json']) // 组装导出对象数组数据 const pageWithComponents = filterFiles.reduce((acc, { jsonFile }) => { const current = path.basename(jsonFile, '.json') const currentDir = path.dirname(jsonFile) const components = listComponents(jsonFile) || [] if (components.length == 0) { return [...acc, { page: current, directory: currentDir, }] } else { // 输入wxml地址,转化为json标签对象 const fileJsonData = getFileJsonData(currentDir + `/${current}.wxml`) const childs = components.reduce((childAcc, { name, path: compPath }) => { let used if (fileJsonData) { used = isWxmlImportComponent(fileJsonData, name) used = used ? 'true' : 'false' } else { used = '解析出错' } return [...childAcc, { page: current, directory: path.resolve(currentDir), component: name, componentPath: compPath, used, }] }, []) return [...acc, ...childs] } }, []) // 导出csv文件 const csv = new ObjectsToCsv(pageWithComponents) const exportPath = `${__dirname}/../..${BASE_EXPORT_COMPONENT}/${exportFileName}` await csv.toDisk(exportPath) [代码] 结论:剔除没有引入的图片资源,减少项目体积;分析页面的组件引入,为项目的组件库的搭建提供数据支持。 三、“测试同学反馈小程序测试流程过于繁琐,复杂,加大测试工作量和小程序的出错率” 1. 分析 小程序测试步骤如下: 开发同学在功能提测阶段,需提供功能分支名给到测试同学,比如说:[代码]feature/monthcard[代码] 测试同学需要切换功能分支,并且拉取最新代码,执行 [代码]git checkout feature/montcard[代码] [代码]git pull origin feature/monthcard[代码] 打开 [代码]小程序开发者工具[代码],更改配置文件环境参数,如:[代码]config.js[代码],比如说修改成 [代码]env = test/dev/pre/pro[代码] 等等,切换到对应的接口环境进行测试 如只需要本地测试,直接在工具上面测试即可,如需要移动端测试,则需要点击 [代码]编译执行[代码] 生成小程序预览码,手机扫码测试 后期开发同学推了代码,需要同步测试同学定期去更新代码,执行: [代码]git pull origin 分支名[代码] 上面就是我司的关于小程序提测时的做法,相信这也是一部分公司的关于小程序的测试流程,又或者一部分公司的做法如下: 开发同学在本地生成测试预览码,然后将预览码截图发给测试同学进行测试(测试预览码有时效限制,需要开发每隔一段时间去重新生成一个新的预览码); 开发同学编写工具,将整个小程序代码包压缩放在内网的一个网页下,每次由测试下载到本地,解压,然后用开发者工具打开测试(一定程度自动化了测试流程和简化了测试同学的流程,但是依然很麻烦); 结论:总得来说,开发和测试同学都没有成功从上面的开发工作流中解耦出来。 2. 解决方案 基于上述的一些问题,我发现这一系列的测试步骤可以通过微信官方提供的ci命令行工具,是完全可以抽象出来,做成一个可以简化测试工作流的工具平台,听着是不是很棒? 下面就是我的一些调研发现: miniprogram-ci 是从微信开发者工具中抽离的关于小程序/小游戏项目代码的编译模块。 开发者可不打开小程序开发者工具,独立使用 miniprogram-ci 进行小程序代码的上传、预览等操作。 微信ci文档地址 搭建工具平台 前端(js) React 搭建前端骨架(借用facebook提供 [代码]create-react-app[代码] 脚手架即可) Bootstrap 作为前端界面布局的ui框架库 后端(nodejs) 采用 [代码]Express[代码] web应用开发框架搭建即可 安装 [代码]miniprogram-ci[代码] 包(构建预览码,提交发版等) 安装 [代码]html2json[代码], [代码]objects-to-csv[代码] 包(用于项目静态资源使用分析等) ps: 这里就不对里面的技术细节做过多阐述了,具体可以查看文末的项目地址,我已经开源出来了。 效果展示: [图片] 项目地址 出于代码保密考虑,开源项目里面分析的小程序源码采用的是自己的一个小程序项目作为分析的基础项目: 小程序项目脚本分析项目地址 https://github.com/csonchen/wx-mall-components 小程序项目的自动化构建服务平台 https://github.com/csonchen/mpcode-manage
2021-02-23 - 小程序顶部自定义导航组件实现原理及坑分享
为什么使用自定义导航 对比默认导航栏,我们会更需要: 统一Android、IOS手机对于页面title的展示样式及位置 更丰富的导航栏定制效果,如添加home图标等 左上角返回事件的监听处理 统一实现双击返回顶部功能 自定义导航组件实现思路 自定义导航组件实现的核心是需要计算导航栏的真实高度 这里以官方文档->扩展能力中的Navigation组件为例分析实现思路。当使用"navigationStyle": "custom"时,默认导航被移除,页面的开始位置变成了屏幕顶部,这时我们需要实现的导航栏是在状态栏下面。 导航栏的真实高度=状态栏高度+导航栏内容。 [图片] 使用wx.getSystemInfo获取到statusBarHeight便是导航栏的高度,但是导航栏内容高度呢? 有人可能觉得导航栏内容高度顾名思义就是导航栏内容高度啊,内容撑起还用管嘛!要,必须要! 因为右上角胶囊按钮是原生加载的,我们的导航栏内容需要正好贴在胶囊的下方且垂直居中。 导航栏内容高度=(胶囊按钮的顶部距离 - 状态高度)*2 + 胶囊高度 [图片] 如何计算胶囊的数据呢?幸运的是我们有 wx.getMenuButtonBoundingClientRect() 获取胶囊按钮的布局位置信息,那么动态计算导航栏的内容高度就很方便啦。 好了,以上就是动态计算的核心思路,我们再来看官方Navigation组件高度是怎么实现的 [代码]page{--height:44px;--right:190rpx;} .weui-navigation-bar .android{--height:48px;--right:222rpx} .weui-navigation-bar__inner{ position:fixed;top:0;left:0;z-index:5001;display:flex;align-items:center; height:var(--height);padding-right:var(--right);width:calc(100% - var(--right)) } [代码] 导航栏内容的高度是通过- -height这个css变量提前声明好的,安卓机型会重新覆盖为新的css变量值,目前没发现有适配问题。 官方就是官方啊,具体尺寸都知道,那就不用一番计算周折啦,直接拿来主义即可。 导航的布局位置已经搞定啦,剩下就是写具体的内容,不同业务实现需求不同这里就不一一赘述了。 完善官方顶部导航组件 本着拿来主义,直接使用官方Navigation组件,但在实际业务开发中还是遇到不少需要自定义的需求,就比如: loadding样式没实现 标题内容超出没有出现省略号 和原生顶部的样式不兼容,导致单个页面引入时跳转有明显差异出现 没有双击返回顶部功能开关功能 引入页面需要获取导航栏的高度,来控制其他元素距离顶部的位置, 不能根据页面栈数据动态显示隐藏back按钮, 针对以上需求,我们对官方的组件进行二次完善开发,满足常规的自定义需求绰绰有余,直接引入开箱即用。 源码使用示例 https://github.com/YuniorZen/minicode-debug/tree/master/minicode02 [图片] 使用说明 [代码]/*自定义头部导航组件,基于官方组件Navigation开发。*/ <navigation-bar title="会员中心" bindgetBarInfo="getBarInfo"></navigation-bar> [代码] 组件属性列表 属性 类型 描述 bindgetBarInfo function 组件实例载入页面时触发此事件,首参为event对象,event.detail携带当前导航栏信息,如导航栏高度 event.detail.topBarHeight bindback function 点击back按钮触发此事件响应函数 backImage string back按钮的图标地址 homeImage string home按钮的图标地址 ext-class string 添加在组件内部结构的class,可用于修改组件内部的样式 title string 导航标题,如果不提供为空 background string 导航背景色,默认#ffffff color string 导航字体颜色 dbclickBackTop boolean 是否开启双击返回顶部功能,默认true border boolean 是否显示顶部导航下边框分割线,默认false loading boolean 是否显示标题左侧的loading,默认false show boolean 显示隐藏导航,隐藏的时候navigation的高度占位还在,默认true left boolean 左侧区域是否使用slot内容,默认false center boolean 中间区域是否使用slot内容,默认false Slot name 描述 left 左侧slot,在back按钮位置显示,当left属性为true的时候有效 center 标题slot,在标题位置显示,当center属性为true的时候有效 自定义顶部导航目前存在的坑 弹窗的背景蒙层无法覆盖原生胶囊按钮 页面下拉刷新的圆点会被自定义导航遮盖 如果要自定义顶部导航,以上问题避免不了,只能忍着接受。 目前还没遇到完美的解决方案,针对下拉刷新圆点被遮挡的问题微信官方还在需求开发中,如果你有好的想法欢迎留言反馈,一起学习交流。
2019-10-31 - 用pages路径生成的小程序码,路径变了怎么办,怎么兼容老的小程序码
用pages路径(比如:pages/home/home)生成的小程序码,已经分享给用户了,现在要进行项目重构,导致home文件路径改变了(比如变为:pages/newHome/home),用户用重构后的小程序扫描已分享出去的小程序码,跳转到微信错误页,提示找不到该页面,如下。请问,重构后,该怎么兼容老的小程序码呢? [图片]
2019-06-14 - 微信小程序下载excel保存本地如何实现?
微信小程序如何实现和游览器一样的下载效果,现在使用小程序只能先通过downloadFile下载成临时文件再通过wx.openDocument打开或者生成下载附件URL,复制到游览器下载,用户体验降低
2020-09-10 - 如何在小程序中快速实现环形进度条
在小程序开发过程中经常涉及到一些图表类需求,其中环形进度条比较属于比较常见的需求 [图片] [中间的文字部分需要自己实现,因为每个项目不同,本工具只实现进度条] 上图中,一方面我们我们需要实现动态计算弧度的进度条,还需要在进度条上加上渐变效果,如果每次都需要自己手写,那需要很多重复劳动,所以决定为为小程序生态圈贡献一份小小的力量,下面来介绍一下整个工具的实现思路,喜欢的给个star咯 https://github.com/lucaszhu2zgf/mp-progress 环形进度条由灰色底圈+渐变不确定圆弧+双色纽扣组成,首先先把页面结构写好: .canvas{ position: absolute; top: 0; left: 0; width: 400rpx; height: 400rpx; } 因为进度条需要盖在文字上面,所以采用了绝对定位。接下来先把灰色底圈给画上: const context = wx.createContext(); // 打底灰色曲线 context.beginPath(); context.arc(this.convert_length(200), this.convert_length(200), r, 0, 2*Math.PI); context.setLineWidth(12); context.setStrokeStyle('#f0f0f0'); context.stroke(); wx.drawCanvas({ canvasId: 'progress', actions: context.getActions() }); 效果如下: [图片] 接下来就要画绿色的进度条,渐变暂时先不考虑 // 圆弧角度 const deg = ((remain/total).toFixed(2))*2*Math.PI; // 画渐变曲线 context.beginPath(); // 由于外层大小是400,所以圆弧圆心坐标是200,200 context.arc(this.convert_length(200), this.convert_length(200), r, 0, deg); context.setLineWidth(12); context.setStrokeStyle('#56B37F'); context.stroke(); // 辅助函数,用于转换小程序中的rpx convert_length(length) { return Math.round(wx.getSystemInfoSync().windowWidth * length / 750); } [图片] 似乎完成了一大部分,先自测看看不是满圆的情况是啥样子,比如现在剩余车位是120个 [图片] 因为圆弧函数arc默认的起点在3点钟方向,而设计想要的圆弧的起点从12点钟方向开始,现在这样是没法达到预期效果。是不是可以使用css让canvas自己旋转-90deg就好了呢?于是我在上面的canvas样式中新增以下规则: .canvas{ transform: rotate(-90deg); } 但是在真机上并不起作用,于是我把新增的样式放到包裹canvas的外层元素上,发现外层元素已经旋转,可是圆弧还是从3点钟方向开始的,唯一能解释这个现象的是官方说:小程序中的canvas使用的是原生组件,所以这样设置css并不能达到我们想要的效果 [图片] 所以必须要在canvas画图的时候把坐标原点移动到弧形圆心,并且在画布内旋转-90deg [图片] // 更换原点 context.translate(this.convert_length(200), this.convert_length(200)); // arc原点默认为3点钟方向,需要调整到12点 context.rotate(-90 * Math.PI / 180); // 需要注意的是,原点变换之后圆弧arc原点也变成了0,0 真机预览效果达成预期 [图片] 接下来添加环形渐变效果,但是canvas原本提供的渐变类型只有两种: 1、LinearGradient线性渐变 [图片] 2、CircularGradient圆形渐变 [图片] 两种渐变中离设计效果最近的是线性渐变,至于为什么能够形成似乎是随圆形弧度增加而颜色变深的效果也只是控制坐标开始和结束的坐标位置罢了 const grd = context.createLinearGradient(0, 0, 100, 90); grd.addColorStop(0, '#56B37F'); grd.addColorStop(1, '#c0e674'); // 画渐变曲线 context.beginPath(); context.arc(0, 0, r, 0, deg); context.setLineWidth(12); context.setStrokeStyle(grd); context.stroke(); 来看一下真机预览效果: [图片] 非常棒,最后就剩下跟随进度条的纽扣效果了 [图片] 根据三角函数,已知三角形夹角根据公式radian = 2*Math.PI/360*deg,再利用cos和sin函数可以x、y,从而计算出纽扣在各部分半圆的坐标 const mathDeg = ((remain/total).toFixed(2))*360; // 计算弧度 let radian = ''; // 圆圈半径 const r = +this.convert_length(170); // 三角函数cos=y/r,sin=x/r,分别得到小点的x、y坐标 let x = 0; let y = 0; if (mathDeg <= 90) { // 求弧度 radian = 2*Math.PI/360*mathDeg; x = Math.round(Math.cos(radian)*r); y = Math.round(Math.sin(radian)*r); } else if (mathDeg > 90 && mathDeg <= 180) { // 求弧度 radian = 2*Math.PI/360*(180 - mathDeg); x = -Math.round(Math.cos(radian)*r); y = Math.round(Math.sin(radian)*r); } else if (mathDeg > 180 && mathDeg <= 270) { // 求弧度 radian = 2*Math.PI/360*(mathDeg - 180); x = -Math.round(Math.cos(radian)*r); y = -Math.round(Math.sin(radian)*r); } else{ // 求弧度 radian = 2*Math.PI/360*(360 - mathDeg); x = Math.round(Math.cos(radian)*r); y = -Math.round(Math.sin(radian)*r); } [图片] 有了纽扣的圆形坐标,最后一步就是按照设计绘制样式了 // 画纽扣 context.beginPath(); context.arc(x, y, this.convert_length(24), 0, 2 * Math.PI); context.setFillStyle('#ffffff'); context.setShadow(0, 0, this.convert_length(10), 'rgba(86,179,127,0.5)'); context.fill(); // 画绿点 context.beginPath(); context.arc(x, y, this.convert_length(12), 0, 2 * Math.PI); context.setFillStyle('#56B37F'); context.fill(); 来看一下最终效果 [图片] 最后我重新review了整个代码逻辑,并且已经将代码开源到https://github.com/lucaszhu2zgf/mp-progress,欢迎大家使用
2020-05-27 - 【集合】花了 3 个月,写了 40 篇小程序文章
前言 花了3个月,一共输出 40 篇文章,这也算是一个阶段性的总结。在此做个文章分类集合,希望对大家有所帮助。 小程序前端 《专治按钮效果不明显(扩散动画效果)》 《小程序开发必备,这 5 款超实用开源插件!》 《仿抽奖助手奖品详情页面向上翻页效果》 《推荐 5 款高仿知名应用的开源项目!》 《生成海报很复杂?有它轻松搞定!》 《推荐一个自定义导航栏开源库》 《前端开发,必备的学习网站!》 《情侣券-领取动画分析》 《通过玩游戏来学习CSS》 《CSS不规范导致的布局显示问题》 《微信小程序如何引入npm包?》 《情侣券-选中卡片翻转动画》 《CSS:实现卡片洗牌效果》 《情侣券 v2.0 使用的 4 款开源组件》 小程序云开发 《使用聚合函数实现打卡排行榜》 《使用云开发做内容安全检查》 《云开发-实现分页功能》 《云开发-实现维护用户表》 《云开发-实现模糊搜索》 《云开发实战:实现订阅消息推送》 《如何优雅的调用云函数?》 《云开发实战-如何维护用户表?(优化版)》 《推荐 10 款使用云开发的开源项目》 《云开发:CloudBase CMS 实战使用指南》 小程序产品 《如何利用小程序提高10倍活动效果?》 《实战:让数据说话之自定义埋点分析》 《#小程序云开发挑战赛#-情侣券》 《小程序运营必备的 3 款官方小程序》 《小程序云开发挑战赛:情侣券 v1.1 版本迭代》 《云开发挑战赛复赛:情侣券介绍PPT》 《参加#小程序云开发挑战赛#复赛收获》 《云开发挑战赛决赛:情侣券介绍PPT》 通用知识 《如何重构?》 《如何高效学习?》 《如何看懂时序图?》 《为什么优秀的程序员都写博客?》 《我从 Android 转到 微信小程序 的思考》 最后 后续计划会写更多云开发相关的文章以及小程序基础系列学习文章。
2020-11-24 - 小程序实现的列表上下拖拽排序
先来看看效果 快速拖拽排序测试演示视频地址:https://v.qq.com/x/page/r3207k4fxe1.html 完整拖拽排序效果演示视频地址:https://v.qq.com/x/page/y3207g6agur.html [图片] 采用技术:uni-app 接下来分析分析实现该效果所需要用到的标签 元素是通过拖拽进行排序的,此处采用的是官方出的 <movable-area> <movable-view> 两位标签大佬解决移动的问题 (主要是相信官方支持的动画会比自己搞更加丝滑一些)。支持拖拽到上下边界,检查可视区域的位置并自动进行滚动, 此处就需要我们的 <scroll-view> 标签大佬坐镇了。标签的选择搞定了,再来了解了解这些标签要用到的重点属性 movable-view 想要移动就必须作为 movable-area 的直接子元素,且 movable-area 必须设置 width,height 属性 (还有些提示可以查看文档)。movable-view 的 x, y 属性决定了 movable-view 再 movable-area 所处的位置 (是不是猜出了要搞些什么东东了)scroll-view 滚动到指定位置可以通过控制 scroll-top 的属性值来进行控制滚动 接下来就是怎么个实现思路,先来捋捋实现的步骤 列表该如何渲染如何控制拖拽元素的跟随如何使拖拽中的元素与相交互的元素进行位置调换如何判断拖拽元素至上下边界滚动屏幕如何使页面的滚动与拖拽时的滚动互不影响 描述完宏观的蓝图,接下来就是代码小细节,客官请随我来 一、解决列表渲染问题 /** * 上面说到 movable-view 可以通过 x,y 决定它的位置, 且 movable-area 需要设置 widht,height 属性 * 配置完这些属性 movable-view 就可以再 movable-area 愉快的拖拽玩耍了 * 思路: * 1. 通过列表的数量乘于显示列表项的高度得出最终可拖拽区域的总高度,赋值给 movable-area * 2. 扩展列表项一些字段,此处使用 y 保存当前项距离顶部位置, idx 保存当前项所在列表的下标 / // 伪代码 // js initList(list) { this.areaHeight = list.length * this.height; // aeraHieght 可拖拽区域总高度, height 为元素所需高度 this.internalList = list.map((item, idx) => { return { ...item, y: idx * this.height, // movable-view 当前项所处的高度 idx: idx, // 当前项所处于列表的下标,用于比较 animation: true, // 主要用于控制拖拽的元素要关闭动画, 其他的元素可以保留动画 } }) } // html 二、 如何控制拖拽元素的跟随 // 主要是通过监听 movable-view 的 touchstart touchmove touchend 三个事件完成拖拽动作的起始、移动、结束。 // methods { _dragStart(e){ // 取得触摸点距离行顶部距离 this.deviationY = (e.mp.touches[0].clientY - this.wrap.top) % this.height; this.internalList[idx].animation = false; // 关闭当前拖拽元素的动画属性 this.activeIdx = idx; // 保存当前拖拽元素的下标 }, _dragMove(e) { const activeItem = this.internalList[this.activeIdx]; if (!activeItem) return; // 实时取得触摸点的位置信息 const clientY = e.mp.touches[0].clientY; let touchY = clientY - this.wrap.top - this.deviationY + this.scrollTop; if (touchY <= 0 || touchY + this.height >= this.areaHeight) return; activeItem.y = touchY; // 拖拽元素的移动秘密就在于此 } } 三、如何使拖拽中的元素与相交互的元素进行位置调换 // 上述代码解决了当前拖拽元素的位置移动问题, 接下来就需要解决拖拽元素和上下元素交互的问题 // methods { __dragMove(e){ // ...同上代码一致 // 上下元素交互位置代码实现 for(let item of this.internalList) { if (item.idx !== activeItem.idx) { if (item.idx > activeItem.idx) { // 如果当前元素下标大于拖拽元素下标,则检查当前拖拽位置是否大于当前元素中心点 if (touchY > item.idx * this.height - this.height / 2) { [activeItem.idx, item.idx] = [item.idx, activeItem.idx]; // 对调位置 item.y = item.idx * this.height; // 更新对调后的位置 break; // 退出循环 } } else { // 如果当前元素下标小于拖拽元素下标,则检查当前拖拽位置是否小于当前元素中心点 if (touchY < item.idx * this.height + this.height / 2) { [activeItem.idx, item.idx] = [item.idx, activeItem.idx]; item.y = item.idx * this.height; break; } } } } } } 四、如何判断拖拽元素至上下边界滚动屏幕 // 将 movable-area 包裹在 scroll-view 标签中, 通过控制 scroll-top 的值来进行滚动 // 思路: 判断当前拖拽元素的位置信息与当前屏幕可视区域进行比较 // methods { _dragMove(e) { // ...同上代码 // 检查当前位置是否处于可视区域 if (activeItem.idx + 1 * this.height + this.height / 2 > this.scrollTop + this.wrap.top) { this.viewTop = this.scrollTop + this.height; // 往上滚动一个元素的高度 } else if (activeItem.idx * this.height - this.height / 2 < this.scrollTop ) { this.viewTop = this.scrollTop - this.height; // 往下滚动一个元素的高度 } } } 五、如何使页面的滚动与拖拽时的滚动互不影响 // 事实上我是通过一种取巧的方式, scroll-veiw 有一个 scroll-y 属性可以控制滚动方向 // 思路: // 1.不进行滚动的时候将 scroll-y 置为 true , 使用默认的滚动效果 // 2.当进入拖拽排序状态时则将 scroll0y 置为 false, 滚动通过拖拽代码比较计算滚动位置 完整代码: 主要小程序上的插槽不允许往外传值、所以自定义元素实现的方式相比于H5实现Vue的方式比较别扭。 因为有多个地方需要用到排序功能,所以边抽离了 js 部分进行混入。 // DargSortMixin.js 文件 export default { props: { list: { type: Array, default() { return []; }, }, sort: { type: Boolean, default: false, }, height: { type: Number, default: 66, }, }, data() { return { areaHeight: 0, // 区域总高度 internalList: [], // 列表 activeIdx: -1, // 移动中激活项 deviationY: 0, // 偏移量 // 包裹容器信息 wrap: { top: 0, height: 0, }, viewTop: 0, // 指定滚动高度 scrollTop: 0, // 容器实时滚动高度 scrollWithAnimation: false, canScroll: true, }; }, created() { // 组件使用选择器,需用使用this const query = this.createSelectorQuery(); query .select('#scroll-wrap') .boundingClientRect(rect => { if (rect) { this.wrap = { top: rect.top, height: rect.height, }; } }) .exec(); }, watch: { list: { handler(val) { this.initList(val); }, immediate: true, }, }, methods: { getList() { return this.internalList .sort((a, b) => { return a.idx - b.idx; }) .map(item => { let newItem = { ...item }; delete newItem.y; delete newItem.idx; delete newItem.animation; return newItem; }); }, initList(list) { this.areaHeight = list.length * this.height; this.internalList = list.map((item, idx) => { return { ...item, y: idx * this.height, idx, animation: true, }; }); }, _dragStart(e, idx) { // 取得触摸点距离行顶部距离 this.deviationY = (e.mp.touches[0].clientY - this.wrap.top) % this.height; this.internalList[idx].animation = false; // 关闭动画 this.activeIdx = idx; this.scrollWithAnimation = true; this.canScroll = false; }, _dragMove(e) { const activeItem = this.internalList[this.activeIdx]; if (!activeItem) return; // 保存触摸点位置和长按时中心一致 const clientY = e.mp.touches[0].clientY; let touchY = clientY - this.wrap.top - this.deviationY + this.scrollTop; if (touchY <= 0 || touchY + this.height >= this.areaHeight) return; activeItem.y = touchY; // 设置位置 // 检查元素和上下交互元素的位置 for (const item of this.internalList) { if (item.idx !== activeItem.idx) { if (item.idx > activeItem.idx) { if (touchY > item.idx * this.height - this.height / 2) { [activeItem.idx, item.idx] = [item.idx, activeItem.idx]; // 对调位置 item.y = item.idx * this.height; // 更新位置 break; } } else { if (touchY < item.idx * this.height + this.height / 2) { [activeItem.idx, item.idx] = [item.idx, activeItem.idx]; // 对调位置 item.y = item.idx * this.height; // 更新位置 break; } } } } // 检查当前位置是否处于可视区域 if ( (activeItem.idx + 1) * this.height + this.height / 2 > this.scrollTop + this.wrap.height ) { this.canScroll = true; activeItem.y = activeItem.idx * this.height; this.$nextTick(() => { this.viewTop = this.scrollTop + this.height; }); } else if (activeItem.idx * this.height - this.height / 2 < this.scrollTop) { this.canScroll = true; activeItem.y = activeItem.idx * this.height; this.$nextTick(() => { this.viewTop = this.scrollTop - this.height; }); } }, _dragEnd(e) { const activeItem = this.internalList[this.activeIdx]; if (!activeItem) return; activeItem.animation = true; activeItem.disabled = true; activeItem.y = activeItem.idx * this.height; this.activeIdx = -1; this.scrollWithAnimation = false; this.canScroll = true; }, _onScroll(e) { this.scrollTop = e.detail.scrollTop; }, }, }; // TheDragSortAreaList.vue 文件 import DragSortMixin from '@/mixins/DragSortMixin'; export default { name: 'TheDragSortTableList', mixins: [DragSortMixin], }; .active-item { z-index: 10; } .drag-item { background: $theme-color; color: $white !important; .count { color: $white !important; } }
2020-11-27 - 2019-04-17
- 2020-11-10
- 小程序代码前端混淆学习记录
群里有不少帖子提到小程序代码被扒,被复制上架的,当然作为我来讲是不必要有这种担心,因为我的小程序还没有到这种爆款的程度, 那么这个问题对我而言,也不能不防备,近期我在考虑这块,给手里的几个小程序加下前端的代码压缩和混淆 参考了不少资料,大部分方案都是推荐一下插件 https://gulpjs.com/ https://www.npmjs.com/package/gulp-uglify 有没有在这块有经验的, 我回头会截图几张前端压缩过的代码具体长什么样? [图片] 备注 我其实看过不少前端代码混淆的小程序项目,这种简单的混淆对于一个较真的开发同学而言是没有太多作用的,
2020-11-11 - 手写一个小程序自动化构建平台
1 前言 😈 如果你同时维护着多个小程序项目,那你每天是否花费了大量的时间在做这样一件时间,切换git分支 -> 执行编译 -> 打开小程序开发者工具 -> 上传小程序。 <br> 🧐 同时维护着5个小程序(两个微信小程序、两个支付宝小程序、一个字节跳动小程序),我发现我每天要花大量的时间做发布小程序的工作。为此我想到了打造一个类似Jenkins的小程序自动化构建平台,将发布小程序的任务移交给测试同事(是的,我就是这么懒)。 <br> 5分钟即可部署到您的服务器,觉得有帮助的话点个start吧。 2 先上项目界面 点击体验 账号: mp 密码: 123456 2.1 登录页 [图片] 2.2 主页 [图片] 2.3 主页带备注 [图片] 2.4 发布预览 [图片] 2.5 发布体验版 [图片] 3 技术实现 下面重点讲解小程序(微信小程序、支付宝小程序、字节跳动小程序)发布功能的实现,其他登录、预览等功能可以在我的github项目中查看。该功能分为三个部分,分别是: 下载github/gitlab项目 使用子进程编译项目 将编译后的代码上传 3.1 首先编写一个配置表,方便后续扩展其他小程序 [代码]const ciConfigure = { // 标识不同小程序的key,命名规范是`${项目名}_${小程序类型}` lzj_wechat: { // 小程序appID appId: 'wxe10f1d56da44430f', // 应用类型,可选值有: miniProgram(小程序)/miniProgramPlugin(小程序插件)/miniGame(小游戏)/miniGamePlugin(小游戏插件) type: 'miniProgram', // 项目下载地址,分为三类: // github地址: `https://github.com:${用户名,我的用户名是 lizijie123}/${代码仓库名,文档代码仓是 uni-mp-study}` // v3版本 gitlab地址: `${gitlab地址}/${用户名}/${代码仓库名}/repository/archive.zip` // v4版本 gitlab地址: `${gitlab地址}/api/v4/projects/${代码仓库id}/repository/archive` // tips: `${gitlab地址}/api/v3/projects`有返回值即为v3版本gitlab,`${gitlab地址}/api/v4/projects`有返回值即为v4版本gitlab,返回的数据中id字段就是代码仓库的id storeDownloadPath: 'https://github.com:lizijie123/uni-mp-study', // gitlab项目,则需要设置gitlab的privateToken,在gitlab个人中心可以拿到 privateToken: '', // 小程序打包构建命令 buildCommand: 'npm run build:wx', // 小程序打包构建完,输出目录与根目录的相对位置 buildProjectChildrenPath: '/dist/build/mp-weixin', // 微信小程序与支付宝小程序需要非对称加密的私钥,privateKeyPath是私钥文件相对根目录的地址,在微信公众平台中拿到 privateKeyPath: '/server/utils/CI/private/lzj-wechat.key', // 与微信小程序开发者工具中的几个设置相同 setting: { es7: false, minify: false, autoPrefixWXSS: false, }, }, lzj_alipay: { // 下文讲到支付宝小程序再补充完善 }, lzj_toutiao: { // 下文讲到字节跳动小程序再补充完善 }, } export default ciConfigure [代码] 3.1 获取github/gitlab项目 下载git项目采用download-git-repo <br> [代码]# 安装download-git-repo npm i download-git-repo -S [代码] 首先封装一个函数来计算项目地址,与项目存储在本地的路径 [代码]import ciConfigure from './utils/ci-configure' // 获取项目地址与本地存储地址 // @params miniprogramType: 小程序类型,与配置文件中的key的值对应 // @parmas branch: 分支名 // @params version: 版本号 // @return: { projectPath: 项目存储在本地的路径, storePath: 项目地址 } function getStorePathAndProjectPath (miniprogramType, branch, version) { let storePath = '' if (ciConfigure[miniprogramType].storeDownloadPath.includes('github')) { storePath = `${ciConfigure[miniprogramType].storeDownloadPath}#${branch}` } else { storePath = `direct:${ciConfigure[miniprogramType].storeDownloadPath}?private_token=${ciConfigure[miniprogramType].privateToken}` if (storePath.includes('v4')) { storePath += `&ref=${branch}` } else { storePath += `&sha=${branch}` } } const projectPath = path.join(process.cwd(), `/miniprogram/${miniprogramType}/${version}`) return { storePath, projectPath, } } [代码] 接着封装一个函数来下载项目 [代码]import * as downloadGit from 'download-git-repo' // 下载github/gitlab项目 // @parmas storePath: 项目地址 // @params projectPath: 项目存储在本地的路径 function download (storePath, projectPath) { return new Promise((resolve, reject) => { downloadGit(storePath, projectPath, null, err => { if (err) reject(err) resolve() }) }) } [代码] 3.2 使用子进程编译项目 使用shelljs来简化子进程(child_process)模块的操作 [代码]# 安装shelljs npm install shelljs -S [代码] 封装一个函数来执行shell命令 [代码]import * as shell from 'shelljs' // 执行shell命令 // @parmas command: 待执行的shell命令 // @params cwd: 待执行shell命令的执行目录 function execPromise (command, cwd) { return new Promise(resolve => { shell.exec(command, { // 值为true则开启新的子进程执行shell命令,false则使用当前进程执行shell命令,会阻塞node进程 async: true, silent: process.env.NODE_ENV === 'development', stdio: 'ignore', cwd, }, (...rest) => { resolve(...rest) }) }) } [代码] 封装编译项目的函数,这部分可以根据自己的项目自行调整 [代码]// 下载依赖包并执行编译命令 // @params miniprogramType: 小程序类型,与配置文件中的key的值对应 // @params projectPath: 项目存储在本地的路径 async build (miniprogramType, projectPath) { // 下载依赖包 await execPromise(`npm install`, projectPath) await execPromise(`npm install --dev`, projectPath) // 执行编译命令 await execPromise(ciConfigure[miniprogramType].buildCommand, projectPath) } [代码] 3.3 将编译后的代码上传(微信小程序版) 3.3.1 获取上传代码用的非对称加密私钥 登录小程序后台 -> 开发 -> 开发设置 -> 小程序代码上传中生成秘钥(配置文件中的privateKeyPath字段就是这里来的) 3.3.2 继续实现功能 使用miniprogram-ci来上传代码 [代码]# 安装 npm install miniprogram-ci -S [代码] 封装代码上传函数 [代码]import * as ci from 'miniprogram-ci' // 微信小程序上传代码 // @params miniprogramType: 小程序类型,与配置文件中的key的值对应 // @params projectPath: 项目存储在本地的路径 // @params version: 版本号 // @params projectDesc: 描述 // @params identification: ci机器人标识,这个可不传 async function upload ({ miniprogramType, projectPath, version, projectDesc = '', identification }) { const project = initProject(projectPath, miniprogramType) await ci.upload({ project, version, desc: projectDesc, setting: ciConfigure[miniprogramType].setting, onProgressUpdate: process.env.NODE_ENV === 'development' ? console.log : () => {}, robot: identification ? identification : null }) } // 创建ci projecr对象 // @params projectPath: 项目存储在本地的路径 // @params miniprogramType: 小程序类型,与配置文件中的key的值对应 function initProject (projectPath, miniprogramType) { return new ci.Project({ appid: ciConfigure[miniprogramType].appId, type: ciConfigure[miniprogramType].type, projectPath: `${projectPath}${ciConfigure[miniprogramType].buildProjectChildrenPath}`, privateKeyPath: path.join(process.cwd(), ciConfigure[miniprogramType].privateKeyPath), ignores: ['node_modules/**/*'], }) } [代码] 3.4 使用上述封装好的函数做一次完整的流程 [代码]// 上传小程序 // @params miniprogramType: 小程序类型,与配置文件中的key的值对应 // @params version: 版本号 // @params branch: 分支 // @params projectDesc: 描述 // @params projectPath: 项目存储在本地的路径 // @params identification: ci机器人标识,微信小程序用 // @params experience: 是否将当前版本设置为体验版,支付宝小程序用 async upload ({ miniprogramType, version, branch, projectDesc, identification, experience }) { // 获取项目地址与本地存储地址 const { storePath, projectPath } = getStorePathAndProjectPath(miniprogramType, branch, version) // 下载项目到本地 download(storePath, projectPath) // 构建项目 build(miniprogramType, projectPath) // 上传体验版 await wechatCi.upload({ miniprogramType, projectPath, version, projectDesc, identification, experience, }) } [代码] 4 其他小程序的上传 4.1 支付宝小程序 使用alipay-dev来上传代码 [代码]# 安装 npm install alipay-dev -S [代码] 4.1.1 获取上传代码用的非对称加密公钥与私钥 [代码]# 先在本地生成非对称加密的公钥与私钥 npx alipaydev key create -w [代码] 4.1.2 将刚刚生成的公钥设置到支付宝开发工具秘钥中 设置开发工具秘钥 -> 将公钥粘贴至开发工具公钥 -> 保存,即可得到工具ID(toolId)(将这里得到的toolId和私钥放置到配置文件中) 4.1.3 继续实现功能 完善支付宝小程序的配置文件 [代码]const ciConfigure = { lzj_wechat: { 省略 }, lzj_alipay: { // 同上 appId: '2021002107681948', // 工具id,支付宝小程序设置了非对称加密的公钥后会生成 toolId: 'b6465befb0a24cbe9b9cf49b4e3b8893', // 同上 storeDownloadPath: 'https://github.com:lizijie123/uni-mp-study', // gitlab项目,则需要设置gitlab的privateToken privateToken: '', // 同上 buildCommand: 'npm run build:ap', // 同上 buildProjectChildrenPath: '/dist/build/mp-alipay', // 同上 privateKeyPath: '/server/utils/CI/private/lzj-alipay.key', }, lzj_toutiao: { 省略 }, } [代码] 接着封装支付宝小程序上传代码函数 [代码]// 上传体验版 // @params miniprogramType: 小程序类型,与配置文件中的key的值对应 // @params projectPath: 项目存储在本地的路径 // @params version: 版本号 // @params experience: 是否将该版本设置为体验版 async function upload ({ miniprogramType, projectPath, version, experience }) { initProject(miniprogramType) const res = await ci.miniUpload({ project: `${projectPath}${ciConfigure[miniprogramType].buildProjectChildrenPath}`, appId: ciConfigure[miniprogramType].appId, packageVersion: version, onProgressUpdate: process.env.NODE_ENV === 'development' ? console.log : () => {}, experience: experience ? experience : false, }) if (res.qrCodeUrl) { return res.qrCodeUrl } } // 创建ci projecr对象 // @params projectPath: 项目存储在本地的路径 // @params miniprogramType: 小程序类型,与配置文件中的key的值对应 function initProject (projectPath: string, miniprogramType: string) { return new ci.Project({ appid: ciConfigure[miniprogramType].appId, type: ciConfigure[miniprogramType].type, projectPath: `${projectPath}${ciConfigure[miniprogramType].buildProjectChildrenPath}`, privateKeyPath: path.join(process.cwd(), ciConfigure[miniprogramType].privateKeyPath), ignores: ['node_modules/**/*'], }) } [代码] 4.2 字节跳动小程序 完善字节跳动小程序配置 [代码]const ciConfigure = { lzj_wechat: { 省略 }, lzj_alipay: { 省略 }, lzj_toutiao: { // 字节跳动小程序账号(登录时的那个) account: '', // 字节跳动小程序密码(登录时的那个) password: '', // 同上 storeDownloadPath: 'https://github.com:lizijie123/uni-mp-study', // 同上 privateToken: '', // 同上 buildCommand: 'npm run build:tt', // 同上 buildProjectChildrenPath: '/dist/build/mp-toutiao', }, } [代码] 使用tt-ide-cli来上传代码 [代码]# 安装 npm install tt-ide-cli -S [代码] 接着封装字节跳动小程序上传代码函数,注意:字节跳动小程序目前只能使用命令行的方式上传代码 [代码]// 上传体验版 // @params miniprogramType: 小程序类型,与配置文件中的key的值对应 // @params projectPath: 项目存储在本地的路径 // @params version: 版本号 // @params projectDesc: 描述 async upload ({ miniprogramType, projectPath, version, projectDesc }) { const currentPath = process.cwd() // 登录命令 const login = `npx tma login-e '${ciConfigure[miniprogramType].account}' '${ciConfigure[miniprogramType].password}'` // 上传命令 const up = `npx tma upload -v '${version}' -c '${projectDesc ? projectDesc : '暂无描述'}' ${projectPath}${ciConfigure[miniprogramType].buildProjectChildrenPath}` await execPromise(login, currentPath) await execPromise(up, currentPath) } [代码] 5 交流 项目已在生成环境中运行了一段时间了,再也不用工作到一半被叫去发布小程序了,下面是项目github地址,欢迎clone,觉得不错的话来点个Star吧。
2020-11-22 - 小程序自定义顶部导航栏实现相关总结
本文背景最近在小程序迭代过程中,打算引入自定义导航组件,这在之前是一个未接触的知识点,对我而言也是一种挑战 [图片] 本文内容本文主要列出我在实现过程中参考社区以及社区之外的文章 1)胶囊的布局位置及尺寸信息 https://developers.weixin.qq.com/miniprogram/dev/api/ui/menu/wx.getMenuButtonBoundingClientRect.html 2)状态栏高度信息 https://developers.weixin.qq.com/miniprogram/dev/api/base/system/system-info/wx.getSystemInfo.html 3)https://developers.weixin.qq.com/miniprogram/design/ 4)小程序组件—自定义顶部导航 https://juejin.im/post/6844903862852141070 5)微信小程序自定义导航栏组件 https://juejin.im/post/6844903860029358094 6)如何实现一个自定义导航栏? - https://developers.weixin.qq.com/community/develop/article/doc/000060f9cf8900246c789a36453413 7)小程序自定义导航栏的开发原理及编码思路实践 (附带可直接使用的自定义导航栏组件)? https://developers.weixin.qq.com/community/develop/article/doc/000646ab68003095767aaa15b5b013 实现效果最终顶部导航栏实现的效果就是如下图所示 效果一,非全屏 [图片] 效果二,全屏 [图片] 代码片段本代码片段参考社区大佬,仙森,在文章的评论区 https://developers.weixin.qq.com/s/m16krgmD78lE 本文总结关于这块代码就不贴了,在上面的参考文章中都有对应的代码。
2020-10-21 - 路由的封装
小程序提供了路由功能来实现页面跳转,但是在使用的过程中我们还是发现有些不方便的地方,通过封装,我们可以实现诸如路由管理、简化api等功能。 页面的跳转存在哪些问题呢? 与接口的调用一样面临url的管理问题; 传递参数的方式不太友好,只能拼装url; 参数类型单一,只支持string。 alias 第一个问题很好解决,我们做一个集中管理,比如新建一个[代码]router/routes.js[代码]文件来实现alias: [代码]// routes.js module.exports = { // 主页 home: '/pages/index/index', // 个人中心 uc: '/pages/user_center/index', }; [代码] 然后使用的时候变成这样: [代码]const routes = require('../../router/routes.js'); Page({ onReady() { wx.navigateTo({ url: routes.uc, }); }, }); [代码] query 第二个问题,我们先来看个例子,假如我们跳转[代码]pages/user_center/index[代码]页面的同时还要传[代码]userId[代码]过去,正常情况下是这么来操作的: [代码]const routes = require('../../router/routes.js'); Page({ onReady() { const userId = '123456'; wx.navigateTo({ url: `${routes.uc}?userId=${userId}`, }); }, }); [代码] 这样确实不好看,我能不能把参数部分单独拿出来,不用拼接到url上呢? 可以,我们试着实现一个[代码]navigateTo[代码]函数: [代码]const routes = require('../../router/routes.js'); function navigateTo({ url, query }) { const queryStr = Object.keys(query).map(k => `${k}=${query[k]}`).join('&'); wx.navigateTo({ url: `${url}?${queryStr}`, }); } Page({ onReady() { const userId = '123456'; navigateTo({ url: routes.uc, query: { userId, }, }); }, }); [代码] 嗯,这样貌似舒服一点。 参数保真 第三个问题的情况是,当我们传递的参数argument不是[代码]string[代码],而是[代码]number[代码]或者[代码]boolean[代码]时,也只能在下个页面得到一个[代码]string[代码]值: [代码]// pages/index/index.js Page({ onReady() { navigateTo({ url: routes.uc, query: { isActive: true, }, }); }, }); // pages/user_center/index.js Page({ onLoad(options) { console.log(options.isActive); // => "true" console.log(typeof options.isActive); // => "string" console.log(options.isActive === true); // => false }, }); [代码] 上面这种情况想必很多人都遇到过,而且感到很抓狂,本来就想传递一个boolean,结果不管传什么都会变成string。 有什么办法可以让数据变成字符串之后,还能还原成原来的类型? 好熟悉,这不就是json吗?我们把要传的数据转成json字符串([代码]JSON.stringify[代码]),然后在下个页面把它转回json数据([代码]JSON.parse[代码])不就好了嘛! 我们试着修改原来的[代码]navigateTo[代码]: [代码]const routes = require('../../router/routes.js'); function navigateTo({ url, data }) { const dataStr = JSON.stringify(data); wx.navigateTo({ url: `${url}?jsonStr=${dataStr}`, }); } Page({ onReady() { navigateTo({ url: routes.uc, data: { isActive: true, }, }); }, }); [代码] 这样我们在页面中接受json字符串并转换它: [代码]// pages/user_center/index.js Page({ onLoad(options) { const json = JSON.parse(options.jsonStr); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] 这里其实隐藏了一个问题,那就是url的转义,假如json字符串中包含了类似[代码]?[代码]、[代码]&[代码]之类的符号,可能导致我们参数解析出错,所以我们要把json字符串encode一下: [代码]function navigateTo({ url, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } // pages/user_center/index.js Page({ onLoad(options) { const json = JSON.parse(decodeURIComponent(options.encodedData)); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] 这样使用起来不方便,我们封装一下,新建文件[代码]router/index.js[代码]: [代码]const routes = require('./routes.js'); function navigateTo({ url, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } function extract(options) { return JSON.parse(decodeURIComponent(options.encodedData)); } module.exports = { routes, navigateTo, extract, }; [代码] 页面中我们这样来使用: [代码]const router = require('../../router/index.js'); // page home Page({ onLoad(options) { router.navigateTo({ url: router.routes.uc, data: { isActive: true, }, }); }, }); // page uc Page({ onLoad(options) { const json = router.extract(options); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] route name 这样貌似还不错,但是[代码]router.navigateTo[代码]不太好记,[代码]router.routes.uc[代码]有点冗长,我们考虑把[代码]navigateTo[代码]换成简单的[代码]push[代码],至于路由,我们可以使用[代码]name[代码]的方式来替换原来[代码]url[代码]参数: [代码]const routes = require('./routes.js'); function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const url = routes[name]; wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } function extract(options) { return JSON.parse(decodeURIComponent(options.encodedData)); } module.exports = { push, extract, }; [代码] 在页面中使用: [代码]const router = require('../../router/index.js'); Page({ onLoad(options) { router.push({ name: 'uc', data: { isActive: true, }, }); }, }); [代码] navigateTo or switchTab 页面跳转除了navigateTo之外还有switchTab,我们是不是可以把这个差异抹掉?答案是肯定的,如果我们在配置routes的时候就已经指定是普通页面还是tab页面,那么程序完全可以切换到对应的跳转方式。 我们修改一下[代码]router/routes.js[代码],假设home是一个tab页面: [代码]module.exports = { // 主页 home: { type: 'tab', path: '/pages/index/index', }, uc: { path: '/pages/a/index', }, }; [代码] 然后修改[代码]router/index.js[代码]中[代码]push[代码]的实现: [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; if (route.type === 'tab') { wx.switchTab({ url: `${route.path}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${route.path}?encodedData=${dataStr}`, }); } [代码] 搞定,这样我们一个[代码]router.push[代码]就能自动切换两种跳转方式了,而且之后一旦页面类型有变动,我们也只需要修改[代码]route[代码]的定义就可以了。 直接寻址 alias用着很不错,但是有一点挺麻烦得就是每新建一个页面都要写一个alias,即使没有别名的需要,我们是不是可以处理一下,如果在alias没命中,那就直接把name转化成url?这也是阔以的。 [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; const url = route ? route.path : name; if (route.type === 'tab') { wx.switchTab({ url: `${url}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } [代码] 在页面中使用: [代码]Page({ onLoad(options) { router.push({ name: 'pages/user_center/a/index', data: { isActive: true, }, }); }, }); [代码] 注意,为了方便维护,我们规定了每个页面都必须存放在一个特定的文件夹,一个文件夹的当前路径下只能存在一个index页面,比如[代码]pages/index[代码]下面会存放[代码]pages/index/index.js[代码]、[代码]pages/index/index.wxml[代码]、[代码]pages/index/index.wxss[代码]、[代码]pages/index/index.json[代码],这时候你就不能继续在这个文件夹根路径存放另外一个页面,而必须是新建一个文件夹来存放,比如[代码]pages/index/pageB/index.js[代码]、[代码]pages/index/pageB/index.wxml[代码]、[代码]pages/index/pageB/index.wxss[代码]、[代码]pages/index/pageB/index.json[代码]。 这样是能实现功能,但是这个name怎么看都跟alias风格差太多,我们试着定义一套转化规则,让直接寻址的name与alias风格统一一些,[代码]pages[代码]和[代码]index[代码]其实我们可以省略掉,[代码]/[代码]我们可以用[代码].[代码]来替换,那么原来的name就变成了[代码]user_center.a[代码]: [代码]Page({ onLoad(options) { router.push({ name: 'user_center.a', data: { isActive: true, }, }); }, }); [代码] 我们再来改进[代码]router/index.js[代码]中[代码]push[代码]的实现: [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; const url = route ? route.path : `pages/${name.replace(/\./g, '/')}/index`; if (route.type === 'tab') { wx.switchTab({ url: `${url}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } [代码] 这样一来,由于支持直接寻址,跳转home和uc还可以写成这样: [代码]router.push({ name: 'index', // => /pages/index/index }); router.push({ name: 'user_center', // => /pages/user_center/index }); [代码] 这样一来,除了一些tab页面以及特定的路由需要写alias之外,我们也不需要新增一个页面就写一条alias这么麻烦了。 其他 除了上面介绍的navigateTo和switchTab外,其实还有[代码]wx.redirectTo[代码]、[代码]wx.navigateBack[代码]以及[代码]wx.reLaunch[代码]等,我们也可以做一层封装,过程雷同,所以我们就不再一个个介绍,这里贴一下最终简化后的api以及原生api的映射关系: [代码]router.push => wx.navigateTo router.replace => wx.redirectTo router.pop => wx.navigateBack router.relaunch => wx.reLaunch [代码] 最终实现已经在发布在github上,感兴趣的朋友可以移步了解:mp-router。
2019-04-26 - 2020-07-29
- 2020-08-16
- 【好文】小程序动态换肤解决方案 - 本地篇
小程序动态换肤方案 – 本地篇 需求说明 在开发小程序的时候,尤其是开发第三方小程序,我们作为开发者,只需要开发一套模板,客户的小程序对我们进行授权管理,我们需要将这套模板应用到对方的小程序上,然后进行发版审核即可; 但是个别客户的小程序需要做 [代码]定制化配色方案[代码],也就是说,不同的小程序个体需要对页面的元素(比如:按钮,字体等)进行不同的配色设置,接下来我们来讨论一下怎么实现它。 方案和问题 一般来说,有两种解决方案可以解决小程序动态换肤的需求: 小程序内置几种主题样式,通过更换类名来实现动态改变小程序页面的元素色值; 后端接口返回色值字段,前端通过 [代码]内联[代码] 方式对页面元素进行色值设置。 当然了,每种方案都有一些问题,问题如下: 方案1较为死板,每次更改主题样式都需要发版小程序,如果主题样式变动不大,可以考虑这种; 方案2对于前端的改动很大,[代码]内联[代码] 也就是通过 [代码]style[代码] 的方式内嵌到[代码]wxml[代码] 代码中,代码的阅读性会变差,但是可以解决主题样式变动不用发版小程序的问题。 ps:我一直在尝试如何在小程序里面,通过js动态修改stylus的变量问题,这样就可以解决上面说的问题了,后期如果实现了,一定周知各位 本文先重点描述第一种方案的实现,文章末尾会贴上我的 [代码]github项目[代码] 地址,方便大家尝试。 前期准备 本文采用的是 [代码]gulp[代码] + [代码]stylus[代码] 引入预编译语言来处理样式文件,大家需要全局安装一下 [代码]gulp[代码],然后安装两个 [代码]gulp[代码] 的插件 [代码]gulp-stylus[代码](stylus文件转化为css文件) [代码]gulp-rename[代码](css文件重命名为wxss文件)。 gulp 这里简单贴一下gulpfile文件的配置,比较简单,其实就是借助 [代码]gulp-stylus[代码] 插件将 [代码].styl[代码] 结尾的文件转化为 [代码].css[代码] 文件,然后引入 [代码]gulp-rename[代码] 插件对文件重命名为 [代码].wxss[代码] 文件; 再创建一个任务对 [代码].styl[代码] 监听修改,配置文件如下所示: [代码]var gulp = require('gulp'); var stylus = require('gulp-stylus'); var rename = require('gulp-rename'); function stylusTask() { return gulp.src('./styl/*.styl') .pipe(stylus()) .pipe(rename(function(path) { path.extname = '.wxss' })) .pipe(gulp.dest('./wxss')) } function autosTask() { gulp.watch('./styl/*.styl', stylusTask) } exports.default = gulp.series(gulp.parallel(stylusTask, autosTask)) [代码] stylus 这里会分为两个文件,一个是主题样式变量定义文件,一个是页面皮肤样式文件,依次如下所示: 主题样式变量设置 [代码]// theme1 theme1-main = rgb(254, 71, 60) theme1-sub = rgb(255, 184, 0) // theme2 theme2-main = rgb(255, 158, 0) theme2-sub = rgb(69, 69, 69) // theme3 theme3-main = rgb(215, 183, 130) theme3-sub = rgb(207, 197, 174) [代码] 页面皮肤样式 [代码]@import './define.styl' // 拼接主色值 joinMainName(num) theme + num + -main // 拼接辅色值 joinSubName(num) theme + num + -sub // 遍历输出改变色值的元素类名 for num in (1..3) .theme{num} .font-vi color joinMainName(num) .main-btn background joinMainName(num) .sub-btn background joinSubName(num) [代码] 输出: [代码].theme1 .font-vi { color: #fe473c; } .theme1 .main-btn { background: #fe473c; } .theme1 .sub-btn { background: #ffb800; } .theme2 .font-vi { color: #ff9e00; } .theme2 .main-btn { background: #ff9e00; } .theme2 .sub-btn { background: #454545; } .theme3 .font-vi { color: #d7b782; } .theme3 .main-btn { background: #d7b782; } .theme3 .sub-btn { background: #cfc5ae; } [代码] 代码我写上了注释,我还是简单说明一下上面的代码:我首先定义一个主题文件 [代码]define.styl[代码] 用来存储色值变量,然后会再定义一个皮肤文件 [代码]vi.styl[代码] ,这里其实就是不同 [代码]主题类名[代码] 下需要改变色值的元素的属性定义,元素的色值需要用到 [代码]define.styl[代码] 预先定义好的变量,是不是很简单,哈哈哈。 具体使用 但是在具体页面中需要怎么使用呢,接下来我们来讲解一下 页面的 [代码]wxss[代码] 文件导入编译后的 [代码]vi.wxss[代码]文件 [代码]@import '/wxss/vi.wxss'; [代码] 页面的 [代码]wxml[代码] 文件需要编写需要改变色值的元素,并且引入变量 [代码]theme[代码] [代码]<view class="intro {{ theme }}"> <view class="font mb10">正常字体</view> <view class="font font-vi mb10">vi色字体</view> <view class="btn main-btn mb10">主色按钮</view> <view class="btn sub-btn">辅色按钮</view> </view> [代码] 页面 [代码]js[代码] 文件动态改变 [代码]theme[代码]变量值 [代码] data: { theme: '' }, handleChange(e) { const { theme } = e.target.dataset this.setData({ theme }) } [代码] 效果预览 [图片] 项目地址 项目地址:https://github.com/csonchen/wxSkin 这是本文案例的项目地址,为了方便大家浏览项目,我把编译后的wxss文件也一并上传了,大家打开就能预览,如果觉得好,希望大家都去点下star哈,谢谢大家。。。
2020-04-23 - 从0到1开发一个小程序cli脚手架(二) --版本发布/管理篇
接上文 《从0到1开发一个小程序cli脚手架(一)–创建页面/组件模版篇》 github地址:https://github.com/jinxuanzheng01/xdk-cli 觉得有用的朋友帮忙给项目一个star,谢谢 上文大家应该大致学会了怎么搭建一个cli脚手架,包括实现了一个快速生成启动模版的功能,本质上作为脚手架应该可以做更多的事情,本篇文章会实现一些新的功能,例如:自动发布体验版,版本号控制,环境变量控制 痛点 不知道大家有没有一天发多次版本或者一天给多个小程序发版的经历,按照微信正常的发布流程,需要: 修改版本号/版本描述 修改发布环境 点击微信开发者工具上传体验版 提交审核 确认环境/版本 点击发布 其中所有的1,2步为手工修改config文件,第5步是确认手工修改config文件的正确性,毕竟人总会犯错,作者表示就干过线下环境发布到测试环境的事情,而且这是在做了第5步的情况下,很遗憾没有仔细核对 为了不再次发生同样的事情导致引咎辞职,那么有没有更好的方法呢 ?自然是有的,既然人不可靠,那么直接把它流程化自然就可以了 准备工作 最好阅读了上篇文章《从0到1开发一个小程序cli脚手架(一)–创建页面/组件模版篇》,并搭建了一个简单的demo 需要了解微信小程序提供的一些cli能力, 点击这里 Let‘ go 后续的很多流程上的实操代码为了缩短篇幅会以伪代码的形式来进行描述,强烈推荐先阅读上文,如果需要更详细的实操代码请去github仓库查看 实际效果图:[图片] 梳理流程 识别命令行 询问问题,拿到版本号和版本描述 调用微信提供的cli能力,进行体验版上传 是不是发现非常简单,事实也是如此,整个功能做下来也就60行代码 ~ 目录结构 项目结构分为入口文件,配置文件 [代码]- lib - publish-weapp.js - index.js - config.js - node_modules - package.json [代码] config.js用来记录一些基础常量和默认项的配置,例如项目路径,执行路径等 [代码]module.exports = { // 根目录 root: __dirname, // 执行命令目录路径 dir_root: process.cwd(), // 小程序项目路径 entry: './', // 项目编译输出文件夹 output: './', } [代码] 识别命令行 在入口文件(index.js)处利用第三方库 commander 识别命令行参数,同时作为路由进行任务分发, [代码]#!/usr/bin/env node const version = require('./package').version; // 版本号 /* = package import -------------------------------------------------------------- */ const program = require('commander'); // 命令行解析 /* = task events -------------------------------------------------------------- */ const publishWeApp = require('./lib/publish-weapp'); // 发布体验版 program .command('publish') .description('发布微信小程序体验版') .action((cmd, options) => publishWeApp(); /* = main entrance -------------------------------------------------------------- */ program.parse(process.argv) [代码] 创建交互命令 接下来是一个QA环节,这时候需要根据自己的实际需要安排自己需要的命令,可以回想一下要解决的问题: 修改版本号/版本描述 修改发布环境 根据cli自动上传体验版 大概得出这样一个队列: [代码]function getQuestion({version, versionDesc} = {}) { return [ // 确定是否发布正式版 { type: 'confirm', name: 'isRelease', message: '是否为正式发布版本?', default: true }, // 设置版本号 { type: 'list', name: 'version', message: `设置上传的版本号 (当前版本号: ${version}):`, default: 1, choices: getVersionChoices(version), filter(opts) { if (opts === 'no change') { return version; } return opts.split(': ')[1]; }, when(answer) { return !!answer.isRelease } }, // 设置上传描述 { type: 'input', name: 'versionDesc', message: `写一个简单的介绍来描述这个版本的改动过:`, default: versionDesc }, ] } [代码] 最后获得的json对象,是这个样子: [代码]{isRelease: true, version: '1.0.1', versionDesc: '这是一个体验版'} [代码] 确定是否发布正式版 主要是因为体验版并非完全是发行版,公司内部测试的时候也是需要发布测试用体验版的,但是又涉及只有正式版本修改本地版本文件,那么就只能多此一问作为区分了 设置版本号 / 设置版本信息 [图片] 设置上述如图的 体验版上的版本号和描述信息,这里有个case是只有第一个问题选择发布正式版才会让你设置版本号,测试版本使用默认版本号0.0.0,这也是区分体验版的一种方式 [代码] when(answer) { return !!answer.isRelease } [代码] 这里只设置了三个问题:是否发布正式版,设置版本号,设置上传描述,当然你也可以设置自己想做的其他事情 上传体验版 翻了下小程序的文档,大概犹如下几个关键点: 找到cli工具 小程序cli并非全局安装,需要自己去索引路径,命令行工具所在位置:macOS: [代码]<安装路径>/Contents/MacOS/cli[代码] Windows: [代码]<安装路径>/cli.bat[代码] mac的 安装路径 如果是默认安装的话,是/Applications/wechatwebdevtools.app/, 外加cli的位置是: /Applications/wechatwebdevtools.app/Contents/Resources/app.nw/bin/cli windows 的话作者表示没有这个环境,只能大家自己探索了 拼凑上传命令 官方文档给了非常详细的描述: [图片] [代码]# 上传路径 /Users/username/demo 下的项目,指定版本号为 1.0.0,版本备注为 initial release cli -u 1.0.0@/Users/username/demo --upload-desc 'initial release' # 上传并将代码包大小等信息存入 /Users/username/info.json cli -u 1.0.0@/Users/username/demo --upload-desc 'initial release' --upload-info-output /Users/username/info.json [代码] 编写上传逻辑 基本流程: 获取cli -> 获取当前版本配置 -> 问题队列(获取上传信息)-> 执行上传(cli命令)-> 修改本地版本文件 -> 成功提示 [代码]// ./lib/publish-weapp.js 文件 module.exports = async function(userConf) { // cli路径 const cli = `/Applications/wechatwebdevtools.app/Contents/Resources/app.nw/bin/cli`; // 版本配置文件路径 const versionConfPath = Config.dir_root + '/xdk.version.json'; // 获取版本配置 const versionConf = require(versionConfPath); // 开始执行问题队列 anser case: {isRelease: true, version: '1.0.1', versionDesc: '这是一个体验版'} let answer = await inquirer.prompt(getQuestion(versionConf)); versionConf.version = answer.version || '0.0.0'; versionConf.versionDesc = answer.versionDesc; //上传体验版 let res = spawn.sync(cli, ['-u', `${versionConf.version}@${Config.output}`, '--upload-desc', versionConf.versionDesc], { stdio: 'inherit' }); if (res.status !== 0) process.exit(1); // 修改本地版本文件 (当为发行版时) !!answer.isRelease && await rewriteLocalVersionFile(versionConfPath, versionConf); // success tips Log.success(`上传体验版成功, 登录微信公众平台 https://mp.weixin.qq.com 获取体验版二维码`); } // 修改本地版本文件 function rewriteLocalVersionFile(filepath, versionConf) { return new Promise((resolve, reject) => { fs.writeFile(filepath, jsonFormat(versionConf), err => { if(err){ Log.error(err); process.exit(1); }else { resolve(); } }) }) } [代码] 注意这里需要在项目根目录下创建一个xdk.version.json的版本文件,详细配置说明见仓库 大致是这样的结构: [代码]// xdk.version.json { "version": "0.12.2", "versionDesc": "12.2版本" } [代码] 到这里基本就完成了上传的所有步骤,可以当前项目下键入[代码]xdk-cli publish[代码]查看一下程序是否正常运行,注意上传出错记得检查是否处于登录状态 扩展:关于版本号自增 可以看到在版本号环节使用的是list类型,而非input类型,这是因为手写版本号有写错的风险, 还是让我做选择题吧 emmm… 最终效果: [图片] 就不在这里展开了,可以下, 搜索[代码]getVersionChoices[代码]函数: https://github.com/jinxuanzheng01/xdk-cli/blob/master/lib/publish-weapp.js 解决打包工具的问题 你会发现是运行cli命令就直接发布了,那么用gulp,webpack等工具项目因为需要上传dist目录而非src的原因,需要先进行打包再进行发布: gulp -> xdk-cli publish ,将一个动作拆成了两个 遗忘了一个怎么办?纠结了 不知道大家对微信开发者工具的上传钩子还有没有印象,要实现的大概就是这么个东西, 一个上传前预处理的钩子 [图片] 这个实现起来也很简单,首先给用户的配置文件(xdk.config.js)开一个可配置项: [代码]// xdk.config.js { // 发布钩子 publishHook: { async before(answer) { this.spawnSync('gulp'); return Promise.resolve(); }, async after(answer) { this.log('发布后的钩子执行了~'); return Promise.resolve(); } } } [代码] 在publish-weapp.js中去识别钩子即可: [代码] // 前置钩子函数 await userConf.publishHook.before.call(originPrototype, answer); //上传体验版 let res = spawn.sync(cli, ['-u', `${versionConf.version}@${Config.output}`, '--upload-desc', versionConf.versionDesc], { stdio: 'inherit' }); // 后置钩子函数 await userConf.publishHook.after.call(originPrototype, answer); [代码] 当然,你需要判断下钩子是否存在,类型判断,包括需要返回给用户配置里一些基本的方法和属性,例如:日志输出,命令行执行等 我这边以gulp为例,可以看到在publsih前先执行[代码]gulp[代码],后进行发布,最后log出一行提示信息 完全符合预期 解决环境变量切换问题 解决了自动上传的问题,接下来就要解决环境变量切换的问题了,这里还要借用下刚才写的钩子函数: [代码]{ // 发布钩子 publishHook: { async before(answer) { this.spawnSync('gulp', [`--env=${answer.isRelease ? 'online' : 'stage'}`]); return Promise.resolve(); }, async after(answer) { this.log.success('发布后的钩子执行了~'); return Promise.resolve(); } } } [代码] 以gulp为例,根据之前的回答的结果answer.isRelease,判断是否为使用正式环境 如果你没有使用编译工具,也可以提出一个env的config文件,使用fs模块直接重写该环境变量 下面是一串伪代码: [代码]目录结构 - app (小程序项目) - page - app.json ... - env.js - xdk.config.js - xdk.version.js - envConf.js // envConf.js module.exports = { ['online']: { appHost1: 'https://app.xxxxxxxxx.com', appHost2: 'https://app.xxxxxxxxx.com', }, ['stage']: { appHost1: 'https://stage.xxxxxxxxx.com', appHost2: 'https://stage.xxxxxxxxx.com', } }; // xdk.config.js publishHook: { async before(answer) { let config = require('envConf.js')[answer.isRelease ? 'online' : 'stage']; fs.writeFile('./app/env.js', jsonFormat(jsonConf), (err) => { if(err){ this.log.error('写入失败') }else { this.log.success('写入成功); } }); return Promise.resolve(); } [代码] 打包工具的问题还有环境变量的问题,我都没有写在cli里面,是因为不同项目之间对环境变量的写法,格式都不尽相同,具体要怎么做还是要留给开发者自己来确定吧,这样看起来更灵活一些 最后 总之利用脚手架解决了从发布到上线的一连串问题,使得不再担心因为切换环境导致线上bug,也不再担心写版本号写错的问题,确认线上环境这个环境也变成了一个非强需求的事情 整个上线流程也只需要:xdk-cli publish -> 提交审核即可,而且整个代码也并不复杂,publish-weapp.js整个文件算上注释也就60行代码,何乐不为呢? 注:下篇会讲一下如何做自定义指令,帮助小伙伴可以更自由的适配不同的项目~
2019-08-05 - 从0到1开发一个小程序cli脚手架(一)--创建页面/组件模版篇
github地址:https://github.com/jinxuanzheng01/xdk-cli 原文地址:https://www.yuque.com/docs/share/c6dddfdf-5a18-4024-8604-5e619cb9845d cli工具是什么? 在正文之前先大致描述下什么是cli工具,cli工具英文名command-line interface,也就是命令行交互接口,比较典型的几个case例如,create-react-app,vue-cli,具体可以去百度一下,下面gif是小打卡目前用的一套自动化发布工具🔧 [图片] 可以看到整个发布流程大致是以选择或默认项的形式实现,大致分析下面几步 选择打包形式 开发模式/debug模式/发布模式 设置版本号 填写发布信息 选择环境 是否提交版本commit 是不是非常无脑?是不是再也不用担心线上发错环境了?有了它就算不同项目间,就算一天发n次版本还需要担心什么呢? 当然除了简单的发布功能还,还可以做很多的事情,比如创建page/component模版等一些更多有趣的事情 为了节约版面就不贴图了,具体可以看下仓库 https://github.com/jinxuanzheng01/xdk-cli(目前该工具是从小打卡现有的cli库中抽离的部分功能) 明确痛点 也就是我为什么要做这么一个工具,其实最开始我只是为了解决一个问题,就是在整个发布流程中需要人工去改动/确认发布环境和版本信息,大致可以想象下把线下环境发布到线上的尴尬处境 后续发现从cli角度触发,很多东西都变得简单了,大致列了下: 环境变量切换(线上环境,线下环境) 创建启动模版,包括页面,组件 自动化发布 … 准备工作 本文会以快速创建页面模版文件为例教你怎么快速撸一个属于自己的cli工具 如果觉得自己做比较麻烦,可以clone下我的仓库自己改装下 需要了解的三方库 中间会用到一些第三方库 commander, 一个解析命令行命令和参数工具 inquirer,常用交互式命令行用户界面的集合 chalk,美化你的终端输出样式 fuzzy,字符串模糊匹配的插件,根据输入关键词进行模糊匹配 json-format,json美化/格式化工具 其他的一些小知识:比如path模块,fs模块,大家可以去node官网自行查看:https://nodejs.org/api/ 搭建开发环境 创建一个空文件夹,并且npm初始化, 并且创建一个index.js页面,这个index.js将作为你整个包的入口文件 [代码]npm init -y [代码] 安装上述的三方包,当然也可以后续按需安装,这样更能清楚每个包是做什么的 [代码] npm install @moyuyc/inquirer-autocomplete-prompt commander chalk commander fuzzy inquirer json-format --save [代码] 在package.json里添加bin字段, 将自定义的命令软连到全局环境,同时执行npm link创建链接,这里如果报错{code EACCES,errno:13,…},是因为权限不足,可以尝试sudo npm link [代码] "bin": { "cli-demo": "./index.js" } [代码] 在入口文件,index.js 行首加入一行[代码]#!/usr/bin/env node[代码]指定当前脚本由node.js进行解析 [代码]#!/usr/bin/env node // 指定运行环境 // 输出文本 console.log('Hello World!!!'); [代码] 这时可以在命令行中执行[代码]cli-demo[代码]验收一下成果了 [图片] ok,可以看到当在全局状态下输入自定义命令时,正确运行了入口文件,也就意味着的开发玩具已经搭建完成 Let‘ Go 整理逻辑 以快速创建页面模版文件为例,就需要考虑需要哪些逻辑: 设置页面名称 找到已有模版文件 copy到项目中 修改app.json 识别命令行 在刚才的[代码]Hello World!!![代码]环节,已经可以正确识别cli-demo,但是需要在一个cli工具中集成更多功能,可能需要有不同的执行策略,以git为例:[代码]git clone, git status,git push[代码],所以需要识别不同的命令和参数, 是时候就需要用到[代码]commander[代码]这个第三方包帮助解析命令行参数了,当然你也可以自己撸一个lib,本质上还是方便解析[代码]process.argv[代码] index.js (本质上这个js就是一个路由) [代码]#!/usr/bin/env node const version = require('./package').version; // 版本号 /* = package import -------------------------------------------------------------- */ const program = require('commander'); // 命令行解析 /* = task events -------------------------------------------------------------- */ const createProgramFs = require('./lib/create-program-fs'); // 创建项目文件 /* = config -------------------------------------------------------------- */ // 设置版本号 program.version(version, '-v, --version'); /* = deal receive command -------------------------------------------------------------- */ program .command('create') .description('创建页面或组件') .action((cmd, options) => createProgramFs(cmd)); /* 后续可以根据不同的命令进行不同的处理,可以简单的理解为路由 */ // program // .command('build [cli]') // .description('执行打包构建') // .action((cmd, env) => callback); /* = main entrance -------------------------------------------------------------- */ program.parse(process.argv) [代码] 这时候当键入[代码]cli-demo create[代码]时会自动执行createProgramFs createProgramFs.js [代码]module.exports = function () { console.log('Hi, create-program-fs.js'); }; [代码] 命令行输入 cli-demo create [图片] 可以看到已经成功的开辟出了一块独立的业务模块,后续就只需要依据需求填补相应的内容即可 创建交互命令 收到执行命令,这个时候按第一张图,是需要开始一系列QA(当然你也可以不做交互式,直接配置命令行参数),<br />引入三方包 [代码]inquirer[代码],来指定问题队列 [代码]const question = [ // 选择模式使用 page -> 创建页面 | component -> 创建组件 { type: 'list', name: 'mode', message: '选择想要创建的模版', choices: [ 'page', 'component', ] }, // 设置名称 { type: 'input', name: 'name', message: answer => `设置 ${answer.mode} 名称 (e.g: index):`, }, ]; module.exports = function() { // 问题执行 inquirer.prompt(question).then(answers => { console.log(answers); }); }; [代码] [图片]、 可以看到通过一系列QA交互,实际输出拿到的是一个json对象,第一步已完成 创建模版文件 创建一个存放模版文件的文件夹template,并准备好你希望的模版 [图片] 项目中使用模版文件 为了方便阅读,下面的代码,需要明确下面变量的定义, Config.dir_root = 命令行执行目录 Config.root = cli项目根目录 Config.appRoot = 小程序项目路径 Config.template = 模版目录 这里有两个点,一个是执行路径的问题,另一个是分包的问题,具体如下: 执行路径 这里一定要弄明白**__dirname, process.cwd()**的区别,同时还有一些小程序是自己搭的gulp/webpack,可能小程序项目是在src目录下,一定要分清楚 __dirname: 被执行js文件的绝对路径,一般在index.js执行时缓存起来作为项目的全局路径,比如找到template文件夹就会使用 [代码]${__dirname}/template[代码] process.cwd():当前命令行运行时的工作目录,比如在/Users/xuan/Documents/cli-demo 如果当前项目在src,或其他文件夹里怎么办?可以提供一个给用户项目中的配置文件,类似于gulpfile.js或是webpack.config.js的形式,内容例如(具体可以看git仓库) [代码]module.exports = { // 小程序路径 app: './src', // 模版文件夹 template: './template' }; [代码] 可以看到对象中app属性,可以指定你当前小程序项目的路径 分包 因为小程序的分包机制会导致页面实际路径与在主包的路径不相符,例如: 主包:pages/index/index 分包:pages/main_module/pages/habit_enlist/habit_enlist 解决这个问题一方面是要有页面创建要有一定的规范,统一格式,另一方面需要根据规则解析app.json,<br />上面的主包,分包路径差不多是我目前使用的规范 解析app.json [代码]// 获取app.json function getAppJson() { let appJsonRoot = path.join(Config.appRoot, '/app.json'); try { return require(appJsonRoot); }catch (e) { Log.error(`未找到app.json, 请检查当前文件目录是否正确,path: ${appJsonRoot}`); process.exit(1); // 异常退出 } } // 解析app.json let parseAppJson = () => { // app Json 原文件 let appJson = __Data__.appJson = getAppJson(); // 获取主包页面 appJson.pages.forEach(path => __Data__.appPagesList[getPathSubSting(path)] = ''); // 获取分包,页面列表 appJson.subPackages.forEach(item => { __Data__.appModuleList[getPathSubSting(item.root)] = item.root; item.pages.forEach(path => __Data__.appPagesList[getPathSubSting(path)] = item.root); }); }; // __Data__.appPagesList = 小程序全部页面 // __Data__.appModuleList = 小程序全部分包页面 // item结构 {util_module: 'pages/util_module/'},这么定义结构是为了方便后续取数 [代码] question队列里,增加删选分包的选项 [代码] // 设置page所属module { type: 'autocomplete', name: 'modulePath', message: 'Set page ownership module', choices: [], suggestOnly: false, source(answers, input) { // none 代表放在主包 return Promise.resolve(fuzzy.filter(input, ['none', ...Object.keys(__Data__.appModuleList)]).map(el => el.original)); }, filter(input) { if (input === 'none') { return ''; } return __Data__.appModuleList[input]; }, when(answer) { return answer.mode === 'page'; } } [代码] autocomplete类型本质上是个列表,但是可以进行模糊查询,非常方便,像小打卡有接近30个分包的情况下效果尤为明显 [图片] 有了文件名,有了分包路径,有了可供copy的模版,接下来就很简单了,把模版文件塞进项目就可以了,下面是一串从仓库里copy的代码,利用async/await很方便的写出一维代码,基本上的流程: 获取路径 -> 校验 -> 获取文件信息 -> 复制文件 -> 修改app.json -> 输出结果信息 [代码]async function createPage(name, modulePath = '') { // 获取模版文件路径 let templateRoot = path.join(Config.template, '/page'); if (!Util.checkFileIsExists(templateRoot)) { Log.error(`未找到模版文件, 请检查当前文件目录是否正确,path: ${templateRoot}`); return; } // 获取业务文件夹路径 let page_root = path.join(Config.appRoot, modulePath, '/pages', name); // 查看文件夹是否存在 let isExists = await Util.checkFileIsExists(page_root); if (isExists) { Log.error(`当前页面已存在,请重新确认, path: ` + page_root); return; } // 创建文件夹 await Util.createDir(page_root); // 获取文件列表 let files = await Util.readDir(templateRoot); // 复制文件 await Util.copyFilesArr(templateRoot, `${page_root}/${name}`, files); // 填充app.json await writePageAppJson(name, modulePath); // 成功提示 Log.success(`createPage success, path: ` + page_root); } [代码] 扩展 一个基本的快速创建页面模版的cli工具就这样完成,但是有可能需要更多的一些功能 自定义模版 比如说每个项目的模版都有可能不太一样,很大程度上需要根据项目进行定制,这时候可能就需要前文提到的给用户开放config文件的插槽了 项目中的config: [代码]// xdk.config.js module.exports = { // 小程序路径 app: './', // 模版文件夹 template: './template' }; // create-program-fs.js module.exports = function() { // 校验:当前是否存在配置文件 let customConfPath = `${Config.dir_root}/xdk.config.js`; if (!Util.checkFileIsExists(customConfPath)) { Log.error('当前项目尚未创建xdk.config.js文件'); return; } // 获取用户配置项 let {app, template = ''} = require(customConfPath); // 小程序目录 Config.appRoot = path.resolve(path.join(Config.dir_root, app)); // 模版文件目录(默认使用cli提供的默认模版,当config文件有设置template路径时,使用自定义路径) !!template && (Config.template = path.resolve(path.join(Config.dir_root, template)))); // 问题执行 inquirer.prompt(question).then(answers => { console.log(answers); }); }; [代码] 发布的npm仓库 目前从开发到调试本质上是在本地提供服务,利用npm link提供软连接到全局PATH,<br />其实也可以直接发到npm上,让其他使用的该cli的成员一建安装,比如npm install -g xxxxxxx 教程的话百度,google有很多,作者表示很懒,遇到问题下面留言吧。。 最后 可以看到整个功能逻辑相对于平时写的复杂的业务逻辑来说相对简单,主要是工具库的一些使用方面的东西,中间的难点可能就是node中概念性的一些东西,然而这些多看一下文档基本就可以解决 顺便预告下后续的话可能会更新一些如何利用cli工具做到自动化发布,版本号控制,环境变量切换,自动生成文档等一系列有趣的功能 下文地址: 《从0到1开发一个小程序cli脚手架(二) --版本发布/管理篇》
2019-08-05 - 怎么在api中调用自定义组件showModal,让自定义组件想wx.showModal一样使用
环境:我们为什么要在api中调用自定义组件的原因我就不说了,用得到的开发者自然用得到! 现在百度上有很多人都写了自定义组件showModal,但是有一个很致命的缺陷,不能像微信小程序的api那样使用(wx.showModal)。 话不多说上代码 css: .mask { position: absolute; left: 0; right: 0; top: 0; bottom: 0; display: flex; justify-content: center; align-items: center; background-color: rgba(0, 0, 0, 0.4); z-index: 9999;} .modal-content { display: flex; flex-direction: column; width: 85%; padding: 10rpx; background-color: #fff; border-radius: 15rpx;} .title { font-size: 40rpx; text-align: center; padding: 15rpx;} .modal-btn-wrapper { display: flex; flex-direction: row; height: 100rpx; line-height: 100rpx; border-top: 2rpx solid rgba(7, 17, 27, 0.1);} .cancel-btn, .confirm-btn { flex: 1; height: 100rpx; line-height: 100rpx; text-align: center; font-size: 32rpx;} .cancel-btn { border-right: 2rpx solid rgba(7, 17, 27, 0.1);} .main-content { flex: 1; height: 100%; overflow-y: hidden;} wxml: <view class='mask' wx:if='{{show}}'> <view class='modal-content'> <view class="title">{{title}}</view> <view>{{content}}</view> <slot></slot> <view class='modal-btn-wrapper'> <view class='cancel-btn' bindtap='cancel' wx:if="{{showCancel}}" style="color:{{cancelColor}}">{{cancelText}}</view> <view class='confirm-btn' bindtap='confirm' style="color:{{confirmColor}}">{{confirmText}}</view> </view> </view></view> js:Component({ /** * 组件的属性列表 */ properties: { title: { type: String, value: '温馨提示' }, content: { type: String, value: '是否导入最近一次刷题记录?' }, //是否显示取消按钮 showCancel: { type: Boolean, value: true }, //取消按钮文字 cancelText: { type: String, value: '取消' }, //取消按钮颜色 cancelColor: { type: String, value: '#000000' }, //确定按钮的文字 confirmText: { type: String, value: '确定' }, //确定按钮的颜色 confirmColor: { type: String, value: '#FECC34' }, //是否显示modal show: { type: Boolean, value: false }, }, /** * 组件的初始数据 */ data: { }, /** * 组件的方法列表 */ methods: { // 取消函数 cancel() { this.setData({ show: false }) var res = {}; res["confirm"] = true; this.data.success && this.data.success(res); }, // 确认函数 confirm() { this.setData({ show: false }) var res = {}; res["confirm"] = false; this.data.success && this.data.success(res); }, showModal({ title, content, showCancel, //是否显示取消按钮 cancelText, //取消按钮文本 cancelColor, //取消按钮颜色 confirmText, //确定按钮文本 confirmColor, //确定按钮颜色 success }) { this.setData({ show: true }); if (title) { this.setData({ title: title }) } if (content) { this.setData({ content: content }) } if (showCancel) { this.setData({ showCancel: showCancel }) } if (cancelText) { this.setData({ cancelText: cancelText }) } if (cancelColor) { this.setData({ cancelColor: cancelColor }) } if (confirmText) { this.setData({ confirmText: confirmText }) } if (confirmColor) { this.setData({ confirmColor: confirmColor }) } this.data.success = success; } }})[图片] 以上是自定义组件的封装,因为CSS和html是随便百度复制的,太简单就不想改了,你们自己把样式改一下就OK 使用方法: [图片][图片] [图片] var toast = that.selectComponent('#toast'); toast.showModal({ title: '温馨提示', content: '是否导入最近一次刷题记录?', showCancel: true, confirmText: "导入", confirmColor: "#FECC34", success: function(result) { console.log(result) } });如果想自己的这个自定义组件在自己的挨批中使用就把对应页面的this传递到对应的api方法中去,然后在api中调用
2020-03-30 - 如何实现一个简单的http请求的封装
好久没发文章了,最近浏览社区看到比较多的请求封装,以及还有在使用原始请求的童鞋。为了减少代码,提升观赏性,我也水一篇吧,希望对大家有所帮助。 默认请求方式,大家每次都这样一些写相同的代码,会不会觉得烦,反正我是觉得头大 😂 [代码]wx.request({ url: 'test.php', //仅为示例,并非真实的接口地址 data: { x: '', y: '' }, header: { 'content-type': 'application/json' // 默认值 }, success (res) { console.log(res.data) } }) [代码] 来,进入正题吧,把这块代码封装下。 首先新建个request文件夹,内含request.js 代码如下: [代码]/** * 网络请求封装 */ import config from '../config/config.js' import util from '../util/util.js' // 获取接口地址 const _getPath = path => (config.DOMAIN + path) // 封装接口公共参数 const _getParams = (data = {}) => { const timestamp = Date.now() //时间戳 const deviceId = Math.random() //随机数 const version = data.version || config.version //当前版本号,自定或者取小程序的都行 const appKey = data.appKey || config.appKey //某个小程序或者客户端的字段区分 //加密下,防止其他人随意刷接口,加密目前采用的md5,后端进行校验,这段里面的参数你们自定,别让其他人知道就行,我这里就是举个例子 const sign = data.sign || util.md5(config.appKey + timestamp + deviceId) return Object.assign({}, { timestamp, sign, deviceId, version, appKey }, data) } // 修改接口默认content-type请求头 const _getHeader = (headers = {}) => { return Object.assign({ 'content-type': `application/x-www-form-urlencoded` }, headers) } // 存储登录态失效的跳转 const _handleCode = (res) => { const {statusCode} = res const {msg, code} = res.data // code为 4004 时一般表示storage里存储的token失效或者未登录 if (statusCode === 200 && (code === 4004)) { wx.navigateTo({ url: '/pages/login/login' }) } return true } /** * get 请求, post 请求 * @param {String} path 请求url,必须 * @param {Object} params 请求参数,可选 * @param {String} method 请求方式 默认为 POST * @param {Object} option 可选配置,如设置请求头 { headers:{} } * * option = { * headers: {} // 请求头 * } * */ export const postAjax = (path, params) => { const url = _getPath(path) const data = _getParams(params) //如果某个参数值为undefined,则删掉该字段,不传给后端 for (let e in data) { if (data[e] === 'undefined') { delete data[e] } } // 处理请求头,加上最近比较流行的jwtToken(具体的自己百度去) const header = util.extend( true, { "content-type": "application/x-www-form-urlencoded", 'Authorization': wx.getStorageSync('jwtToken') ? `Bearer ${wx.getStorageSync('jwtToken')}` : '', }, header ); const method = 'POST' return new Promise((resolve, reject) => { wx.request({ url, method, data, header, success: (res) => { const result = _handleCode(res) result && resolve(res.data) }, fail: function (res) { reject(res.data) } }); }) } [代码] 那么如何调用呢? [代码]//把request的 postAjax注册到getApp()下,调用时: const app = getApp() let postData = { //这里填写请求参数,基础参数里的appKey等参数可在这里覆盖传入。 } app.postAjax(url, postData).then((res) => { if (res.success) { //这里处理请求成功逻辑。 } else { //wx.showToast大家觉得麻烦也可以写到util.js里,调用时:util.toast(msg) 即可。 wx.showToast({ title: res.msg || '服务器错误,请稍后重试', icon: "none" }) } }).catch(err => { //这里根据自己场景看是否封装到request.js里 console.log(err) }) [代码] config.js 主要是处理正式环境、预发环境、测试环境、开发环境的配置 [代码]//发版须修改version, env const env = { dev: { DOMAIN: 'https://dev-api.weixin.com' }, test: { DOMAIN: 'https://test-api.weixin.com', }, pro: { DOMAIN: 'https://api.qtshe.com' } } module.exports = { ...env.pro } [代码] 以上就是简单的一个request的封装,包含登录态失效统一跳转、包含公共参数的统一封装。 老规矩,最后放代码片段,util里内置了md5方法以及深拷贝方法,具体的我也不啰嗦,大家自行查看即可~ https://developers.weixin.qq.com/s/gbPSLOmd7Aft
2020-04-03 - 教你怎么监听小程序的返回键
更新:2020年7月28日08:51:11 基础库2.12.0起,可以调用wx.enableAlertBeforeUnload监听原生右上角返回、物理返回以及wx.navigateBack时弹框提示 AIP详情请看: https://developers.weixin.qq.com/miniprogram/dev/api/ui/interaction/wx.enableAlertBeforeUnload.html //======================================== 怎么监听小程序的返回键? 应该有很多人想要监听用户的这个动作吧,但是很遗憾,小程序不会给你这个API的,那是不是就没辙了? 幸好我们还可以自定义导航栏,这样一来我们就可以监听用户的这一动作了。 什么?这你已经知道啦? 那好咱们就不说自定义导航栏的返回监听了,说一下物理返回和左滑?右滑?(不管了,反正是滑)返回上一页怎么监听。 监听物理返回 首先说一下这个监听方法的缺点,虽说是监听,但是还是无法真正意义上的监听并拦截来阻止页面跳转,页面还是会返回上一页,而后重新载入刚刚的页面,如果这不是你想要的,那可以不用往下看了 其次说一下用到什么东西: wx.onAppRoute、wx.showModal 最后是一些主要代码: 重写wx.showModal,主要是加个confirmStay参数和使wx.showModal Promise化 [代码]const { showModal } = wx; Object.defineProperty(wx, 'showModal', { configurable: false, // 是否可以配置 enumerable: false, // 是否可迭代 writable: false, // 是否可重写 value(...param) { return new Promise(function (rs, rj) { let { success, fail, complete, confirmStay } = param[0] param[0].success = (res) => { res.navBack = (res.confirm && !confirmStay) || (res.cancel && confirmStay) wx.setStorageSync('showBackModal', !res.navBack) success && success(res) rs(res) } param[0].fail = (res) => { fail && fail(res) rj(res) } param[0].complete = (res) => { complete && complete(res) (res.confirm || res.cancel) ? rs(res) : rj(res) } return showModal.apply(this, param); // 原样移交函数参数和this }.bind(this)) } }); [代码] 使用wx.onAppRoute实现返回原来的页面 [代码]wx.onAppRoute(function (res) { var a = getApp(), ps = getCurrentPages(), t = ps[ps.length - 1], b = a && a.globalData && a.globalData.pageBeforeBacks || {}, c = a && a.globalData && a.globalData.lastPage || {} if (res.openType == 'navigateBack') { var showBackModal = wx.getStorageSync('showBackModal') if (c.route && showBackModal && typeof b[c.route] == 'function') { wx.navigateTo({ url: '/' + c.route + '?useCache=1', }) b[c.route]().then(res => { if (res.navBack){ a.globalData.pageBeforeBacks = {} wx.navigateBack({ delta: 1 }) } }) } } else if (res.openType == 'navigateTo' || res.openType == 'redirectTo') { if (!a.hasOwnProperty('globalData')) a.globalData = {} if (!a.globalData.hasOwnProperty('lastPage')) a.globalData.lastPage = {} if (!a.globalData.hasOwnProperty('pageBeforeBacks')) a.globalData.pageBeforeBacks = {} if (ps.length >= 2 && t.onBeforeBack && typeof t.onBeforeBack == 'function') { let { onUnload } = t wx.setStorageSync('showBackModal', !0) t.onUnload = function () { a.globalData.lastPage = { route: t.route, data: t.data } onUnload() } } t.onBeforeBack && typeof t.onBeforeBack == 'function' && (a.globalData.pageBeforeBacks[t.route] = t.onBeforeBack) } }) [代码] 改造Page [代码]const myPage = Page Page = function(e){ let { onLoad, onShow, onUnload } = e e.onLoad = (() => { return function (res) { this.app = getApp() this.app.globalData = this.app.globalData || {} let reinit = () => { if (this.app.globalData.lastPage && this.app.globalData.lastPage.route == this.route) { this.app.globalData.lastPage.data && this.setData(this.app.globalData.lastPage.data) Object.assign(this, this.app.globalData.lastPage.syncProps || {}) } } this.useCache = res.useCache res.useCache ? reinit() : (onLoad && onLoad.call(this, res)) } })() e.onShow = (() => { return function (res) { !this.useCache && onShow && onShow.call(this, res) } })() e.onUnload = (() => { return function (res) { this.app.globalData = Object.assign(this.app.globalData || {}, { lastPage: this }) onUnload && onUnload.call(this, res) } })() return myPage.call(this, e) } [代码] 在需要监听的页面加个onBeforeBack方法,方法返回Promise化的wx.showModal [代码]onBeforeBack: function () { return wx.showModal({ title: '提示', content: '信息尚未保存,确定要返回吗?', confirmStay: !1 //结合content意思,点击确定按钮,是否留在原来页面,confirmStay默认false }) } [代码] 运行测试,Oj8K 是不是很简单,马上去试试水吧,效果图就不放了,静态图也看不出效果,动态图懒得弄,想看效果的自己运行代码片段吧 代码片段 https://developers.weixin.qq.com/s/hc2tyrmw79hg
2020-07-28