uniapp小程序多线程 Worker 实战【2024】

需求

最近遇到个小程序异步解码的需求,采用了WebAssembly,涉及大量的计算。由于小程序的双线程模型只有一个线程处理数据,因此只能寻求其它的解决方案。查看小程序的文档,发现小程序还提供一个异步线程的Worker方案,可以并行的。于是尝试采用Worker来进行异步运算,看了下文档,貌似只能有一个Worker异步进行,但是聊胜于无,能多一个线程并行计算,页面逻辑不会卡住就已经很不错了。

由于本人采用uniapp来进行小程序开发,由于引入了uniapp编译,导致整个开发过程更加复杂,本文记录了本人采用uni-best框架使用Worker过程遇到的一排深坑以及爬坑方案

小广告

uni-best不愧为2024年最佳的uni-app开发框架,uniapp+vue3+ts+unocss+uni-helper,typescript语言,体验极致的开发效率。本次项目就是在unibest生成的项目里进行uniapp开发

unibest最好用的 uniapp 开发模板https://codercup.github.io/unibest-docs/

整合过程

下面按照官方的整合过程走一遍

创建目录

在src项目下创建workers目录,并建立index.js。这里是坑A,注意小程序Worker的引入必须显式的指定为.js,因此即使ts能够自动的编译为js,但是由于书写的原因。index文件必须为javascript而不是typeScript,但是index.js再引入的文件,是可以使用typescript格式的

引入文件

在页面采用一个按钮,点击开始进行异步的计算。按钮点击的代码如下:

在App.vue onShow里初始化

onShow(() => {
  const createNewWorker = () => wx.createWorker('workers/index.js', { useExperimentalWorker: true }) // 开启编码多线程
  let worker = createNewWorker()
  worker.onProcessKilled(() => {
    // 重新创建一个worker
    worker = createNewWorker()
  })
})

按照微信的文档,在某些情况下异步线程会被系统杀死。因此在这里采用了开启useExperimentalWorker保活机制

编写调用

下面按照微信官方的说明结合本人的项目开始编写

异步线程接收事件

下一步开始编写index.js,开启异步线程接口

worker.onMessage((obj) => {
  if (obj.event === 'add') {
    worker.postMessage({ event: 'addResult', data: obj.data.a + obj.data.b })
  }
})

解释下为什么这样写:

因为worker的调用是采用统一的调用接口,因此需要设计自己的消息格式,本人的消息格式设计如下

export interface IWorkderMessage {
  event: string
  params: any
}

event承载不同的消息给Worker,这样Worker可以做不同的事情。这里的例子只使用一个简单的调用,把消息参数里的a和b在异步线程相加,然后返回给主线程相加的结果

主线程发起事件

主线程的调用,在本人的结构里是采用mitt全局消息模型的,这样在统一的入口注册后。任何单元代码的任何地方都可以随时对异步线程发起调用。

  utils.on(Global.CC_WORKER_MESSAGE, (data: IWorkderMessage) => {
    worker.postMessage(data)
  })

页面发起异步调用

function doWorker() {
  utils.emit(Global.CC_WORKER_MESSAGE, { event: 'add', data: { a: 2, b: 3 } })
}

按微信官方的说法,在worker.onMessage里打印到console,理应看到输出(实际有错)。姑且先不管运行的结果,我们先按微信官方文档说明把代码写完。

主线程接收异步线程结果

主线程同样是采用worker.onMessage来接收异步线程的返回结果。我们加入到startWorker方法里,写成这样

onShow(() => {
  const createNewWorker = () => wx.createWorker('workers/index.js', { useExperimentalWorker: true }) // 开启编码多线程
  let worker = createNewWorker()
  worker.onProcessKilled(() => {
    // 重新创建一个worker
    worker = createNewWorker()
  })

  worker.onMessage((obj: Record<string, any>) => {
    // 异步线程全局消息转发
    utils.emit(obj.event, obj.data)
  })

})

这里的utils.emit是我引入mitt后的全局消息模式,这样可以把返回的消息通过全局消息模型转发到对应的页面里

说明下这里为什么obj类型用Record<string,any>而不是IWorkderMessage,因为在小程序定义的d.ts里,已经把类型定义为Record,因此只能这样写

然后在对应界面写个全局的事件接收,这里仅打印下接收结果

  utils.on('addResult', (c) => {
    console.log(`addResult is ${c}`)
  })

坑来了

坑B

[worker] Uncaught Error: module 'workers/index.js' is not defined, require args is 'workers/index.js'

看到这里本人起初也是一头雾水的,啥叫index.js没定义,需要index.js。经过了一圈排查,才发现。我的编译后的dist\dev\mp-weixin目录里,没有workers目录!心态炸了,这叫什么错误,其它的文件都在,为毛单对workers过不去?

时间一分一秒过去,经过数小时冷静后。突然想到一个问题,vue3默认开启了Tree Shaking来优化代码,是不因为编译优化不认识worker机制,把从workders入口开始的整个代码链给弄丢了呢?按腾讯文档说,worker代码独立运行,会自动从createWorker开始运行,实时不是TreeShaking不认这一套,没代码调用的模块全部扫出家门了呢。之前require引入代码也不认,TreeShaking也给弄丢了,估计也是一个德行。

想完说干就干,修改下workers/index.js,做个简单的默认导出

worker.onMessage((obj) => {
  if (obj.event === 'add') {
    worker.postMessage({ event: 'addResult', data: obj.data.a + obj.data.b })
  }
})

export default 'workers'

然后在App.vue导入,啥其它都不干,就打印下,这下编译器应该认为该模块是有用的吧

import workers from '@/workers'
console.log(workers)

然后开启调试,内牛满面,workers目录出现了,遗憾的是,继续出现错误了

坑C

估计很多人爬到这里,就会爬不动了。小程序上还是显示错误

app.js错误:
 Error: module 'workers/index.js' is not defined, require args is './workers/index.js'

看起来错误和前面的一样,但是仔细看又不一样。前面的是worker报错,是在启动worker的时候找不到模块,这里是app.js错误,而且仔细看是./workers/index.js找不到。那这个'workers/index.js' is not defined又是哪门子毛病呢?

经过数小时排查,发现编译后的app.js有这样一句代码:

但是如果我修改为workers/index.js就直接编译报错了

在这个地方卡了数小时。各种方法试过,一气之下想既然导入不对,干脆不要导入算了。于是把编译后的app.js的c=require("./workers/index.js")直接修改为c="hahaha",然后直接导入小程序模拟器运行。竟然成功了!

也就是说,对于最终编译的app.js,如果我把坑B产生的代码在最终编译结果去掉的话,代码就可以正常运行了。TMD VUE,TMD编译器优化!!!

但是不能每次都这样每编一次手动改一次呀,还不得把人累死,于是有了下一步

自动处理导入

既然是在编译阶段处理,那么我们应该是可以通过插件解决的,例如scss等插件都是可以对最终结果进行处理。于是想自己写个插件,对于从没写过插件的我来说难度又上了一个等级,幸好有GPT帮助,在折磨一阵子GPT后,再参考下其他类似代码。于是有了这个插件:

图片里vite.config.ts里的代码(顶部记得import fs from 'node:fs'):

      process.env.UNI_PLATFORM === 'mp-weixin' && {
        name: 'fix-uni-app-workers',
        apply: 'build',
        async closeBundle() {
          const buildType = process.env.NODE_ENV === 'development' ? 'dev' : 'build'
          const filePath = path.resolve(__dirname, `./dist/${buildType}/mp-weixin/app.js`) // 由app.js引入,修复这个即可
          fs.readFile(filePath, 'utf8', (err, data) => {
            if (err) {
              console.error('Error reading file:', err)
              return
            }
            console.log(`patch ${filePath}`)
            const result = data.replace(/require\("\.\/workers\/[a-zA-z-_]+\.js"\)/g, '""')
            // 写回文件
            fs.writeFile(filePath, result, 'utf8', (err) => {
              if (err) {
                console.error('Error writing file:', err)
              }
            })
            console.log('uniapp 小程序 worker 补丁完毕')
          })
        }
      }

解释下这个插件干了啥。

它在判断微信编译时(留着以后H5可以用编译开关写页面的Worker)开启,对编译目标目录的app.js进行处理。因此你的引用代码必须写到app.js。即

import workers from '@/workers'
console.log(workers)

这个是写在App.vue的,写到其它文件别怪我没提醒

然后对生成的文件做替换,把里面所有引入的js文件入口

=require("./workers/XXXXX.js")都替换成了="",这样都是打印空字符串,不会报错

对于workders里其它文件,也需要在app.js里通过写console.log的 方式注册,否则还是会出诡异的require args报错,这个正则把所有workers里的引入都替换成了常量字符

这样uniapp使用小程序的Workers就可以正常工作了????????????

按钮调用:

function doWorker() {
  utils.emit(Global.CC_WORKER_MESSAGE, { event: 'add', data: { a: 2, b: 3 } })
}

日志打印:

功能已经正常!!!

上一篇:【前端面试3+1】18 vue2和vue3父传子通信的差别、props传递的数据在子组件是否可以修改、如何往window上添加自定义属性、【多数元素】-Vue


下一篇:架构学习:什么是业务架构图?如何画业务架构图?-02如何画业务架构图?