- Mac小程序开发说明
Mac微信2.4.0以上版版本中,支持打开聊天中分享的小程序,开发者可下载安装Mac微信版内测版本进行体验和适配。 Mac微信内测版:点击下载 兼容性:系统要求macOS 10.12或更高版本 1、开发工具支持 运行环境要求下载并安装最新开发版开发者工具:https://developers.weixin.qq.com/miniprogram/dev/devtools/nightly.html 小程序预览微信开发者工具菜单栏点击 设置->通用设置,在自动预览部分勾选“启动 MAC 端自动预览”。 使用自动预览功能,点击 预览->自动预览->编译并预览,成功的话将在微信MAC版上自动拉起小程序。 [图片] 小程序真机调试微信开发者工具菜单栏点击 设置->通用设置,在自动预览部分勾选“启动 MAC 端真机调试”。 使用真机调试功能,点击 真机调试->自动真机小时->编译并自动调试,成功的话将在微信MAC版上自动拉起小程序。 [图片] 2、开发者适配 SystemInfowx.getSystemInfo接口中返回的参数和手机返回的定义区别 [图片] 同时,Mac小程序还会根据屏幕的大小自动选择默认的窗口大小。提供的窗口大小从小到大依次为: [图片] web-view组件web-view组件中打开的域名请支持Mac浏览器的UserAgent。 支付小程序在使用wx.requestPayment时,将会拉起二维码让用户使用手机支付 3、常见问题 Q:小程序如何判断是Mac平台? A:通过 getSystemInfo 官方接口(platform 是 mac)/ 通过 UserAgent(Mac UserAgent 包含 MiniProgramEnv/Mac) Q:Mac小程序如何支持横屏? A:"resizable": true 可使小程序在PC上横屏窗口展示,体验可参考腾讯文档小程序。 Q:横屏模式下小程序默认的窗口大小为 A:webview 嵌入的页面在Mac上不能操作或者显示或操作异常? 检查一下页面是否没响应鼠标事件检查是否是UserAgent没有支持Q:页面布局为何出现错乱? A:检查一下是否使用屏幕尺寸来计算布局,Mac 上屏幕尺寸比窗口尺寸大,应该使用窗口尺寸来计算。 Q:为什么有的功能无法使用? A:Mac微信小程序暂不支持地图、蓝牙、卡包、以及硬件相关的功能。后续功能敬请期待。
2021-01-25 - [打怪升级]小程序自定义头部导航栏“完美”解决方案
[图片] 为什么要做这个? 主要是在项目中,智酷君发现的一些问题 一些页面是通过扫码和订阅消息访问后,没有直接可以点击去首页的,需要添加一个home链接 需要添加自定义搜索功能 需要自定义一些功能按钮 [图片] 其实,第一个问题,在最近的微信版本更新中已经优化了,通过 小程序模板消息 过来的,系统会自动加上home按钮,但对于其他的访问方式则没有支持~ 一个不大不小的问题:两边ICON不对齐问题 [图片] 智酷君之前尝试了各种解决方法,发现有一个问题,就是现在手机屏幕太多种多样,有 传统头部、宽/窄刘海屏、水滴屏等等,无法八门,很多解决方案都无法解决特殊头部,系统**“胶囊按钮”** 和 自定义按钮在Android屏幕可能有 几像素不对齐 的问题(强迫症的噩梦)。 下面分享下一个相对比较完善的解决方案: [图片] 小程序代码段DEMO Link: https://developers.weixin.qq.com/s/cuUaCimT72cH ID: cuUaCimT72cH 智酷君做了一个demo代码段,方便大家直接用IDE工具查看源码~ [图片] 页面配置 1、页面JSON配置 [代码]{ "usingComponents": { "NavComponent": "/components/nav/common" //以插件的方式引入 }, "navigationStyle": "custom" //自定义头部需要设置 } [代码] 如果需要自定义头部,需要设置navigationStyle为 “custom” 2、页面代码 [代码]<!-- home 类型的菜单 --> <NavComponent v-title="自定义头部" bind:commonNavAttr="commonNavAttr"></NavComponent> <!-- 搜索菜单 --> <NavComponent is-search="true" bind:commonNavAttr="commonNavAttr"></NavComponent> [代码] 可以在自定义导航标签上添加属性配置来设置功能,具体按照实际需要来 3、目录结构 [代码]│ ├─components │ └─nav │ common.js │ common.json │ common.wxml │ common.wxss │ ├─images │ back.png │ home.png │ └─index index.js index.json index.wxml index.wxss search.js search.json search.wxml search.wxss [代码] 仅供参考 插件对应的JS部分 components/nav/common.js部分 [代码]const app = getApp(); Component({ properties: { vTitle: { type: String, value: "" }, isSearch:{ type: Boolean, value: false } }, data: { haveBack: true, // 是否有返回按钮,true 有 false 没有 若从分享页进入则没有返回按钮 statusBarHeight: 0, // 状态栏高度 navbarHeight: 0, // 顶部导航栏高度 navbarBtn: { // 胶囊位置信息 height: 0, width: 0, top: 0, bottom: 0, right: 0 }, cusnavH: 0, //title高度 }, // 微信7.0.0支持wx.getMenuButtonBoundingClientRect()获得胶囊按钮高度 attached: function () { if (!app.globalData.systeminfo) { app.globalData.systeminfo = wx.getSystemInfoSync(); } if (!app.globalData.headerBtnPosi) app.globalData.headerBtnPosi = wx.getMenuButtonBoundingClientRect(); console.log(app.globalData) let statusBarHeight = app.globalData.systeminfo.statusBarHeight // 状态栏高度 let headerPosi = app.globalData.headerBtnPosi // 胶囊位置信息 console.log(statusBarHeight) console.log(headerPosi) let btnPosi = { // 胶囊实际位置,坐标信息不是左上角原点 height: headerPosi.height, width: headerPosi.width, top: headerPosi.top - statusBarHeight, // 胶囊top - 状态栏高度 bottom: headerPosi.bottom - headerPosi.height - statusBarHeight, // 胶囊bottom - 胶囊height - 状态栏height (胶囊实际bottom 为距离导航栏底部的长度) right: app.globalData.systeminfo.windowWidth - headerPosi.right // 这里不能获取 屏幕宽度,PC端打开小程序会有BUG,要获取窗口高度 - 胶囊right } let haveBack; if (getCurrentPages().length != 1) { // 当只有一个页面时,并且是从分享页进入 haveBack = false; } else { haveBack = true; } var cusnavH = btnPosi.height + btnPosi.top + btnPosi.bottom // 导航高度 console.log( app.globalData.systeminfo.windowWidth, headerPosi.width) this.setData({ haveBack: haveBack, // 获取是否是通过分享进入的小程序 statusBarHeight: statusBarHeight, navbarHeight: headerPosi.bottom + btnPosi.bottom, // 胶囊bottom + 胶囊实际bottom navbarBtn: btnPosi, cusnavH: cusnavH }); //将实际nav高度传给父类页面 this.triggerEvent('commonNavAttr',{ height: headerPosi.bottom + btnPosi.bottom }); }, methods: { _goBack: function () { wx.navigateBack({ delta: 1 }); }, bindKeyInput:function(e){ console.log(e.detail.value); } } }) [代码] 解决不同屏幕头部不对齐问题的终极办法是 wx.getMenuButtonBoundingClientRect() 这个方法从微信7.0.0开始支持,通过这个方法我们可以获取到右边系统胶囊的top、height、right等属性,这样无论是水滴屏、刘海屏、异形屏,都能完美对齐右边系统默认的胶囊bar,完美治愈强迫症~ APP.js 部分 [代码]//app.js App({ /** * 加载页面 * @param {*} options */ onShow: function (options) { }, onLaunch: async function () { let self = this; //设置默认分享 this.globalData.shareData = { title: "智酷方程式" } // this.getSysInfo(); }, globalData: { //默认分享文案 shareData: {}, qrCodeScene: false, //二维码扫码进入传参 systeminfo: false, //系统信息 headerBtnPosi: false, //头部菜单高度 } }); [代码] 将获取的参数存储在一个全局变量globalData中,可以减少反复调用的性能消耗。 插件HTML部分 [代码]<view class="custom_nav" style="height:{{navbarHeight}}px;"> <view class="custom_nav_box" style="height:{{navbarHeight}}px;"> <view class="custom_nav_bar" style="top:{{statusBarHeight}}px; height:{{cusnavH}}px;"> <!-- 搜索部分--> <block wx:if="{{isSearch}}"> <input class="navSearch" style="height:{{navbarBtn.height-2}}px;line-height:{{navbarBtn.height-4}}px; top:{{navbarBtn.top+1}}px; left:{{navbarBtn.right}}px; border-radius:{{navbarBtn.height/2}}px;" maxlength="10" bindinput="bindKeyInput" placeholder="输入文字搜索" /> </block> <!-- HOME 部分--> <block wx:else> <view class="custom_nav_icon {{!haveBack||'borderLine'}}" style="height:{{navbarBtn.height}}px;line-height:{{navbarBtn.height-2}}px; top:{{navbarBtn.top}}px; left:{{navbarBtn.right}}px; border-radius:{{navbarBtn.height/2}}px;"> <view wx:if="{{haveBack}}" class="icon-back" bindtap='_goBack'> <image src='/images/back.png' mode='aspectFill' class='back-pre'></image> </view> <view wx:if="{{haveBack}}" class='navbar-v-line'></view> <view class="icon-home"> <navigator class="home_a" url="/pages/home/index" open-type="switchTab"> <image src='/images/home.png' mode='aspectFill' class='back-home'></image> </navigator> </view> </view> <view class="nav_title" style="height:{{cusnavH}}px; line-height:{{cusnavH}}px;"> {{vTitle}} </view> </block> </view> </view> </view> [代码] 主要是对几种状态的判断和定位的计算。 插件CSS部分 [代码]/* components/nav/test.wxss */ .custom_nav { width: 100%; background: #3a7dd7; position: relative; z-index: 99999; } .custom_nav_box { position: fixed; width: 100%; background: #3a7dd7; z-index: 99999; border-bottom: 1rpx solid rgba(255, 255, 255, 0.3); } .custom_nav_bar { position: relative; z-index: 9; } .custom_nav_box .nav_title { font-size: 28rpx; color: #fff; text-align: center; position: absolute; max-width: 360rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; top: 0; left: 0; right: 0; bottom: 0; margin: auto; z-index: 1; } .custom_nav_box .custom_nav_icon { position:absolute; z-index: 2; display: inline-block; border-radius: 50%; vertical-align: top; font-size:0; box-sizing: border-box; } .custom_nav_box .custom_nav_icon.borderLine { border: 1rpx solid rgba(255, 255, 255, 0.3); background: rgba(0, 0, 0, 0.1); } .navbar-v-line { width: 1px; margin-top: 14rpx; height: 32rpx; background-color: rgba(255, 255, 255, 0.3); display: inline-block; vertical-align: top; } .icon-back { display: inline-block; width: 74rpx; padding-left: 20rpx; vertical-align: top; /* margin-top: 12rpx; vertical-align: top; */ height: 100%; } .icon-home { /* margin-top: 8rpx; vertical-align: top; */ display: inline-block; width: 80rpx; text-align: center; vertical-align: top; height: 100%; } .icon-home .home_a { height: 100%; display: inline-block; vertical-align: top; width: 35rpx; } .custom_nav_box .back-pre, .custom_nav_box .back-home { width: 35rpx; height: 35rpx; vertical-align: middle; } .navSearch { width: 200px; background: #fff; font-size: 14px; position: absolute; padding: 0 20rpx; z-index: 9; } [代码] 总结: 通过微信API: getMenuButtonBoundingClientRect(),结果各类手机屏幕的适配问题 将算好的参数存储在全局变量中,一次计算全局使用,爽YY~ 往期回顾: [填坑手册]小程序PC版来了,如何做PC端的兼容?! [填坑手册]小程序Canvas生成海报(一) [拆弹时刻]小程序Canvas生成海报(二)
2021-09-13 - JS中的二进制数据处理
前言 在现有的计算机中,二进制常常以字节数组的形式存在于程序当中。例如在C#里面,就用byte[],标准C里面没有byte类型,但可以通过typedef把byte定义为unsigned char的别名,效果是一样的。JS设计之初似乎就没想过要处理二进制,对于字节的概念可以说是非常非常的模糊。如果要表达字节数组,那么似乎只能用一个普通数组来表示。 然而随着业务需求的逐渐发展,出现了WebGL这样的技术。所谓WebGL,就是指浏览器与显卡之间的通信接口。为了满足JavaScript与显卡之间大量的、实时的数据交换,它们之间的数据通信必须是二进制的,而不能是传统的文本格式。类型化数组(Typed Array)就是在这种背景下诞生的。而类型化数组是建立在ArrayBuffer对象的基础上的。下面介绍一下Arraybuffer。 一、Arraybuffer1.1 基本概念 ArrayBuffer 对象是 ES6 才纳入正式 ECMAScript 规范,是 JavaScript 操作二进制数据的一个接口。ArrayBuffer 对象是以数组的语法处理二进制数据,也称二进制数组。它不能直接读写,只能通过视图(TypedArray视图和DataView视图)来读写。 ❝ArrayBuffer 简单说是一片内存,但是你不能直接用它。这就好比你在 C 里面,malloc 一片内存出来,你也会把它转换成 unsigned_int32 或者 int16 这些你需要的实际类型的数组/指针来用。这就是 JS 里的 TypedArray 的作用,那些 Uint32Array 也好,Int16Array 也好,都是给 ArrayBuffer 提供了一个 “View”,MDN 上的原话叫做 “Multiple views on the same data”,对它们进行下标读写,最终都会反应到它所建立在的 ArrayBuffer 之上。❝1.2 基本操作「语法」 new ArrayBuffer(length) 参数:length 表示要创建的 ArrayBuffer 的大小,单位为字节;返回值:ArrayBuffer 对象;异常:如果 length 大于 Number.MAX_SAFE_INTEGER(>= 2 ** 53)或为负数,则抛出一个 RangeError 异常;「示例」 const buffer = new ArrayBuffer(32); buffer.byteLength; // 32 const v = new Int32Array(buffer); ArrayBuffer.isView(v) // true const buffer2 = buffer.slice(0, 1); 上面代码表示实例对象 buffer 占用 32 个字节。 它有实例属性 byteLength ,表示当前实例占用的内存字节长度。 它拥有一个静态方法isView(),这个方法可以用来判断是否为TypedArray实例或DataView实例。 它拥有实例方法 slice(),用来复制一部分内存,使用方式同数组的slice方法。 除了slice方法,ArrayBuffer对象不提供任何直接读写内存的方法,只允许在其上方建立视图,然后通过视图读写。 二、视图2.1 TypedArray TypedArray一共包含九种类型,每一种都是一个构造函数。(DataView视图不支持Uint8ClampedArray,其他都支持) 名称描述长度(字节)Int8Array8位有符号整数1Uint8Array8位无符号整数1Uint8ClampedArray8位无符号整型固定数组(数值在0~255之间)1Int16Array16位有符号整数2Uint16Array16位无符号整数2Int32Array32位有符号整数4Uint32Array32 位无符号整数4Float32Array32 位 IEEE 浮点数4Float64Array64 位 IEEE 浮点数8 每一种视图都有一个BYTES_PER_ELEMENT常数,表示这种数据类型占据的字节数。 Int8Array.BYTES_PER_ELEMENT // 1 Uint8Array.BYTES_PER_ELEMENT // 1 Int16Array.BYTES_PER_ELEMENT // 2 Uint16Array.BYTES_PER_ELEMENT // 2 Int32Array.BYTES_PER_ELEMENT // 4 Uint32Array.BYTES_PER_ELEMENT // 4 Float32Array.BYTES_PER_ELEMENT // 4 Float64Array.BYTES_PER_ELEMENT // 8 这 9 个构造函数生成的数组,统称为TypedArray视图。它们很像普通数组,都有length属性,普通数组的操作方法和属性,对TypedArray 数组完全适用。 普通数组与 TypedArray 数组的差异主要在以下方面: [图片] TypedArray和Array之间也可以互相转换 const typedArray = new Uint8Array([1, 2, 3, 4]); const normalArray = Array.apply([], typedArray); 「建立TypedArray视图」 // 创建一个8字节的ArrayBuffer const a = new ArrayBuffer(8); // 创建一个指向a的Int32视图,开始于字节0,直到缓冲区的末尾 const a1 = new Int32Array(a); // 创建一个指向a的Uint8视图,开始于字节4,直到缓冲区的末尾 const a2 = new Uint8Array(a, 4); // 创建一个指向a的Int16视图,开始于字节4,长度为2 const a3 = new Int16Array(a, 4, 2); 上面代码在一段长度为 8 个字节的内存(a)之上,生成了三个视图:a1、a2和a3。 视图的构造函数可以接受三个参数: 第一个参数(必选):视图对应的底层ArrayBuffer对象;第二个参数:视图开始的字节序号,默认从 0 开始;第三个参数:视图包含的数据个数,默认直到本段内存区域结束; 建立了视图以后,就可以进行各种操作了。这里需要明确的是,视图其实就是普通数组,语法完全没有什么不同,只不过它直接针对内存进行操作,而且每个成员都有确定的数据类型。所以,视图就被叫做“类型化数组”。 「TypedArray视图操作」 const buffer = new ArrayBuffer(8); const int16View = new Int16Array(buffer); for (let i = 0; i < int16View.length; i++) { int16View[i] = i * 2; } console.log(int16View) // [0, 2, 4, 6] 上面代码生成一个8字节的ArrayBuffer对象,然后在它的基础上,建立了一个16位整数的视图。由于每个字节占据8位,那么16位就占据了2个字节(1个字节等于8位),所以一共可以写入4个整数,依次为0,2,4,6。 如果在这段数据上接着建立一个8位整数的视图,则可以读出完全不一样的结果。 const int8View = new Int8Array(buffer); for (let i = 0; i < int8View.length; i++) { int8View[i] = i; } console.log(int8View) // [0, 0, 2, 0, 4, 0, 6, 0] 首先整个ArrayBuffer对象会被分成8段。然后,由于x86体系的计算机都采用小端字节序(具体概念理解请自主查询),相对重要的字节排在后面的内存地址,相对不重要字节排在前面的内存地址,所以就得到了上面的结果。还可以看到下面这个例子 const buffer = new ArrayBuffer(4); const v1 = new Uint8Array(buffer); v1[0] = 10; v1[1] = 3; v1[2] = 11; v1[3] = 8; console.log(v1) // [10, 3, 11, 8] const uInt16View = new Uint16Array(buffer); // [0xa, 0x3, 0xb, 0x8] console.log(uInt16View) // 计算机采用小端字节序 [0x030a, 0x080b] => [778, 2059] 如果一段数据是大端字节序(大端字节序主要用于数据传输),TypedArray 数组将无法正确解析,因为它只能处理小端字节序!为了解决这个问题,JavaScript 引入DataView对象,可以设定字节序。 2.2 DataView DataView 视图是一个可以从二进制 ArrayBuffer 对象中读写多种数值类型的底层接口,使用它时,不用考虑不同平台的字节序问题。 ❝ 字节顺序,又称端序或尾序(英语:Endianness),在计算机科学领域中,指存储器中或在数字通信链路中,组成多字节的字的字节的排列顺序。字节的排列方式有两个通用规则。例如,一个多位的整数,按照存储地址从低到高排序的字节中,如果该整数的最低有效字节(类似于最低有效位)在最高有效字节的前面,则称小端序;反之则称大端序。在网络应用中,字节序是一个必须被考虑的因素,因为不同机器类型可能采用不同标准的字节序,所以均按照网络标准转化。例如假设上述变量 x 类型为int,位于地址 0x100 处,它的值为 0x01234567,地址范围为 0x100~0x103字节,其内部排列顺序依赖于机器的类型。大端法从首位开始将是:0x100: 01, 0x101: 23,..。而小端法将是:0x100: 67, 0x101: 45,..。❝「语法」 new DataView(buffer [, byteOffset [, byteLength]]) 相关的参数说明如下: buffer:ArrayBuffer 对象 或 SharedArrayBuffer 对象;byteOffset(可选):此 DataView 对象的第一个字节在 buffer 中的字节偏移。如果未指定,则默认从第一个字节开始;异常:此 DataView 对象的字节长度。如果未指定,这个视图的长度将匹配 buffer 的长度;「示例」 const buffer = new ArrayBuffer(16); const view = new DataView(buffer, 0); view.setInt8(1, 68); view.getInt8(1); // 68 如果一次操作(get或者set)两个或两个以上字节,就必须明确数据的存储方式,到底是小端字节序还是大端字节序。DataView的操作方法默认使用大端字节序解读数据,如果需要使用小端字节序解读,必须在操作方法中指定参数为true(get方法的第二个参数和set方法的第三个参数)。 const buffer = new ArrayBuffer(24); const dv = new DataView(buffer); // 1个字节,默认大端字节序 const v1 = dv.getUint8(0); // 小端字节序 const v1 = dv.getUint16(1, true); // 大端字节序 const v2 = dv.getUint16(3, false); // 在第5个字节,以小端字节序写入值为11的32位整数 dv.setInt32(4, 11, true); 对于直接处理ArrayBuffer对象的业务场景不是特别多,特别是写页面比较多的同学。笔者深刻认识并运用的场景,主要是在处理比较复杂且数据量比较大的点云数据,前端接收到的点云数据已经是原始采集数据转换过的二进制数据,前端需要对二进制数据进行解析,运用的解析方法就是上述提到的各种方法。下面介绍一下业务场景中比较常见到的一种二进制表示类型——Blob。 三、Blob3.1 基本介绍 Blob 对象比较常用于文件上传、文件读写操作等。在对文件读写的时候,我们更多的时候只是操作File对象,而File继承了所有Blob的属性。所以在我们看来,File对象可以看作一种特殊的Blob对象。 而Blob 对象与 ArrayBuffer 的区别在于,Blob 对象用于操作二进制文件, ArrayBuffer 用于直接操作内存,所以他们有如下图的关系: [图片] 「语法」 const blob = new Blob(array [, options]); 相关的参数说明如下: array:字符串或二进制对象,表示新生成的Blob实例对象的内容;options(可选):比较常用的属性 type,表示数据的 MIME 类型,默认空字符串;「示例」 const array = ['Hello World! ']; const blob = new Blob(array, {type : 'text/html'}); 「属性和方法」 [图片] 由上图可以看到,Blob对象拥有size和type两个属性,以及多种自有方法。比较常用的方法slice、arrayBuffer等;slice方法主要用来拷贝原来的数据,返回的也是一个Blob实例,这个方法可以用来做切片上传。arrayBuffer方法返回一个 Promise 对象,包含 blob 中的数据,并在 ArrayBuffer 中以二进制数据的形式呈现。 const blob = new Blob([]); blob.slice(0, 1); blob.arrayBuffer().then(buffer => /* 处理 ArrayBuffer 数据的代码……*/); 3.2 运用场景通过window.URL.createObjectURL方法可以把一个blob转化为一个Blob URL,并且用做文件下载或者图片显示的链接。 Blob URL所实现的下载或者显示等功能,仅仅可以在单个浏览器内部进行。而不能在服务器上进行存储,亦或者说它没有在服务器端存储的意义。 下面是一个Blob的例子,可以看到它很短 blob:d3958f5c-0777-0845-9dcf-2cb28783acaf 和冗长的Base64格式的Data URL相比,Blob URL的长度显然不能够存储足够的信息,这也就意味着它只是类似于一个浏览器内部的“引用“。从这个角度看,Blob URL是一个浏览器自行制定的一个伪协议。 「文件下载」 [图片] 「图片显示」 [图片] 「切片上传」 [图片] 「本地文件读取」 [图片]
2021-05-10 - 文件上传杂谈
一、写在前面文件上传是前端很常见的一类场景。图片、视频和文档等等都属于文件范畴,每个文件则是通过 File.Type 进行更细的划分。本文将针对文件上传的一些通用维度场景做简单的剖析和尝试,抛砖引玉,希望共同学习,共同成长。 本文案例里使用的组件来源于组件库 zent@7.4.4二、常见的上传场景及实现上传的形式或场景各式各样,除了业务级别的封装外,常遇到的通用场景有如下: 重复上传上传预览拖拽上传上传裁剪上传进度可视化文件压缩上传前置校验切片上传上传加密暂停&断网续传 ...我们抽取部分场景进行实现: 2.1 上传前置校验在文件上传前,经常会需要对文件格式进行校验,我们需要在文件上传/展示预览图前提示用户图片是否完成校验。 常用的格式校验:文件类型、文件大小、上传的尺寸 我们先看看和文件相关的两个对象的定义:[代码]Blob[代码] 和 [代码]File[代码] /** A file-like object of immutable, raw data.Blobs represent data that isn't necessarily in a JavaScript-native format. The File interface is based on Blob, inheriting blob functionality and expanding it to support files on the user's system. */ interface Blob { readonly size: number; readonly type: string; arrayBuffer(): Promise; slice(start?: number, end?: number, contentType?: string): Blob; stream(): ReadableStream; text(): Promise; } /** Provides information about files and allows JavaScript in a web page to access their content. */ interface File extends Blob { readonly lastModified: number; readonly name: string; } 通过定义我们知道, [代码]Blob[代码]是一个不可变、存储文件原数据的一个类文件,但其并非是JS的原生数据,而 [代码]File[代码]继承于 [代码]Blob[代码],使得 [代码]Blob[代码]信息扩展为用户操作系统可支持的文件,并使得页面里可以使用 [代码]Javascript[代码]访问其文件信息。 除了继承与原有的 size 和 type 属性, [代码]File[代码]对象还额外返回 lastModified (返回文件最后修改日期)和 name (文件名)属性。 以下是某个文件的 [代码]File[代码]实例信息 { lastModified: 1581424451211 lastModifiedDate: Tue Feb 11 2020 20:34:11 GMT+0800 (中国标准时间) name: "计算机网络.pdf" size: 70809807 type: "application/pdf" webkitRelativePath: "" } 通过上面信息,我们可以很轻松地校验文件类型和文件大小。具体的实现我们接着看下去。 2.1.1 限制文件上传类型1.使用 input 自带属性 [代码]accept[代码] Mime 类型列表 属性描述值例子accept 期望文件类型 image/* , audio/* , video/* ... image/jpeg ... [图片] 图1 Input限制上传类型 2.使用文件后缀或 MIME-TYPE // ... const acceptTypes = ['image/png', 'image/jpeg']; const picSlipt = name.split('.'); // 切割文件名后缀 const picSuffix = `image/${picSlipt[picSlipt.length - 1]}`; // 直接使用解析的文件信息 const fileType = file.type; if (acceptTypes.includes(picSuffix) || acceptTypes.includes(fileType)) { console.log('通过文件类型校验!'); }; //... 3.使用二进制文件信息流读取 但我们知道直接更改文件后缀并不会改变文件类型的本质。比如以下我直接更改一张 png 图片后缀为 jpg,那么它就很有可能绕过了我们的规则 [代码]image/jpeg[代码](虽然想要绕过前端的规则校验有非常多的方法) [图片] 图2 通过更改png图片后缀绕过前端上传规则 但实际上它还是png图片,我们可以通过图像信息查询网站可以得出该图片信息实际如下: [图片] 图3 后缀和类型不一致 上传校验的绕过会给服务器带来很多潜在危险,因此我们可能需要通过更严格的类型校验:文件头信息进行格式鉴别👇 文件类型的信息一般是头文件里前8个字节 我们看一下上面那张图avatar.jpg,第一行头信息里有什么? [图片] 图4 文件的16进制信息 通过vscode的插件hexdump可以查看到该文件头部信息前8个字节为:89 50 4E 47 0D 0A 1A 0A。这其实是 png 图片的头部信息,前8个字节属于 png 图片的头标识,后4个字节为数据域长度,最后4个字节为 png 的 IHDR 标识,是图片宽高等数据流前的第一个数据块。通过前8个字节证明了即使图片后缀为 jpg,但文件类型仍然为 png 以下列举一些常见的文件格式前字节标识信息(可从网上查阅或用 vscode 读取) 文件类型规则hex(十六进制)png 前8个字节 89 50 4E 47 0D 0A 1A 0A jpg 前2个字节 FF D8 gif 前6个字节 47 49 46 38 39 61 bmp 前2个字节 42 4D ES6已经支持我们我们直接通过 [代码]ArrayBuffer[代码]对象存储文件的二进制数据,并通过 [代码]DataView[代码]去读取。 const reader = new FileReader(); reader.onload = function () { // 这里从0开始获取文件二进制数据的前8个字节 const dataView = new DataView(this.result, 0, 8); for (let i = 0; i < dataView.byteLength; i++) { // 读取 1 个字节,返回一个无符号的 8 位整数 bufferUint8Array.push(dataView.getUint8(i)) } } // 这里生成包含文件信息的二进制数据,但不允许直接读写 reader.readAsArrayBuffer(file); 也可以在生成ArrayBuffer时先对文件进行切割: // 1.生成对象 reader.readAsArrayBuffer(file.slice(0, 8))); // 2.提取头部信息 new DataView(this.result); 完整代码 // index.js const handleChange = async e => { const files = e.target.files; const isPNG = await checkType(files[0]); } // utils.js export const checkType = file => { return new Promise(resolve => { const reader = new FileReader(); reader.onload = function () { // PNG文件头标识(16进制) const PNG_HEADER_HEX = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; const dataView = new DataView(this.result); const bufferUint8Array = new Array(dataView.byteLength).fill('').map((_, index) => dataView.getUint8(index)) console.log(`文件: ${file.name} 的前8个字节十进制为, ${bufferUint8Array}`); // 用获取到的字节和图片头信息进行对比 const isPNG = PNG_HEADER_HEX.every((hex, index) => { return hex === bufferUint8Array[index]; }); resolve(isPNG); } reader.readAsArrayBuffer(file.slice(0, 8)); }) } 现在我们分别上传一张标准 png 图片、一张更改后缀为 jpg 的 png 图片和一张标准 jpg 判断其是否符合标准的 png 头部信息。 [图片] 图5 判断上传文件是否为png格式 可以看到: avatar.png: 十进制: [137, 80, 78, 71, 13, 10, 26, 10] 十六进制为:0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A avatar.jpg: 十进制: [137, 80, 78, 71, 13, 10, 26, 10] 十六进制为:0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A banner.jpg: 十进制: [255, 216, 255, 224, 0, 16, 74, 70] 十六进制: 0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x18, 0x45, 0x78 2.1.2 文件尺寸的校验因为文件的尺寸无法通过 [代码]File[代码]对象直接获得,我们可以使用以下方法 1.使用 [代码]Image[代码]获取上传图片尺寸 const reader = new FileReader(); const widthLimit = 100; const heightLimit = 100; console.log('限制图片的宽度 & 高度', `${widthLimit}px`, `${heightLimit}px`); reader.readAsDataURL(file); reader.onload = async function () { // 加载图片获取图片真实高度和上传 const src = reader.result; const image = new Image(); image.onload = await function () { const width = image.width; const height = image.height; console.log('上传图片的宽度 & 高度', `${width}px`, `${height}px`); if (Number(widthLimit) !== width || Number(heightLimit) !== height) { console.log(` %c x 校验不通过 ,请上传${widthLimit}*${heightLimit}的尺寸图片`, 'color: #ed6a0c'); resolve(false) } else { console.log('%c y 校验通过!', 'color: #2da641'); resolve(true); } } // 放置onload后 image.src = src; } 2.使用二进制文件信息流读取通过下面两种方式可以利用文件的头信息找到宽高: 通过找到图片信息的前置标志,然后再进行字节偏移直接进行字节偏移(仅适用于信息在固定位的格式,例如 png、gif 等。jpg 的前置标志没有固定的位置,只能通过第一种方式)文件类型前置标志读取方式png IHDR(13-16字节) 宽度:17-20字节(4 bytes) 高度: 21-24字节(4 bytes) gif GIF89(1-6字节) GIF尺寸是反着存储 宽度:第8字节+第7字节(2 bytes) 高度: 第10字节+第9字节(2 bytes) bmp - 宽度:18-21字节(4 bytes) 高度: 22-25字节(4 bytes) jpg SOF0、SOF1... 偏移3个字节后(n)开始计算 高度:(n, n+1)(2 bytes) 宽度:(n+2,n+3)(2 bytes) 完整代码 export const checkPxByHeader = file => { console.log('文件信息', file); const reader = new FileReader(); reader.onload = function () { const dataView = new DataView(this.result); isPNG(dataView); } // 如果是判断jpg图片需要遍历整个Buffer,不能切割 // png的前置标志固定在13-16字节 reader.readAsArrayBuffer(file.slice(0, 50)); } // png文件信息第一块数据表示 IHDR(49 48 44 52) const isPNG = dataView => { const IHDR_HEX = [0x49, 0x48, 0x44, 0x52]; // 方法一 查找数据块标志 new Array(dataView.byteLength - 4).fill('').map((_, index) => { const fourBytesArr = [index, index + 1, index + 2, index + 3].map(num => dataView.getUint8(num)); // 通过提取的4位无符号的8-bit整数与标准的PNG-IHDR16进制对比,判断是否遍历到了IHDR位置 const isTouchIHDR = fourBytesArr.every((hex, index) => { return hex === IHDR_HEX[index]; }); if (isTouchIHDR) { // 找到IHDR位置,偏移4个字节后获取4个字节的32位整数即可获取宽度 const width = dataView.getInt32(index + 4); const height = dataView.getInt32(index + 8); console.log('方法一获取 width', width); console.log('方法一获取 height', height); } if (!isTouchIHDR && index === dataView.byteLength - 4) { console.log('方法一获取 上传文件并非png'); } }) // 方法二 直接偏移字节 // 从第17个字节开始读取 const width = dataView.getInt32(16); const height = dataView.getInt32(20); console.log('方法二获取 width', width); console.log('方法二获取 height', height); } [图片] 图6 通过文件信息获取宽高 2.2 大文件上传之切片上传 接口超时、上传失败后又从零开始上传等是大文件上传经常要面临的问题,通过切片上传、断点续传等方式可以很好地解决以上痛点,改善交互体验。我们先来看一下基础的大文件上传最终效果: [图片] 图7 切片上传完整演示图 其实切片上传和单文件上传没有很大的区别,切片上传实际上就是一个个小切片的单文件上传。可以归纳成以下几步操作,我们一一实现: 获取上传文件信息。前端根据实际情况进行切片。如果是断点续传,则需要从已上传的切片数后面开始切割。(注:需要给每个切片的名字带上唯一标志,一般为索引值)上传切片至服务端。通过 ajax 的 [代码]ProgressEvent[代码]读取上传进度,前端展示。(注:ProgressEvent返回的是每个切片上传的进度,总进度应该是所有切片上传的进度)服务器接收切片。切片上传完毕后,前端发送请求通知服务器端合并切片,最后清除切片缓存。返回上传结果 & 文件路径。 2.2.1 获取上传文件信息通过调用 [代码]input[代码]的实例,打开选择文件弹窗并获取上传文件信息。 [图片] 图8 获取上传文件信息 完整代码 /** html */ 添加文件 /** constants */ export const uploadStatusMap = { 'pending': 0, 'uploading': 1, 'done': 2, 'pause': 3, 'error': 4, } export const uploadStatus = { 0: '未上传', 1: '上传中', 2: '已上传', 3: '暂停中', }; /** js */ const inputRef = React.useRef(null); const [fileList, setFileList] = React.useState([]); // 打开文件选择框 const handleAddFile = () => { const inputEv = inputRef.current; inputEv.click(); }; // 上传文件后回调 const handleFileSelect = async e => { const File = e.target.files[0]; // 存储文件相关信息 let filesToCurrent = { id: createUploadId(), fileName: File.name, fileType: File.type, fileSize: File.size, File, chunkCount, uploadSingleProgress: 0, currentChunk: 0, uploadStatus: uploadStatusMap.pending, }; // 表格里显示文件信息 setFileList([...fileList, filesToCurrent]); } 2.2.2 生成切片这里提供了2种生成切片数量的方式,可以根据具体情况选择: 1.根据默认的切片数量切割 好处: 限定了http请求的数量坏处: 文件过大时有可能导致每块切片大小依然很大,失去了切片的意义2.根据默认的切片大小切割 好处: 限定了切片的大小坏处: 切片数量过多容易造成http负担通过切片数量来计算每个切片大小。例如一张图片总大小为15M,切割成5份后每份切片大小为15 / 3 = 3M。因为合并切片的时候需要按切割顺序进行,所以需要记录当前切片的索引值,在上传切片时带上(本场景把 index 拼接到切片名字里)。 // index从1开始计算 `${File.name}-chunk-${fileChunkList.length + 1}` 切片索引值除了合并切片时使用外,在读取上传进度等地方也发挥了很大作用。[图片] 图9 前端生成切片信息 2.2.3 上传切片需要使用post方法结合multipart/form-data头才能将文件内容填充到body中。 const formData = new FormData(); formData.append('name', name); formData.append('file', file); axios({ method: 'post', data: formData, header: { 'Content-type': 'multipart/form-data', }, // ... }) 2.2.4 展示上传进度为了演示方便,本场景里暂时使用发送所有请求的方案,会并发无序执行所有请求,直到所有的请求响应完成再发送合并切片请求。所以需要记录每个切片上传的进度,并通过其占有的进度比计算出最终的文件上传进度。例如: 一个文件15M,分成5份切片,每个切片大小占比20%,发送上传请求一段时间,假设切片1返回进度是50%,其他4个切片返回进度均为25%,则文件总上传进度是多少? (0.5 * 0.2 + 0.25 * 0.8) * 100 = 30(%) [图片] 图10 每个切片返回上传的进度 完整代码 // index.js const uploadPromise = uploadChunkList.map(async ({ name, file }) => { return axios({ // ... // 记录上传进度 onUploadProgress: uploadInfo => { let chunkUploadInfo = {}; // 计算当前切片上传百分比 已上传数/总共需要上传数(这里计算的是每个切片的上传进度) const chunkProgress = Number((uploadInfo.loaded / uploadInfo.total)); console.log('当前上传切片序号:', index); console.log('当前上传切片进度', `${(chunkProgress * 100).toFixed(2)}%`); chunkUploadInfo[index] = chunkProgress; currentUploadItem.isSingle = false; /** * 总的上传百分比是由 切片上传进度 * 切片分数占比 * chunkUploadInfo的格式为{[index]: progress1, [index1]: progress2, ...} index为切片索引值 */ currentUploadItem.chunkUploadInfo = { ...currentUploadItem.chunkUploadInfo, [index]: chunkProgress, }; // 切片上传进度100%时,更新当前上传切片的索引值 if (chunkProgress === 1) { currentUploadItem.currentChunk = index + 1; } setFileList([...newFileList]); }, // ... }) } // utils.js 计算表格里展示的总进度 export const getSliceFileUpload = (chunkUploadInfo = {}) => { let progress = 0; // chunkUploadInfo数据格式为: {0: 0, 1: 0, [切片索引值]: [切片上传进度], ...} const chunkCountArr = Object.keys(chunkUploadInfo); chunkCountArr.forEach(chunkIdx => { progress += chunkUploadInfo[chunkIdx] * (100 / chunkCountArr.length) }) return progress; } 2.2.5 服务器端接收切片node层要接收解析二进制文件流。提取的实现比较麻烦,这边直接使用@koa/multer@1.0.2(版本不一样使用方式可能也不一样,具体可查看官方文档),当然还有其他非常多优秀的npm包可以选择formidable [代码]@koa/multer[代码]允许用户设定一个存放文件的位置。其实例对象提供了几种模式,为方便演示,本文案例统一使用 single。具体区别可以查看其定义。 /** 流存放位置 */ const chunksPath = path.join(__dirname, '../static/stream'); [代码]@koa/multer[代码]允许用户通过不同方法接收上传的文件 interface Instance { /** Accept a single file with the name fieldName. The single file will be stored in req.file. */ single(fieldName?: string): Koa.Middleware; /** Accept an array of files, all with the name fieldName. Optionally error out if more than maxCount files are uploaded. The array of files will be stored in req.files. */ array(fieldName: string, maxCount?: number): Koa.Middleware; /** Accept a mix of files, specified by fields. An object with arrays of files will be stored in req.files. */ fields(fields: Field[]): Koa.Middleware; /** Accepts all files that comes over the wire. An array of files will be stored in req.files. */ any(): Koa.Middleware; } // 实例 router.post('/upload-chunk', koaMulterUpload.single('file'), async (ctx) => { const file = ctx.req.file; }) [代码]@koa/multer[代码]会默认为接收到的文件生成如下信息: { fieldname: 'file', originalname: 'blob', encoding: '7bit', mimetype: 'application/octet-stream', destination: '/YourLocalPath/static/stream', filename: 'ff7cd26c15305dbfd9173be5f80f9770', path: '/YourLocalPath/static/stream/ff7cd26c15305dbfd9173be5f80f9770', size: 14161959 } 为了方便后续合并切片,将切片名重命名为特定的格式 /** * 重命名二进制流文件 * 注意路径需要对齐 */ // 从前端接收到的重命名格式,例如`${fileName}-chunk-${index}` const { name } = ctx.req.body; const file = ctx.req.file; const chunkName = `${chunksPath}/${name}`; fs.renameSync(file.path, chunkName); [图片] 图11 切片存放位置以及切片合并后生成的文件 完整代码 const koaMulter = require('koa-multer'); /** fs的封装模块 */ const fs = require('fs-extra'); /** 流存放位置 */ const chunksPath = path.join(__dirname, '../static/stream'); const koaMulterUpload = koaMulter({ dest: chunksPath }); router.post('/upload-chunk', koaMulterUpload.single('file'), async (ctx) => { /** * axios方法 * ctx.req.file 文件流信息 * ctx.req.body 请求参数 */ const { name } = ctx.req.body; const file = ctx.req.file; const chunkName = `${chunksPath}/${name}`; /** * 重命名切片文件名 * 注意路径需要对齐 */ fs.renameSync(file.path, chunkName); ctx.status = 200; ctx.res.end(`upload chunk: ${name} success!`); }); 2.2.6 服务器端合并切片node根据约定的切片名字格式去读取已存储的切片文件,合并之后清除切片文件。 完整代码 // node.js router.post('/merge-chunk', async (ctx) => { /** * axios.post方法 * ctx.request.body 请求参数 */ // 由前端告诉服务端生成切片数量 const { fileName = '未命名', chunkCount } = ctx.request.body || {}; // 1.创建存储文件,初始为空 const filePath = `${uploadFilePath}/${fileName}`; fs.writeFileSync(filePath, ''); console.log('chunkCount', chunkCount); // 2.读取所有chunk数据 // 3.开始写入数据 for (let idx = 1; idx <= chunkCount; idx++) { /** * 约定的chunk文件名格式: fileName + '-' + index */ const chunkFile = `${chunksPath}/${fileName}-chunk-${idx}`; fs.appendFileSync(filePath, fs.readFileSync(chunkFile)); } /** 删除chunk文件 */ fs.emptyDirSync(`${chunksPath}/${fileName}`); ctx.status = 200; ctx.res.end('successful'); }); 2.3 大文件上传之断点续传断点续传的核心是在已经上传切片数后面续传,为了更好地实现,我们对上面切片上传的逻辑做几个优化的点: 切片需要按顺序上传。即在上传切片1后再上传切片2,解决同时上传出现后面的切片比前面的切片先上传成功的情况,避免续传时重新切割切片无法找到起点。为方便找到上传文件已上传的切片,在切片完全上传更换名字的时候存放到特定文件夹里(案例里会以文件本名为存放 chunks 的文件名)。 [图片] 图12 断点续传切片存放位置 node返回切片信息后,只需要从已存在切片数+1位置进行切割。文章案例是会在切片完全上传后进行重命名,所以根据重命名后的切片数量判断重新切割位置能保证最后合成的文件信息无误。虽然会导致未完全上传的切片在续传的时候丢失(可能会出现上传进度86%,暂停重启后进度变为80%),本案例暂不考虑该情况。 针对以上优化的点做代码优化: 2.3.1 切片按顺序上传由于 [代码]map[代码]是js的同步方法,去掉 [代码]map[代码]和 [代码]axios.all[代码],使用 [代码]for...of[代码]代替, [代码]for...of[代码]是ES6推出的具有iterator(可迭代)特性的方法,受控于方法里的异步操作(await等),详细可查看for...of 循环 但 [代码]for...of[代码]无法拿到索引值,因为我们需要对原数组做处理,这里使用 [代码]Object.entries[代码],数组的索引值会被填入内容里转化成 [代码][a,b]=>[[index1,a],[index2,b]][代码]格式,注意获取的index类型为 [代码]string[代码]。 for (let [indexStr, { name, file, fileName }] of Object.entries(uploadChunkList)) { //... } [图片] 图13 按顺序上传切片 2.3.2 切片存放位置在切片上传成功后重命名至以该文件名为命名的文件夹里 // node/index.js const chunksContinuePath = `${chunksPath}/${fileName}`; if (!fs.existsSync(chunksContinuePath)) { await fs.mkdirs(chunksContinuePath); } const chunkName = `${chunksPath}/${fileName}/${name}`; fs.renameSync(file.path, chunkName); 2.3.3 获取已上传切片在上传切片前,客户端需要先从node里读取已上传切片数量,并依据此切割新的切片。通过文件名去读取存放文件夹下是否有对应的切片。 // client/index.js // 查找文件是否已经存在上传的切片信息 const existChunksList = await axios.get(`/chekck-file_chunk-upload?fileName=${File.name}`).then(({ data = [] }) => data); // 存在切片信息 if (existChunksList.length) { // 状态更改为暂停 filesToCurrent.uploadStatus = uploadStatusMap.pause; // 存储切片最后的索引值 filesToCurrent.currentChunk = existChunksList.length; // 读取到的切片上传进度都设置为100% filesToCurrent.chunkUploadInfo = { ...new Array(chunkCount).fill('').map((_, index) => index < existChunksList.length ? 1 : 0) }; }; // node/index.js router.get('/chekck-file_chunk-upload', async (ctx) => { const { query: { fileName }, } = ctx; // 切片读取位置和重命名的路径要一致 const chunksContinuePath = `${chunksPath}/${fileName}`; let uploadedChunksList = []; if (fs.existsSync(chunksContinuePath)) { uploadedChunksList = fs.readdirSync(chunksContinuePath); } ctx.body = uploadedChunksList }); 2.3.4 重新切割前端拿到已上传切片数量后只需要从索引值位置开始切割即可,再将剩余的切片上传完成进行合并即可。 const currentSize = chunkSize * currentChunk; // 计算剩余切片大小 for (let current = currentSize; current < File.size; current += chunkSize) { fileChunkList.push({ fileName: File.name, // 注意名字里的索引值应该是从已上传切片数量+1开始 name: `${File.name}-chunk-${currentChunk + fileChunkList.length + 1}`, // 使用Blob.slice方法来对文件进行分割。 file: File.slice(current, current + chunkSize), }); } 这边通过axios的 [代码]CancelToken[代码]简单模拟一下断点的操作 const axiosCancelToken = axios. CancelToken ; const axiosSourceCancel = axiosCancelToken.source(); // 暂停上传 const handleStopUpload = ({ id }) => { axiosSourceCancel.cancel( '中断上传' ); // ... }; 因为要模拟继续上传的场景,因此不能在原取消的请求上继续发起,我们需要发起新的请求实例,简单地做一下封装,并使用每次生成的CancelToken去做取消操作。 [图片] 图14 断点续传 三、结尾对于切片上传、断点续传等功能其实很多第三方CDN服务已经提供了成熟的方案,例如七牛云的文件上传。除了上传前置校验、断点续传等场景外,还有上传压缩、上传加密等场景各式各样。以及经常与其绑定一起的文件下载相关技术(例如有趣的HLS)都是非常值得去学习的。 感兴趣的商家,可点击 → 免费试用有赞店铺~ [图片]
2021-01-12