使用Service Worker的 Web Push 功能

现代的 Android 应用和 iOS 应用都有相应的推送系统,可以给用户推送消息。即使用户没有打开这个应用,也同样可以收到消息。当然,网页既然要向手机应用靠拢的话,那推送功能也是必不可少。 今天我们就来介绍一下,网页如何实现类似的推送功能。

# 基于 Service Worker 的推送功能

Service Worker 提供了两种推送服务,一种是基于 Firebase Cloud Messaging 的推送功能,一种是基于 VAPID 的推送功能。第一种已经过时了,所以本文只介绍第二种。

# Create a public/private key pair

首先,我们需要创建公钥和私钥,类似与 HTTPS 的 RSA 加密。客户端使用公钥订阅推送,服务端使用私钥来发送通知。创建既可以在浏览器进行,也可以使用 Node 创建。当然,推荐在后端生成更安全。

前端生成代码参考链接:util.ts (opens new window)

后端使用web-push (opens new window)可以更方便的生成公钥和私钥。代码如下:

function generateVAPIDKeys() {
  const vapidKeys = webpush.generateVAPIDKeys();

  return {
    publicKey: vapidKeys.publicKey,
    privateKey: vapidKeys.privateKey
  };
}
1
2
3
4
5
6
7
8

# Subscribing with the public key

接下来我们需要订阅推送:

navigator.serviceWorker.ready
  .then(registration => {
    return registration.pushManager.getSubscription().then(subscription => {
      if (subscription === null) {
        return registration.pushManager.subscribe({
          userVisibleOnly: true,
          applicationServerKey: this.vapidPublicKey
        });
      } else {
        return subscription;
      }
    });
  })
  .then(subscription => {
    this.subJSON = subscription.toJSON();
  });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

我们使用公钥进行订阅,并且把订阅之后的内容打印出来,方便服务器使用。实际工程中需要发送给服务器进行保存。使用 Chrome 浏览器订阅的信息是这样的:

{
  "endpoint": "https://fcm.googleapis.com/fcm/send/d3cMmjx0Eg8:APA91bF1i7wgLJRw-VgOh3Evn6RG1xqdOR6Y0CeTUm1xiD36BCHXaDoceVfilDYiifWdI_rWdU8IdJjqSxaCVscRp5zl9lon8u4mf9mha0fmSVKJzUOx5r5Jba2yiNmCFRxxKcTJm51S",
  "expirationTime": null,
  "keys": {
    "p256dh": "BDtGmJB0Bkyum0WJw8NiiCn4U9ckX8UjhzXPUad2HM0yID0ced8zUHKr-Yhf6p2Z7IS0G07dGG7Tnl5jlwQVog4",
    "auth": "25JGSYqTKvj_nAeodHBHSQ"
  }
}
1
2
3
4
5
6
7
8

我们可以看到推送的网址是 Chrome 提供的服务,Firefox 浏览器下订阅的信息是这样的:

{
  "endpoint": "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABghCrItYXGJw0eCyp0Ae1lTxMXkb6Fxhg8tRck2cMoY4bZjvkV2j5t95FfrPftdieUgeaNthjmb0_XyoIVqWIy7cpy9lMjczHb5TYpC7sKnOw4IekwrtQbmBo6Vn54TZaUSrBIb40PEy2KXF5QlyOj2QxlTz6d6NPB6mMvJxuYNSg-5xs",
  "keys": {
    "auth": "MCeQnXlaz4A-CBuALNPbcQ",
    "p256dh": "BL4am0lzx005spT_UBbMagfWb93Cfgh8XtkCtP7y697dODFnO0wCVVI783BsiHePRTl-mrpoHolJ0gKTYR1T4SQ"
  }
}
1
2
3
4
5
6
7

endpoint 我们可以看出推送服务本身是浏览器提供的,如果无法访问这个网址,自然就无法使用推送服务。

# Server push

接下来,我们让服务端发送一个消息试试:

const webpush = require('web-push');

const vapidPublicKey = 'public key';

const vapidPrivateKey = 'private key';

// PushSubscriptionJSON Info
const pushSubscription = {
  endpoint: '',
  expirationTime: null,
  keys: {
    p256dh: '',
    auth: ''
  }
};

const payload = 'hello world!';

const options = {
  vapidDetails: {
    subject: 'mailto:example@web-push-node.org',
    publicKey: vapidPublicKey,
    privateKey: vapidPrivateKey
  },
  TTL: 60
};
webpush
  .sendNotification(pushSubscription, payload, options)
  .then(function(result) {
    console.log('success!');
    console.log(result);
  })
  .catch(function(err) {
    console.log('fail!');
    console.error(err);
  });
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

在上述代码中填入相应的认证信息,就可以使用了。浏览器的推送功能并不需要把相应的网站打开,只要浏览器是打开的就可以收到推送。

# 总结

浏览器的推送功能非常方便,但是首先需要用户授权通知权限才能使用,其次是浏览器提供的推送服务,可能在中国无法正常使用。所以现在这个功能还没有大规模的应用。

# 参考

Introduction to Push Notifications (opens new window)

Adding Push Notifications to a Web App (opens new window)

Service Worker Push Notifications Demo (opens new window)

Vue代码风格

良好的代码风格更容易维护代码,虽然没有绝对的标准,但保持自己一贯的代码风格可以提高代码质量。接下来就来介绍我开发 Vue 代码的大致风格吧。

# DatePicker.vue

<template>
  <!-- 组件名字应该大写字母开头,组件最外层节点类名和组件文件名保持一致,PascalCase改成下划线 -->
  <div class="date-picker" v-click-outside="clickClose">
    <!-- 属性定义应该先定义原生属性,再定义Vue属性 -->
    <div
      class="date-picker-rel"
      ref="reference"
      @click="handleClick"
      :class="{ 'o-disabled': disabled }"
    >
      <slot name="picker">
        <VueInput
          v-model="currentDate"
          class="date-picker-input"
          :disabled="disabled"
          :placeholder="placeholder"
          :clearable="clearable"
          @on-clear="clearDate"
          @on-input="input"
          @on-blur="updateDate"
        >
          <template v-slot:suffix>
            <svg-icon
              name="date"
              class="date-picker-icon"
              :class="{ 'o-disabled': disabled, 'o-show': visible }"
            />
          </template>
        </VueInput>
      </slot>
    </div>
    <transition name="fade">
      <div
        class="date-picker-popper"
        :style="styles"
        ref="popper"
        v-show="visible && !disabled"
      >
        <DateTable
          :selectedDate="formatedDate"
          :isRange="isRange"
          :visible="visible"
          @select="selectDate"
        />
      </div>
    </transition>
  </div>
</template>

<script lang="ts">
/**
 * 模块定义:
 * 1. 先第三方后自定义
 * 2. 除非是组件内部私有模块,否则全部使用 @ 绝对路径引用
 * 3. 按需引用
 */
import { Component, Mixins, Prop, Watch } from 'vue-property-decorator';
// @ts-ignore
import { directive as clickOutside } from 'v-click-outside-x';
import Input from '@/components/input';
import PopperMixin from '@/mixins/popper';
import dateUtils from '@/utils/date';
import DateTable from './DateTable.vue';
import { namespace } from 'vuex-class';

const Info = namespace('information');

@Component({
  directives: {
    clickOutside
  },
  components: {
    DateTable,
    VueInput: Input
  },
  provide() {
    return {
      picker: this
    };
  }
})
// 类名和组件名保持一致,方便调试
export default class DatePicker extends Mixins(PopperMixin) {
  /**
   * 属性定义区域
   */
  @Prop() readonly value!: Date | null | Array<Date | null>;
  @Prop({ default: false }) readonly disabled!: boolean;
  @Prop({ default: false, type: Boolean }) readonly clearable!: boolean;
  @Prop({ default: '' }) readonly placeholder!: string;
  /**
   * State映射区域,定义顺序是: State Getter Action Mutation
   */
  @Info.State notifications!: Array<IAnnouncement>;

  /**
   * data定义区域
   */
  currentDate = '';
  updateByInput = false;

  /**
   * computed区域, 如果同时有 get 和 set 记得配对写一起
   */
  get formatedDate(): Array<Date | null> {
    if (Array.isArray(this.value)) {
      return this.value;
    } else {
      return [this.value];
    }
  }

  get isRange() {
    return Array.isArray(this.value);
  }

  /**
   * watch区域, 函数名 on + 属性名 + Changed
   */
  @Watch('value', { immediate: true })
  onValueChanged() {
    this.currentDate = dateUtils.formatDate(this.value);
  }

  /**
   * 生命周期函数
   * beforeCreate -> created -> beforeMount -> mounted -> beforeUpdate ->
   * updated -> activated -> deactivated -> beforeDestroy -> destroyed -> errorCaptured
   */
  created() {
    // noop
  }

  /**
   * methods 区域
   */
  clickClose() {
    if (this.visible && !this.disabled) {
      this.closePopper();
    }
  }

  handleClick() {
    if (!this.disabled) {
      if (this.visible) {
        this.closePopper();
      } else {
        this.showPopper();
      }
    }
  }

  clearDate() {
    if (this.isRange) {
      this.$emit('input', [null, null]);
    } else {
      this.$emit('input', null);
    }
  }

  input() {
    this.updateByInput = true;
  }

  updateDate() {
    if (this.updateByInput) {
      if (this.isRange) {
        const dates = this.currentDate.split(' - ');
        if (dates.length > 1) {
          const [start, end] = dates;
          const startDate = dateUtils.parseDate(start);
          const endDate = dateUtils.parseDate(end);
          if (
            !Number.isNaN(startDate.getTime()) &&
            !Number.isNaN(endDate.getTime())
          ) {
            this.$emit(
              'input',
              [startDate, endDate].sort((s, e) => {
                return s.getTime() - e.getTime();
              })
            );
          } else {
            this.$emit('input', this.value);
            this.currentDate = dateUtils.formatDate(this.value);
          }
        }
      } else {
        const startDate = new Date(this.currentDate);
        if (!Number.isNaN(startDate.getTime())) {
          this.$emit('input', startDate);
        } else {
          this.$emit('input', this.value);
          this.currentDate = dateUtils.formatDate(this.value);
        }
      }
      this.updateByInput = false;
    }
  }

  selectDate(date: Date | Array<Date>) {
    this.currentDate = dateUtils.formatDate(date);
    this.$emit('input', date);
    this.clickClose();
  }
}
</script>
<style lang="scss">
.date-picker {
  display: block;
  /**
   * CSS书写顺序
   * 以 Formatting Model (布局方式,位置) -> Box Model (尺寸) -> Typographic (文本相关) -> Visual (视觉效果)
   * 的顺序书写,可以提高代码的可读性。
   * Formatting Model 相关属性包括: position, top, right, bottom, left, float, display, overflow 等
   * Box Model 相关属性包括: border, margin, padding, width, height 等
   * Typographic 相关属性包括: font, line-height, text-align, word-wrap 等
   * Visual 相关属性包括: background, color, transition, list-style 等
   * 如果有 content 属性, 应该放在最前面。
   */

  &-popper {
    width: 272px;
  }
}
</style>
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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226

# 总结

良好的代码风格可以提高代码的可读性,建议大家都保持一致的风格,方便团队合作。

# 参考

Organizing your CSS (opens new window)

数据库查询的N+1问题

在工作中有时也会涉及到后端开发,后端开发的性能优化多是查询优化。类似前端 DOM 操作很消耗性能,后端开发数据库查询次数也会相当消耗性能。我们在开发中应该尽量减少 SQL 查询次数,避免 N+1 问题来提升性能。接下来就来介绍数据库查询的 N+1 问题。

# SQL N+1

举一个简单的例子,数据库中有一张 cars 表存储汽车的相关信息,还有一张 wheels 表存储汽车的轮胎信息。

查询汽车的语句是:

SELECT * FROM cars;
1

查询每个汽车的轮子的语句是:

SELECT * FROM wheels WHERE car_id = ${car_id};
1

这里的 N 就是汽车的数量,如果需要知道每个汽车的轮胎信息那就正好需要查询 N+1 次。

# 解决 N+1

如果汽车和轮胎是一对一的关系可以通过使用 join 语句来减少数据库查询次数。把查询降为 1 次。修正之后的查询语句是:

SELECT cars.*, wheels.* FROM cars INNER JOIN wheels ON wheels.car_id = cars.id
1

如果汽车和轮胎是一对多的关系可以通过使用:

SELECT * FROM cars;
SELECT * FROM wheels WHERE wheels.car_id IN (1,2,3)
1
2

一次性查询所有需要的轮胎把查询降为两次。

# 现有框架解决方案

# Django

Django 可以使用select-related (opens new window)prefetch-related (opens new window)来解决这个问题。

select-related 主要用于一对一的关系,而 prefetch-related 主要用于多对多或者多对一的关系。

# Ruby on Rails

使用 includes (opens new window) 来解决 N+1 问题。

# 总结

后端开发需要时刻注意 N+1 问题, 在开发模式下,日志会输出 SQL 查询语句,需要多加注意。也可以使用一些工具来检测这个问题。

# 参考

What is the “N+1 selects problem” in ORM (opens new window)

Active Record Query Interface (opens new window)