什么是Peek|点对点快传
通俗点说,Peek 是一个简易的数据传输工具,它基于WebRTC进行点对点的数据通讯,并在此基础上实现聊天、音视频通话、文件传输等。得益于peerjs项目的杰出贡献,它抹去了大部分的WebRTC底层实现,使得此项目能专注于传输界面和功能的实现,并且通过peerjs的官方STUN服务器实现NAT穿透,使得Peek无需服务器也能正常使用,成为一个纯粹的静态前端SPA项目。
Peek的开发过程历时悠久,尽管1.0版本在很早以前就被我已经发布在了github上,但是比较简陋,代码结构也比较随意。随着工程经验的增加,我决定对初版Peek进行一次全方位重构,并为其增加更多功能。在重构的过程中,我梳理出了一些可以复用的经验,并记录在此专栏中。
由于我的技术栈以Vue为主,因此此次对Peek的优化也可以看作是一次对Vue3项目最佳实践的探索,在此之前,由于经常使用Vue2带来的思维惯性,以及对Element UI的大量使用导致部分组件化思维的固化,有许多代码结构在Vue3框架下显得臃肿且多余;同时,在经历了大量TS类型体操的训练之后,我对Typescript的了解也逐步加深,因此将当前对部分优化过程的思考放在这里,供日后查看精进。
从搭建项目开始
构建工具
毋庸置疑地说,在Vite 1.0版本发布之后的新项目,都应该使用Vite来搭建,这是我在第一次体验到Vite开发后最大的感受,更不用说目前Vite已经更新到了4.0版本,跟Webpack比起来在开发体验上好了太多,因此Peek从一开始就选择了使用Vite作为构建工具。
构建插件
我在Peek中选择的构建插件主要如下:
UnoCSS
Sass
UnoCSS是一个原子化CSS项目,和它的前辈TailwindCSS、WindiCSS一样,它可以使用组合类名来代替具体的类名+手写CSS。虽然看起来有点像开Bootstrap之流的历史倒车,但是得益于新的动态生成和按需加载技术,使用现代的原子化CSS得到的CSS代码体积要比单纯引入一个超大的bootstrap.css文件要小得多,并且拓展性更强,更重要的是,虽然看起来写多个原子式的类名比只写一个类名要打更多的字,但实际体验上比起不停地在style块和html块里来回切换,只靠阅读html就能知道并修改样式要舒服得多。
在实践中,Peek几乎完全使用原子类名来撰写样式,而Sass只是在部分情况下,例如多重父子选择器和通用组件样式覆盖这类情况下使用,归根结底是为了更好的样式书写体验。
我没有使用auto-import这类的插件,一方面是我担心在某些情况下,自动引入可能会导致打包时的tree-shaking失效,另一方面是使用自动引入的Vue组件会失去对props的自动提示,得不偿失。
值得注意的是,我在项目中使用到了UnoCSS的Iconify图标插件,通过这个插件,我可以在类名中直接使用如“i-mdi:icon-named”的方式来使用Iconify中的图标,十分方便。不过,在部分Node.js版本中,直接使用“import(’xxx.json’)”的方式来引入json文件可能会因为Node版本不支持动态引入JSON文件而报错,可以手写一个使用fs.read的readJson方法来代替:
const readJsonFile = async (path: string) =>
JSON.parse(
await readFile(
resolve(__dirname, `./node_modules/${path}`), { encoding: 'utf-8' }
)
)
UnoCSS其实也支持在CSS中使用如“@apply bg-red;”这种方式来嵌入预设的样式,只是需要手动开启:
import { defineConfig, Plugin, loadEnv } from 'vite'
import UnoCSS from "unocss/vite";
import { presetIcons, transformerDirectives, presetWind } from "unocss";
export default defineConfig({
plugins:[
UnoCSS({
presets: [
presetWind(),
presetIcons({
collections: {
material: () => readJsonFile('@iconify-json/mdi/icons.json').then(i => i.default),
}
}),
],
// 在这里来应用指令语法
transformers: [transformerDirectives()]
}),
]
})
另外,由于项目中会使用到WebRTC与音视频录制,所以需要在安全的浏览器上下文环境中才能调用此类API,因此需要使用 vite-plugin-mkcert 插件来启用https证书支持。
更好的Typescript支持
如果使用create-vite选择vue+ts模板搭建项目的话,就已经可以得到完整的typescript支持了,不过为了更好的开发体验,还是可以增加一些设置。
TS的“alias”可以让我们为文件夹设置别名,例如将“src”文件夹设置为“@”,这样可以方便从各个不同的文件夹中引入函数时省去开头的一堆“../../”,提升代码阅读体验,也能把各个文件的依赖看的一目了然。
在tsconfig.json中:
{
"compilerOptions": {
"paths": {
"@/*": [
"./src/*"
]
},
},
}
这样ts就可以自动提示和自动引入了,不过vite还不能识别,需要在vite.config.ts中配置:
export default defineConfig(({ mode }) => ({
// ...others
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
}))
Lint & Formatter
对于需要团队合作或者有着代码强迫症的人来说,lint是一个好选择,但目前来说,我还没有找到一个eslint、prettier和volar之间能够完美合作的配置,要不然是格式化时冲突,要不然就是代码提示疯狂跳舞,每次保存都让你无法预料到底走的是哪一套规则,所以我的建议是,都不用。
除了Vue文件使用Volar之外,其余的让VS Code自己决定去吧。
影子路由
对于Peek这样简单的SPA来说,router其实是可有可无的,因为它总共只有两个页面,上router似乎有点杀鸡用牛刀的感觉,但router的作用其实不止于此。
在我看来,router其实是一种组件组织方式,它并不只是一个路由工具,强依附于url,而是自成体系,与url无关。通过router,我们可以将多个页面拆分成框架+内容的模式,并通过router的父子关系将它们组合起来。
router的children往往给人一种错觉,让人觉得router配置的形状就代表了页面真实的路由形状,虽然大多数情况下确实如此,但其实两者之并没有紧密的联系。
// router.ts
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/personal',
component: () => import('@/views/HomeLayout.vue'),
children:[
{
path: '/personal',
component: () => import('@/views/Personal.vue')
},
{
path: '/discover',
component: () => import('@/views/Discover.vue')
}
]
},
{
path: '/chat',
component: () => import('@/views/Chat.vue')
}
]
如上面的例子所示,很容易让人觉得 / 和 /chat 是同级关系,而 /personal /discover是 / 下的子路由,然而事实上,/personal /discover /chat其实是三个平级关系,页面中并不存在 / 这个路由,它只是起了一个容器的作用,告知router-view在渲染时,将HomeLayout作为框架组件,然后再根据路由的不同为HomeLayout分配不同的子组件。
一旦理解了这层含义,就能清楚地认识到,router的本质其实是一个组件嵌套工具,通过它我们可以方便地整理出页面的拼装方式,并反应到router配置中。因此,vue-router团队特意将router的history抽离成插件的形式,因为当我们使用createWebHashHistory时,我们只是选用了浏览器url这种方式来显示地展示当前的页面标识符。如果我们并不希望展示出来,例如为了防止用户错误地前进后退到意外的路由,我们可以使用createMemoryHistory,将路由信息保存到内存中,这样,我们就摆脱了浏览器网址的限制,同时依然享受router带来的便利。
比起手动使用v-if方式来切换组件,router的好处多了太多:
开箱即用的组件懒加载
进入和离开守卫,防止错误地进入某些页面
方便的过渡效果
直观的页面组织方式
命令式地跳转
关于最后一点,我相信应该有很多人都深有体会。虽然Vue的设计哲学是声明式地编写界面,但是某些情况下,命令式地调用函数往往比修改某个响应式变量的值要舒服的多。例如当我们要在某个嵌套得很深的子组件内切换页面时,使用v-if需要我们把事件穿过层层组件上报给父组件,或者把父组件的方法provide下去,这样往往会增加组件间的耦合性,更好的做法是使用全局store或者Vue3的hooks把currentPage拆分到独立的文件中,于是在考虑了许多边界条件之后,我们兴奋地发现,我们重新实现了router。
异步,异步,不择手段地异步
Peek的核心是基于peerjs的数据通讯系统,而peerjs的api是基于监听器的,如果直接基于peerjs编写传输功能,会消耗大量的时间在检查监听器设置是否正确,以及判断各种边界条件上,并且整个peerjs实例会在各个组件中被用来用去,不利于后续bug排查,也很容易导致冲突,因此对peerjs进行封装很有必要。
在封装的设计上,我把整个数据通讯模块分为了两个部分,第一个部分为通讯主体,它负责处理通讯握手阶段,然后在握手成功后将peerjs实例和连接实例传递给插件使用;第二个部分为插件,它们会在握手成功后被初始化,并根据自身用途调用peerjs相关的api,例如发生消息,进行通话等。
先来看握手阶段,由于peerjs的connect方法是直接建立连接,并没有用户确认的过程,因此我们需要在此之上模拟一层用户手动确认的过程,即需要被连接的用户向发起者发送确认信息,才能进入真正的聊天阶段。因此,这里需要用到许多监听器,但是这样会导致组件产生太多业务无关的代码,我希望组件能专注界面展现,需要一个便捷的接口来实现这一点:
// 伪代码展示Peek的使用
// 主动连接
const connect = async (id:string, info:any) => {
const accepted = peek.connect(id, info);
try{
await accepted;
console.log("connect success")
}catch{
console.log("connect rejected")
}
}
// 被动连接
peek.onBeRequest( async (info:any, accept, reject) => {
try{
await confirm(`${info.name} wants to connect with you, confirm?`)
accept()
}catch{
reject()
}
})
我们把整个握手过程抽象成一次异步调用过程,极大地降低了理解成本,也减少了在组件中插入的非业务代码,让组件专注于UI处理。
不过,最终功能的完成还是少不了监听器的,脏活还是要交给peek内部来实现
// Peek内部实现伪代码
const createPeek=()=>{
const peer=new Peer();
const connect=(id:string, info:any)=>{
const connection=peer.connect(id, {metadata:info})
return new Promise((res, rej)=>{
// 监听收到的信息
connection.once('data', (data)=>{
if(data==='accept'){
res()
}else{
rej()
connection.close()
}
})
})
}
const onBeRequest=(fn:(info:any, accept:Function ,reject:Function)=>void)=>{
peer.on('connection', (connection)=>{
const info = connection.metadata
const accpet=()=>{
// 发送确认信息
connection.send('accept')
}
const reject=()=>{
// 发送拒绝信息并关闭连接
connection.send('reject')
connection.close()
}
fn(info, accept, reject)
})
}
return {
connect,
onBeRequest,
}
}
这里省去了许多peerjs的api用法展示,主要专注于整套握手逻辑的实现,使用promise搭配监听器,将原本的监听过程变为异步的等待过程,降低了代码复杂度,并易于理解。
这样的思想也贯穿在后续插件的设计中,比如Message插件,需要实现消息发送成功的回调,也同样用到了类似的方法。Call插件的异步则较为复杂,因为音视频通话还存在一个中途取消的过程,所以用到的监听器和判断条件也更多,再加上peerjs本身还存在一些未解决的bug,导致Call插件的实现逻辑更为复杂,不过依然保持了业务分离原则,让组件保持尽可能地纯粹。