我们将继续讨论“ 您会爱上前端”会议上讨论的主题。 本文的灵感来自Michaela Lehr的一次演讲。 只要有幻灯片,会议视频将在本周提供。 ( 视频已可用 )

Michaela Lehr使用Web API(即Web Bluetooth API)将振动器连接到浏览器。 Prosniferif在应用程序和振动器之间进行通信,她发现发送的命令非常简单,例如: vibrate: 5
。 然后,她教她从网上可以找到的视频中听到to吟声,她实现了自己的目标:)
我没有这样的玩具,设计也不适合使用,但是有一个Polar H10心率监测器,它使用蓝牙来传输数据。 实际上,我决定“破解”。
没有黑客
首先,值得了解如何将设备连接到浏览器? Google或Yandex,具体取决于您的喜好: Web Bluetooth API
,在第一个链接上,我们看到有关此主题的文章。
不幸的是,一切都变得简单得多,如果您不想将某些内容发送到不需要的设备上,也不会闻到任何东西。 在同一篇文章中,甚至还演示了连接的心率监视器。

即使是源代码,它也令我非常沮丧。 什么时间过去了?
我们连接设备
让我们用典型的标记创建index.html
:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> </head> <body> </body> </html>
由于我认证的心率监测器设备是在中国车间制造的,但符合标准,因此其连接和使用不会造成任何困难。 有这样的东西- 通用属性(GATT) 。 我并没有详细介绍,但是如果简单的话,那就是蓝牙设备遵循的一种规范。 关贸总协定描述了它们的性质和相互作用。 对于我们的项目,这就是我们需要知道的。 对我们来说,有用的链接也是服务列表 (实际上是设备)。 然后,我发现了心跳服务(org.bluetooth.service.heart_rate) ,它看起来像我们需要的。
为了将设备连接到浏览器,用户必须自觉与UI进行交互。 一般而言,安全性一般,考虑到进入体育馆时,我的心率监测器无声地连接到我想要的一切(一次让我感到惊讶)。 当然要感谢浏览器开发人员,但是为什么呢? 哦,这并不困难,也不会那么令人恶心。
让我们在<body>
主体中的页面上添加按钮和处理程序:
<button id="pair">Pair device</button> <script> window.onload = () => { const button = document.getElementById('pair') button.addEventListener('pointerup', function(event) { </script>
如您所见,到目前为止,我还没有承诺要通过标题来判断的Vue。 但是我本人并不了解所有内容,并且一直在写文章。 所以我们正在这样做:)
为了连接设备,我们必须使用navigator.bluetooth.requestDevice
。 此方法可以接受过滤器数组。 由于我们的应用程序大部分仅适用于心率监视器,因此我们将按它们进行过滤:
navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] })
在浏览器中打开html文件或使用browser-sync
:
browser-sync start --server --files ./
我戴着心率监测器,几秒钟后Chrome发现了它:

找到所需的设备后,有必要从中读取数据。 为此,请将其连接到GATT服务器:
navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] }) .then((device) => { return device.gatt.connect(); })
我们要读取的数据在服务特征中。 心率监测器只有3个特征,我们对org.bluetooth.characteristic.heart_rate_measurement感兴趣
为了考虑这一特性,我们需要获得主要服务。 老实说,我不知道为什么。 也许某些设备具有几个子服务。 然后获得推荐并注册通知。
.then(server => { return server.getPrimaryService('heart_rate'); }) .then(service => { return service.getCharacteristic('heart_rate_measurement'); }) .then(characteristic => characteristic.startNotifications()) .then(characteristic => { characteristic.addEventListener( 'characteristicvaluechanged', handleCharacteristicValueChanged ); }) .catch(error => { console.log(error); }); function handleCharacteristicValueChanged(event) { var value = event.target.value; console.log(parseValue(value)); }
parseValue
用于解析数据parseValue
函数,可以在此处找到数据规范-org.bluetooth.characteristic.heart_rate_measurement 。 我们不会在此功能上详细介绍,那里的一切都是陈旧的。
parseValue parseValue = (value) => { // Chrome 50+ DataView. value = value.buffer ? value : new DataView(value); let flags = value.getUint8(0); // let rate16Bits = flags & 0x1; let result = {}; let index = 1; // if (rate16Bits) { result.heartRate = value.getUint16(index, true); index += 2; } else { result.heartRate = value.getUint8(index); index += 1; } // RR let rrIntervalPresent = flags & 0x10; if (rrIntervalPresent) { let rrIntervals = []; for (; index + 1 < value.byteLength; index += 2) { rrIntervals.push(value.getUint16(index, true)); } result.rrIntervals = rrIntervals; } return result; }
从这里获取: heartRateSensor.js
因此,在控制台中,我们可以看到所需的数据。 除了心率,我的心率监视器还显示RR间隔。 我从来没有想过如何使用它们,这是您的作业:)
整页代码 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> </head> <body> <button id="pair">Pair device</button> <script> window.onload = () => { const button = document.getElementById('pair') parseValue = (value) => { // Chrome 50+ DataView. value = value.buffer ? value : new DataView(value); let flags = value.getUint8(0); // let rate16Bits = flags & 0x1; let result = {}; let index = 1; // if (rate16Bits) { result.heartRate = value.getUint16(index, true); index += 2; } else { result.heartRate = value.getUint8(index); index += 1; } // RR let rrIntervalPresent = flags & 0x10; if (rrIntervalPresent) { let rrIntervals = []; for (; index + 1 < value.byteLength; index += 2) { rrIntervals.push(value.getUint16(index, true)); } result.rrIntervals = rrIntervals; } return result; } button.addEventListener('pointerup', function(event) { navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] }) .then((device) => { return device.gatt.connect(); }) .then(server => { return server.getPrimaryService('heart_rate'); }) .then(service => { return service.getCharacteristic('heart_rate_measurement'); }) .then(characteristic => characteristic.startNotifications()) .then(characteristic => { characteristic.addEventListener('characteristicvaluechanged', handleCharacteristicValueChanged); }) .catch(error => { console.log(error); }); function handleCharacteristicValueChanged(event) { var value = event.target.value; console.log(parseValue(value)); // See https://github.com/WebBluetoothCG/demos/blob/gh-pages/heart-rate-sensor/heartRateSensor.js } }); } </script> </body> </html>
设计方案
下一步是考虑应用程序的设计。 哦,当然,乍一看很简单的文章变成了一项不平凡的任务。 我想使用各种各样的方法,在我的脑海里有很多文章需要在CSS Grids上阅读 弹性盒 和 使用JS的CSS动画处理(脉冲动画不是静态的)。
草绘
我喜欢漂亮的设计,但是设计师和我一样。
我没有Photoshop,我们会以某种方式摆脱困境。
首先,让我们使用Vue-cli创建一个新的Vue.js项目
vue create heart-rate
我选择了手动设置,第一个设置页面如下所示:

接下来,自己选择,但我有一个Airbnb,Jest和Sass配置。
我看了一半的Wes Bos CSS Grids教程,我建议它们是免费的。
是时候进行初始布局了。 我们不会全部使用任何CSS框架。 当然,我们也不考虑支持。
猫头鹰绘图魔术
因此,首先让我们定义我们的layout
。 实际上,该应用程序将包括两个部分。 我们将其称为first
和second
。 在第一部分中,我们将在第二张图中以数字表示(每分钟的心跳数)。
我决定从这里偷配色方案。

如果您尚未启动Vue应用程序,请启动它:
npm run serve
该工具本身会打开浏览器(或不打开),有一个热重载和一个用于外部测试的链接。 我立即在我旁边放了一部手机,因为我们正在考虑移动优先设计。 不幸的是,我在模板中添加了PWA,并且在手机上,当关闭浏览器时,缓存被清除了,但是它确实发生了,并且可以更新以保存。 总的来说,这是我无法理解的一个不可理解的时刻。
首先,添加带有我们解析值功能的utils.js,在项目中的eslint下对其进行重构。
utils.js export const parseHeartRateValues = (data) => { // Chrome 50+ DataView. const value = data.buffer ? data : new DataView(data); const flags = value.getUint8(0); // const rate16Bits = flags & 0x1; const result = {}; let index = 1; // if (rate16Bits) { result.heartRate = value.getUint16(index, true); index += 2; } else { result.heartRate = value.getUint8(index); index += 1; } // RR const rrIntervalPresent = flags & 0x10; if (rrIntervalPresent) { const rrIntervals = []; for (; index + 1 < value.byteLength; index += 2) { rrIntervals.push(value.getUint16(index, true)); } result.rrIntervals = rrIntervals; } return result; }; export default { parseHeartRateValues, };
然后,我们从HelloWolrd.vue
删除所有不必要的内容,将其重命名为HeartRate.vue
,此组件将负责显示每分钟的节拍。
<template> <div> <span>{{value}}</span> </div> </template> <script> export default { name: 'HeartRate', props: { </script> // SCSS <style scoped lang="scss"> @import '../styles/mixins'; div { @include heart-rate-gradient; font-size: var(--heart-font-size); // } </style>
为图表创建HeartRateChart.vue
:
// HeartRateChart.vue <template> <div> chart </div> </template> <script> export default { name: 'HeartRateChart', props: { values: { type: Array, default: () => [], . . }, }, }; </script>
更新App.vue
:
应用程序 <template> <div class=app> <div class=heart-rate-wrapper> <HeartRate v-if=heartRate :value=heartRate /> <i v-else class="fas fa-heartbeat"></i> <div> <button v-if=!heartRate class=blue>Click to start</button> </div> </div> <div class=heart-rate-chart-wrapper> <HeartRateChart :values=heartRateData /> </div> </div> </template> <script> import HeartRate from './components/HeartRate.vue'; import HeartRateChart from './components/HeartRateChart.vue'; import { parseHeartRateValues } from './utils'; export default { name: 'app', components: { HeartRate, HeartRateChart, }, data: () => ({ heartRate: 0, heartRateData: [], }), methods: { handleCharacteristicValueChanged(e) { this.heartRate = parseHeartRateValues(e.target.value).heartRate; }, }, }; </script> <style lang="scss"> @import './styles/mixins'; html, body { margin: 0px; } :root { // COLORS
实际上是mixins.scss
,而只有一个mixin负责图标和文本每分钟显示节拍的颜色。
@mixin heart-rate-gradient { background: -webkit-linear-gradient(
原来是这样的:
有趣的是-使用了本地CSS变量,但使用了SCSS的mixins
。
整个页面是CSS Grid
:
display: grid; grid-gap: 1rem; height: 100vh; grid-template-rows: 1fr 1fr; grid-template-areas: "first" "second";
与flexbox
一样,父容器必须具有某种display
。 在这种情况下,它是grid
。
grid-gap
columns
和rows
之间的一种间隔。
height: 100vh
整个viewport
的高度, fr
必须在整个高度上占据空间(我们应用程序的2个部分)。
grid-template-rows
定义我们的模板, fr
是一个糖单位,它考虑了grid-gap
和其他影响大小的属性。
grid-template-areas
-在我们的示例中,仅是语义。
Chrome尚未提供用于CSS Grids检查的常规工具:

同时在莫夫:

现在,我们需要添加一个按钮单击处理程序,与之前的操作类似。
添加处理程序:
// App.vue <button v-if=!heartRate @click=onClick class=blue>Click to start</button>
// Methods: {} onClick() { navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }], }) .then(device => device.gatt.connect()) .then(server => server.getPrimaryService('heart_rate')) .then(service => service.getCharacteristic('heart_rate_measurement')) .then(characteristic => characteristic.startNotifications()) .then(characteristic => characteristic.addEventListener('characteristicvaluechanged', this.handleCharacteristicValueChanged.bind(this))) .catch(error => console.log(error)); },
别忘了,这仅适用于chrome,仅适用于android上的chrome :)
接下来,添加一个图表,我们将使用Chart.js
和Chart.js
的包装器
npm install vue-chartjs chart.js
极地有5个训练区 。 因此,我们需要以某种方式区分这些区域和/或存储它们。 我们已经有了heartRateData
。 为了美观,我们将采用以下形式的默认值:
heartRateData: [[], [], [], [], [], []],
我们将根据5个区域分散值:
pushData(index, value) { this.heartRateData[index].push({ x: Date.now(), y: value }); this.heartRateData = [...this.heartRateData]; }, handleCharacteristicValueChanged(e) { const value = parseHeartRateValues(e.target.value).heartRate; this.heartRate = value; switch (value) { case value > 104 && value < 114: this.pushData(1, value); break; case value > 114 && value < 133: this.pushData(2, value); break; case value > 133 && value < 152: this.pushData(3, value); break; case value > 152 && value < 172: this.pushData(4, value); break; case value > 172: this.pushData(5, value); break; default: this.pushData(0, value); } },
Vue.js ChartJS的用法如下:
// Example.js import { Bar } from 'vue-chartjs' export default { extends: Bar, mounted () { this.renderChart({ labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], datasets: [ { label: 'GitHub Commits', backgroundColor: '#f87979', data: [40, 20, 12, 39, 10, 40, 39, 80, 40, 20, 12, 11] } ] }) } }
您导入必要的图表样式,展开您的组件,然后使用this.renderChart
显示图表。
在我们的例子中,有必要在新数据到达时更新时间表,因此我们将显示隐藏在单独的updateChart
方法中,并在mounted
调用它,并使用手表监视values
:
HeartRateChart.vue <script> import { Scatter } from 'vue-chartjs'; export default { extends: Scatter, name: 'HeartRateChart', props: { values: { type: Array, default: () => [[], [], [], [], [], []], }, }, watch: { values() { this.updateChart(); }, }, mounted() { this.updateChart(); }, methods: { updateChart() { this.renderChart({ datasets: [ { label: 'Chilling', data: this.values[0], backgroundColor: '#4f775c', borderColor: '#4f775c', showLine: true, fill: false, }, { label: 'Very light', data: this.values[1], backgroundColor: '#465f9b', borderColor: '#465f9b', showLine: true, fill: false, }, { label: 'Light', data: this.values[2], backgroundColor: '#4e4491', borderColor: '#4e4491', showLine: true, fill: false, }, { label: 'Moderate', data: this.values[3], backgroundColor: '#6f2499', borderColor: '#6f2499', showLine: true, fill: false, }, { label: 'Hard', data: this.values[4], backgroundColor: '#823e62', borderColor: '#823e62', showLine: true, fill: false, }, { label: 'Maximum', data: this.values[5], backgroundColor: '#8a426f', borderColor: '#8a426f', showLine: true, fill: false, }, ], }, { animation: false, responsive: true, maintainAspectRatio: false, elements: { point: { radius: 0, }, }, scales: { xAxes: [{ display: false, }], yAxes: [{ ticks: { beginAtZero: true, fontColor: '#394365', }, gridLines: { color: '#2a334e', }, }], }, }); }, }, }; </script>
我们的应用程序已准备就绪。 但是,为了不跳到屏幕前面并进入第五级,让我们添加一个按钮,该按钮将为我们生成所有五级的随机数据:
// App.vue <div> <button v-if=!heartRate @click=onClickTest class=blue>Test dataset</button> </div> ... import data from './__mock__/data'; ... onClickTest() { this.heartRateData = [ data(300, 60, 100), data(300, 104, 114), data(300, 133, 152), data(300, 152, 172), data(300, 172, 190), ]; this.heartRate = 73; },
// __mock__/date.js const getRandomIntInclusive = (min, max) => Math.floor(Math.random() * ((Math.floor(max) - Math.ceil(min)) + 1)) + Math.ceil(min); export default (count, from, to) => { const array = []; for (let i = 0; i < count; i += 1) { array.push({ y: getRandomIntInclusive(from, to), x: i }); } return array; };
结果:

结论
使用Web蓝牙API非常简单。 有时需要使用按位运算符读取数据,但这是一个特定的领域。 当然,缺点是支持。 目前只有Chrome,而在手机上只有chrome。

Github资源
演示版