- 微信小程序使用自定义目录(文件路径)进行下载/保存 案例(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 - 常见小程序优化方案总结
一、首次启动性能优化 1、首次打开一个小程序,用户一般会观察到如下图所示的三种状态 [图片] 这张图中的三种状态对应的都是什么呢?小程序启动时,微信会为小程序展示一个固定的启动界面,界面内包含小程序的图标、名称和加载提示图标。 此时,微信会在背后完成几项工作:下载小程序代码包、加载小程序代码包、初始化小程序首页。下载到的小程序代码包不是小程序的源代码,而是编译、压缩、打包之后的代码包。 2、小程序加载的顺序 微信会在小程序启动前为小程序准备好通用的运行环境。这个运行环境包括几个供小程序使用的线程,并在其中完成小程序基础库的初始化,预先执行通用逻辑,尽可能做好小程序的启动准备。这样可以显著减少小程序的启动时间。 [图片] 通过这张图可以对比发现,小程序首次启动的 第一张图是资源准备(代码包下载);第二张图是业务代码的注入以及落地页首次渲染;第三张图是落地页数据请求时的loading态(部分小程序存在)。 3、优化方案 控制包大小:上传代码时要先进行压缩、静态图片资源除小的icon外其余放到cdn、无用代码清除; 分包加载:根据业务场景,将用户访问率高的页面放在主包里,将访问率低的页面放入子包里,按需加载; 分包预加载:在进入小程序某个页面时,由框架自动预下载可能需要的分包,提升进入后续分包页面时的启动速度。对于独立分包,也可以预下载主包。分包预下载 官方文档链接 独立分包技术:区别于子包,和主包之间是无关的,在功能比较独立的子包里,使用户只需下载分包资源;独立分包 官方文档链接 二、渲染性能优化 1、数据渲染优化 双线程下的界面渲染,小程序的逻辑层和渲染层是分开的两个线程。在渲染层,宿主环境会把WXML转化成对应的JS对象,在逻辑层发生数据变更的时候,我们需要通过宿主环境提供的setData方法把数据从逻辑层传递到渲染层,再经过对比前后差异,把差异应用在原来的Dom树上,渲染出正确的UI界面。 [图片] 页面初始化的时间大致由页面初始数据通信时间和初始渲染时间两部分构成。其中,数据通信的时间指数据从逻辑层开始组织数据到视图层完全接收完毕的时间,数据量小于64KB时总时长可以控制在30ms内。传输时间与数据量大体上呈现正相关关系,传输过大的数据将使这一时间显著增加。因而减少传输数据量是降低数据传输时间的有效方式。 [图片] 在数据传输时,逻辑层会执行一次JSON.stringify来去除掉setData数据中不可传输的部分,之后将数据发送给视图层。同时,逻辑层还会将setData所设置的数据字段与data合并,使开发者可以用this.data读取到变更后的数据。因此,为了提升数据更新的性能,可以参考如下方法: 1.不要过于频繁调用setData,应考虑将多次setData合并成一次setData调用; 2.数据通信的性能与数据量正相关,因而如果有一些数据字段不在界面中展示且数据结构比较复杂或包含长字符串,则不应使用setData来设置这些数据; 3.与界面渲染无关的数据最好不要设置在data中,可以考虑设置在page对象的其他字段下; 4.勿在后台页面去setData; 5.建议创建一个检测data大小的方法,如果超过64K可以打印报警日志提醒开发者; 2、长列表优化方案 无限下拉加载后会大数据量展现导致的性能问题,一个常见的方法在诸多C端都有使用,一句话说就是"只渲染所需的元素"。虚拟列表是按需显示思路的一种实现,即虚拟列表是一种根据滚动容器元素的可视区域来渲染长列表数据中某一个部分数据的技术。简而言之,虚拟列表指的就是「可视区域渲染」的列表。有三个概念需要了解一下: 滚动容器元素:一般情况下,滚动容器元素是 window 对象。然而,我们可以通过布局的方式,在某个页面中任意指定一个或者多个滚动容器元素。只要某个元素能在内部产生横向或者纵向的滚动,那这个元素就是滚动容器元素考虑每个列表项只是渲染一些纯文本。在本文中,只讨论元素的纵向滚动。 可滚动区域:滚动容器元素的内部内容区域。假设有 100 条数据,每个列表项的高度是 50,那么可滚动的区域的高度就是 100 * 50。可滚动区域当前的具体高度值一般可以通过(滚动容器)元素的 scrollHeight 属性获取。用户可以通过滚动来改变列表在可视区域的显示部分。 可视区域:滚动容器元素的视觉可见区域。如果容器元素是 window 对象,可视区域就是浏览器的视口大小(即视觉视口);如果容器元素是某个 div 元素,其高度是 300,右侧有纵向滚动条可以滚动,那么视觉可见的区域就是可视区域。 实现虚拟列表就是在处理用户滚动时,要改变列表在可视区域的渲染部分,其具体步骤如下: 计算当前可见区域起始数据的 startIndex 计算当前可见区域结束数据的 endIndex 计算当前可见区域的数据,并渲染到页面中 计算 startIndex 对应的数据在整个列表中的偏移位置 startOffset,并设置到列表上 计算 endIndex 对应的数据相对于可滚动区域最底部的偏移位置 endOffset,并设置到列表上 [图片] 虚拟列表的实现原理可以参考这篇文章:浅说虚拟列表的实现原理 3、长列表局部渲染技巧 在一个列表中,有n条数据,采用上拉加载更多的方式。假如这个时候想对其中某一个数据进行点赞操作,还能及时看到点赞的效果,可以采用setData全局刷新,点赞完成之后,重新获取数据,再次进行全局重新渲染,这样做的优点是:方便,快捷!缺点是:用户体验极其不好,当用户刷量100多条数据后,重新渲染量大会出现空白期(没有渲染过来)。 优化步骤: 1.将点赞的[代码]id[代码]传过去,知道点的是那一条数据, 将点赞的[代码]id[代码]传过去,知道点的是那一条数据 <view wx:if="{{!item.status}}" class=“btn” data-id="{{index}}" bindtap=“couponTap”>立即领取</view> 2.重新获取数据,查找相对应id的那条数据的下标([代码]index[代码]是不会改变的) 3.用setData进行局部刷新 this.setData({ list[index] : newList[index] }) 4、用户事件优化 视图层将事件反馈给逻辑层时,同样需要一个通信过程,通信的方向是从视图层到逻辑层。因为这个通信过程是异步的,会产生一定的延迟,延迟时间同样与传输的数据量正相关,数据量小于64KB时在30ms内。降低延迟时间的方法主要有两个。 1.去掉不必要的事件绑定(WXML中的bind和catch),从而减少通信的数据量和次数; 2.事件绑定时需要传输target和currentTarget的dataset,因而不要在节点的data前缀属性中放置过大的数据。 三、生命周期优化 1、异步请求,页面渲染需要的数据最好在onLoad时异步请求数据,不要在onReady时请求;非页面渲染需要的数据,尽量放在onReady生命周期去调用; 2、定时器、事件监听、播放组件、音视频组件等,在页面转入后台(onHide)或者销毁(onUnload)时应该中止掉; 四、图片静态资源预加载 在日常小程序的开发中,有很多的大图片是放置于cdn上的,在需要进行展示的时候,如果没有预加载有可能出现图片展示的不及时,造成不好的体验,所以如下方式实现了图片预加载的功能,可以封装成组件的形式。 实现思路是将图片添加进页面中,设置不可见,然后加载图片,实现一个预加载的功能。 1、添加模版文件: img-loader.wxml <template name=“img-loader”> <image mode=“aspectFill” wx:for="{{ imgLoadList }}" wx:key="*this" src="{{ item }}" data-src="{{ item }}" bindload="_imgOnLoad" binderror="_imgOnLoadError" style=“width:0;height:0;opacity:0” /> </template> 2、添加js文件:img-loader.js /** 图片预加载组件 */ class ImgLoader { /** 初始化方法,在页面的 onLoad 方法中调用,传入 Page 对象及图片加载完成的默认回调 */ constructor(pageContext, defaultCallback) { this.page = pageContext this.defaultCallback = defaultCallback || function () { } this.callbacks = {} this.imgInfo = {} [代码]this.page.data.imgLoadList = [] //下载队列 this.page._imgOnLoad = this._imgOnLoad.bind(this) this.page._imgOnLoadError = this._imgOnLoadError.bind(this) [代码] } /** 加载图片 @param {String} src 图片地址 @param {Function} callback 加载完成后的回调(可选),第一个参数个错误信息,第二个为图片信息 */ load(src, callback) { if (!src) return; [代码]let list = this.page.data.imgLoadList, imgInfo = this.imgInfo[src] if (callback) this.callbacks[src] = callback //已经加载成功过的,直接回调 if (imgInfo) { this._runCallback(null, { src: src, width: imgInfo.width, height: imgInfo.height }) //新的未在下载队列中的 } else if (list.indexOf(src) == -1) { list.push(src) this.page.setData({ 'imgLoadList': list }) } [代码] } _imgOnLoad(ev) { let src = ev.currentTarget.dataset.src, width = ev.detail.width, height = ev.detail.height [代码]//记录已下载图片的尺寸信息 this.imgInfo[src] = { width, height } this._removeFromLoadList(src) this._runCallback(null, { src, width, height }) [代码] } _imgOnLoadError(ev) { let src = ev.currentTarget.dataset.src this._removeFromLoadList(src) this._runCallback(‘Loading failed’, { src }) } //将图片从下载队列中移除 _removeFromLoadList(src) { let list = this.page.data.imgLoadList list.splice(list.indexOf(src), 1) this.page.setData({ ‘imgLoadList’: list }) } //执行回调 _runCallback(err, data) { let callback = this.callbacks[data.src] || this.defaultCallback callback(err, data) delete this.callbacks[data.src] } } module.exports = ImgLoader 3、在需要使用预加载功能的xxx.wxml页面中加入模版文件和使用代码: <import src="…/…/templates/img-loader.wxml"/> <template is=“img-loader” data="{{ imgLoadList }}"></template> 4、在需要使用预加载功能页面的xxx.js文件中引入文件和使用代码: import ImgLoader from ‘…/…/templates/img-loader.js’; let images = [ ‘http://cdn.weimob.com/saas/activity/bargain/images/arms/shoulie.png’, ‘http://cdn.weimob.com/saas/activity/bargain/images/arms/shandian.png’, ‘http://cdn.weimob.com/saas/activity/bargain/images/arms/fengbao.png’ ] //初始化图片预加载组件,并指定统一的加载完成回调 this.imgLoader = new ImgLoader(this, this.imageOnLoad.bind(this)); images.forEach(item => { this.imgLoader.load(item) }) 备注:如有错误请帮忙指出;如有侵权,请联系我们删除,谢谢!
2019-09-03 - 微信开发者工具创建的Typescript小程序需要改造目录结构后才能使用第三方组件
微信开发者工具创建的typescript小程序目录结构并不能良好的使用npm的“自定义组件”,想要漂亮地使用“自定义组件”,得自行改造工程结构 注:这里是指wxml中使用的“自定义组件”,不是ts或js中可调用的纯js 问题现象分析如下: 微信开发者工具,创建出的Typescript小程序工程结构如下: [代码]|--miniprogram | |--pages | |--app.js | |--app.wxss | |--app.json |--typings | |--wx | |--index.d.ts | |--lib.wa.es6.d.ts |--package.json |--project.config.js |--ts.json [代码] 这样的结构,小程序根目录在miniprogram,导致package.json不在小程序根目录内,也即node_modules目录中的第三方组件不在小程序根目录,违背了npm支持中node_modules位置的要求:npm 支持-使用npm包 此时,如果app.json或index.json中,无论如何引用不到node_modules中的第三方组件(注意是第三方开发的微信“自定义组件”,如vant-weapp,而纯js是可用的) 如: [图片] 图中的"…/node_modules"无论如何修改,控制台也会报类似的错误: [图片] 问题原因: 经过测试,存在两个约束导致了以上问题: app.json或index.json(以及任何页面的配置文件)的"usingComponents"不能引用node_modules目录下的组件; "usingComponents"只能引用小程序根目录(此例是miniprogram)及子目录下的组件。 通过以下步骤,就可以验证: 将node_modules下的vant-weapp目录复制到miniprogram目录下,"usingComponents"修改为其相对位置,组件即可用: [代码]|--miniprogram | |--pages | |--app.js | |--app.wxss | |--app.json | |--want-weapp |--typings | |--wx | |--index.d.ts | |--lib.wa.es6.d.ts |--package.json |--project.config.js |--ts.json [代码] [图片] 成功使用自定义组件的效果: [图片] 2. 如果vant-weapp放在miniprogram以外的目录,仍然引用不到。 但是,有代码洁癖的同学肯定不能满足于,把所有的依赖都手工复制到miniprogram目录这么弱智的操作方法吧? 解决方案: 怎样才能做到npm安装的组件,可以直接在小程序中引用呢? 其实答案就在npm 支持这篇文章里,只不过其写的比较含糊,没有解释清楚细节,总结起来就是对模板工程做两步改造,分别解决上面提到的两个问题: 将小程序根目录即miniprogram移动到与package.json同级别; 使用“npm构建”功能(将node_modules目录的组件构建到miniprogram_npm目录下)构建后,再直接引用组件即可使用。 操作步骤如下: 第一步:移动miniprogram目录下的所有文件到根目录(与package.json同级): [代码]|--pages |--app.js |--app.wxss |--app.json |--want-weapp |--typings | |--wx | |--index.d.ts | |--lib.wa.es6.d.ts |--package.json |--project.config.js |--ts.json [代码] 第二步:修改project.config.json,去掉下面这行: [代码]"miniprogramRoot": "miniprogram/", [代码] 或者修改为 [代码]"miniprogramRoot": "./", [代码] 第三步:修改tsconfig.json中的include/exclude以确保自己的ts代码被编译: [代码] "include": [ "./miniprogram/**/*.ts" ], "exclude": [ "node_modules", "miniprogram_dist", "miniprogram_npm", "**/*.spec.ts", "typings/**/*.d.ts" ] [代码] 改为 [代码]"include": [ "./**/*.ts" ], "exclude": [ "node_modules", "miniprogram_dist", "miniprogram_npm", "**/*.spec.ts", "typings/**/*.d.ts" ] [代码] 说明:include目录外移一层,同时exclude掉typings和miniprogram_npm目录 第四步:使用微信开发者工具的“工具-构建npm”功能构建组件到miniprogram_npm目录 第五步:在app.json(或者页面的json配置)里面引入组件 [代码] "usingComponents": { "van-button": "vant-weapp/button/index" } [代码] 注意:这里使用相对于miniprogramnpm的目录就可以了,并不需要相对于app.json文件 后记: 微信开发者工具创建出的typescript小程序,本意应该是将小程序根目录与typescript、node环境隔离开来,提供一个清晰的目录结构,但是对npm集成、“自定义组件”支持的不佳,说明了微信团队在“自定义组件”,npm支持,typescript支持还处在起步阶段,团队间的磨合还不到位,文档也不够透彻,当然这需要时间去改进。 我希望微信小程序团队通过以下几点,能很快地原生支持typescript+npm+第三方自定义组件: 1、调整微信开发者工具的typescript小程序模板工程结构,或者反过来让支持现在的工程结构下使用第三方的npm自定义组件; 2、完善npm支持相关的文档,如果自己完善不来,其实可以用wiki的方式,鼓励广大开发者一起来完善
2019-08-11