Bootstrap

实现微信小程序web-view内嵌H5中的下载功能(大文件切片下载)

一、项目场景:

微信小程序的开发框架是uniapp,使用uniapp脚手架搭建,其中有页面是展示另一个小程序,在这个页面主体内容使用了标签将H5的页面内容展示,H5中有页面存放了下载的路径。点击下载按钮下载文件,或者预览文件让用户手动保存。

难点

  • 如果是pc端,下载用一个<a>标签就很容易,但是在小程序里的标签中是行不通的。
  • 此外,小程序里的<web-view>与小程序的通行方式主要用postMessage,但是触发条件非常苛刻,参照微信的官方文档,只在小程序后退组件销毁,还有分享时才会触发postMessage并一次性把值全部带出来,使用起来非常不便,对于小程序实际内容主体是<web-view>内嵌的spa的H5页面的情况下,销毁组件会带来很多麻烦,因此最后放弃了这个方案

解决方案:

1、H5微信小程序:

a、首先必不可少的是安装jweixin-module模块:
npm i jweixin-module
b、在main.js中将依赖绑定:
import wx from  "jweixin-module"
Vue.prototype.$wx = wx 
c、H5对应页面点击下载时代码为:
this.$wx.miniProgram.navigateTo({
  url:"/pages/study/downLoading?url="+encodeURIComponent(fileInfo.content_url) 
})

2、uni-app的小程序

a、对应的/pages/study/downLoading页面
<template> <div>下载中。。。</div> </template>
<script lang="ts" setup>
import { onLoad } from '@dcloudio/uni-app';
onLoad((option: any) => {
  console.log('gfdjhwghjghjkjghjklghjk', option.url);
  handleDown(decodeURIComponent(option.url));
});
const handleDown = (url: string) => {
  uni.showLoading({
    title: '下载中', //提示文字
    mask: true, //是否显示透明蒙层,防止触摸穿透,默认:false
  });
  console.log(url);
  let imageType = ['gif', 'png', 'jpg', 'tif', 'bmp', 'webp', 'jpeg', 'JPG'];
  const downloadTask = uni.downloadFile({
    url: url,
    success: res => {
      console.log(res, 'resresresres');
      uni.hideLoading();
      if (res.statusCode === 200) {
        console.log(res.tempFilePath, '1111111');
        let originFileNameSplit = res.tempFilePath.split('.');
        let fileNameType = originFileNameSplit[originFileNameSplit.length - 1];
        uni.getFileSystemManager().saveFile({
          //微信保存文件,这个存储有点复杂
          // 临时存储路径,先有临时存储路径方可使用wx官方的存储本地路径( uni.env.USER_DATA_PATH )
          tempFilePath: res.tempFilePath,
          //定义本地的存储路径及名称
          success(res) {
            const savedFilePath = res.savedFilePath;
            uni.setStorageSync('uploadData', savedFilePath); //保存传入的参数
            uni.setStorageSync('isImageType', imageType.includes(fileNameType)); //保存传入的参数
            uni.hideLoading();
            uni.navigateBack({
              delta: 1,
            });
          },
          fail(err) {
            uni.showToast({
              title: '预览失败',
              icon: 'error',
              duration: 1500,
            });
            setTimeout(() => {
              uni.navigateBack({
                delta: 1,
              });
            }, 3000);
          },
        });
      }
    },
    fail: err => {
      uni.hideLoading();
      uni.showToast({
        title: '下载失败',
        icon: 'error',
        duration: 1500,
      });
      console.log(err);
      setTimeout(() => {
        uni.navigateBack({
          delta: 1,
        });
      }, 3000);
    },
  });
  downloadTask.onProgressUpdate(res => {
    // console.log('下载进度', res.progress);
    // console.log('已经下载的数据长度', res.totalBytesWritten);
    // console.log('预期需要下载的数据总长度', res.totalBytesExpectedToWrite);
  });
};
</script>
b、可以预览的时候返回到web-view页面:
<template>
  <view class="study">
    <web-view class="webviewOut" :src="你的url地址"></web-view>
  </view>
</template>

<script setup lang="ts">
onShow(() => {
  const url = uni.getStorageSync('uploadData');
  console.log('1111111111111111111111111111111111', url);
  if (url) {
    uni.hideHomeButton();
    handleDown(url); //具体的微信下载文件的方法
    //每次onShow执行完,还有上面的下载方法执行完后要把这个标记重置为false,这样不同情况触发的onShow才能区分是否是下载文件页面回来的。可能写的重复但是多写几次比较放心
  }
});
const handleDown = (savedFilePath: string) => {
  const isImageType = uni.getStorageSync('isImageType');
  if (isImageType) {
    uni.previewImage({
      urls: [savedFilePath],
      success: function (res) {
        uni.removeStorageSync('uploadData');
        uni.removeStorageSync('isImageType');
      },
      fail: function (err) {
        console.log(res);
        uni.showToast({
          title: '预览失败',
          icon: 'error',
          duration: 1500,
        });
        uni.removeStorageSync('uploadData');
        uni.removeStorageSync('isImageType');
      },
    });
  } else {
    uni.openDocument({
      //微信打开文件
      filePath: savedFilePath,
      showMenu: true,
      success: function (res) {
        uni.removeStorageSync('uploadData');
        uni.removeStorageSync('isImageType');
      },
      fail: function (err) {
        console.log(res);
        uni.showToast({
          title: '预览失败',
          icon: 'error',
          duration: 1500,
        });
        uni.removeStorageSync('uploadData');
        uni.removeStorageSync('isImageType');
      },
    });
  }
};
</script>

<style scoped lang="scss">
.study {
  height: 100vh;
  box-sizing: border-box;
  padding-bottom: 98rpx;
}
</style>

二、注意(下载大文件)

uni.downloadFile 主要包含两个参数:urlfilePath,其中 url 代表下载文件的路径,filePath 代表下载文件保存的路径。
uni.downloadFile 可以用来下载较小的文件,但是对于大文件的下载需要通过 分片下载方式 。具体来说,可以使用 uni.downloadFile 和 uni.uploadFile 结合使用实现分片下载。同时,需要注意为下载文件分配足够的存储空间,以便能够完整保存整个文件。

具体步骤如下:

1.获取文件的总大小。

uni.request({
    url: 'http://example.com/fileUrl',
    method: 'HEAD',
    success: function (res) {
        let contentLength = res.header['Content-Length'];
        // 计算出下载总块数
        let blockCount = Math.ceil(contentLength / BLOCK_SIZE);
        // 创建一个和块数相同的数组
        let blockList = new Array(blockCount).fill(0);
        // 进行分块下载
        downloadFileByBlock(url, savePath, blockList);
    }
});

2.将文件分成若干个块(BLOCK_SIZE 为每个块的大小),以便分批下载。

3.循环下载每一块。

function downloadFileByBlock(url, savePath, blockList) {
    blockList.forEach(function (v, i) {
        let rangeStart = i * BLOCK_SIZE;
        let rangeEnd = (i + 1) * BLOCK_SIZE - 1;
        // 利用 Range 请求头指定下载区间
        uni.downloadFile({
            url: url,
            filePath: savePath,
            header: {
                'Range': 'bytes=' + rangeStart + '-' + rangeEnd
            },
            success: function (res) {
                console.log(res);
            },
            fail: function (err) {
                console.log(err);
            }
        });
    });
}

大文件上传也可以采用类似的方式, 即将文件分成若干块,每次上传一个块,直到上传完整个文件。

完整代码(自己改了改)

这里给出针对微信小程序 web-view 内嵌 H5 中大文件切片下载的完整代码实现:

// download.js

const downloadUrl = 'http://example.com/files/file.zip' // 下载地址
const fileName = 'file.zip' // 下载的文件名
const fileSize = 1024 * 1024 * 100 // 文件大小,100MB
const chunkSize = 1024 * 1024 * 2 // 分片大小,2MB
const tempFilePath = wx.env.USER_DATA_PATH + '/downloads/' // 临时文件存储路径

Page({
  data: {
    progress: 0, // 下载进度
    isDownloading: false, // 是否正在下载
    downloadTask: null // 下载任务句柄
  },

  // 开始下载
  startDownload() {
    if (this.data.isDownloading) {
      return
    }
    this.setData({ isDownloading: true })
    this.downloadChunks()
  },

  // 取消下载
  cancelDownload() {
    if (this.data.downloadTask) {
      this.data.downloadTask.abort()
    }
    this.resetState()
  },

  // 处理分片下载任务队列,完成后进行合并操作
  downloadChunks() {
    const chunkNum = Math.ceil(fileSize / chunkSize)
    const chunks = []
    for (let i = 0; i < chunkNum; i++) {
      const start = i * chunkSize
      const end = Math.min(start + chunkSize, fileSize)
      chunks.push({
        index: i,
        start,
        end,
        downloaded: false,
        filePath: `${tempFilePath}${i}`
      })
    }

    // 进度状态:已下载的字节数
    let loadedBytes = 0

    // 下载分片
    const downloadChunk = (chunk) => {
      return new Promise((resolve, reject) => {
        const downloadTask = wx.downloadFile({
          url: downloadUrl,
          header: {},
          filePath: chunk.filePath,
          success: (res) => {
            if (res.statusCode === 200) {
              loadedBytes += chunk.end - chunk.start + 1
              chunk.downloaded = true
              resolve()
            } else {
              reject(new Error('Download chunk error.'))
            }
          },
          fail: () => {
            reject(new Error('Download chunk fail.'))
          }
        })
        downloadTask.onProgressUpdate((res) => {
          const progress = Math.floor((loadedBytes + res.totalBytesWritten) / fileSize * 100)
          console.log('分片下载进度:', progress)
          this.setData({ progress })
        })
        this.setData({ downloadTask })
      })
    }

    Promise.all(chunks.map(downloadChunk))
      .then(() => {
        this.mergeFiles()
      })
      .catch((err) => {
        console.log(err)
        this.resetState()
      })
  },

  // 合并已下载的分片文件
  mergeFiles() {
    const fs = wx.getFileSystemManager()
    const stream = fs.createWriteStream(`${wx.env.USER_DATA_PATH}/downloads/${fileName}`)

    const write = (index) => {
      const chunk = chunks[index]
      if (!chunk.downloaded) {
        console.log(`Chunk ${chunk.index} not downloaded.`)
        return
      }
      fs.readFile(chunk.filePath, 'binary', (err, data) => {
        if (err) {
          console.log(err)
          return
        }
        stream.write(data, 'binary')
        if (index < chunks.length - 1) {
          write(index + 1)
        } else {
          // 文件合并完成
          stream.end()
          this.resetState()
        }
      })
    }

    write(0)
  },

  // 重置下载状态
  resetState() {
    this.setData({
      progress: 0,
      isDownloading: false,
      downloadTask: null
    })
  }
})

在 H5 页面中可以引入此文件作为下载操作的逻辑处理。同时还需要在 wxml 文件中添加下载按钮及进度条,代码如下:

<!-- download.wxml -->

<view class="download-container">
  <button class="download-btn" bindtap="startDownload">下载</button>
  <progress percent="{{ progress }}%" />
</view>

需要在小程序的 app.json 文件中添加下载临时路径配置,代码如下:

抱歉,似乎代码显示出现了截断,请继续查看完整的 app.json 中的配置:

{
  "pages": [
    "pages/index/index",
    "pages/download/download"
  ],
  "permission": {
    "scope.userLocation": {
      "desc": "小程序将获取您的定位信息"
    }
  },
  "downloadFile": {
    "domain": [ "example.com" ],
    "timeout": 10000
  },
  "subpackages": []
}

其中,downloadFile 字段下对应了下载文件的配置,包括限定下载域名、超时时间等。文件下载的域名必须在此处进行配置,否则会被微信限制,无法正常下载。

总结:

大致过程就是这样,网上有很多解决方案都是在uni-app小程序的loading页面直接去缓存一下h5发过来的下载地址之后,直接返回到web-view页面去进行下载预览,他的 缺点点击下载时页面会一闪而过空白的微信页面再回来,我把下载直接放到了downLoading页面就是为了可以告诉用户我已经在下载了,可以给用户一点好的体验。

uni.navigateBack({delta: 1}); 返回到web-view的目的是为了你在预览界面点击左上角返回时可以回到web-view页面,直接下载预览都在downLoading页面的话,你在预览页面点击返回就会回到downLoading页面,体验不是很好。

;