Vuepress插件我所做的优化
今天终于做了一个较大的优化,当然也顺带修复了上个版本的各种 bug,也顺便引入了一些新的 bug,留着下次修复吧....... 还是先记录下自己做的优化吧!
关于 Vuepress 插件,之前就发现了它的一个问题:就是每新开一个页面,播放器会重头开始播放,根本就记不住之前播放到了哪里!!!这怎么能行呢,要解决这个问题的话,关键点在于播放状态的共享。查阅了以下资料,新开的页面协议、主机和端口号都没有变化,只是路径发生变化,还是处于同源下的,最简单的方法就是利用 localStorage 在同源页面上进行状态共享。
# 状态共享思路
既然要共享的话,如果新开了一个页面,信息状态同步的方式是怎么的呢?是播放新页面的音乐呢,还是继续播放旧页面的音乐呢?
我尝试去参考了两大巨头的思路:
- 某 Q:不管从哪个页面点击播放音乐会单开一个播放列表页面,或者跳转到播放列表页面;通过这个页面来管理所有的播放音乐。(这不适用于我这种情况啊)
- 某云:每个页面底部在播放状态下,都会有一个播放器,它们只会共享当前播放歌曲,不共享播放当前进度;同时不同页面播放和暂停状态是互斥的,只能有一个处于播放状态。(而且新开的页面不会去主动请求歌曲信息,只有在播放的时候请求,也有可能是读取缓存的,是网络优化的一个小细节啊)
再结合这个插件的特性,音乐播放我设计时决定还是放在原来页面播放,但要保证新开页面能够同步播放歌单和播放进度,同时要保证多个页面只有一个处于播放状态。
提示
上述问题其实总结起来是:前端如何跨页面通信
,除了利用 localStorage 方法外,它是在同源页面上进行通信,还有其他方式,我总结在这篇文章里面了:扩展-跨页面通信
# 跨页面通信
下面则是记录利用 localStorage 来进行跨页面通信,从而共享播放状态:
# 1. 修复上个版本刷新页面重新播放,以及新开一个 tab 栏也会重新播放的 bug
排查发现在created(){}
生命周期里,写了这么一段代码:
created() {
// 此时可以访问this,做数据初始化;或者异步数据请求
this.init();
// this.playPlaylistByID(this.playListID);
},
2
3
4
5
我直接把播放歌单的函数写在了数据初始化这个生命周期里;这样每次刷新和新开页面当然会重新开始播放啊。果断注释掉这段代码,那这样的话刚开始歌单播放放在哪里呢,思考一番决定放在init()
,通过条件判断,如果已经加载了对应歌单的话,从缓存中读取,如果首次运行的话,则加载传入的歌单 ID。
怎么判断是否首次运行呢?当然是判断缓存里面是否存在歌曲,我选择的是 player._list 这个属性,它代表的是当前播放歌单列表,如果存在的话,说明已经不是首次加载了,直接读取缓存内容,否则则初始化 player 信息,存储到缓存中。
init() {
// 读取缓存内容
this._loadSelfFromLocalStorage();
Howler.autoUnlock = false;
Howler.usingWebAudio = true;
Howler.volume(this.volume);
// 如果没有读取到,说明是首次使用插件,则加载配置歌单ID
if (!this.player._list) {
this.playPlaylistByID(this.playListID);
}
// 不是首次加载的话,读取缓存
if (this.player._enabled) {
// 恢复当前播放歌曲
this._replaceCurrentTrack(this.player._currentTrack.id, false).then(
() => {
let time = 0;
// if (localStorage.getItem("playerCurrentTrackTime")) {
// time = localStorage.getItem("playerCurrentTrackTime");
// }
if (this.player._progress) {
time = this.player._progress;
}
this.player._howler?.seek(time);
this.player._playing = false;
}
);
// ......
}
// ......
},
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
改进:在测试时候发现,首次加载的话,player 还没有初始化,从而选择缓存中是否存在 player 项判断,这样更加合理。
// 首次使用插件时候加载配置歌单ID
if (typeof process == "undefined") {
if (!localStorage.getItem("player")) {
this.playPlaylistByID(this.playListID);
}
}
2
3
4
5
6
# 2. Vuepress 插件(node 端编译)怎么控制 localStorage 读写
因为 Vuepress 插件是在 node 端编译的,而 node 端根本没有 localStorage 啊!这里采用了 npm 依赖包node-localstorage
,它可以在 node 端操作 localStroage,并且操作 localStorage 方法和浏览器端一样。
created() {
// ......
if (typeof localStorage === "undefined" || localStorage === null) {
var LocalStorage = require("node-localstorage").LocalStorage;
localStorage = new LocalStorage("./scratch");
}
// localStorage.setItem("myFirstKey", "myFirstValue1");
},
2
3
4
5
6
7
8
9
通过上述三行代码就可以创建一个全局的 localStorage 对象,并且可以进行缓存设置、读取和删除,但是只能在 node 端使用,生产环境还是用不了。开发环境下它并没有进行 html 渲染;而在正式环境中npm run build
有以下步骤:
√ Client Compiled successfully in 1.77m √ Server Compiled successfully in 1.52m
wait Rendering static HTML...
2
在渲染静态 html 时候,是在 node 端进行地,自然是没有 localStroage 的,最终通过判断typeof process
JS 运行环境,在浏览器端环境条件下,才进行 localStorage 的操作,这是实现信息的共享的基础啦。记下来要关注的就是,信息什么时候存储、什么时候读取呢?
if (typeof process === "undefined") {
// 当前环境为浏览器端,做localStroage的存取操作
}
2
3
# 3. localStorage 存储和取出逻辑
首先什么数据需要存储到 localStroage 里面呢?这个不需要过多思考,player.vue 组件中 data(){}中的数据,关于播放数据,我都放在了player{}
对象中了:
data() {
return {
player: {
_playing: false, // 是否正在播放中
_progress: 0, // 当前播放歌曲的进度
_enabled: false, // 是否启用Player
_repeatMode: "off", // off | on | one
_shuffle: false,
_reversed: false,
_volume: 1, // 0 to 1
_volumeBeforeMuted: 1, // 用于保存静音前的音量
_list: [], // 播放列表
_shuffledList: [], // 被随机打乱的播放列表,随机播放模式下会使用此播放列表
_current: 0, // 当前播放歌曲在播放列表里的index
_playlistSource: { type: "album", id: 123 }, // 当前播放列表的信息
_currentTrack: { id: 86827685 }, // 当前播放歌曲的详细信息
_playNextList: [], // 当这个list不为空时,会优先播放这个list的歌
// howler (https://github.com/goldfire/howler.js)
_howler: null,
},
// ......
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
player 这个对象中包括了当前播放歌曲、播放歌单、播放进度等信息,其中主要发生变化的则是_playing
、_currentTrack
这几个,其他大部分时间不会变化。那么什么时候存储到 localStorage 里面,能够保证读取的时候也是最新的状态呢?
这个时候就用到了 Vue 的生命周期钩子 updated(),此生命周期表示 data(){}中的数据发生变化,同时已经更新到视图当中了。将缓存存储写在这里,可以保证没有存储的都是最新的播放信息。
updated() {
this.saveSelfToLocalStorage();
}
// 缓存播放信息
saveSelfToLocalStorage() {
let player = {};
for (let [key, value] of Object.entries(this.player)) {
if (key == "_howler") {
continue;
}
player[key] = value;
}
if (typeof process === "undefined") {
localStorage.setItem("player", JSON.stringify(player));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
提示
这里_howler 是 howler.js 的播放器对象,因为存在循环引用,所以决定不保存到缓存里面,而是选择在新开页面 created()之后,销毁重新生成一个。
接下来是取出 localStroage 逻辑:当 vue 数据初始化完毕之后,在 created 中进行数据初始化init()
时,读取 localStroage 数据赋值给 data()中的 player 对象,这样子就实现了跨页面同步播放状态啦!还有一个问题是:还不能保证同时只有一个在播放,暂时还没想到解决方案。
created() {
// 此时可以访问this,做数据初始化;或者异步数据请求
this.init();
}
init() {
this._loadSelfFromLocalStorage();
Howler.autoUnlock = false;
Howler.usingWebAudio = true;
Howler.volume(this.volume);
// ......
}
//player 实例对象从缓存读取
_loadSelfFromLocalStorage() {
if (typeof process === "undefined") {
const player = JSON.parse(localStorage.getItem("player"));
if (!player) return;
for (const [key, value] of Object.entries(player)) {
this.player[key] = value;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 多个页面播放器逻辑控制
关于多个页面播放信息状态同步了,但是还存在一个问题:如果两个及以上页面都点击播放的话,会同时播放,这样会产生两个新的问题,一个是 localStorage 的信息会频繁的存入,且进度不一样,另外一个问题是这样也不符合逻辑啊。这个地方参考某云的思路,无论哪个页面点击播放,其他页面都需要暂停播放。
当然这个问题也涉及到跨页面通信了,但这里 localStorage 方式已经不太适用了,因为需要进行先存再取的方式,多了一层操作,而且仅仅是点击播放这个动作,有没有可能这个点击动作会通知其他页面,我要播放了,你们别播放了这样的操作呢?
嗯!这就是广播嘛,在浏览器的 api 中的BroadCast channel
,它可以创建一个用于广播的通信频道,所有页面都可以监听这个频道的消息,某一个页面发送消息,其他页面都可以接收到!
# 1. 新建 BroadCastChannel
首先在 data(){}中,初始化一个广播频道playerChannel
:
data(){
return {
// ......
playerChannel: null
}
}
2
3
4
5
6
# 2. 唯一标识 playerChannel
然后实例化 playerChannel,并传入一个唯一标志符,其他地方如果用同一个标识符的话,就相当于是同一个频道,可以进行通信,有点类似于 Symbol。所以在 created 钩子中进行实例化,和传入唯一标志:
created() {
// 此时可以访问this,做数据初始化;或者异步数据请求
this.init();
if(typeof process === 'undefined') {
this.playerChannel = new BroadcastChannel("immersivePlayer");
}
},
2
3
4
5
6
7
# 3. 在当前页面播放时广播信息
在当前页面点击播放时,通过 postMessage 来发送消息,通知其他所有的同源页面。
// 播放
play() {
this.playerChannel.postMessage({ pauseOtherTabs: true });
// ......
},
2
3
4
5
# 4. 广播信号的监听
当当前页面点击播放时候,广播了信息,其他页面通过对广播信号的监听来关闭音频的播放。这个时候的问题就是,要保证当前页面播放,其他页面的音频播放需要暂停。
那么怎么判断浏览器当前页面了,通过查资料,发现 document.hidden 这个属性为 false 时,表示页面在当前 tab,为 true 时表示 tab 页面在隐藏状态。
同时怎么控制音频的暂停呢?因为采用的是 Howler.js 这个库,可以通过 Howler 这个实例对象方法来控制。将上述监听事件放在 DOM 节点挂载之后比较合理。
mounted() {
// ......
this.playerChannel.onmessage = function (e) {
if(e.data.pauseOtherTabs && document.hidden ) {
// this.player?._howler.pause();
console.log('closing pause');
window.Howler.stop();
this.player._howler?.seek(time);
this.player._playing = false;
}
};
},
2
3
4
5
6
7
8
9
10
11
12
这样就可以实现多个页面的播放暂停控制了,不过这个 API 兼容性不是很好,不过支持 Web 端的大部分浏览器,移动端的暂时不考虑在内。当然还是引入了一些 bug,后面再修复吧......这里再补充以下其他的跨页面通信方式。
提示
补充:上述代码写完之后有 bug,可以实现当前页面点击播放,其他页面会暂停,但是其他页面的进度会丢失,会显示暂停按钮,实际上应该是显示播放按钮。这里设置播放器状态失效,发现控制台打印错误:没有_howler,怎么回事呢?
想了很久,突然想起来 playerChannel 这个实例对象的方法,this 的指向当然是自己的 BroadcastChannel 构造函数啊!你这里根本访问不到 Vue 组件中的数据!!!找到问题就很好解决了,在这个监听事件外面把 this 赋值给 that,利用 that 来设置 player 的播放进度和播放状态。(当然也可以用匿名函数啦,这样this执行就是window啦,而vue的数据就在全局this里面)
然后又又发现一个 bug,在页面最小化的时候,当歌曲播放完毕之后,不会自动播放下一首,因为 document.hidden 在最小化的情况下,也是 true;还需要加上一个判断当前页面是否激活状态,再进行操作。
修改之后的代码如下:
created() {
// 此时可以访问this,做数据初始化;或者异步数据请求
this.init();
if(typeof process === 'undefined') {
this.playerChannel = new BroadcastChannel("immersivePlayer");
}
let that = this;
window.onblur = function() {
that.playerChannel.onmessage = function (e) {
if(e.data.pauseOtherTabs && document.hidden ) {
// this.player?._howler.pause();
console.log('closing pause');
// window.Howler.stop();
// console.log(this,that);
that.player._playing = false;
that.player._howler.pause();
let player = JSON.parse(localStorage.getItem('player'))
that.player._howler?.seek(player._progress);
}
};
}
},
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 扩展-跨页面通信
在同源页面间的跨页面通信,除了 localStroage 之外,这里还有这几种:
# 1. BroadCast Channel
创建一个用于广播的通信频道。当所有页面都监听同一频道的消息时,其中某一个页面通过它发送的消息就会被其他所有页面收到。
// 创建一个具有simonzhangs blog标识的频道
const bc = new BroadcastChannel("Simonzhangs blog");
// 各个页面通过onmessage来监听被广播的消息
bc.onmessage = function (e) {
const data = e.data;
const text = "[receive] " + data.msg + " —— tab " + data.from;
console.log("[BroadcastChannel] receive message:", text);
};
// 页面调用实例上的postMessage方法来发送消息
bc.postMessage(mydata);
2
3
4
5
6
7
8
9
10
11
12
Broadcast Channel 是一个非常好用的多页面消息同步 API,然而兼容性却不是很乐观。
# 2. Service Worker
Service Worker 是一个可以长期运行在后台的 Worker,能够实现与页面的双向通信。多个页面可以将 Service Worker 作为消息处理中心,这样就可以实现广播效果。当前最常见的应用是 PWA。
可以自己封装 service worker 来使用,我接触到的还有一个 npm 包register-service-worker
,可以简化事件的注册,还附带各个阶段的钩子。但是基本原理也是需要知道的:首先注册 Service Worker,然后通过添加事件监听,然后利用 postMessage 发送消息到所有注册页面,这样就实现了广播,最后添加监听特定事件的处理逻辑即可实现跨页面通信。
# 3. Shared Worker
Shared Worker 是 Worker 中的一种,多个 tab 注册的 Shared Worker 可以实现数据共享。但是数据变更无法主动通知所有页面,需要通过轮询的方式来拉取最新数据。
# 4. IndexedDB
与 Shared Worker 类似,消息发送方先将消息存至 IndexedDB 中,接收方则通过轮询去获取最新的消息。也有封装好的库可以使用:Dexie
。这两种方式都是共享存储+ 长轮询的方式。共享存储非常适用的场景是:离开 tabA 到 tabB 操作,操作完毕之后又回到 tabA,需要将 tabB 操作的信息同步回来的时候,只需要监听 visibilitychange 事件,来做一次信息同步即可。
# 5. 非同源页面-iframe
非同源页面的话可以采用 WebSocket 或者 iframe 来进行通信,先码住后续学习。iframe 我记得也常常会用在 CSRF 攻击,获取用户在其他网站的 cookie 来攻击,先挖个坑后面补充。
# 扩展-判断 JS 是否运行环境
# 1. globalThis
通过判断 XMLHttpRequest 类型来判断,异步 http 请求只存在于浏览器端:
function canMakeHTTPRequest() {
return typeof globalThis.XMLHttpRequest === "function";
}
console.log(canMakeHTTPRequest());
// expected output (in a browser): true
2
3
4
5
6
提示
全局属性 globalThis 包含全局的 this 值,类似于全局对象(global object)。globalThis 提供了一个标准的方式来获取不同环境下的全局 this 对象(也就是全局对象自身),为便于记忆,你只需要记住,全局作用域中的 this 就是 globalThis。
浏览器端与 node 端区别:
- 在 node 环境中 this 指向 global,而浏览器环境中 this 则指向 window;
- Node 采用 commonJS 标准,而浏览器采用 ES Modules 标准;(require/ module.exports , import/export)
- Node 不可以操作 DOM,而浏览器 JS 可以操作 DOM;(服务端技术,不需要页面操作 / 表现层,需要页面交互操作)
- I/O 读写操作不同;(buffer)
- 模块加载不同。(浏览器端,会涉及到闭包,原生没提供包引用的 API 会一次性把要加载的东西全执行以便,没有逻辑性和复用性 / node 端 npm 依赖包管理工具)
# 2. typeof + Object.prototype.toString().call()
if (
typeof window === "object" &&
Object.prototype.toString.call(window) === "[object Window]"
) {
// window只存在于浏览器端
} else if (Object.prototype.toString.call(process) === "[object process]") {
//判断procss
}
2
3
4
5
6
7
8
# 3. this.$isServer
判断当前 Vue 实例是否运行在服务器端,为 true 则表示实例运行在服务器。此属性一般用于服务器渲染,用以区分代码是否在服务端上运行。
# 扩展-Object.keys、Obejct.entries 和 Object.values
# Object.keys(obj)
- 参数:要返回其枚举自身属性的对象
- 返回值:一个表示给定对象的所有可枚举属性的字符串数组;
处理对象,返回可枚举的属性数组;处理数组,返回索引值数组;处理字符串,返回索引值数组
提示
这里要提一下for...of,用于遍历对象的可枚举属性的值,但普通对象obj的属性不可枚举,可以搭配for...of和Object.keys(obj)来遍历键名,从而得到obj的属性的value。
for (const key of Object.keys(obj)) {
console.log(key + ': ' + obj[key]);
}
2
3
# Object.values(obj)
返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的键值。
返回数组的成员顺序,与属性的遍历排列顺序规则一致;
const obj = {100 : "a", 2 : "b", 7 : "c"};
console.log(Object.values(obj)); //["b", "c", "a"]
2
属性为数值的属性,是按照数值大小,从小到大遍历的。
- 只会遍历对象自身的可遍历属性,不会找原型链上的,并得到对应的属性值。
- 会过滤属性名为Symbol值的属性;
- 如果用于字符串,返回的是各个字符组成的数组。
- 如果参数不是对象,会先将其转为对象;(数值和布尔类型会返回空数组)
# Object.entries(obj)
- 返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的键值对数组;
- 如果原对象的属性名是一个Symbol值,该属性会被忽略;
常用于遍历对象的属性,如下代码实现vue中player对象数据取出,并把其存储到localStorage中。
let player = {};
for(let [key, value] of Object.entries(this.player)) {
if(key === '_howler') { continue; }
player[key] = value;
}
localStorage.setItem('player', JSON.stringfy(player));
2
3
4
5
6
也可以利用map转成真正的Map结构:const map = new Map(Object.entries(obj))
。
提示
Object.entries()可以利用for...of和Object.keys()实现。
这三个方法都是返回对象自身(不包含原型链上)的可枚举的属性名或属性值。而for...in的话会找到原型链上面,可以通过Object.hasProperty()来判断是否是对象自身属性来限制。
对于for...of,只有部署了Iterator的对象才可以使用,表示是可迭代的;数组、Set、Map是已经自动部署的,可以使用;自动部署在Symbol.iterator属性上。而普通对象是没有部署这个接口的,可以通过Array.from将其转为数组就会自动部署接口了,可以采用for...of。一般情况下对象的遍历还是用for...in,如果要取到属性值,for...of配合Object.keys()得到的属性名数组即可。
# 扩展-为什么要限制同源通信呢?
同源策略是浏览器端的措施,为了保证用户的安全,并不是服务端做的限制。你想想啊,大家都用浏览器,浏览器相当于一个中介服务商,为你提供各个网站的信息服务,如果不限制同源而造成用户的损失,那谁还用你的浏览器啊!
那关于前端的安全问题,这里总结了常见了攻击方式和预防措施:Web开发常见攻击及应对方法
这个是从浏览器端来考虑的,不过限制了同源对开发者来说真的好恼火!开发环境还可以用devServer来配置下测试,生产环境的话又是一回事儿了。这里总结了几种常用的解决跨域的方法:
# 1. JSONP
JSON with padding
# 2. CORS
# 3. 反向代理
反向代理和正向代理的区别。
# localStroage、cookie、sessionStroage区别
参考链接: 前端跨页面通信,你知道哪些方法? (opens new window) Object.keys()、Object.values()、Object.entries()的用法 (opens new window)