Simonzhangs' blog Simonzhangs' blog
首页
  • 前端文章

    • HTML
    • CSS
    • JavaScript
  • 学习笔记

    • 《JavaScript教程》
    • 《JavaScript高级程序设计》
    • 《ES6 教程》
    • JS设计模式总结
  • 《Vue》
  • 《React》
  • 《TypeScript 从零实现 axios》
  • TypeScript
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • apple music
  • extension
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

Simonzhangs

前端学习探索者
首页
  • 前端文章

    • HTML
    • CSS
    • JavaScript
  • 学习笔记

    • 《JavaScript教程》
    • 《JavaScript高级程序设计》
    • 《ES6 教程》
    • JS设计模式总结
  • 《Vue》
  • 《React》
  • 《TypeScript 从零实现 axios》
  • TypeScript
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • apple music
  • extension
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • Vuepress音乐播放器插件

    • 个性化Vuepress音乐播放器插件
    • 插件介绍与配置
    • 关于个性化样式
    • Vuepress插件我所做的优化
      • 状态共享思路
      • 跨页面通信
        • 1. 修复上个版本刷新页面重新播放,以及新开一个 tab 栏也会重新播放的 bug
        • 2. Vuepress 插件(node 端编译)怎么控制 localStorage 读写
        • 3. localStorage 存储和取出逻辑
      • 多个页面播放器逻辑控制
        • 1. 新建 BroadCastChannel
        • 2. 唯一标识 playerChannel
        • 3. 在当前页面播放时广播信息
        • 4. 广播信号的监听
      • 扩展-跨页面通信
        • 1. BroadCast Channel
        • 2. Service Worker
        • 3. Shared Worker
        • 4. IndexedDB
        • 5. 非同源页面-iframe
      • 扩展-判断 JS 是否运行环境
        • 1. globalThis
        • 2. typeof + Object.prototype.toString().call()
        • 3. this.$isServer
      • 扩展-Object.keys、Obejct.entries 和 Object.values
        • Object.keys(obj)
        • Object.values(obj)
        • Object.entries(obj)
      • 扩展-为什么要限制同源通信呢?
        • 1. JSONP
        • 2. CORS
        • 3. 反向代理
      • localStroage、cookie、sessionStroage区别
    • 为什么选择howerjs音频库
  • Apple Music 高颜值在线音乐播放器

  • 学生助手浏览器插件

  • 用户管理系统

  • 作品集
  • Vuepress音乐播放器插件
simonzhangs
2022-05-03
目录

Vuepress插件我所做的优化

今天终于做了一个较大的优化,当然也顺带修复了上个版本的各种 bug,也顺便引入了一些新的 bug,留着下次修复吧....... 还是先记录下自己做的优化吧!

关于 Vuepress 插件,之前就发现了它的一个问题:就是每新开一个页面,播放器会重头开始播放,根本就记不住之前播放到了哪里!!!这怎么能行呢,要解决这个问题的话,关键点在于播放状态的共享。查阅了以下资料,新开的页面协议、主机和端口号都没有变化,只是路径发生变化,还是处于同源下的,最简单的方法就是利用 localStorage 在同源页面上进行状态共享。

# 状态共享思路

既然要共享的话,如果新开了一个页面,信息状态同步的方式是怎么的呢?是播放新页面的音乐呢,还是继续播放旧页面的音乐呢?

我尝试去参考了两大巨头的思路:

  • 某 Q:不管从哪个页面点击播放音乐会单开一个播放列表页面,或者跳转到播放列表页面;通过这个页面来管理所有的播放音乐。(这不适用于我这种情况啊)
  • 某云:每个页面底部在播放状态下,都会有一个播放器,它们只会共享当前播放歌曲,不共享播放当前进度;同时不同页面播放和暂停状态是互斥的,只能有一个处于播放状态。(而且新开的页面不会去主动请求歌曲信息,只有在播放的时候请求,也有可能是读取缓存的,是网络优化的一个小细节啊)

再结合这个插件的特性,音乐播放我设计时决定还是放在原来页面播放,但要保证新开页面能够同步播放歌单和播放进度,同时要保证多个页面只有一个处于播放状态。

提示

上述问题其实总结起来是:前端如何跨页面通信,除了利用 localStorage 方法外,它是在同源页面上进行通信,还有其他方式,我总结在这篇文章里面了:扩展-跨页面通信

# 跨页面通信

下面则是记录利用 localStorage 来进行跨页面通信,从而共享播放状态:

# 1. 修复上个版本刷新页面重新播放,以及新开一个 tab 栏也会重新播放的 bug

排查发现在created(){}生命周期里,写了这么一段代码:

created() {
    // 此时可以访问this,做数据初始化;或者异步数据请求
    this.init();
    // this.playPlaylistByID(this.playListID);
},
1
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;
      }
    );
    // ......
  }
  // ......
},
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

改进:在测试时候发现,首次加载的话,player 还没有初始化,从而选择缓存中是否存在 player 项判断,这样更加合理。

// 首次使用插件时候加载配置歌单ID
if (typeof process == "undefined") {
  if (!localStorage.getItem("player")) {
    this.playPlaylistByID(this.playListID);
  }
}
1
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");
},
1
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...
1
2

在渲染静态 html 时候,是在 node 端进行地,自然是没有 localStroage 的,最终通过判断typeof processJS 运行环境,在浏览器端环境条件下,才进行 localStorage 的操作,这是实现信息的共享的基础啦。记下来要关注的就是,信息什么时候存储、什么时候读取呢?

if (typeof process === "undefined") {
  // 当前环境为浏览器端,做localStroage的存取操作
}
1
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,
      },
      // ......
    };
}
1
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));
  }
}
1
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;
    }
  }
}
1
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
  }
}
1
2
3
4
5
6

# 2. 唯一标识 playerChannel

然后实例化 playerChannel,并传入一个唯一标志符,其他地方如果用同一个标识符的话,就相当于是同一个频道,可以进行通信,有点类似于 Symbol。所以在 created 钩子中进行实例化,和传入唯一标志:

created() {
  // 此时可以访问this,做数据初始化;或者异步数据请求
  this.init();
  if(typeof process === 'undefined') {
    this.playerChannel = new BroadcastChannel("immersivePlayer");
  }
},
1
2
3
4
5
6
7

# 3. 在当前页面播放时广播信息

在当前页面点击播放时,通过 postMessage 来发送消息,通知其他所有的同源页面。

// 播放
play() {
  this.playerChannel.postMessage({ pauseOtherTabs: true });
  // ......
},
1
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;
    }
  };
},
1
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);
      }
    };
  }
},
1
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);
1
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
1
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
}
1
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]);
}
1
2
3

# Object.values(obj)

  1. 返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的键值。

  2. 返回数组的成员顺序,与属性的遍历排列顺序规则一致;

const obj = {100 : "a", 2 : "b", 7 : "c"};
console.log(Object.values(obj));   //["b", "c", "a"]
1
2

属性为数值的属性,是按照数值大小,从小到大遍历的。

  1. 只会遍历对象自身的可遍历属性,不会找原型链上的,并得到对应的属性值。
  2. 会过滤属性名为Symbol值的属性;
  3. 如果用于字符串,返回的是各个字符组成的数组。
  4. 如果参数不是对象,会先将其转为对象;(数值和布尔类型会返回空数组)

# 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));
1
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)

编辑 (opens new window)
上次更新: 2022/05/08, 19:49:13
关于个性化样式
为什么选择howerjs音频库

← 关于个性化样式 为什么选择howerjs音频库→

最近更新
01
一些有意思的类比
06-16
02
the-super-tiny-compiler解析
06-06
03
计算机编译原理总概
06-06
更多文章>
Theme by Vdoing | Copyright © 2021-2022
蜀ICP备2021023197号-2
Simonzhans | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式
  • 飙升榜
  • 新歌榜
  • 云音乐民谣榜
  • 美国Billboard榜
  • UK排行榜周榜