本文是基于web平台对接腾讯IM的一些体会和总结,对于没有对接IM经验或者是刚接触IM项目的小伙伴来说,看到这么多可选的平台,这么丰富的接口和看似如此庞大的项目,你的心里可能会发怵,但是,当你看到这篇文章的时候,你应该会心一笑,因为这里整理了web端跑通整个demo对接的基本流程和一些问题,话不多说,继续往下看。

腾讯IM提供在线demo和本地demo,在线demo可以查看官方案例的最完整的功能,本地demo是供本地开发调试时使用,以web平台为例,去SDK下载区下载对应平台的demo,如果是Web(通用)平台找到这个H5/dist/debug/GenerateTestUserSig.js目录,把文件中的SDKAPPIDSECRETKEY项换成自己的,在项目目录下安装依赖npm install,启动本地项目npm start注意,官方不推荐直接访问http://localhost:8080,而是在项目的dist目录下直接打开index.html,这时候我们可以在user0-user29中随便登陆一个了,重新打开一次,再登录一个账户,两个账户就可以即时通讯,至此,官方demo在本地跑起来了。那么,我们如何集成SDK到我们自己的web项目呢?

目前,很多前端web项目都采用MV*框架,比如官方的demo就采用Vue+ElementUI技术,因此,先安装SDK依赖

1
2
3
4
// IM Web SDK
npm install tim-js-sdk --save
// 发送图片、文件等消息需要的 COS SDK
npm install cos-js-sdk-v5 --save

为了实现模块化,我们新建tim.js文件作为tim模块独立出来,引入对应的包,并导出tim

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import TIM from 'tim-js-sdk';
import COS from "cos-js-sdk-v5";

let options = {
SDKAppID: 0 // 接入时需要将0替换为您的即时通信 IM 应用的 SDKAppID
};
// 创建 SDK 实例,`TIM.create()`方法对于同一个 `SDKAppID` 只会返回同一份实例
let tim = TIM.create(options); // SDK 实例通常用 tim 表示

// 设置 SDK 日志输出级别,详细分级请参见 setLogLevel 接口的说明
tim.setLogLevel(0); // 普通级别,日志量较多,接入时建议使用
// tim.setLogLevel(1); // release 级别,SDK 输出关键信息,生产环境时建议使用

// 注册 COS SDK 插件
tim.registerPlugin({'cos-js-sdk': COS});

export default tim

为了能在本地登录账户,需要利用客户端计算UserSig生成签名,再配上userID,就可以登录到IM系统,这里需要借助官方demo的两个文件GenerateTestUserSig.jslib-generate-test-usersig.min.js来生成签名,由于模块化开发,需要对GenerateTestUserSig.js进行修改

1
2
3
4
5
6
7
8
9
10
//首先导入lib-generate-test-usersig.min.js
import LibGenerateTestUserSig from './lib-generate-test-usersig.min'
//......
//修改lib的调用方法,官方案例是注入在window里面new window.LibGenerateTestUserSig(...)
var generator = new LibGenerateTestUserSig(SDKAPPID, SECRETKEY, EXPIRETIME);
//......
//导出
export {
genTestUserSig
}

为了方便后续使用,我们可以在window和Vue中全局注入tim

1
2
3
4
5
6
7
8
//main.js
import tim from './tim'
import TIM from 'tim-js-sdk'

window.tim = tim
window.TIM = TIM
Vue.prototype.tim = tim
Vue.prototype.TIM = TIM

接下来需要添加事件监听,查看所有的事件绑定,执行登录操作,顺便提下退出操作

1
2
3
4
5
6
7
8
9
10
11
12
//登录
tim.login({userID: 'your userID', userSig: 'your userSig'}).then((imRespone)=>{
console.log(imResponse.data); // 登录成功
}).catch((imError)=>{
console.warn('login error:', imError); // 登录失败的相关信息
})
//退出
tim.logout().then((imResponse)=>{
console.log(imResponse.data); // 退出成功
}).catch((imError)=>{
console.warn('logout error:', imError);
});

登录之后我们可以获取会话列表,获取每个会话下面的消息列表,其中涉及到的数据状态和细节操作是比较复杂的,先看看文本中的表情处理,这里简要说下思路。

1
2
3
4
5
6
7
8
9
10
//emojiMap.js
export const emojiUrl = 'https://imgcache.qq.com/open/qcloud/tim/assets/emoji/'
export const emojiMap = {
'[调皮]': 'emoji_113@2x.png',
'[龇牙]': 'emoji_141@2x.png'
}
export const emojiName = [
'[龇牙]',
'[调皮]'
]

据官方demo来看,所有的表情都是图片的映射,比如’[微笑]'的图片链接’https://imgcache.qq.com/open/qcloud/tim/assets/emoji/emoji_49@2x.png’,只需要更改结尾,拼凑图片链接即可,点击显示所有表情供选择的时候,是通过遍历所有映射放入img标签显示的,发送消息的时候,我们选择了某个表情,只需要把对于的emojiName比如’[微笑]'追加到文本消息里就行。接下来是接收带有表情的文本消息解析的问题,官方给出了解析代码,只需要导入emoji映射,再把这个解析函数导出就可以了。

然后就是发送文件信息,获取对应的文件DOM,调用发送文件消息的接口即可,显示的时候只需要显示文件名和文件大小即可,然后点击下载,可参考下面三个函数

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
size() {
const size = this.payload.fileSize
if (size > 1024) {
if (size / 1024 > 1024) {
return `${this.toFixed(size / 1024 / 1024)} Mb`
}
return `${this.toFixed(size / 1024)} Kb`
}
return `${this.toFixed(size)}B`
},
toFixed(number, precision = 2) {
return number.toFixed(precision)
}
downloadFile() {
// 浏览器支持fetch则用blob下载,避免浏览器点击a标签,跳转到新页面预览的行为
if (window.fetch) {
fetch(this.fileUrl)
.then(res => res.blob())
.then(blob => {
let a = document.createElement('a')
let url = window.URL.createObjectURL(blob)
a.href = url
a.download = this.fileName
a.click()
})
} else {
let a = document.createElement('a')
a.href = this.fileUrl
a.target = '_blank'
a.download = this.filename
a.click()
}
}

图片消息和文件消息大同小异,需要注意的是这里图片消息是可以点击放大缩小旋转查看的,下面贴上官方demo中写的图片操作,经供参考,icon是Iview的

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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
<template>
<div class="image-previewer-wrapper" v-show="showPreviewer" @mousewheel="handleMouseWheel">
<div class="image-wrapper">
<img
class="image-preview"
:style="{transform: `scale(${zoom}) rotate(${rotate}deg)`}"
:src="previewUrl"
@click="close"
/>
</div>
<Icon type="md-close" class="close-button" @click="close" />
<Icon type="md-arrow-back" class="prev-button" @click="goPrev" />
<Icon type="md-arrow-forward" class="next-button" @click="goNext"></Icon>
<div class="actions-bar">
<Icon type="ios-remove-circle-outline" @click="zoomOut"></Icon>
<Icon type="ios-add-circle-outline" @click="zoomIn"></Icon>
<Icon type="md-undo" @click="rotateLeft"></Icon>
<Icon type="md-redo" @click="rotateRight"></Icon>
<span class="image-counter">{{index+1}} / {{imgUrlList.length}}</span>
</div>
</div>
</template>

<script>
import { mapGetters } from 'vuex'
export default {
name: 'ImagePreviewer',
data() {
return {
url: '',
index: 0,
visible: false,
zoom: 1,
rotate: 0,
minZoom: 0.1
}
},
computed: {
...mapGetters(['imgUrlList']),
showPreviewer() {
return this.url.length > 0 && this.visible
},
imageStyle() {
return {
transform: `scale(${this.zoom});`
}
},
previewUrl() {
return this.formatUrl(this.imgUrlList[this.index])
}
},
mounted() {
this.$bus.$on('image-preview', this.handlePreview)
},
methods: {
handlePreview({ url }) {
this.url = url
this.index = this.imgUrlList.findIndex(item => item === url)
this.visible = true
},
handleMouseWheel(event) {
if (event.wheelDelta > 0) {
this.zoomIn()
} else {
this.zoomOut()
}
},
zoomIn() {
this.zoom += 0.1
},
zoomOut() {
this.zoom =
this.zoom - 0.1 > this.minZoom ? this.zoom - 0.1 : this.minZoom
},
close() {
Object.assign(this, { zoom: 1 })
this.visible = false
},
rotateLeft() {
this.rotate -= 90
},
rotateRight() {
this.rotate += 90
},
goNext() {
this.index = (this.index + 1) % this.imgUrlList.length
},
goPrev() {
this.index =
this.index - 1 >= 0 ? this.index - 1 : this.imgUrlList.length - 1
},
formatUrl(url) {
if (!url) {
return ''
}
return url.slice(0, 2) === '//' ? `https:${url}` : url
}
}
}
</script>

<style scoped>
.image-previewer-wrapper {
position: fixed;
width: 100%;
left: 0;
top: 0;
height: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
background: rgba(14, 12, 12, 0.7);
z-index: 2000;
cursor: zoom-out;
}

.close-button {
cursor: pointer;
font-size: 28px;
color: #000;
position: fixed;
top: 50px;
right: 50px;
background: rgba(255, 255, 255, 0.8);
border-radius: 50%;
padding: 6px;
}
.image-wrapper {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.image-preview {
transition: transform 0.1s ease 0s;
}
.actions-bar {
display: flex;
justify-content: space-around;
align-items: center;
position: fixed;
bottom: 50px;
left: 50%;
margin-left: -100px;
padding: 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.8);
}
.actions-bar i {
font-size: 24px;
cursor: pointer;
margin: 0 6px;
}

.prev-button,
.next-button {
position: fixed;
cursor: pointer;
background: rgba(255, 255, 255, 0.8);
border-radius: 50%;
font-size: 24px;
padding: 12px;
}
.prev-button {
left: 0;
top: 50%;
}
.next-button {
right: 0;
top: 50%;
}
.image-counter {
background: rgba(20, 18, 20, 0.53);
padding: 3px;
border-radius: 3px;
color: #fff;
}
</style>