celsius的个人博客

星星之火可以燎原

0%

@TOC


前言

废话不多说,我又来搞前端网页连接读卡器了,这次的设备是明华智能支付终端,虽然叫支付终端,但是还支持读取 银行卡,身份证,接触类IC卡,非接触类IC卡,二维码,记录一下技术方案


一、开发方案

前端想要连接硬件,那必须要硬件的驱动支持并提供对应的方案,目前我知道的web方案的话有

  • ocx控件,基于ActiveX技术,对于前端来说最大的缺点就是只能运行在IE内核内,并且比较挑运行环境
  • websocket,驱动在本地开启一个socket服务,页面通过websocket连接,可以实现双向通信
  • 占用一个本地网络端口,页面通过请求本地ip + 对应端口获取数据,优点是接入非常简单

而这次就遇到了最难搞的ocx控件,而接入ocx最简单的方法就是让用户在IE内核的浏览器内运行(比如IE,Edge IE模式,360浏览器兼容模式),但是IE内核那及其恶心的兼容性,让我放弃了这个方法,转而去网上寻找能在谷歌浏览器运行ocx的方法。

如果你有像我一样去找的话,就会发现大部分的回答都是通过谷歌浏览器napi的办法去调用ocx,这个办法当然是能行的,但是谷歌浏览器在46版本之后就不支持napi了,然而我用的谷歌浏览器版本已经是119,不可能去降版本(更不可能让用户去降版本),只好继续寻找,最终,发现了一个神奇的东西——中间件

二、中间件

1. 选择中间件

这里的中间件,指的是硬件驱动和网页之间连接的一个转发层,类似于桥接模式,目前我在网上找到了两款中间件

我大致看了一下,两个中间件的功能都挺牛逼的,能够让网页调用office,调用桌面程序功能,当然也能调用ocx对应的函数,不过猿大师要收费,AppEmit的免费版只是有弹窗,因此选择AppEmit

2. 接入中间件

首先还是去看文档,AppEmit提供的demo还是挺多的,并且,对于ocx的接入也有两个方案

  1. 将ocx控件所对应的桌面程序函数提取出来,转换为dll文件,然后通过中间件直接调用
  2. 在谷歌浏览器中,嵌入一个IE内核进来调用ocx控件,并通过中间件来控制调用以及通信,这里我用的就是这个方案

完整代码以及安装程序我都放在了github上,地址在最后,这里放一下核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<script setup>
import axios from "axios";
import { ref } from 'vue'
const info = ref(null)
const getData = (val) => {
// 获取传过来的数据
info.value = val
}
window.getData = getData
const openDevice = () => {
AE.InitApp('ws://localhost:80/appemit?cid=00000-1&sid=1&flag=1');
}
const openPage = () => {
const ReqPar7 = {
"emit": "open", "Obj": "web", "AppType": 1, "pos": 1, "AppId": 1, "par": {
"URL": '/index.htm', "htmlStr": null, "HttpServer_startUrl": null //"HttpServer_startUrl":"/demo/htmlDemo/ocx.html" //使用本地html
, "embed_object": { //多个object可以设置embed_object为数组
"objName": "objIDCard"
, "reg": true //null 不执行注册 1强制操作 true 未注册则注册 false 卸载注册 (重启AppEmit后,就不能加载dll了)
, "asAdmin": 0 //1需要管理员权限注册 0不需要 ,win7管理员身份会有提示确认窗口
// ,"pid":"292A53CD-DA8F-46A5-808D-B286F2759C37" //使用私有插件,则需要增加下列信息 如果dll/ocx文件改变了,需要更新appemit登陆窗口中我的应用里面的sha1值
, "dllFile": "/MT8ActiveX.ocx" //为了安全,限制为AppEmit文件夹,不能注册或者注销其它文件夹下面的ocx
, "CLASSES_ROOT": "HKEY_CLASSES_ROOT\\真实的ReportProj1.ReportX\\CLSID" //可选,reg= true检查是否已经注册,一定要是\\分隔
, "clsId": "10EC554B-357B-4188-9E5E-AC5039454D8B" // 可选,reg= true检查是否已经注册
// ,"AuthKey":"A1-ZneY-2qGoXRfc7h6GZZxBB2gceORBwyhoxsA6GK5agLtIAwLhh6BnK61W8fORVNv"
}
}
};
AE.OpenApp(ReqPar7);
}
</script>

<template>
<header>
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
<div class="btn-group">
<div @click="openDevice">建立中间件连接</div>
<div @click="openPage">开启读卡页面</div>
</div>
<div>返回的数据:</div>
<div class="">{{ info }}</div>
<div id="AppEmbed1" style=" border: solid 2px #F0F0F0; min-width:30px;min-height:20px;width:600px;height:400px" ></div>
</header>
</template>

运行页面
在这里插入图片描述
整个流程简单来说的话就是

  1. 安装中间件
  2. 注册并登录中间件(发布之后修改cip和clientKey,就不需要这一步了)
  3. 通过websocket与中间件建立连接
  4. 向中间件发送一串json命令,其参数包括触发类型,触发参数,详见文档,这里我们的命令就是使用IE内核打开我们调用ocx控件的页面,同时运行ocx控件
  5. 在调用ocx控件的页面进行硬件连接,获取读卡信息等操作,并在获取数据之后,通过emitBack方法返回给我们的主页面
  6. 主页面拿到数据,进行对应的操作

    总结

    其实搞这么老多,最好的办法还是用钞能力让硬件厂家搞一下websocket支持,因为ocx控件有点挑运行环境
    完整代码:https://github.com/Ceslsius/cardReader-demo

@TOC


前言

接到一个前端调用身份证读卡器的需求,提供的设备是华视CVR-100uc,记录一下开发过程


一、寻找开发包

前端要调用硬件,肯定是需要硬件驱动支持的,因此我们需要去官网找找对应的驱动和开发包
服务下载_华视电子
解压之后的web文件夹里就有对应前端控件,不过问题来了,这里提供的是ocx控件,而ocx控件在谷歌浏览器中并不能运行,只能运行在IE内核的浏览器里面


没办法,在网上再搜一搜有没有其他解决办法,最后发现,华视电子官方提供的2022年的web开发包不是ocx控件,而是一个exe安装文件,于是我在网上找了一圈,总算找到了这个安装包(这个安装包我也放github上了),安装之后查看demo,发现他的原理是在本地占用一个端口,通过接口请求返回数据

二、代码实现

运行安装包后,直接请求对应的本地地址就行了,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<script setup>
import { RouterLink, RouterView, stringifyQuery } from 'vue-router'
import axios from "axios";
import HelloWorld from './components/HelloWorld.vue'
import {ref} from 'vue'
const info = ref(null)
const openDevice = () => {
axios.get("http://localhost:19196/openDevice").then(res => {
console.log(res)
if (res.data.resultFlag == 0) {
console.log('建立链接成功')
}
})
}
const readCard = () => {
axios.get("http://localhost:19196/readCard").then((res) => {
if (res.data.resultFlag == 0) {
const obj = res.data
// 处理身份证照片
obj.identityPic = 'data:image/jpeg;base64,' + obj.identityPic
// 处理身份证类型
if (obj.certType == " ") {
obj.certType = "身份证";
} else if (obj.certType == "I") {
obj.certType = "外国人居住证";
} else if (obj.certType == "J") {
obj.certType = "港澳台居住证";
} else {
obj.certType = "未知";
}
info.value = obj
console.log(info.value)
console.log(obj)
} else {
console.log('读卡失败')
}
})
}
const CloseDevice = () => {
axios.get("http://localhost:19196/CloseDevice")
}
</script>

<template>
<header>
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
<div class="btn-group">
<div @click="openDevice">建立链接</div>
<div @click="readCard">读取信息</div>
<div @click="CloseDevice">关闭连接</div>
</div>
<img :src="info.identityPic" v-if="info"/>
<div class="">{{ info ? JSON.stringify(info) : '' }}</div>
</header>

<!-- <RouterView /> -->
</template>



不过这里要验证一下发到服务器上之后能不能请求本地的网络端口,注意这里需要https


代码仓库地址

https://github.com/Ceslsius/ID-demo


前言

废话不多说,使用uniapp时scan方法不支持h5端,因此h5端需要自己引入三方库,我这里用的是zxing
完整代码在最后


一、引入zxing

这里有两种方法,npm和直接使用js文件,按需使用

npm

1
pnpm add @zxing/library
1
import { BrowserMultiFormatReader } from '@zxing/library'

直接使用js文件

js地址:https://unpkg.com/@zxing/library@latest/umd/index.min.js

1
import { BrowserMultiFormatReader } from '@/utils/zxing.js'

二、功能实现

直接使用zxing提供的方法就行,默认参数就够用,如果需要自定义,比如修改视频流清晰度之类的可以看看文档

1. 获取摄像头

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 这里的处理是因为uniapp会对video标签进行二次封装
var video = document.getElementById('video_nav_id').getElementsByTagName('video')[0]
video.setAttribute('id', 'video_id')
video.setAttribute('class', 'video_calss')
codeReader.value = new BrowserMultiFormatReader();
codeReader.value.listVideoInputDevices().then(res => {
if (res.length > 0) {
videoInputDevices.value = res
// 这里默认选择最后一个摄像头
deviceId.value = res[res.length - 1].deviceId
} else {
// 没有可用的摄像头
// todo
}
}).catch(err => {
console.error(err)
})

2.开始扫描

1
2
3
4
5
6
7
try {
codeReader.value.decodeFromVideoDevice(deviceId.value, 'video_id', (res, err) => {
if (res) handleScanningResult(res)
})
} catch (err) {
uni.showToast({ title: `初始化失败${err}`, icon: 'none' });
}

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
<template>
<view class="camera_page">
<view class="camera_content">
<view class="code_close" @click="closeClick()">
<!-- <img src="../../static/close.png"> -->
</view>
<view class="loop_line"></view>
<view class="video_nav">
<video id="video_nav_id" autoplay :controls="false"></video>
</view>
</view>
</view>
</template>
<script setup>
import { onLoad } from "@dcloudio/uni-app";
import { ref } from "vue";
import { BrowserMultiFormatReader } from '@zxing/library'
// import { BrowserMultiFormatReader } from '@/utils/zxing.js'
const codeReader = ref(null)
const deviceId = ref(null)
const videoInputDevices = ref([])
const initEvent = () => {
// 这里的处理是因为uniapp会对video标签进行二次封装
var video = document.getElementById('video_nav_id').getElementsByTagName('video')[0]
video.setAttribute('id', 'video_id')
video.setAttribute('class', 'video_calss')
codeReader.value = new BrowserMultiFormatReader();
codeReader.value.listVideoInputDevices().then(res => {
if (res.length > 0) {
videoInputDevices.value = res
// 这里默认选择最后一个摄像头
deviceId.value = res[res.length - 1].deviceId
} else {
// 没有可用的摄像头
// todo
}
}).catch(err => {
console.error(err)
})
startScanning()
}
onLoad(() => {
setTimeout(() => {
initEvent();
}, 3000);
});
const startScanning = () => {
try {
codeReader.value.decodeFromVideoDevice(deviceId.value, 'video_id', (res, err) => {
if (res) handleScanningResult(res)
})
} catch (err) {
uni.showToast({ title: `初始化失败${err}`, icon: 'none' });
}
}
let startTime = null
const handleScanningResult = (res) => {
if (!startTime) startTime = new Date().getTime()
// 间隔时间500ms
const time = 500
if (new Date().getTime() - startTime >= time) {
startTime = null
// 这里处理扫描结果
console.log(res.text)
}
}
// 停止扫描
const stopScanning = () => {
codeReader.value.reset()
}
const closeClick = () => {
// todo
}
</script>
<style scoped lang="scss">
.camera_page {
height: 100vh;
width: 100vw;
}

.camera_content {
height: 100%;
width: 100%;
position: relative;
}

.code_close {
height: 50rpx;
width: 50rpx;
position: absolute;
left: 30rpx;
top: 30rpx;
z-index: 999999;
}

.code_close>img {
height: 100%;
width: 100%;
display: block;
}

.loop_line {
height: 3px;
width: 80%;
background-color: aliceblue;
border-radius: 50%;
box-shadow: 1px -4px 25px 7px rgba(255, 255, 255, 0.5);
position: absolute;
left: 50%;
top: 20%;
transform: translateX(-50%);
animation: myfirst 3s infinite;
z-index: 999999;
}

@keyframes myfirst {
from {
top: 20%;
}

to {
top: 80%;
}
}

.video_nav {
height: 100%;
width: 100%;
}

#video_nav_id {
height: 100%;
width: 100%;
}

:deep(.uni-video-cover) {
display: none;
}
</style>

注意事项:
摄像头权限需要https协议才能调用,也即

  1. 部署到服务器时,域名需要SSL证书,也就是域名以https开头
  2. 本地开发调试时,需要开启https并加载本地证书,这里可以看看我的另一篇笔记vue项目,uniapp项目开启https,加载本地证书


前言

废话不多说,前端开发调用摄像头,获取地址位置时需要https的问题,在本地运行时可以参考这篇文章操作
我这里用的mkcert
如果你使用的vite,那么可以直接使用vitejs/plugin-basic-ssl


一、通过mkcert创建本地证书并加载

1. 安装mkcert

1
npm install -g mkcert

2. 生成证书

1
2
mkcert create-ca
mkcert create-cert

这时,你应该能看到生成了四个文件

ca.crt
ca.key
cert.crt
cert.key

3. 加载证书

  1. mac
    双击ca.cert,在弹出的界面选择Test CA,选择“信任”,然后选择“始终信任”,最后输入密码确认即可
  2. windows
    双击ca.crt,在弹出的界面中选择安装证书,选择“当前用户”或“本地计算机”均可,关键的是下一步,选择“将所有的证书都放入下列存储”,并且选择为“受信任的根证书颁发机构”,然后一路确定即可

    二、在vue或uniapp中使用刚刚的证书

    1.vue2

    将刚刚生成的cert.crt和cert.key两个拷贝到项目的src/ssl文件夹中
1
2
3
4
5
6
7
8
9
10
11
12
// vue.config.js
const path = require('path')
const fs = require('fs')
module.exports = {
devServer: {
open: true,
https: {
cert: fs.readFileSync(path.join(__dirname, 'src/ssl/cert.crt')),
key: fs.readFileSync(path.join(__dirname, 'src/ssl/cert.key'))
}
}
}

2.vue3

将刚刚生成的cert.crt和cert.key两个拷贝到项目的src/ssl文件夹中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
const fs = require('fs')
const path = require('path')
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.join(__dirname, 'src')
}
},
server: {
open: true,
https: {
// 主要是下面两行的配置文件,不要忘记引入 fs 和 path 两个对象
cert: fs.readFileSync(path.join(__dirname, 'src/ssl/cert.crt')),
key: fs.readFileSync(path.join(__dirname, 'src/ssl/cert.key'))
}
}
})

3.uniapp

uniapp则有点不一样,需要手动复制cert.crt和cert.key两个文件的内容到manifest.json中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// manifest.json
"h5" : {
"devServer" : {
"https" : {
"cert" : "-----BEGIN CERTIFICATE-----\nMIIDTDCCAjSgAwIBAgIFNzg2OTEwDQYJKoZIhvcNAQELBQAwXjEQMA4GA1UEAxMH\nVGVzdCBDQTELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNV\nBAcTDVNhbiBGcmFuY2lzY28xEDAOBgNVBAoTB1Rlc3QgQ0EwHhcNMjMwOTE5MDcw\nNDA0WhcNMjQwOTE4MDcwNDA0WjAUMRIwEAYDVQQDEwlsb2NhbGhvc3QwggEiMA0G\nCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDm4YK3YNrZY1ppyPlW6nMLpX66Mk0G\npgYQazD/bgZbHgkjPo1tSg53WlSKh/Au5t4gQZIquIjxtbNEYzqZl8PM0gG4G6z/\nLTq+2g+gOaPirr7Xn1aoOi9ScYXPB+1/InJ7GSF3mHakXcl6tLcKgudJjIxdQkKG\nwRTcDfd3uvrll69HAuBCT1aFVQ4e/fT4rkHKGkHEc5jeCx96pYXdlsO+OWea4EZM\nHA5aSGbWqA5fy6CG6YzbqJmaRfxy9uy7iRVQlSDjya7nfzyqrQj4LOvjjBqw+a6y\nBRyFkVYwx+1z8i7lm0Gf6mVJHAzNbn4HdSS0++z3/Tl86aAYSXXA11YhAgMBAAGj\nWzBZMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsG\nAQUFBwMBBggrBgEFBQcDAjAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwDQYJ\nKoZIhvcNAQELBQADggEBAA6KRRKZQY1o6e63vGJYcnzKfDCM2L/SzkytJMRPY083\n+HIQQ35crpe8NmWLP+O9WFPzN5dWmL52HZ59c1Qtv9VeNWRfx93PEI1G8NX+t4//\ngeD4dFRhiFnCDxz004Q0KvjVHjY11Z+irvfVyPDa+/6PVSLgyBNsQN/1p2A2gtQB\nZk01NRk1H4o/1ys9CTHwhEqioaiVy5FJ4bspWlKIs0uGBxOAtsXX2MEEneWffIKK\nOx25xFIKdW9zJsyowebELh97qRsI1Kw8NfeCw/3pxJlQozU0hZyujf3qMBsXXu8m\n7Zvz3s0C+VZww9TOOeYZOm/WobHj2Mog8sooNPfiS/s=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIDXjCCAkagAwIBAgIFNTA3NTkwDQYJKoZIhvcNAQELBQAwXjEQMA4GA1UEAxMH\nVGVzdCBDQTELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNV\nBAcTDVNhbiBGcmFuY2lzY28xEDAOBgNVBAoTB1Rlc3QgQ0EwHhcNMjMwOTE5MDcw\nMzQ4WhcNMjQwOTE4MDcwMzQ4WjBeMRAwDgYDVQQDEwdUZXN0IENBMQswCQYDVQQG\nEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNj\nbzEQMA4GA1UEChMHVGVzdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC\nggEBALfx9Wt6MeovAGs0gLysmqQTW+NGjgpM8P2NvcAYFUok0bSvEfCcd95lnePh\nuQbtUbWs3dkGK1C5KexTtVOeAbM3DYcef1uks9+dzShB6zOUlFWGF/tzNvf8imMC\nRxWuMSr7CAR4GfhmXscFMSeGCtW1rVZhB5+DxZm19lY2bwpiVIRnrnX7zZRdlLBH\nywTCxH8vjJmc3HkqlSO8d2xrA7eQTZpkzMcPm4boOFyoqPEpM1SQXB2oh7byN4zU\nsH2f7Y7baqxGqkCe8aretVdXYdsr8C3PnUS8BUqlUosg9CY9Ax7AMur8K78zF0ws\nG4nFBvm3mE7WM2hGwJBZcsiHNuECAwEAAaMjMCEwDwYDVR0TAQH/BAUwAwEB/zAO\nBgNVHQ8BAf8EBAMCAgQwDQYJKoZIhvcNAQELBQADggEBAELHcueFTazDfzofj0V3\nOgyx5CQAQcCNu6cDBOkH13Ytvzi5rnttQhFGLACPPLT/vuanTXTuP7ko2dPDIVLn\nE0V8YCZfFt29cGRooHRkQzvuJC/CrphSw/zwfRz5Oi9qKzeJZ3+4hze/sbD5XnXo\nGnoOJ/aKn7WTw6YS88AFbgUyqb6QQyoHIMNQcPE1H8Lo6ICL45ngjCxoss9qtak5\ntGX7wGgLW2fuj/6kvmfe0bEQp20HDUiiM5Eog8yONr8QCj5omO1RkL1YAI2fYQhQ\nOqVIelbijX9ehKBe3Gh6ZW1p6Mw6zweBRU0XYgDyJtij2hf4gQTpauQ5/AC1o2bU\nvjQ=\n-----END CERTIFICATE-----\n",
"key" : "-----BEGIN RSA PRIVATE KEY-----\nMIIEpQIBAAKCAQEA5uGCt2Da2WNaacj5VupzC6V+ujJNBqYGEGsw/24GWx4JIz6N\nbUoOd1pUiofwLubeIEGSKriI8bWzRGM6mZfDzNIBuBus/y06vtoPoDmj4q6+159W\nqDovUnGFzwftfyJyexkhd5h2pF3JerS3CoLnSYyMXUJChsEU3A33d7r65ZevRwLg\nQk9WhVUOHv30+K5ByhpBxHOY3gsfeqWF3ZbDvjlnmuBGTBwOWkhm1qgOX8ughumM\n26iZmkX8cvbsu4kVUJUg48mu5388qq0I+Czr44wasPmusgUchZFWMMftc/Iu5ZtB\nn+plSRwMzW5+B3UktPvs9/05fOmgGEl1wNdWIQIDAQABAoIBAQDh23OTToVoqd+1\nomwAkaNvnjrljPV67oj6INIy3gALvMs1liil5A9YxJ31UtwsFZ7wMRT7A8C7q4Mu\nAGBABo39XOdfontOuYFBWHJ2zEw9IbMBnhcYxKqsXO+IQvobJ6iUMVZi91wAmNrT\nVdvFxZ7afUqGpJ4N0u5EAsm2b8ynMSqflyo00bNTWzIjOPqsJSLGuOXUmh9SLvEx\nFA0Jv/FW1D6GVbnODGTUVyGPPv4EXC1u7G6S68raW8oCU/VznC1gN6IQGBYSGzJJ\nQTh6uEfTbUOlw0tYfL4H/WthVT8bW2M98ol8t+BYptXFMBmgteaZKhf0TE/PdAyd\nNwhbPhtBAoGBAP4WZaaqVNYX7bY79kgpSGmo5IwY2zJkwjaEMXkSado+WXLfRSRM\nV99btQSgKDRZbl6SKBDD5w6X8/MmMnjNJ3HtCFY/9tx/6rPhutP2SUt/30sbh3Xa\nMBcoMWJMQaZlWmQmNUrtuyT1Qowsgv88jcDfYGYPaFePNE6ZS5mYlOapAoGBAOie\nZYfHM3RN3ULFEMOBnAjakdLclicJ33u9dIhdN91goG7IaCL+iBy8M/ctUTACpFfa\ni7lXVhccBnpX5W663DjAkpWa4ecwE04Mi/5Pe8nWsPKI+NgYg/zH3D5gYROKOXst\nIwi0Ku0wiskI8KShuJ8x9/RG+xSyix+Nuj0uDTa5AoGBANIvKtVsz5Lu5mg6P+e/\nprQozy6JHUIZkYLRZa92qPOtYgchZKu7L3GIBj0WU0vCswcuNlBwDcHHtzhrX5MI\nelnGr3/Rl98dPcShtQ/ECyuWUttpoWu/Gsr9J6Td2ezQqK5x4OHjuEzBSdR0ucRz\njft/jNnAYxsTTjy6mIwtaPdJAoGAI3SJGyHkmNSJZfTtci8eHkdaNJ6EkFyz4INm\n8KXsruRcb6ZCIRtUmbSh4wnYNw4gCHf6FfzW1VzjK+Cbyr3WdRv8+6qIxhkQyDOn\nXtK0lnYPsFfqPd22uF+qFSPRiA3oZvrhPSFg7ZgZfzzKmP1BS5ptJaFwAZ6ZD+0l\nNs//GZkCgYEA0NdSF/XomDS7NLh1B2vWW/tcCoOmv+FQ3/mtnnx4JNs9KEDKazSb\nA6jUyuzmQfwHJrHTLbSoVcvPwSmdU+4mvd4ktomg4CTp/DjgHAjPveMsWiv/++v/\n8VtcXkc/M/c0AMrdZBv9J2jXngbz8rvOrK/QZpsaoohlOAsHjFl4lWg=\n-----END RSA PRIVATE KEY-----\n"
},
"proxy" : {}
},
"async" : {
"delay" : "10000"
},
"router" : {
"base" : "./"
}
},

总结

到此,证书生成安装结束了,项目跑起来就ok了

前言

最近学习了一下JS反混淆的知识,记录一下


一、JSA加密

  • 比较基础的加密,只对变量进行了修改,通常与代码压缩一起使用

  • JSA加密示例:

    1
    2
    3
    4
    5
    6
    7
    8
    function o00($) {
    console.log("\x1b[32m%s\x1b[0m", $)
    }
    function o01($) {
    console.log("\x1b[41m%s\x1b[0m", $)
    }
    o00("logR");
    o01("logG")
  • 这种加密直接丢给JS NICE

    二、JS NICE

  • JS Nice 是一款让经过混淆处理的 JavaScript 代码可读性更好的工具。它使用一种新型的用于 JavaScript 代码美化的去混淆和去压缩引擎。JSNice 采用先进的机器学习和程序分析技术,从可用的开源项目学习命名和类型规律。

  • 经过测试,其只能做到通用型的一些格式化,重命名等操作,不过试试也可能会有惊喜

  • 地址:http://jsnice.org/

    三、base62编码

  • 最明显的特征是生成的代码以 eval(function(p,a,c,k,e,r)) 开头

  • 这类混淆的关键思想在于将需要执行的代码进行一次编码,在执行的时候还原出浏览器可执行的合法的脚本,然后执行之。看上去和可执行文件的加壳有那么点类似。Javascript 提供了将字符串当作代码执行(evaluate)的能力,可以通过 Function 构造器、eval、setTimeout、setInterval 将字符串传递给 js 引擎进行解析执行。

  • 无论代码如何进行变形,其最终都要调用一次 eval 等函数。解密的方法不需要对其算法做任何分析,只需要简单地找到这个最终的调用,改为 console.log 或者其他方式,将程序解码后的结果按照字符串输出即可

    四、OB混淆

  • 现在 JavaScript 混淆主流的实现是 javascript-obfuscator 这个库,也就是OB混淆,其能实现代码压缩,变量名混淆,字符串混淆,代码自我保护,控制流平坦化,僵尸代码注入,对象键名替换,调试保护,特殊编码等。其中每一个拿出来都很棘手,组合在一起就更难搞了。

  • OB混淆网站:https://obfuscator.io/

  • ob混淆特征:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 开头一个大数组
var _0xa441 = ['\x49\x63\x4b\x72\x77\x70\x2f\x44\x6c\x67\x3d\x3d', ···]
// 自执行函数对数组进行位移
(function (_0x56a234, _0xa44115) {
var _0x532345 = function (_0x549d7c) {
while (--_0x549d7c) {
_0x56a234['push'](_0x56a234['shift']());
}
};
_0x532345(++_0xa44115);
}(_0xa441, 0x1d0));
// 解密函数
var _0x5323 = function (_0x56a234, _0xa44115) {
// 里面有段自执行函数生成atob函数
······
}
// 下方多处调用解密函数,如
var _0x239123 = _0x5323('\x30\x78\x32\x30', '\x70\x59\x48\x73');
  • 对于OB混淆还原,没有找到在线网站,因此我结合node环境的还原工具写了一个。当然,因为OB混淆的版本以及配置项很多,并不能做到完美还原,需要搭配其他方式一起使用。

  • 在线地址:http://celsius.icu/web/deOb/index.html

    五、控制流平坦化

  • 这里需要把控制流平坦化单独拿出来说说,因为大部分混淆工具都会用到,加密效果显著。控制流平坦化,简单来讲就是将代码块之间的关系打断,由一个分发器来控制代码块的跳转。

  • 如果看到代码里出现for循环while循环switch执行等方式来控制代码的执行顺序,八成就是控制流平坦化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 混淆前
window = {};
window.atob = function(r) {
e = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
var o = String(r).replace(/=+$/, "");
if(o.length % 4 == 1) throw new t("'atob' failed: The string to be decoded is not correctly encoded.");
for(var n, a, i = 0, c = 0, d = ""; a = o.charAt(c++); ~a && (n = i % 4 ? 64 * n + a : a, i++ % 4) ? d += String.fromCharCode(255 & n >> (-2 * i & 6)) : 0) a = e.indexOf(a);
return d
}
// 混淆后
window = {};
window.atob = function (_0xc0750g) {
var _0xfbb16d = "4|1|2|3|0"["split"]("|"),
_0xec2868 = 0;
while (!![]) {
switch (_0xfbb16d[_0xec2868++]) {
case "0":
return _0xec4345;
continue;
case "1":
var _0x1adc16 = String(_0xc0750g).replace(/=+$/, "");
continue;
case "2":
if (_0x1adc16.length % 4 == 1) throw new t("'atob' failed: The string to be decoded is not correctly encoded.");
continue;
case "3":
for (var _0xb342a5, _0x7ecb47, _0xc54a87 = 0, _0xa14d1g = 0, _0xec4345 = ""; _0x7ecb47 = _0x1adc16.charAt(_0xa14d1g++); ~_0x7ecb47 && (_0xb342a5 = _0xc54a87 % 4 ? 64 * _0xb342a5 + _0x7ecb47 : _0x7ecb47, _0xc54a87++ % 4) ? _0xec4345 += String.fromCharCode(255 & _0xb342a5 >> (-2 * _0xc54a87 & 6)) : 0) _0x7ecb47 = e.indexOf(_0x7ecb47);
continue;
case "4":
e = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
continue;
}
break;
}
};
  • 要对这种混淆后的代码进行分析,AST语法树是最好的选择,当然也可以灵活使用浏览器调试工具的debugger功能,直接调试运行结果

  • 示例博客:https://security.tencent.com/index.php/blog/msg/112

    六、AST语法树

  • 基于AST语法树对代码进行分析是最直接的,当然也是最考验代码能力的

  • 以esprima为例,其提供了js代码与语法树之间的转换能力,再结合estools的几个辅助库即可对js进行静态代码分析:

    escope Javascript 作用域分析工具

     esutil 辅助函数库,检查语法树节点是否满足某些条件
     estraverse 语法树遍历辅助库,接口有一点类似 SAX 方式解析 XML
     esrecurse 另一个语法树遍历工具,使用递归
     esquery 使用 css 选择器的语法从语法树中提取符合条件的节点
     escodegen 与 esprima 功能互逆,将语法树还原为代码
    
  • JS代码解析为AST在线地址:http://esprima.org/demo/parse.html

  • AST语法树的东西比较多,这里说不完,感兴趣可以深入学习

    七、隐写术

  • 一种比较少见的代码加密方法,严格来说的话也不算加密,只是将 js 代码隐藏到了特定的介质当中。如通过最低有效位(LSB)算法嵌入到图片的 RGB 通道、隐藏在图片 EXIF 元数据、隐藏在 HTML 空白字符等。

  • 隐写的方式同样需要解码程序和动态执行,所以破解的方式和base62编码相同,在浏览器上下文中劫持替换关键函数调用的行为,改为文本输出即可得到载体中隐藏的代码。

  • 其特点是执行代码在哪里都要花时间来找,不过在WebAssembly出现之后就被其全面碾压

    八、黑盒加密:Emscripten与WebAssembly

  • Emscripten:某些 JavaScript 的核心功能可以使用 C/C++ 语言实现,然后通过 Emscripten 编译成 asm.js,再由 JavaScript 调用执行,如果要对其进行分析,就需要将JS反编译成C/C++,难度无疑是直线上升

  • WebAssembly:WebAssembly 是经过编译器编译之后的字节码,可以从 C/C++ 编译而来,得到的字节码具有和 JavaScript 相同的功能,但它体积更小,而且在语法上完全脱离 JavaScript,同时具有沙盒化的执行环境。也就是说对其分析需要分析二进制码,这几乎是无法做到的事情,代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
WebAssembly.compile(new Uint8Array(`
00 61 73 6d 01 00 00 00 01 0c 02 60 02 7f 7f 01
7f 60 01 7f 01 7f 03 03 02 00 01 07 10 02 03 61
64 64 00 00 06 73 71 75 61 72 65 00 01 0a 13 02
08 00 20 00 20 01 6a 0f 0b 08 00 20 00 20 00 6c
0f 0b`.trim().split(/[\s\r\n]+/g).map(str => parseInt(str, 16))
)).then(module => {
const instance = new WebAssembly.Instance(module)
const { add, square } = instance.exports
console.log('2 + 4 =', add(2, 4))
console.log('3^2 =', square(3))
console.log('(2 + 5)^2 =', square(add(2 + 5)))
})

九、反混淆实操学习

  • 实践是检验真理的唯一标准,这里我推荐一个反混淆实操学习平台,在实际操作中学习应用。
  • 猿人学在线地址:https://match.yuanrenxue.cn/list

基于node.js实现分销类微信聊天机器人


前言

最近遇到了一个微信聊天机器人的需求,主要功能是自动同意好友申请,定时向微信群发送商品优惠信息,收到微信私聊信息后搜索优惠卷信息发送回去。那我作为一个前端开发,第一时间就是去找node.js的相关库了呀,经历了各种坑,总算是给他拿下


一、涉及技术栈

  1. 首先需要一个node.js的运行环境,可以是服务器也可以在你本地上挂机跑
  2. 优惠卷分销是接入的大淘客,绑定淘宝,京东等账号后可以对应的获取相关商品的优惠卷,并且成功购买后有返利
  3. 微信机器人库的话去网上一搜,很多都会让你使用微信网页端抓包搞出来的一个api库,但是这个微信网页端有使用条件,具体是啥我就不说了,反正我是不符合。最后是找到了基于pad版微信app搞出来的一个库——基于wechaty-puppet的ding-dong-talk,它的原理是模拟在pad中运行微信app,包括请求头,数据加密解密等都是反编译来的,所以微信一改就会用不了,且用且珍惜吧。

二、实现代码

1.登录模块

引入相关库,再注册对应事件
使用qrcode生成二维码,手机微信扫码登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import type * as PUPPET   from 'wechaty-puppet'
import qrcodeTerminal from 'qrcode-terminal'
import {
PuppetXp,
} from '../src/mod.js'
const puppet = new PuppetXp()
puppet
.on('logout', onLogout)
.on('login', onLogin)
.on('scan', onScan)
.on('error', onError)
.on('message', onMessage)
function onScan (payload: PUPPET.payloads.EventScan) {
if (payload.qrcode) {
const qrcodeImageUrl = [
'https://wechaty.js.org/qrcode/',
encodeURIComponent(payload.qrcode),
].join('')
console.info('StarterBot', 'onScan: %s(%s) - %s', payload.status, qrcodeImageUrl)
qrcodeTerminal.generate(payload.qrcode, { small: true }) // 将二维码打印到控制台
console.info(`[${payload.status}] ${payload.qrcode}\nScan QR Code above to log in: `)
} else {
console.info(`[${payload.status}]`)
}
}

2.聊天消息接收事件

接收到消息会触发onMessage事件并携带消息内容发送人等参数
我这里的逻辑是如果是我本人发送消息,则用来控制每日优惠自动发送事件,如果是其他人发送消息,则使用大淘客接口获取商品优惠卷链接并发送回去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
async function onMessage ({
messageId,
}: PUPPET.payloads.EventMessage) {
const {
fromId, // 发送人id
roomId, // 发送群群id
text, // 消息内容
} = await puppet.messagePayload(messageId)
if (/ding/i.test(text || '')) {
await puppet.messageSendText(roomId! || fromId!, 'dong')
}
console.log(puppet.messagePayload(messageId))
if(fromId == 'wxid_gkc181kbu5bd22'){
// 我自己的微信号发来的消息
if(text == '1'){
console.log("开始发送")
await puppet.messageSendText('wxid_gkc181kbu5bd22', '开始发送')
handleStart()
} else if (text == '2') {
console.log("停止发送")
await puppet.messageSendText('wxid_gkc181kbu5bd22', '停止发送')
hanldeEnd()
} else if (text == '0'){
console.log("索引清零")
await puppet.messageSendText('wxid_gkc181kbu5bd22', '索引清零')
idx = 0
pageId = 0
itemList = []
} else if (text.includes('time')){
console.log("修改发送间隔",text.replace(/[^0-9]/ig, "") * 60000)
await puppet.messageSendText('wxid_gkc181kbu5bd22', '修改发送间隔成功')
time = text.replace(/[^0-9]/ig, "") * 60000
} else if (text.includes('idx')){
console.log("修改索引",text.replace(/[^0-9]/ig, ""))
await puppet.messageSendText('wxid_gkc181kbu5bd22', '修改索引成功')
idx = Number(text.replace(/[^0-9]/ig, ""))
pageId = 0
itemList = []
}
} else {
// 其他人发送的商品链接,使用大淘客接口获取优惠信息并发回去
const res = apiGetGoodsInfo(text)
console.log("商品信息",res)
}
}

总结

这部分功能实现没啥问题,唯一的问题是不知道什么时候微信官方修改就会导致无法使用。
除此之外,也可以使用auto.js来对真机进行操作来实现,更稳定。

git地址

项目代码

前言

最近遇到了一个疑似header头引发的bug,需要修改header头来验证,于是写了个插件来实现


一、插件结构

浏览器插件相关的东西我这里就不说了,简单说下结构
在这里插入图片描述

background.js: 实现修改header头的逻辑,插件加载即运行
manifest.json: 插件的一些配置项

二、代码

1.插件配置项

manifest.json代码如下:

1
2
3
4
5
6
7
8
9
10
11
{
"name": "editHeader",
"description": "edit header",
"version": "1.0.0",
"manifest_version": 2,
"permissions": ["webRequest", "webRequestBlocking", "http://*/*", "https://*/*"],
"background": {
"scripts": ["./background/background.js"],
"persistent": true
}
}

每个参数对应的含义我就不多说了,manifest.json的配置项很多,可以自己百度
其中比较关键的是permissions中的webRequest,这个配置了插件才能有拦截请求的权限

2.功能代码

background.js代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
let targetUrl = 'https://gitee.com/' //要修改的目标url
let headerObj = {
test: '123456',
'User-Agent': '456'
} //要修改的header头参数,有则修改,无则添加
chrome.runtime.onInstalled.addListener(function () {
console.log('插件已被安装')
})
function replaceHeader(url, requestHeaders) {
if (url === targetUrl) {
for (let key in headerObj) {
const idx = requestHeaders.findIndex(x => x.name === key)
if (idx !== -1) {
// 有,则修改
requestHeaders[idx].value = headerObj[key]
} else {
// 无,则添加
requestHeaders.push({ name: key, value: headerObj[key] })
}
}
}
return requestHeaders
}
function updateListeners() {
if (!listener)
listener = function (details) {
var header_map = { requestHeaders: details.requestHeaders }
if (details && details.url && details.requestHeaders && details.requestHeaders.length > 0) {
header_map = { requestHeaders: replaceHeader(details.url, details.requestHeaders) }
}
return header_map
}
chrome.webRequest.onBeforeSendHeaders.addListener(listener, { urls: ['<all_urls>'] }, ['requestHeaders', 'blocking', 'extraHeaders'])
}
var listener = null
updateListeners()

大致逻辑的话就是通过chrome提供的onBeforeSendHeaders钩子来修改header头


项目地址

github: https://github.com/Ceslsius/editHeader


总结

目前每修改一次参数就需要重新加载一次插件,后续的话准备做成可配置

前言

最近接到了一个web的拼图游戏需求,要求在前端生成凹凸碎片样式。在github逛了逛,发现基本都是切割成方块,不满足需求;要么是用pixi.js或者cocos这种有学习成本,于是先研究一下怎么简单一点的实现。


一、如何将图片切割成凹凸形状

这里用到了canvas的一个属性

1
imgContext.globalCompositeOperation = 'destination-in'

详细的大家可以去查看文档,简单来说的话就是将canvas两次绘制的图形进行一个保留重合部分的操作
,所以我们先绘制出完整的图片,再绘制凹凸碎片,就能得到想要的有图案的碎片,然后修改绘制凹凸碎片的位置重复多次操作就能得到想要的全部碎片。

二、完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>凹凸拼图</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, shrink-to-fit=no" />
</head>

<body>
<canvas id="canvas" class="puzzle_game" id="puzzleGame" width="300" height="450"></canvas>
</body>
<script type="text/javascript">
var shade = document.getElementById('canvas')
var imgContext = shade.getContext('2d')
var img = new Image()
img.src = './a.jpg'
img.onload = function () {
imgContext.drawImage(img, 0, 0)
imgContext.globalCompositeOperation = 'destination-in'
var img2 = new Image()
img2.src = './clipart.png'
img2.onload = function () {
imgContext.drawImage(img2, 100, 100, 50, 50)
}
}
</script>
<style>
.puzzle_game {
width: 300px;
height: 450px;
background: blue;
}
</style>
</html>

绘制图片:
绘制图片
绘制凹凸拼图碎片:
绘制凹凸拼图碎片
可以看到我们得到了其中一块碎片,之后我们修改第二次绘制的坐标,就能得到全部的碎片。

总结

这种办法生成,优点是代码简单,微信小程序也能用;缺点是生成时间较长,计算拼图碎片放置位置有点麻烦。

简介

微信⼩程序,简称⼩程序,英⽂名 Mini Program,是⼀种不需要下载安装即可使⽤的应⽤,它实现了应⽤“触⼿可及”的梦想,⽤⼾扫⼀扫或搜⼀下即可打开应⽤

微信小程序的优势:

1.微信有海量⽤⼾,⽽且粘性很⾼,在微信⾥开发产品更容易触达⽤⼾;
2.推⼴app或公众号的成本太⾼。
3.开发适配成本低。
4.容易⼩规模试错,然后快速迭代。
5.跨平台。

一、开发前的准备工作

1-1 注册小程序

官方注册文档: https://developers.weixin.qq.com/miniprogram/introduction/
微信小程序注册地址:https://mp.weixin.qq.com/
官方给的注册步骤很详细了,跟着教程来就行,需要注意几点

  • 记得保存appid,appsecret,如果丢失需要重新生成,可能会影响线上应用
  • 服务器域名、业务域名等配置项位置:开发-开发管理-开发设置
  • 服务器域名必须是https,小程序发布类别(如社交,音视频等)需要证书,这些东西可以在开发时同步进行

    1-2 开发者工具

    开发者工具下载地址:https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html
    安装完成后,扫码登录,新建/导入小程序项目时输入appid即可
    微信小程序标准的目录结构
  • 上面是微信小程序标准的目录结构,无论使用什么框架(比如uni-app)最后打包成微信小程序时也是生成类似的结构
  • json文件配置文档(微信小程序):https://developers.weixin.qq.com/miniprogram/dev/framework/config.html
  • json文件配置文档(uni-app):https://uniapp.dcloud.net.cn/collocation/pages.html
  • 在开发时,可以打开 设置-本地设置-不校验合法域名 来关闭对服务器域名、业务域名等的校验,包括在体验版时也可以通过打开调试模式来关闭校验
  • 如果后面要使用uni-app项目,需要将开发者工具的服务端口选项打开(设置-安全设置)

    1-3 微信小程序注意事项

  • 微信官方时不时就会修改API(获取用户信息修改了三四次了),或者API收费(获取手机号),要有心理准备
  • 微信小程序每个包大小不能超过2M,可以用分包来解决,全部包加起来不能超过16-20M
  • 因为大小的限制,静态资源最好是放在自己的服务器上
  • 扫描二维码打开小程序,h5跳转小程序,微信消息通知,这些功能很有用、接入也很简单,但是一定要仔细阅读开发文档
  • 获取用户位置,音视频通话,红包等功能是需要开通的,一定要先确定能够开通再选择使用

    二、开发

    2-1 选择开发方式

    目前主流混合开发方式
    原生开发 uni-app nvue weex RN flutter
    所属公司 腾讯 Dcloud Dcloud 阿里 Facebook Google
    开发方式 js vue.js vue.js vue.js react.js Dart
    渲染方式 魔改webview webview weex原生渲染 weex原生渲染 webview 原生渲染
    支持系统 微信小程序 web、Android、iOS、多方小程序 Android、iOS web、Android、iOS web、Android、iOS、多方小程序 Android、iOS、Fuchsia
    性能 一般 一般 较快 较快 一般

总结:

  • 如果只考虑小程序和h5端,个人建议是uni-app
  • 如果应用只需要考虑 Android 和 iOS,个人认为性能体验方面:flutter > nvue/weex > RN > uni-app ;
  • 如果要跨多个端,除了 Android 和 iOS,还包括微信、支付宝小程序等等,毋庸置疑,首选uni-app;
  • 如果要考虑桌面端,flutter 本身就支持桌面端,RN 则需要使用react-native-windows这个项目,uni-app 则需要集成 electron

2-2 开发项目

这里我以uni-app为例,至于为什么选择uni-app,我的理由是

1.微信原生开发的状态管理及双向绑定并不好用
2.uni-app的vue语法让开发更敏捷
3.虽然有兼容性问题,uni-app打包多端的能力仍然好使
4.多年的迭代使得uni-app目前有较多的插件
5.跨平台

没有最好的,只有最合适的,实际选用时也要视情况而定

2-2-1 项目搭建

可以从uni-app官网下载模板,也可以从github拉
当然也可以导入已有的项目

2-2-2 开发工具及插件安装

HBuilderX下载地址:https://www.dcloud.io/hbuilderx.html
安装后,打开我们刚刚的代码,点击 运行-运行到小程序模拟器 即可拉起小程序预览项目
比如安装UI库uView
uni-app插件市场地址:https://ext.dcloud.net.cn/
搜索到需要的插件点击导入HBuilderX,然后选择对应项目导入即可

2-2-3 开写

uni-app开发规范:

1.页面文件遵循 Vue 单文件组件 (SFC) 规范,即每个页面是一个.vue文件
2.组件标签靠近小程序规范,详见uni-app 组件规范
3.接口能力(JS API)靠近小程序规范,但需将前缀 wx、my 等替换为 uni,详见uni-app接口规范
4.数据绑定及事件处理同 Vue.js 规范,同时补充了应用生命周期及页面的生命周期
5.如需兼容app-nvue平台,建议使用flex布局进行开发

简单来说的话就是跟写vue差不多,这里我就不多说vue该怎么写了
值得一说的:
1.rpx
不用去管实际运行时的窗口尺寸,750rpx = 100vw,也就是让UI出图时使用750px为基准,编辑样式时一比一换成rpx即可

1
2
3
#app{
width: 750rpx;
}

2.条件编译

1
2
3
4
5
6
// #ifdef H5  
console.log("这段代码只会在h5端运行")
// #endif
// #ifndef H5
console.log("这段代码只会在非h5端运行")
// #endif

3.id含义

1
2
3
appid: 小程序id,小程序的唯一识别
openid: 微信用户id,对于当前小程序的微信用户的唯一识别
unionId: 微信用户id,对于当前小程序所属微信商户账号的微信用户的唯一识别

三、测试及发布

3-1 打包上传

如果是原生开发,直接上传代码
注意一下开发者工具的几个配置项,按需选择就行
配置项
如果是uni-app项目,则在HBuilderX里选择 发行-小程序-微信,然后再点击上传

3-2 设置体验版

上传包后,在微信公众平台 管理-版本管理中选择刚刚的开发版本为体验版
版本管理
然后,再将测试人员添加到体验人员中(管理-人员管理-体验成员)
此时,测试人员扫描体验版二维码,就能使用小程序了

3-3 提交审核及发布

测试完成后,点击提交审核,在1-3个工作日内会出结果,如果审核不通过,按照提示信息修改;如果通过,点击发布小程序即可
中(管理-人员管理-体验成员)
此时,测试人员扫描体验版二维码,就能使用小程序了

3-3 提交审核及发布

测试完成后,点击提交审核,在1-3个工作日内会出结果,如果审核不通过,按照提示信息修改;如果通过,点击发布小程序即可
注:第一次审核时,部分类别需要工信部二次审核,时间较长

最近学习了一下 wasm

WebAssembly 是由主流浏览器厂商组成的 W3C 社区团体 制定的一个新的规范。

这是一个运行于沙箱中的指令集,其二进制的格式能最大效率发挥硬件能力,运行速度是 js 的几倍十几倍

而因为其是二进制格式,你可以将 C/C++/java 等等等语言编译成 wasm 格式,并在各种语言的虚拟机中运行,自然也包括 JavaScript 虚拟机

这也就意味着我们能在前端实现一些以前只能在后端实现的功能

这里我们以 ffmpeg 举例,这是一个 C++ 开发的很有名的开源库,能实现大部分视频的编码格式处理,包括压缩,转码等功能

我们将 ffmpeg 转成 wasm 格式后就能在前端实现视频处理

注:如果只是想直接使用,请直接滑到最下面

Emscripten & asm.js

Emscripten: An LLVM-to-JavaScript Compiler

Asm.js an extraordinarily optimizable, low-level subset of JavaScript

这是 wasm 过程中 2 个比较重要的工具,官网链接在上:

  • Emscripten: wasm 的编译器,根据上面的介绍,这是一个 LLVM 到 js 的编译器。咱对于编译原理这些底层的东西了解也不太多,简单理解的话 LLVM 就是一个编译的中间状态,使用 llvm-gcc 或者 clang 这些编译工具可以将 c/c++编译到 LLVM bitcode 这样一个中间代码的状态, 同样类似像 rust 的语言也可以编译到 LLVM。有了 LLVM bitcode,就可以使用 Emscripten 这个工具来将其编译成更底层的汇编 js 代码。
  • 那么更底层的汇编 js 代码是啥,早期在 wasm 还没有实现之前,汇编 js 的实现就是 Asm.js(字如其名。。)。asm.js 的核心功能就是模拟一个汇编语言的运行环境,通过一个大的 UintArray 数组来模拟机器内存,然后通过 js 实现各种汇编指令来对这个虚拟的内存(UintArray 对象)进行操作。

通过上面这 2 个步骤,我们先是把 c/c++通过编译器编译到 LLVM,再利用 emscripten 将 LLVM 编译成 asm.js,当我们在运行这段 asm.js 代码的时候,就会省去大量的解释器开销,相当于直接使用汇编代码一样来直接操作内存空间运行。

但是 asm.js 实现的汇编环境毕竟还是基于 js 的,在性能上还是不能完全满意,这个时候,WebAssebmly 就应运而生了,由浏览器来实现汇编环境,规定好 webassembly 的汇编格式,使得性能进一步提示

编译过程

扯了这么多,还是具体说说编译 FFmpeg 的过程吧。编译 ffmpeg 参考了 github 上的一个现有项目,videoconverter.js,下面从 0 开始按照顺序说明一下

  • 编译 Emcc 工具链

首先我们需要编译 Emcripten 到你的机器上,我这里用的是一台 Ubuntu14.04.

在执行下面命令之前,请先确保你的机器上有如下依赖

cmake git python2.7.X

1
2
3
4
git clone https://github.com/juj/emsdk && cd emsdk
./emsdk install sdk-incoming-64bit binaryen-master-64bit
./emsdk activate sdk-incoming-64bit binaryen-master-64bit
. ./emsdk_env.sh

编译完成之后,按照输出给出的提示,以后只要直接 source ./emsdk_env.sh 就会自动配置环境变量,将各种编译工具的设置成 emsdk 下的 path

至此,在命令行输入 emcc –help,看到正常的输出说明编译和配置成功。现在你就拥有了一个编译 wasm 的编译器。

开始编译 FFmpeg 到 LLVM

跟着上篇的进度,现在我们已经拥有了 Emscripten 的编译环境,现在让我们正式开始编译

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 首先,从github等地方获取ffmpeg的源代码
git clone https://github.com/FFmpeg/FFmpeg
cd FFmpeg

# 开始configure
# 这里的参数参考自videoconverter.js,其中注意需要额外带上下面第一行的CPPFLAGS
# 否则不能在最新的emcripten下编译通过
# 这里通过--cc="emcc"来指定编译器为emcc,emcc会调用clang来将target设置成LLVM
CPPFLAGS="-D_POSIX_C_SOURCE=200112 -D_XOPEN_SOURCE=600" \
emconfigure ./configure --cc="emcc" \
--prefix=$(pwd)/../dist --enable-cross-compile --target-os=none --arch=x86_64 \
--cpu=generic --disable-ffplay --disable-ffprobe --disable-ffserver \
--disable-asm --disable-doc --disable-devices --disable-pthreads \
--disable-w32threads --disable-network --disable-hwaccels \
--disable-parsers --disable-bsfs --disable-debug --disable-protocols \
--disable-indevs --disable-outdevs --enable-protocol=file

# ?等待运行完成,执行make开始编译
make

如果一切正常,你将看到下面的结果

img

你会得到一个叫 ffmpeg 的输出文件(如果最后阶段报 strip error 直接无视掉就好,和我们的栗子没有关系)

img

这个 ffmpeg 文件就是我们第一步要得到的 LLVM bitcode,下一步我们就可以将这个 LLVM bitcode 编译到 js 或者 wasm 里面啦

行百里者半九十

在执行最后一步之前,有个疑问,ffmpeg 正常编译出来的二进制应该是个命令行文件,接受命令行参数执行操作。但当我们把它编译到 js 里时,该怎么向它传递参数呢?传递了参数之后又要怎么样才能拿到它的执行结果呢?

理想的状况下,我们希望最终编译出来的 wasm/asm.js 文件能向外 export 一个函数来作为入口,我们通过这个函数入口传入参数,通过返回值或者回调函数等方法获取结果。

其实这就是 wasm/asm.js 文件和浏览器 js 的信息通信问题,Emscripten 为我们提供了这样一些解决方案,以下摘自 Emscripten 官网文档 API Reference - Emscripten 1.37.10 documentation

  • pre.js 和 post.js

–pre-js Specify a file whose contents are added before the generated code. This is done before optimization, so it will be minified properly if the Closure Compiler is run. –post-js Specify a file whose contents are added after the generated code. This is done before optimization, so it will be minified properly if the Closure Compiler is run.

这 2 个其实是 emcc 编译时的 2 个可选项,通过传入用户自己编写的 pre.js 和 post.js,来将最终生成的代码包裹在 pre.js 和 post.js 之下,就像这样 [pre.js]–[生成的 wasm/asm.js]–[post.js]

也就是可以让我在 wasm 被执行之前,运行 pre.js 里的代码,在 wasm 代码被执行后(但并不是执行结束后)运行 post.js 里的代码。

那这样有什么意义呢?其实最方便的就是我们可以在 pre.js 里 export 一个自定义的函数给外部,或者比如说在 pre.js 里做一些必要的初始化,具体做法看下面的介绍

  • Module 对象

Module is a global JavaScript object with attributes that Emscripten-generated code calls at various points in its execution.

简单来说,这里的 Module 是一个特殊全局对象,当我们执行最终的 wasm/asm.js 文件时,在不同的阶段,程序会自动调用 Module 下不同的方法。比如执行结束时(Module.postrun),输出到 stdout(Module.print)时等等。我们就可以自己实现 Module 下那些阶段的函数来挂钩子,比如将返回值返回给回调函数,将 log 打印到浏览器 console 等等。

结合上面的 pre.js,是不是想到什么了?

没错,我们可以在 pre.js 里编写定义 Module 函数,来控制 wasm/asm.js 程序的输入和输出。

Module.arguments The commandline arguments. The value of arguments contains the values returned if compiled code checks argc and argv.

通过给 Module 的 arguments 传入值,emscripten 就会把这个值带到 wasm/asm.js 程序的命令行输入里,也就是 ffmpeg 的命令行输入。

这样,我们就大概解决了命令行参数输入的问题,但是还有几个点需要考虑。

ffmpeg 输入文件名之后,但是文件要怎么传给他? ffmpeg 输出的文件我们又要怎么得到?

  • File System API

File System API - Emscripten 1.37.10 documentation

没错,解决方案依旧是 EmscriptenAPI

emscripten 为我们提供了一套文件系统 api,通过这套 API,就可以用 ArrayBuffer,Web File 等方法来创建文件,并将这个文件写入 emscripten 为我们提供的一个虚拟文件系统。

之后 wasm/asm.js 程序请求文件都会去使用这个虚拟的文件系统,包括读入和写出。

具体 api 的使用方法这里就不说太多了有兴趣的可以看看文档。

至此,我们通过这 3 个方法,解决了我们的需求:

  • 导出一个入口函数到上层 js
  • 传入函数参数来控制 ffmpeg 命令行参数
  • 通过虚拟文件系统传入输入文件以及获取输出文件

Final Battle 编译 LLVM 到 WebAssmbly

这里使用的命令依旧是 emcc,但是注意此时 emcc 的输入为 LLVM bitcode,它将会调用 emscriptem 来将其编译到 js (和第一步 emcc 的行为不同,因为输入格式不同,target 也会不同)

1
2
3
4
5
6
7
8
9
10
# 这里的ffmpeg是上一步编译输出的LLVM bitcode
cp ffmpeg ffmpeg.bc

# 最终的输出是 -o 指定的,这些 -s 参数的意义可以从emcc的文档中找到
# 这里打开了ALLOW_MEMORY_GROWTH是因为在移动端测试下会遇到内存(wasm/asm.js的虚拟内存)
# 不够的情况,默认内存大小是TOTAL_MEMORY指定的
# 设置WASM=1就会编译到WebAssembly,默认编译到asm.js
emcc -s ASSERTIONS=1 -s VERBOSE=1 -s TOTAL_MEMORY=33554432 \
-s ALLOW_MEMORY_GROWTH=1 -s WASM=1 -O2 -v ffmpeg.bc \
-o ../ffmpeg.js --pre-js ../ffmpeg_pre.js --post-js ../ffmpeg_post.js

如果一切顺利,你就会得到最终的输出,包括这 2 货

img

因为我们之前在 pre.js 里写了入口函数并将其 export,所以我们只需要直接 require(“ffmpeg.js”),或者在 worker 里直接 importScript(“ffmpeg.js”)就可以拿到入口函数。

注意 ffmpeg.js 和 ffmpeg.wasm 需要放在一个路径下,执行 ffmpeg.js 的时候会去请求 wasm 文件

下面贴上使用案例,worker 下的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
self.importScripts("ffmpeg.js");

onmessage = function (e) {
console.log("ffmpeg_run", ffmpeg_run);
var files = e.data;
console.log(files);
ffmpeg_run(
{
arguments: ["-i", "/input/" + files[0].name, "-b:v", "64k", "-bufsize", "64k", "-vf", "showinfo", "-strict", "-2", "out.mp4"],
files: files,
},
function (results) {
console.log("result", results);
self.postMessage(results[0].data, [results[0].data]);
}
);
};

workers 的输入就是一个 WebFile,通过 emscriptenAPI 的 workerfs 可以直接将 html5 的 File 对象传入虚拟文件系统

输出就是输出文件的 ArrayBuffer,最后再贴个效果图

img

使用 ffmpeg.wasm 在前端实现视频压缩

当然如果我们不想自己编译,可以直接用已经编译好的wasm文件

比如这个:github 仓库地址: https://github.com/ffmpegwasm/ffmpeg.wasm

实例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Title</title>
<script src="https://unpkg.com/@ffmpeg/ffmpeg@0.9.5/dist/ffmpeg.min.js"></script>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
}
body {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
</head>
<body>
<h3>视频前端压缩方案</h3>
<video id="output-video" controls></video><br />
<input type="file" id="uploader" />
<p id="message"></p>
<script>
const { createFFmpeg, fetchFile } = FFmpeg;
const message = document.getElementById("message");
const ffmpeg = createFFmpeg({
log: true,
progress: ({ ratio }) => {
message.innerHTML = `Complete: ${(ratio * 100.0).toFixed(2)}%`;
},
});
const transcode = async ({ target: { files } }) => {
const { name } = files[0];
message.innerHTML = "Loading ffmpeg-core.js";
await ffmpeg.load();
message.innerHTML = "Start transcoding";
ffmpeg.FS("writeFile", name, await fetchFile(files[0]));
await ffmpeg.run("-i", name, "output.mp4");
message.innerHTML = "Complete transcoding";
const data = ffmpeg.FS("readFile", "output.mp4");
const video = document.getElementById("output-video");
video.src = URL.createObjectURL(
new Blob([data.buffer], {
type: "video/mp4",
})
);
};
document.getElementById("uploader").addEventListener("change", transcode);
</script>
</body>
</html>

压缩一个 30M 的视频能达到 2.3M 左右,但是因为 ffmpeg 库本身比较大,我们又是全部编译过来的,有大概 20 多 M,因此不建议在移动端使用

当然如果你是 C++ 大佬,可以自己精简 ffmpeg 库之后再编译