什么是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方法来代替:

typescript
const readJsonFile = async (path: string) =>
  JSON.parse(
    await readFile(
      resolve(__dirname, `./node_modules/${path}`), { encoding: 'utf-8' }
    )
  )

UnoCSS其实也支持在CSS中使用如“@apply bg-red;”这种方式来嵌入预设的样式,只是需要手动开启:

typescript
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中:

json
{
  "compilerOptions": {
    "paths": {
      "@/*": [
        "./src/*"
      ]
    },
  },
}

这样ts就可以自动提示和自动引入了,不过vite还不能识别,需要在vite.config.ts中配置:

typescript
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配置的形状就代表了页面真实的路由形状,虽然大多数情况下确实如此,但其实两者之并没有紧密的联系。

typescript
// 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方法是直接建立连接,并没有用户确认的过程,因此我们需要在此之上模拟一层用户手动确认的过程,即需要被连接的用户向发起者发送确认信息,才能进入真正的聊天阶段。因此,这里需要用到许多监听器,但是这样会导致组件产生太多业务无关的代码,我希望组件能专注界面展现,需要一个便捷的接口来实现这一点:

typescript
// 伪代码展示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内部来实现

typescript
// 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插件的实现逻辑更为复杂,不过依然保持了业务分离原则,让组件保持尽可能地纯粹。