ApliNi/blog

为网站添加订阅推送!

ApliNi opened this issue · 0 comments

参考资料

知乎 :: 浏览器通知推送服务选型
Mozilla :: Push API
Mozilla :: Service Worker API
Mozilla :: IndexedDB
Mozilla :: ServiceWorkerRegistration.showNotification()
web.dev :: The Web Push Protocol

网络推送 Web Push

原理图1

网络推送也称订阅推送/Web Push, 与服务器推送和APP消息推送类似, 只要用户订阅了一个站点的 Web Push 服务, 即使用户关闭浏览器, 依然可以收到来自站点推送的消息.

相对于邮件推送和APP消息推送, 这种方式更加简单且成本低. 邮箱推送通常需要用户主动填写邮箱地址, 如果不是重要的消息, 大部分用户是不愿意提供的. 但是网络推送依赖用户浏览器中的本地存储和 Service Worker, 非常容易被清理而失效, 但用于推送文章这些不重要的消息则非常合适.

尝试实现

第一次实现最后失败了, 因为当时参考的资料不够多, 忽略了Push API, 而是使用轮询查询数据(因为没有足够的资源, 没有使用 WebSocket). 没有实现预期的效果, 这里就当成笔记本了, 万一哪天要看看还能找到().
完整实现部分请看下一个二级标题.

展开收起

Web Push 需要在网站的 Service Worker (它与 web worker 类似, 运行在主线程外) 中进行. 所以先创建一个 Service Worker.

// sw.js
console.log('sw - 开始运行');
// main.js
// 注册 Service Worker
if('serviceWorker' in window.navigator){
	let $sw = navigator.serviceWorker.register('sw.js');

		$sw.then(function(reg){
			console.log('sw', reg);
			// 注册完成
		});

		$sw.catch(function(err){
			console.log('sw', err);
		});
	console.log('成功注册 Service Worker');
}else{
	console.log('浏览器不支持 Service Worker');
}

接下来试试主线程与 Service Worker 通讯.

// sw.js
// 接收来自主线程的消息
this.addEventListener('message', function(event){
	let $tp = event.data;
	console.log('sw - 收到来自主程序的指令', $tp);
});
// main.js
// 连接sw
function connectSW(back = null){
	navigator.serviceWorker.getRegistrations().then((e) => {
		e.map((w) => {
			if(w?.active !== null){
				if(back !== null) back(w);
			}else{
				// 连接失败, 等待5毫秒后重新连接
				setTimeout(function(){connectSW(back);}, 5);
			}
		});
	});
);

// 向sw发送消息
connectSW((w) => {
	w.active.postMessage('Hello');
});

尝试通过 Service Worker 发送通知.

// sw.js
// 消息通知点击事件
self.addEventListener('notificationclick', function(event){
	// 关闭当前的弹窗
	event.notification.close();
	// 在新窗口打开页面
	event.waitUntil(
		clients.openWindow('https://ipacel.cc/ApliNi/?')
	);
});
// 创建消息通知
function _Notification($title, $body){
	// 触发一条通知
	self.registration.showNotification($title, {
		body: $body,
		icon: 'plugins/icon_512_big_b.png',
	});
};


// 接收来自主线程的消息
this.addEventListener('message', function(event){
	let $tp = event.data;
	console.log('sw - 收到来自主程序的指令', $tp);

	if($tp.mode === 'testNotification'){
		_Notification('这是一条测试通知', '在查询到新数据时, 您将会收到这样的通知, 点击后跳转到网页');
	}
});
// main.js

connectSW((w) => {
	w.active.postMessage({mode: "testNotification"});
});

尝试调用本地存储, Service Worker 无法调用同步运行的 localStorage, 所以使用 indexedDB.

indexedDB 封装
// sw.js

// 初始化 indexedDB
// 封装的代码来源于网络(找不到了), 我对其稍作修改
(function () {
	dbObj = {};
	/**
	 * 打开数据库
	 */
	dbObj.init = function (param, back) {
		this.dbName = param.dbName;
		this.dbVersion = param.dbVersion;
		this.dbStoreName = param.dbStoreName;
		// if (!window.indexedDB) {
		// 	alert('浏览器不支持indexedDB')
		// }
		var request = indexedDB.open(this.dbName, this.dbVersion);
		// 打开数据库失败
		request.onerror = function (event) {
			console.log('数据库连接失败: ', event)
		}
		// 打开数据库成功
		request.onsuccess = function (event) {
			// 获取数据对象
			dbObj.db = event.target.result;
			// console.log('连接数据库成功');
			back(event);
		}

		// if (this.db.objectStoreNames.contains(dbObj.dbStoreName)) {
		//     console.log('数据仓库已存在');
		// }
		// 创建数据库
		request.onupgradeneeded = function (event) {
			dbObj.db = event.target.result;
			dbObj.db.createObjectStore(dbObj.dbStoreName, {
			   //  keyPath: "id", //设置主键 设置了内联主键就不可以使用put的第二个参数(这里是个坑)
				autoIncrement: true // 自增
			});
		}
	}

	dbObj.getStore = function (dbStoreName, mode) {
		// 获取事务对象
		var ts = dbObj.db.transaction(dbStoreName, mode);
		// 通过事务对象去获取对象仓库
		return ts.objectStore(dbStoreName);
	}
	/**
	 * 添加和修改数据
	 */
	dbObj.put = function (msg, key, back) {
		var store = this.getStore(dbObj.dbStoreName, 'readwrite')
		var request = store.put(msg, key);
		request.onsuccess = function () {
			//  if (key)
			//  	console.log('sw - 同步数据库成功');
			// else
			// 	console.log('添加成功');
		};
		request.onerror = function (event) {
			//console.log(event);
			back(event);
		}
	}
	/**
	 * 删除数据
	 */
	dbObj.delete = function (id) {
		var store = this.getStore(dbObj.dbStoreName, 'readwrite')
		var request = store.delete(id);
		request.onsuccess = function () {
			//alert('删除成功');
		}

	}
	/**
	 * 查询数据
	 */
	dbObj.select = function (key, back) {
		var store = this.getStore(dbObj.dbStoreName, 'readwrite')
		if (key)
			var request = store.get(key);
		else
			var request = store.getAll();
		request.onsuccess = function () {
			//console.log(request.result);
			back(request.result);
		}
	}
	/**
	 * 删除表
	 */
	dbObj.clear = function () {
		var store = this.getStore(dbObj.dbStoreName, 'readwrite')
		var request = store.clear();
		request.onsuccess = function () {
			//alert('清除成功');
		}
	};
	//window.dbObj = dbObj;
})();


// 数据库二次封装, 仅用于读写主配置
function db($mode, back = null){
	// 向数据库同步数据
	if($mode === 'SET'){
		dbObj.put({name: 'Config', age: $c}, 1,
			(e) => {
				if(back !== null) back(e);
			}
		);
	}else
	// 从数据库查询数据
	if($mode === 'GET'){
		dbObj.select(1,
			($data) => {
				if(back !== null) back($data);
			}
		);
	}else
	// 初始化数据库
	if($mode === 'INIT'){
		dbObj.init({
				dbName: 'sw_subscribe',
				dbVersion: '1.0',
				dbStoreName: 'Config',
			}, (e) => {
				if(back !== null) back(e);
			}
		);
	}
};

通过轮询查询数据(这里不应该用轮询, 原因上方有提到).

// sw.js

// 测试使用
let $c = {
	"UUID": '000000',
	"time": 0,
};

main();
function main(){
	// 循环
	setTimeout(function(){
		main();
	}, 600000 * 10); // 10分钟 // 600000 * 10

	// 任务队列, 循环
	console.log('sw - 循环');
	tpData();
};

// 请求新的文章
function tpData(){

	// 创建配置
	let $nowTime = Date.parse(new Date()) / 1000;
	let $io = JSON.stringify({
		"uuid": $c['UUID'],
		"load": {
			"mode": "SW",
			"data": $c['time'],
		},
		"time": Date.parse(new Date()) / 1000,
	});

	let $data = new FormData()
		$data.append('io', $io)
	;

	fetch('cake/FastLoad.php?from=blog_sw', {
		method: 'POST', // or 'PUT'
		headers: {
			//"Content-Type": "multipart/form-data"
		},
		body: $data,
	})
	.then(response => response.text())
	.catch((error) => {
		//console.error('sw-Err: ', error);
	})
	.then(data => {
		console.log('sw - 来自后端的数据: ', data);
		if(data == '') return;
		data = JSON.parse(data);
		// 显示通知
		_Notification(data.iM.Title, data.iM.Info);
		// 更新客户端时间
		$c.time = $nowTime;
		// 更新数据库
		db('SET');
	});
};

正片 使用第三方服务 OneSignal 实现订阅推送

OneSignal/OneSignal-Website-SDK#367
OneSignal 可能会在国内的 Chrome 浏览器上失效, 除非用户使用代理.

OneSignal 是一个实现网络推送的平台, 有免费的版本, 我从其他博客的订阅系统里找到它.
假设您已经完成了 OneSignal 的账号注册.

使用

  1. 转到 //app.onesignal.com/apps/ 创建一个项目.
  2. 打开项目, 点击 Settings(设置).
  3. 选择 Platforms(平台), 在 App Settings(应用设置) 中点击 Web(网络) 方框的 Activate(启用) 按钮.
    1. 选择 Typical Site(典型网站)
    2. 填入网站名称/ 网址和用于显示通知的网站图标(要求256x256分辨率)
    3. 添加 Subscription Bell(订阅铃)
    4. 随便
    5. 关闭 PERSISTENCE(持久显示), 防止消息一直固定在桌面上
  4. 将 OneSignal 的代码添加到网站.

测试消息推送

  1. 打开网站, 点击订阅.
  2. 回到主页面, 点击 Message. 点击 New Message. 选择 New Push.
  3. 填写信息, 发送, 观察桌面是否出现消息通知.

自动化添加新消息

参考文档: Create notification.

按需添加组件即可.

优化前端加载速度

大概是我的网络不好, 但它加载速度很慢且会造成堵塞, 所以需要让它在网页加载完成后运行.
下载 https://cdn.onesignal.com/sdks/OneSignalSDK.js 中的代码.

在网站的js中添加:

window.onload = function(){
	// 下载下来的代码

	// "使用" 部分第4步复制的代码
	window.OneSignal = window.OneSignal || [];
	OneSignal.push(function() {
		OneSignal.init({
		appId: "xxxxxxxxxxxxxxx",
		});
	});
};