阿宝哥

阿宝哥 查看完整档案

厦门编辑集美大学  |  自动化 编辑  |  填写所在公司/组织 www.semlinker.com 编辑
编辑

http://www.semlinker.com/
聚焦全栈,专注分享 Angular、TypeScript、Node.js/Java 、Spring 技术栈等全栈干货

欢迎各位小伙伴关注本人公众号全栈修仙之路

个人动态

阿宝哥 赞了文章 · 1月18日

探索 Vue.js 响应式原理

提到“响应式”三个字,大家立刻想到啥?响应式布局?响应式编程?

响应式关键词.png

从字面意思可以看出,具有“响应式”特征的事物会根据条件变化,使得目标自动作出对应变化。比如在“响应式布局”中,页面根据不同设备尺寸自动显示不同样式。

Vue.js 中的响应式也是一样,当数据发生变化后,使用到该数据的视图也会相应进行自动更新。

接下来我根据个人理解,和大家一起探索下 Vue.js 中的响应式原理,如有错误,欢迎指点😺~~

一、Vue.js 响应式的使用

现在有个很简单的需求,点击页面中 “leo” 文本后,文本内容修改为“你好,前端自习课”。

我们可以直接操作 DOM,来完成这个需求:

<span id="name">leo</span>
const node = document.querySelector('#name')
node.innerText = '你好,前端自习课';

实现起来比较简单,当我们需要修改的数据有很多时(比如相同数据被多处引用),这样的操作将变得复杂。

既然说到 Vue.js,我们就来看看 Vue.js 怎么实现上面需求:

<template>
  <div id="app">
    <span @click="setName">{{ name }}</span>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      name: "leo",
    };
  },
  methods: {
    setName() {
      this.name = "你好,前端自习课";
    },
  },
};
</script>

观察上面代码,我们通过改变数据,来自动更新视图。当我们有多个地方引用这个 name 时,视图都会自动更新。

<template>
  <div id="app">
    <span @click="setName">{{ name }}</span>
    <span>{{ name }}</span>
    <span>{{ name }}</span>
    <span>{{ name }}</span>
  </div>
</template>

当我们使用目前主流的前端框架 Vue.js 和 React 开发业务时,只需关注页面数据如何变化,因为数据变化后,视图也会自动更新,这让我们从繁杂的 DOM 操作中解脱出来,提高开发效率。

二、回顾观察者模式

前面反复提到“通过改变数据,来自动更新视图”,换个说法就是“数据改变后,使用该数据的地方被动发生响应,更新视图”。

是不是有种熟悉的感觉?数据无需关注自身被多少对象引用,只需在数据变化时,通知到引用的对象即可,引用的对象作出响应。恩,有种观察者模式的味道?

关于观察者模式,可阅读我之前写的《图解设计模式之观察者模式(TypeScript)》

1. 观察者模式流程

观察者模式表示一种“一对多”的关系,n 个观察者关注 1 个被观察者,被观察者可以主动通知所有观察者。接下图:

observer.png
在这张图中,粉丝想及时收到“前端自习课”最新文章,只需关注即可,“前端自习课”有新文章,会主动推送给每个粉丝。该过程中,“前端自习课”是被观察者,每位“粉丝”是观察者。

2. 观察者模式核心

观察者模式核心组成包括:n 个观察者和 1 个被观察者。这里实现一个简单观察者模式:

2.1 定义接口

// 观察目标接口
interface ISubject {
    addObserver: (observer: Observer) => void; // 添加观察者
    removeObserver: (observer: Observer) => void; // 移除观察者
    notify: () => void; // 通知观察者
}

// 观察者接口
interface IObserver {
    update: () => void;
}

2.2 实现被观察者类

// 实现被观察者类
class Subject implements ISubject {
    private observers: IObserver[] = [];

    public addObserver(observer: IObserver): void {
        this.observers.push(observer);
    }

    public removeObserver(observer: IObserver): void {
        const idx: number = this.observers.indexOf(observer);
        ~idx && this.observers.splice(idx, 1);
    }

    public notify(): void {
        this.observers.forEach(observer => {
            observer.update();
        });
    }
}

2.3 实现观察者类

// 实现观察者类
class Observer implements IObserver {
    constructor(private name: string) { }

    update(): void {
        console.log(`${this.name} has been notified.`);
    }
}

2.4 测试代码

function useObserver(){
    const subject: ISubject = new Subject();
    const Leo = new Observer("Leo");
    const Robin = new Observer("Robin");
    const Pual = new Observer("Pual");

    subject.addObserver(Leo);
    subject.addObserver(Robin);
    subject.addObserver(Pual);
    subject.notify();

    subject.removeObserver(Pual);
    subject.notify();
}

useObserver();
// [LOG]: "Leo has been notified." 
// [LOG]: "Robin has been notified." 
// [LOG]: "Pual has been notified." 
// [LOG]: "Leo has been notified." 
// [LOG]: "Robin has been notified." 

三、回顾 Object.defineProperty()

Vue.js 的数据响应式原理是基于 JS 标准内置对象方法 Object.defineProperty()方法来实现,该方法不兼容 IE8 和 FF22 及以下版本浏览器,这也是为什么 Vue.js 只能在这些版本之上的浏览器中才能运行的原因。

理解 Object.defineProperty() 对我们理解 Vue.js 响应式原理非常重要

Vue.js 3 使用 proxy 方法实现响应式,两者类似,我们只需搞懂Object.defineProperty()proxy 也就差不多理解了。

1. 概念介绍

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
语法如下:

Object.defineProperty(obj, prop, descriptor)
  • 入参说明:

obj :要定义属性的源对象
prop :要定义或修改的属性名称Symbol
descriptor :要定义或修改的属性描述符,包括 configurableenumerablevaluewritablegetset,具体的可以去参阅文档

  • 出参说明:

修改后的源对象。

举个简单🌰例子:

const leo = {};
Object.defineProperty(leo, 'age', { 
    value: 18,
    writable: true
})
console.log(leo.age); // 18
leo.age = 22;
console.log(leo.age); // 22

2. 实现 getter/setter

我们知道 Object.defineProperty() 方法第三个参数是属性描述符(descriptor),支持设置 getset 描述符:

  • get 描述符:当访问该属性时,会调用此函数,默认值为 undefined ;
  • set 描述符:当修改该属性时,会调用此函数,默认值为 undefined
一旦对象拥有了 getter/setter 方法,我们可以简单将该对象称为响应式对象。

这两个操作符为我们提供拦截数据进行操作的可能性,修改前面示例,添加 getter/setter 方法:

let leo = {}, age = 18;
Object.defineProperty(leo, 'age', { 
    get(){
        // to do something
          console.log('监听到请求数据');
        return age;
    },
    set(newAge){
        // to do something
          console.log('监听到修改数据');
        age = newAge > age ? age : newAge
    }
})
leo.age = 20;  // 监听到修改数据
console.log(leo.age); // 监听到请求数据  // 18

leo.age = 10;  // 监听到修改数据
console.log(leo.age); // 监听到请求数据  // 10

访问 leo 对象的 age 属性,会通过 get 描述符处理,而修改 age 属性,则会通过 set 描述符处理。

四、实现简单的数据响应式

通过前面两个小节,我们复习了“观察者模式”和“Object.defineProperty()” 方法,这两个知识点在 Vue.js 响应式原理中非常重要。

接下来我们来实现一个很简单的数据响应式变化,需求如下:点击“更新数据”按钮,文本更新。

data-change.png

接下来我们将实现三个类:

  • Dep 被观察者类,用来生成被观察者;
  • Watcher 观察者类,用来生成观察者;
  • Observer 类,将普通数据转换为响应式数据,从而实现响应式对象

用一张图来描述三者之间关系,现在看不懂没关系,这小节看完可以再回顾这张图:
observer-watcher-dep.png

1. 实现精简观察者模式

这里参照前面复习“观察者模式”的示例,做下精简:

// 实现被观察者类
class Dep {
    constructor() {
        this.subs = [];
    }
    addSub(watcher) {
        this.subs.push(watcher);
    }
    notify(data) {
        this.subs.forEach(sub => sub.update(data));
    }
}
// 实现观察者类
class Watcher {
    constructor(cb) {
        this.cb = cb;
    }
    update(data) {
        this.cb(data);
    }
}

Vue.js 响应式原理中,观察者模式起到非常重要的作用。其中:

  • Dep 被观察者类,提供用来收集观察者( addSub )方法和通知观察者( notify )方法;
  • Watcher 观察者类,实例化时支持传入回调( cb )方法,并提供更新( update )方法;

2. 实现生成响应式的类

这一步需要实现 Observer 类,核心是通过 Object.defineProperty() 方法为对象的每个属性设置 getter/setter,目的是将普通数据转换为响应式数据,从而实现响应式对象

reactive-data.png

这里以最简单的单层对象为例(下一节会介绍深层对象),如:

let initData = {
    text: '你好,前端自习课',
    desc: '每日清晨,享受一篇前端优秀文章。'
};

接下来实现 Observer 类:

// 实现响应式类(最简单单层的对象,暂不考虑深层对象)
class Observer {
    constructor (node, data) {
        this.defineReactive(node, data)
    }

    // 实现数据劫持(核心方法)
    // 遍历 data 中所有的数据,都添加上 getter 和 setter 方法
    defineReactive(vm, obj) {
        //每一个属性都重新定义get、set
        for(let key in obj){
            let value = obj[key], dep = new Dep();
            Object.defineProperty(obj, key, {
                enumerable: true,
                configurable: true,
                get() {
                    // 创建观察者
                    let watcher = new Watcher(v => vm.innerText = v);
                    dep.addSub(watcher);
                    return value;
                },
                set(newValue) {
                    value = newValue;
                    // 通知所有观察者
                    dep.notify(newValue);
                }
            })
        }
    }
}

上面代码的核心是 defineReactive 方法,它遍历原始对象中每个属性,为每个属性实例化一个被观察者(Dep),然后分别调用 Object.defineProperty() 方法,为每个属性添加 getter/setter。

  • 访问数据时,getter 执行依赖收集(即添加观察者),通过实例化 Watcher 创建一个观察者,并执行被观察者的 addSub() 方法添加一个观察者;
  • 修改数据时,setter 执行派发更新(即通知观察者),通过调用被观察者的 notify() 方法通知所有观察者,执行观察者 update() 方法。

3. 测试代码

为了方便观察数据变化,我们为“更新数据”按钮绑定点击事件来修改数据:

<div id="app"></div>
<button id="update">更新数据</button>

测试代码如下:

// 初始化测试数据
let initData = {
    text: '你好,前端自习课',
    desc: '每日清晨,享受一篇前端优秀文章。'
};

const app = document.querySelector('#app');

// 步骤1:为测试数据转换为响应式对象
new Observer(app, initData);

// 步骤2:初始化页面文本内容
app.innerText = initData.text;

// 步骤3:绑定按钮事件,点击触发测试
document.querySelector('#update').addEventListener('click', function(){
    initData.text = `我们必须经常保持旧的记忆和新的希望。`;
    console.log(`当前时间:${new Date().toLocaleString()}`)
})

测试代码中,核心在于通过实例化 Observer,将测试数据转换为响应式数据,然后模拟数据变化,来观察视图变化。
每次点击“更新数据”按钮,在控制台中都能看到“数据发生变化!”的提示,说明我们已经能通过 setter 观察到数据的变化情况。

当然,你还可以在控制台手动修改 initData 对象中的 text 属性,来体验响应式变化~~

到这里,我们实现了非常简单的数据响应式变化,当然 Vue.js 肯定没有这么简单,这个先理解,下一节看 Vue.js 响应式原理,思路就会清晰很多。

这部分代码,我已经放到我的 Github,地址:https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/Basics-Reactive-Demo.js

可以再回顾下这张图,对整个过程会更清晰:

observer-watcher-dep.png

五、Vue.js 响应式实现

本节代码:https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/leo-vue-reactive/

这里大家可以再回顾下下面这张官网经典的图,思考下前面讲的示例。

(图片来自:https://cn.vuejs.org/v2/guide/reactivity.html

上一节实现了简单的数据响应式,接下来继续通过完善该示例,实现一个简单的 Vue.js 响应式,测试代码如下:

// index.js
const vm = new Vue({
    el: '#app',
    data(){
        return {
            text: '你好,前端自习课',
            desc: '每日清晨,享受一篇前端优秀文章。'
        }
    }
});

是不是很有内味了,下面是我们最终实现后项目目录:

- mini-reactive
    / index.html   // 入口 HTML 文件
  / index.js     // 入口 JS 文件
  / observer.js  // 实现响应式,将数据转换为响应式对象
  / watcher.js   // 实现观察者和被观察者(依赖收集者)
  / vue.js       // 实现 Vue 类作为主入口类
  / compile.js   // 实现编译模版功能

知道每一个文件功能以后,接下来将每一步串联起来。

1. 实现入口文件

我们首先实现入口文件,包括 index.html / index.js  2 个简单文件,用来方便接下来的测试。

1.1 index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <script data-original="./vue.js"></script>
    <script data-original="./observer.js"></script>
    <script data-original="./compile.js"></script>
    <script data-original="./watcher.js"></script>
</head>
<body>
    <div id="app">{{text}}</div>
    <button id="update">更新数据</button>
    <script data-original="./index.js"></script>
</body>
</html>

1.2 index.js

"use strict";
const vm = new Vue({
    el: '#app',
    data(){
        return {
            text: '你好,前端自习课',
            desc: '每日清晨,享受一篇前端优秀文章。'
        }
    }
});

console.log(vm.$data.text)
vm.$data.text = '页面数据更新成功!'; // 模拟数据变化
console.log(vm.$data.text)

2. 实现核心入口 vue.js

vue.js 文件是我们实现的整个响应式的入口文件,暴露一个 Vue 类,并挂载全局。

class Vue {
    constructor (options = {}) {
        this.$el = options.el;
        this.$data = options.data();
        this.$methods = options.methods;

        // [核心流程]将普通 data 对象转换为响应式对象
        new Observer(this.$data);

        if (this.$el) {
            // [核心流程]将解析模板的内容
            new Compile(this.$el, this)
        }
    }
}
window.Vue = Vue;

Vue 类入参为一个配置项 option ,使用起来跟 Vue.js 一样,包括 $el 挂载点、 $data 数据对象和 $methods 方法列表(本文不详细介绍)。

通过实例化 Oberser 类,将普通 data 对象转换为响应式对象,然后判断是否传入 el 参数,存在时,则实例化 Compile 类,解析模版内容。

总结下 Vue 这个类工作流程 :
vue-class.png

3. 实现 observer.js

observer.js 文件实现了 Observer 类,用来将普通对象转换为响应式对象:

class Observer {
    constructor (data) {
        this.data = data;
        this.walk(data);
    }

    // [核心方法]将 data 对象转换为响应式对象,为每个 data 属性设置 getter 和 setter 方法
    walk (data) {
        if (typeof data !== 'object') return data;
        Object.keys(data).forEach( key => {
            this.defineReactive(data, key, data[key])
        })
    }

    // [核心方法]实现数据劫持
    defineReactive (obj, key, value) {
        this.walk(value);  // [核心过程]遍历 walk 方法,处理深层对象。
        const dep = new Dep();
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get () {
                console.log('[getter]方法执行')
                Dep.target &&  dep.addSub(Dep.target);
                return value
            },
            set (newValue) {
                console.log('[setter]方法执行')
                if (value === newValue) return;
                // [核心过程]当设置的新值 newValue 为对象,则继续通过 walk 方法将其转换为响应式对象
                if (typeof newValue === 'object') this.walk(newValue);
                value = newValue;
                dep.notify(); // [核心过程]执行被观察者通知方法,通知所有观察者执行 update 更新
            }
        })
    }
}

相比较第四节实现的 Observer 类,这里做了调整:

  • 增加 walk 核心方法,用来遍历对象每个属性,分别调用数据劫持方法( defineReactive() );
  • defineReactive() 的 getter 中,判断 Dep.target 存在才添加观察者,下一节会详细介绍 Dep.target
  • defineReactive() 的 setter 中,判断当前新值( newValue )是否为对象,如果是,则直接调用 this.walk() 方法将当前对象再次转为响应式对象,处理深层对象

通过改善后的 Observer 类,我们就可以实现将单层或深层嵌套的普通对象转换为响应式对象

4. 实现 watcher.js

这里实现了 Dep 被观察者类(依赖收集者)和 Watcher 观察者类。

class Dep {
    constructor() {
        this.subs = [];
    }
    addSub(watcher) {
        this.subs.push(watcher);
    }
    notify(data) {
        this.subs.forEach(sub => sub.update(data));
    }
}

class Watcher {
    constructor (vm, key, cb) {
        this.vm = vm;   // vm:表示当前实例
        this.key = key; // key:表示当前操作的数据名称
        this.cb = cb;   // cb:表示数据发生改变之后的回调

        Dep.target = this; // 全局唯一
        this.oldValue = this.vm.$data[key]; // 保存变化的数据作为旧值,后续作判断是否更新
        Dep.target = null;
    }
    
    update () {
        console.log(`数据发生变化!`);
        let oldValue = this.oldValue;
        let newValue = this.vm.$data[this.key];
        if (oldValue != newValue) {  // 比较新旧值,发生变化才执行回调
            this.cb(newValue, oldValue);
        };
    }
}

相比较第四节实现的 Watcher  类,这里做了调整:

  • 在构造函数中,增加 Dep.target 值操作;
  • 在构造函数中,增加 oldValue 变量,保存变化的数据作为旧值,后续作为判断是否更新的依据;
  • update() 方法中,增加当前操作对象 key 对应值的新旧值比较,如果不同,才执行回调。

Dep.target当前全局唯一的订阅者,因为同一时间只允许一个订阅者被处理。target当前正在处理的目标订阅者,当前订阅者处理完就赋值为 null 。这里 Dep.target 会在 defineReactive() 的 getter 中使用到。

通过改善后的 Watcher 类,我们操作当前操作对象 key 对应值的时候,可以在数据有变化的情况才执行回调,减少资源浪费。

4. 实现 compile.js

compile.js 实现了 Vue.js 的模版编译,如将 HTML 中的 {{text}} 模版转换为具体变量的值。

compile.js 介绍内容较多,考虑到篇幅问题,并且本文核心介绍响应式原理,所以这里就暂时不介绍 compile.js 的实现,在学习的朋友可以到我 Github 上下载该文件直接下载使用即可,地址:
https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/leo-vue-reactive/compile.js

5. 测试代码

到这里,我们已经将第四节的 demo 改造成简易版 Vue.js 响应式,接下来打开 index.html 看看效果:

当 index.js 中执行到:

vm.$data.text = '我们必须经常保持旧的记忆和新的希望。';

页面便发生更新,页面显示的文本内容从“你好,前端自习课”更新成“我们必须经常保持旧的记忆和新的希望。”。

到这里,我们的简易版 Vue.js 响应式原理实现好了,能跟着文章看到这里的朋友,给你点个大大的赞👍

六、总结

本文首先通过回顾观察者模式和 Object.defineProperty() 方法,介绍 Vue.js 响应式原理的核心知识点,然后带大家通过一个简单示例实现简单响应式,最后通过改造这个简单响应式的示例,实现一个简单 Vue.js 响应式原理的示例。

相信看完本文的朋友,对 Vue.js 的响应式原理的理解会更深刻,希望大家理清思路,再好好回味下~

参考资料

  1. 官方文档 - 深入响应式原理 
  2. 《浅谈Vue响应式原理》
  3. 《Vue的数据响应式原理》 
查看原文

赞 7 收藏 5 评论 0

阿宝哥 发布了文章 · 1月13日

前端进阶不可错过的 10 个 Github 仓库

2021 年已经来了,相信有一些小伙伴已经开始立 2021 年的 flag 了。在 2020 年有一些小伙伴,私下问阿宝哥有没有前端的学习资料。为了统一回答这个问题,阿宝哥精心挑选了 Github 上 10 个不错的开源项目。

当然这 10 个项目不仅限于前端领域,希望这些项目对小伙伴的进阶能有所帮助。下面我们先来介绍第一个项目 —— build-your-own-x

build-your-own-x

🤓 Build your own (insert technology here)

https://github.com/danistefan...

WatchStarForkDate
3.5K92.3K8.1K2021-01-04

该仓库涉及了 27 个领域的内容,每个领域会使用特定的语言来实现某个功能。下图是与前端领域相关的内容:

关注「全栈修仙之路」阅读阿宝哥原创的 4 本免费电子书(累计下载2.2万+)及 50 几篇 “重学TS” 教程。

JavaScript Algorithms

📝 Algorithms and data structures implemented in JavaScript with explanations and links to further readings

https://github.com/trekhleb/j...

WatchStarForkDate
3.6K91.6K15.4K2021-01-04

该仓库包含了多种 基于 JavaScript 的算法与数据结构。每种算法和数据结构都有自己的 README,包含相关说明和链接,以便进一步阅读 (还有相关的视频) 。

30 Seconds of Code

Short JavaScript code snippets for all your development needs

https://github.com/30-seconds...

WatchStarForkDate
2K66.9K7.4K2021-01-04

该仓库包含了众多能满足你开发需求,简约的 JavaScript 代码片段。比如以下的 listenOnce 函数,可以保证事件处理器只执行一次。

const listenOnce = (el, evt, fn) => {
  let fired = false;
  el.addEventListener(evt, (e) => {
    if (!fired) fn(e);
    fired = true;
  });
};

listenOnce(
  document.getElementById('my-btn'),
  'click',
  () => console.log('Hello!')
);  // 'Hello!' will only be logged on the first click

Node Best Practices

✅ The Node.js best practices list

https://github.com/goldbergyo...

WatchStarForkDate
1.7K58.5K5.6K2021-01-04

该仓库介绍了 Node.js 应用的最佳实践,包含以下的内容:

RealWorld example apps

"The mother of all demo apps" — Exemplary fullstack Medium.com clone powered by React, Angular, Node, Django, and many more 🏅

https://github.com/gothinkste...

WatchStarForkDate
1.6K52.5K4.5K2021-01-04

对于大多数的 “Todo” 示例来说,它们只是简单介绍了框架的功能,并没有完整介绍使用该框架和相关技术栈,构建真正应用程序所需要的知识和视角。

RealWorld 解决了这个问题,它允许你选择任意前端框架(React,Vue 或 Angular 等)和任意后端框架(Node,Go,Spring 等)来驱动一个真实的、设计精美的全栈应用程序 “Conduit“ 。下图是目前已支持的前端框架(内容较多,只截取部分内容):

clean-code-javascript

🛁 Clean Code concepts adapted for JavaScript

https://github.com/ryanmcderm...

WatchStarForkDate
1.5K43.9K5.3K2021-01-04

该仓库介绍了如何写出整洁的 JavaScript 代码,比如作者建议使用可检索的名称:

不好的

// 86400000 的用途是什么?
setTimeout(blastOff, 86400000);

好的

// 使用通俗易懂的常量来描述该值
const MILLISECONDS_IN_A_DAY = 60 * 60 * 24 * 1000; //86400000;

setTimeout(blastOff, MILLISECONDS_IN_A_DAY);

该仓库包含了 11 个方面的内容,具体的目录如下图所示:

javascript-questions

A long list of (advanced) JavaScript questions, and their explanations ✨

https://github.com/lydiahalli...

WatchStarForkDate
85027K3.6K2021-01-04

该仓库包含了从基础到进阶的 JavaScript 知识,利用该仓库你可以测试你对 JavaScript 知识的掌握程度,也可以帮助你准备面试。

awesome-design-patterns

A curated list of software and architecture related design patterns.

https://github.com/DovAmir/aw...

WatchStarForkDate
47711.6K9312021-01-04

该仓库包含了软件与架构相关的设计模式的精选列表。在软件工程中,设计模式(Design Pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。这个术语是由埃里希·伽玛(Erich Gamma)等人在1990年代从建筑设计领域引入到计算机科学的。

developer-roadmap

Roadmap to becoming a web developer in 2021

https://github.com/kamranahme...

WatchStarForkDate
7.4K142K21.3K2021-01-04

该仓库包含一组图表,这些图表展示了成为一个 Web 开发者的学习路线图。该仓库含有前端、后端和 DevOps 的学习路线图,这里我们只介绍前端的学习路线图(原图是长图,这里只截取部分区域):

Free Programming Books

📚 Freely available programming books

https://github.com/EbookFound...

WatchStarForkDate
9.2K170K39.8K2021-01-04

该仓库包含了多种语言的免费学习资源列表,下图是中文免费资源列表(内容较多,只截取部分内容):

好的,到这里所有的开源项目都已经介绍完了,如果小伙伴有其他的不错的开源项目,欢迎给阿宝哥留言哟。

关注「全栈修仙之路」阅读阿宝哥原创的 4 本免费电子书(累计下载 2.2万+)及 9 篇源码分析系列教程。
查看原文

赞 33 收藏 20 评论 0

阿宝哥 赞了文章 · 1月1日

为了搞清楚类加载,竟然手撸JVM!


作者:小傅哥
博客:https://bugstack.cn
Github:https://github.com/fuzhengwei/CodeGuide/wiki

沉淀、分享、成长,让自己和他人都能有所收获!😄

一、前言

学习,不知道从哪下手?

当学习一个新知识不知道从哪下手的时候,最有效的办法是梳理这个知识结构的脉络信息,汇总出一整张的思维导出。接下来就是按照思维导图的知识结构,一个个学习相应的知识点,并汇总记录。

就像JVM的学习,可以说它包括了非常多的内容,也是一个庞大的知识体系。例如:类加载加载器生命周期性能优化调优参数调优工具优化方案内存区域虚拟机栈直接内存内存溢出元空间垃圾回收可达性分析标记清除回收过程等等。如果没有梳理的一头扎进去,东一榔头西一棒子,很容易造成学习恐惧感。

如图 24-1 是 JVM 知识框架梳理,后续我们会按照这个结构陆续讲解每一块内容。

图 24-1 JVM 知识框架

二、面试题

谢飞机,小记!,很多知识根本就是背背背,也没法操作,难学!

谢飞机:大哥,你问我两个JVM问题,我看看我自己还行不!

面试官:啊?嗯!往死了问还是?

谢飞机:就就就,都行!你看着来!

面试官:啊,那 JVM 加载过程都是什么步骤?

谢飞机:巴拉巴拉,加载、验证、准备、解析、初始化、使用、卸载!

面试官:嗯,背的挺好!我怀疑你没操作过! 那加载的时候,JVM 规范规定从第几位开始是解析常量池,以及数据类型是如何定义的,u1、u2、u4,是怎么个玩意?

谢飞机:握草!算了,告诉我看啥吧!

三、类加载过程描述

图 24-2 JVM 类加载过程

JVM 类加载过程分为加载链接初始化使用卸载这四个阶段,在链接中又包括:验证准备解析

  • 加载:Java 虚拟机规范对 class 文件格式进行了严格的规则,但对于从哪里加载 class 文件,却非常自由。Java 虚拟机实现可以从文件系统读取、从JAR(或ZIP)压缩包中提取 class 文件。除此之外也可以通过网络下载、数据库加载,甚至是运行时直接生成的 class 文件。
  • 链接:包括了三个阶段;

    • 验证,确保被加载类的正确性,验证字节流是否符合 class 文件规范,例魔数 0xCAFEBABE,以及版本号等。
    • 准备,为类的静态变量分配内存并设置变量初始值等
    • 解析,解析包括解析出常量池数据和属性表信息,这里会包括 ConstantPool 结构体以及 AttributeInfo 接口等。
  • 初始化:类加载完成的最后一步就是初始化,目的就是为标记常量值的字段赋值,以及执行 <clinit> 方法的过程。JVM虚拟机通过锁的方式确保 clinit 仅被执行一次
  • 使用:程序代码执行使用阶段。
  • 卸载:程序代码退出、异常、结束等。

四、写个代码加载下

JVM 之所以不好掌握,主要是因为不好实操。虚拟机是 C++ 写的,很多 Java 程序员根本就不会去读,或者读不懂。那么,也就没办法实实在在的体会到,到底是怎么加载的,加载的时候都干了啥。只有看到代码,我才觉得自己学会了!

所以,我们这里要手动写一下,JVM 虚拟机的部分代码,也就是类加载的过程。通过 Java 代码来实现 Java 虚拟机的部分功能,让开发 Java 代码的程序员更容易理解虚拟机的执行过程。

1. 案例工程

interview-24
├── pom.xml
└── src
    └── main
    │    └── java
    │        └── org.itstack.interview.jvm
    │             ├── classpath
    │             │   ├── impl
    │             │   │   ├── CompositeEntry.java
    │             │   │   ├── DirEntry.java 
    │             │   │   ├── WildcardEntry.java 
    │             │   │   └── ZipEntry.java    
    │             │   ├── Classpath.java
    │             │   └── Entry.java    
    │             ├── Cmd.java
    │             └── Main.java
    └── test
         └── java
             └── org.itstack.interview.jvm.test
                 └── HelloWorld.java

以上,工程结构就是按照 JVM 虚拟机规范,使用 Java 代码实现 JVM 中加载 class 文件部分内容。当然这部分还不包括解析,因为解析部分的代码非常庞大,我们先从把 .class 文件加载读取开始了解。

2. 代码讲解

2.1 定义类路径接口(Entry)

public interface Entry {

    byte[] readClass(String className) throws IOException;
    
    static Entry create(String path) {
        //File.pathSeparator;路径分隔符(win\linux)
        if (path.contains(File.pathSeparator)) {
            return new CompositeEntry(path);
        }
        if (path.endsWith("*")) {
            return new WildcardEntry(path);
        }
        if (path.endsWith(".jar") || path.endsWith(".JAR") ||
                path.endsWith(".zip") || path.endsWith(".ZIP")) {
            return new ZipEntry(path);
        }
        return new DirEntry(path);
    }
}
  • 接口中提供了接口方法 readClass 和静态方法 create(String path)
  • jdk1.8 是可以在接口中编写静态方法的,在设计上属于补全了抽象类的类似功能。这个静态方法主要是按照不同的路径地址类型,提供不同的解析方法。包括:CompositeEntry、WildcardEntry、ZipEntry、DirEntry,这四种。接下来分别看每一种的具体实现

2.2 目录形式路径(DirEntry)

public class DirEntry implements Entry {

    private Path absolutePath;

    public DirEntry(String path){
        //获取绝对路径
        this.absolutePath = Paths.get(path).toAbsolutePath();
    }

    @Override
    public byte[] readClass(String className) throws IOException {
        return Files.readAllBytes(absolutePath.resolve(className));
    }

    @Override
    public String toString() {
        return this.absolutePath.toString();
    }
}
  • 目录形式的通过读取绝对路径下的文件,通过 Files.readAllBytes 方式获取字节码。

2.3 压缩包形式路径(ZipEntry)

public class ZipEntry implements Entry {

    private Path absolutePath;

    public ZipEntry(String path) {
        //获取绝对路径
        this.absolutePath = Paths.get(path).toAbsolutePath();
    }

    @Override
    public byte[] readClass(String className) throws IOException {
        try (FileSystem zipFs = FileSystems.newFileSystem(absolutePath, null)) {
            return Files.readAllBytes(zipFs.getPath(className));
        }
    }

    @Override
    public String toString() {
        return this.absolutePath.toString();
    }

}
  • 其实压缩包形式与目录形式,只有在文件读取上有包装差别而已。FileSystems.newFileSystem

2.4 混合形式路径(CompositeEntry)

public class CompositeEntry implements Entry {

    private final List<Entry> entryList = new ArrayList<>();

    public CompositeEntry(String pathList) {
        String[] paths = pathList.split(File.pathSeparator);
        for (String path : paths) {
            entryList.add(Entry.create(path));
        }
    }

    @Override
    public byte[] readClass(String className) throws IOException {
        for (Entry entry : entryList) {
            try {
                return entry.readClass(className);
            } catch (Exception ignored) {
                //ignored
            }
        }
        throw new IOException("class not found " + className);
    }


    @Override
    public String toString() {
        String[] strs = new String[entryList.size()];
        for (int i = 0; i < entryList.size(); i++) {
            strs[i] = entryList.get(i).toString();
        }
        return String.join(File.pathSeparator, strs);
    }
    
}
  • File.pathSeparator,是一个分隔符属性,win/linux 有不同的类型,所以使用这个方法进行分割路径。
  • 分割后的路径装到 List 集合中,这个过程属于拆分路径。

2.5 通配符类型路径(WildcardEntry)

public class WildcardEntry extends CompositeEntry {

    public WildcardEntry(String path) {
        super(toPathList(path));
    }

    private static String toPathList(String wildcardPath) {
        String baseDir = wildcardPath.replace("*", ""); // remove *
        try {
            return Files.walk(Paths.get(baseDir))
                    .filter(Files::isRegularFile)
                    .map(Path::toString)
                    .filter(p -> p.endsWith(".jar") || p.endsWith(".JAR"))
                    .collect(Collectors.joining(File.pathSeparator));
        } catch (IOException e) {
            return "";
        }
    }

}
  • 这个类属于混合形式路径处理类的子类,唯一提供的方法就是把类路径解析出来。

2.6 类路径解析(Classpath)

启动类路径扩展类路径用户类路径,熟悉吗?是不经常看到这几句话,那么时候怎么实现的呢?

有了上面我们做的一些基础类的工作,接下来就是类解析的实际调用过程。代码如下:

public class Classpath {

    private Entry bootstrapClasspath;  //启动类路径
    private Entry extensionClasspath;  //扩展类路径
    private Entry userClasspath;       //用户类路径

    public Classpath(String jreOption, String cpOption) {
        //启动类&扩展类 "C:\Program Files\Java\jdk1.8.0_161\jre"
        bootstrapAndExtensionClasspath(jreOption);
        //用户类 F:\..\org\itstack\demo\test\HelloWorld
        parseUserClasspath(cpOption);
    }

    private void bootstrapAndExtensionClasspath(String jreOption) {
        
        String jreDir = getJreDir(jreOption);

        //..jre/lib/*
        String jreLibPath = Paths.get(jreDir, "lib") + File.separator + "*";
        bootstrapClasspath = new WildcardEntry(jreLibPath);

        //..jre/lib/ext/*
        String jreExtPath = Paths.get(jreDir, "lib", "ext") + File.separator + "*";
        extensionClasspath = new WildcardEntry(jreExtPath);

    }

    private static String getJreDir(String jreOption) {
        if (jreOption != null && Files.exists(Paths.get(jreOption))) {
            return jreOption;
        }
        if (Files.exists(Paths.get("./jre"))) {
            return "./jre";
        }
        String jh = System.getenv("JAVA_HOME");
        if (jh != null) {
            return Paths.get(jh, "jre").toString();
        }
        throw new RuntimeException("Can not find JRE folder!");
    }

    private void parseUserClasspath(String cpOption) {
        if (cpOption == null) {
            cpOption = ".";
        }
        userClasspath = Entry.create(cpOption);
    }

    public byte[] readClass(String className) throws Exception {
        className = className + ".class";

        //[readClass]启动类路径
        try {
            return bootstrapClasspath.readClass(className);
        } catch (Exception ignored) {
            //ignored
        }

        //[readClass]扩展类路径
        try {
            return extensionClasspath.readClass(className);
        } catch (Exception ignored) {
            //ignored
        }

        //[readClass]用户类路径
        return userClasspath.readClass(className);
    }

}
  • 启动类路径,bootstrapClasspath.readClass(className);
  • 扩展类路径,extensionClasspath.readClass(className);
  • 用户类路径,userClasspath.readClass(className);
  • 这回就看到它们具体在哪使用了吧!有了具体的代码也就方便理解了

2.7 加载类测试验证

private static void startJVM(Cmd cmd) {
    Classpath cp = new Classpath(cmd.jre, cmd.classpath);
    System.out.printf("classpath:%s class:%s args:%s\n", cp, cmd.getMainClass(), cmd.getAppArgs());
    //获取className
    String className = cmd.getMainClass().replace(".", "/");
    try {
        byte[] classData = cp.readClass(className);
        System.out.println(Arrays.toString(classData));
    } catch (Exception e) {
        System.out.println("Could not find or load main class " + cmd.getMainClass());
        e.printStackTrace();
    }
}

这段就是使用 Classpath 类进行类路径加载,这里我们测试加载 java.lang.String 类。你可以加载其他的类,或者自己写的类

  • 配置IDEA,program arguments 参数:-Xjre "C:\Program Files\Java\jdk1.8.0_161\jre" java.lang.String
  • 另外这里读取出的 class 文件信息,打印的是 byte 类型信息。

测试结果

[-54, -2, -70, -66, 0, 0, 0, 52, 2, 28, 3, 0, 0, -40, 0, 3, 0, 0, -37, -1, 3, 0, 0, -33, -1, 3, 0, 1, 0, 0, 8, 0, 15, 8, 0, 61, 8, 0, 85, 8, 0, 88, 8, 0, 89, 8, 0, 112, 8, 0, -81, 8, 0, -75, 8, 0, -47, 8, 0, -45, 1, 0, 0, 1, 0, 3, 40, 41, 73, 1, 0, 20, 40, 41, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 79, 98, 106, 101, 99, 116, 59, 1, 0, 20, 40, 41, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110, 103, 59, 1, 0, 3, 40, 41, 86, 1, 0, 3, 40, 41, 90, 1, 0, 4, 40, 41, 91, ...]

这块部分截取的程序运行打印结果,就是读取的 class 文件信息,只不过暂时还不能看出什么。接下来我们再把它翻译过来!

五、解析字节码文件

JVM 在把 class 文件加载完成后,接下来就进入链接的过程,这个过程包括了内容的校验、准备和解析,其实就是把 byte 类型 class 翻译过来,做相应的操作。

整个这个过程内容相对较多,这里只做部分逻辑的实现和讲解。如果读者感兴趣可以阅读小傅哥的《用Java实现JVM》专栏。

1. 提取部分字节码

//取部分字节码:java.lang.String
private static byte[] classData = {
        -54, -2, -70, -66, 0, 0, 0, 52, 2, 26, 3, 0, 0, -40, 0, 3, 0, 0, -37, -1, 3, 0, 0, -33, -1, 3, 0, 1, 0, 0, 8, 0,
        59, 8, 0, 83, 8, 0, 86, 8, 0, 87, 8, 0, 110, 8, 0, -83, 8, 0, -77, 8, 0, -49, 8, 0, -47, 1, 0, 3, 40, 41, 73, 1,
        0, 20, 40, 41, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 79, 98, 106, 101, 99, 116, 59, 1, 0, 20, 40, 41,
        76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110, 103, 59, 1, 0, 3, 40, 41, 86, 1, 0, 3,
        40, 41, 90, 1, 0, 4, 40, 41, 91, 66, 1, 0, 4, 40, 41, 91, 67, 1, 0, 4, 40, 67, 41, 67, 1, 0, 21, 40, 68, 41, 76,
        106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110, 103, 59, 1, 0, 4, 40, 73, 41, 67, 1, 0, 4};
  • java.lang.String 解析出来的字节码内容较多,当然包括的内容也多,比如魔数、版本、类、常量、方法等等。所以我们这里只截取部分进行进行解析。

2. 解析魔数并校验

很多文件格式都会规定满足该格式的文件必须以某几个固定字节开头,这几个字节主要起到标识作用,叫作魔数(magic number)。

例如;

  • PDF文件以4字节“%PDF”(0x25、0x50、0x44、0x46)开头,
  • ZIP文件以2字节“PK”(0x50、0x4B)开头
  • class文件以4字节“0xCAFEBABE”开头
private static void readAndCheckMagic() {
    System.out.println("\r\n------------ 校验魔数 ------------");
    //从class字节码中读取前四位
    byte[] magic_byte = new byte[4];
    System.arraycopy(classData, 0, magic_byte, 0, 4);
    
    //将4位byte字节转成16进制字符串
    String magic_hex_str = new BigInteger(1, magic_byte).toString(16);
    System.out.println("magic_hex_str:" + magic_hex_str);
    
    //byte_magic_str 是16进制的字符串,cafebabe,因为java中没有无符号整型,所以如果想要无符号只能放到更高位中
    long magic_unsigned_int32 = Long.parseLong(magic_hex_str, 16);
    System.out.println("magic_unsigned_int32:" + magic_unsigned_int32);
    
    //魔数比对,一种通过字符串比对,另外一种使用假设的无符号16进制比较。如果使用无符号比较需要将0xCAFEBABE & 0x0FFFFFFFFL与运算
    System.out.println("0xCAFEBABE & 0x0FFFFFFFFL:" + (0xCAFEBABE & 0x0FFFFFFFFL));
    
    if (magic_unsigned_int32 == (0xCAFEBABE & 0x0FFFFFFFFL)) {
        System.out.println("class字节码魔数无符号16进制数值一致校验通过");
    } else {
        System.out.println("class字节码魔数无符号16进制数值一致校验拒绝");
    }
}
  • 读取字节码中的前四位,-54, -2, -70, -66,将这四位转换为16进制。
  • 因为 java 中是没有无符号整型的,所以只能用更高位存放。
  • 解析后就是魔数的对比,看是否与 CAFEBABE 一致。

测试结果

------------ 校验魔数 ------------
magic_hex_str:cafebabe
magic_unsigned_int32:3405691582
0xCAFEBABE & 0x0FFFFFFFFL:3405691582
class字节码魔数无符号16进制数值一致校验通过

3. 解析版本号信息

刚才我们已经读取了4位魔数信息,接下来再读取2位,是版本信息。

魔数之后是class文件的次版本号和主版本号,都是u2类型。假设某class文件的主版本号是M,次版本号是m,那么完整的版本号可以表示成“M.m”的形式。次版本号只在J2SE 1.2之前用过,从1.2开始基本上就没有什么用了(都是0)。主版本号在J2SE 1.2之前是45,从1.2开始,每次有大版本的Java版本发布,都会加1{45、46、47、48、49、50、51、52}

private static void readAndCheckVersion() {
    System.out.println("\r\n------------ 校验版本号 ------------");
    //从class字节码第4位开始读取,读取2位
    byte[] minor_byte = new byte[2];
    System.arraycopy(classData, 4, minor_byte, 0, 2);
    
    //将2位byte字节转成16进制字符串
    String minor_hex_str = new BigInteger(1, minor_byte).toString(16);
    System.out.println("minor_hex_str:" + minor_hex_str);
    
    //minor_unsigned_int32 转成无符号16进制
    int minor_unsigned_int32 = Integer.parseInt(minor_hex_str, 16);
    System.out.println("minor_unsigned_int32:" + minor_unsigned_int32);
    
    //从class字节码第6位开始读取,读取2位
    byte[] major_byte = new byte[2];
    System.arraycopy(classData, 6, major_byte, 0, 2);
    
    //将2位byte字节转成16进制字符串
    String major_hex_str = new BigInteger(1, major_byte).toString(16);
    System.out.println("major_hex_str:" + major_hex_str);
    
    //major_unsigned_int32 转成无符号16进制
    int major_unsigned_int32 = Integer.parseInt(major_hex_str, 16);
    System.out.println("major_unsigned_int32:" + major_unsigned_int32);
    System.out.println("版本号:" + major_unsigned_int32 + "." + minor_unsigned_int32);
}
  • 这里有一个小技巧,class 文件解析出来是一整片的内容,JVM 需要按照虚拟机规范,一段一段的解析出所有的信息。
  • 同样这里我们需要把2位byte转换为16进制信息,并继续从第6位继续读取2位信息。组合出来的才是版本信息。

测试结果

------------ 校验版本号 ------------
minor_hex_str:0
minor_unsigned_int32:0
major_hex_str:34
major_unsigned_int32:52
版本号:52.0

4. 解析全部内容对照

按照 JVM 的加载过程,其实远不止魔数和版本号信息,还有很多其他内容,这里我们可以把测试结果展示出来,方便大家有一个学习结果的比对印象。

classpath:org.itstack.demo.jvm.classpath.Classpath@4bf558aa class:java.lang.String args:null
version: 52.0
constants count:540
access flags:0x31
this class:java/lang/String
super class:java/lang/Object
interfaces:[java/io/Serializable, java/lang/Comparable, java/lang/CharSequence]
fields count:5
value          [C
hash          I
serialVersionUID          J
serialPersistentFields          [Ljava/io/ObjectStreamField;
CASE_INSENSITIVE_ORDER          Ljava/util/Comparator;
methods count: 94
<init>          ()V
<init>          (Ljava/lang/String;)V
<init>          ([C)V
<init>          ([CII)V
<init>          ([III)V
<init>          ([BIII)V
<init>          ([BI)V
checkBounds          ([BII)V
<init>          ([BIILjava/lang/String;)V
<init>          ([BIILjava/nio/charset/Charset;)V
<init>          ([BLjava/lang/String;)V
<init>          ([BLjava/nio/charset/Charset;)V
<init>          ([BII)V
<init>          ([B)V
<init>          (Ljava/lang/StringBuffer;)V
<init>          (Ljava/lang/StringBuilder;)V
<init>          ([CZ)V
length          ()I
isEmpty          ()Z
charAt          (I)C
codePointAt          (I)I
codePointBefore          (I)I
codePointCount          (II)I
offsetByCodePoints          (II)I
getChars          ([CI)V
getChars          (II[CI)V
getBytes          (II[BI)V
getBytes          (Ljava/lang/String;)[B
getBytes          (Ljava/nio/charset/Charset;)[B
getBytes          ()[B
equals          (Ljava/lang/Object;)Z
contentEquals          (Ljava/lang/StringBuffer;)Z
nonSyncContentEquals          (Ljava/lang/AbstractStringBuilder;)Z
contentEquals          (Ljava/lang/CharSequence;)Z
equalsIgnoreCase          (Ljava/lang/String;)Z
compareTo          (Ljava/lang/String;)I
compareToIgnoreCase          (Ljava/lang/String;)I
regionMatches          (ILjava/lang/String;II)Z
regionMatches          (ZILjava/lang/String;II)Z
startsWith          (Ljava/lang/String;I)Z
startsWith          (Ljava/lang/String;)Z
endsWith          (Ljava/lang/String;)Z
hashCode          ()I
indexOf          (I)I
indexOf          (II)I
indexOfSupplementary          (II)I
lastIndexOf          (I)I
lastIndexOf          (II)I
lastIndexOfSupplementary          (II)I
indexOf          (Ljava/lang/String;)I
indexOf          (Ljava/lang/String;I)I
indexOf          ([CIILjava/lang/String;I)I
indexOf          ([CII[CIII)I
lastIndexOf          (Ljava/lang/String;)I
lastIndexOf          (Ljava/lang/String;I)I
lastIndexOf          ([CIILjava/lang/String;I)I
lastIndexOf          ([CII[CIII)I
substring          (I)Ljava/lang/String;
substring          (II)Ljava/lang/String;
subSequence          (II)Ljava/lang/CharSequence;
concat          (Ljava/lang/String;)Ljava/lang/String;
replace          (CC)Ljava/lang/String;
matches          (Ljava/lang/String;)Z
contains          (Ljava/lang/CharSequence;)Z
replaceFirst          (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
replaceAll          (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
replace          (Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Ljava/lang/String;
split          (Ljava/lang/String;I)[Ljava/lang/String;
split          (Ljava/lang/String;)[Ljava/lang/String;
join          (Ljava/lang/CharSequence;[Ljava/lang/CharSequence;)Ljava/lang/String;
join          (Ljava/lang/CharSequence;Ljava/lang/Iterable;)Ljava/lang/String;
toLowerCase          (Ljava/util/Locale;)Ljava/lang/String;
toLowerCase          ()Ljava/lang/String;
toUpperCase          (Ljava/util/Locale;)Ljava/lang/String;
toUpperCase          ()Ljava/lang/String;
trim          ()Ljava/lang/String;
toString          ()Ljava/lang/String;
toCharArray          ()[C
format          (Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;
format          (Ljava/util/Locale;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;
valueOf          (Ljava/lang/Object;)Ljava/lang/String;
valueOf          ([C)Ljava/lang/String;
valueOf          ([CII)Ljava/lang/String;
copyValueOf          ([CII)Ljava/lang/String;
copyValueOf          ([C)Ljava/lang/String;
valueOf          (Z)Ljava/lang/String;
valueOf          (C)Ljava/lang/String;
valueOf          (I)Ljava/lang/String;
valueOf          (J)Ljava/lang/String;
valueOf          (F)Ljava/lang/String;
valueOf          (D)Ljava/lang/String;
intern          ()Ljava/lang/String;
compareTo          (Ljava/lang/Object;)I
<clinit>          ()V

Process finished with exit code 0

六、总结

  • 学习 JVM 最大的问题是不好实践,所以本文以案例实操的方式,学习 JVM 的加载解析过程。也让更多的对 JVM 感兴趣的研发,能更好的接触到 JVM 并深入的学习。
  • 有了以上这段代码,大家可以参照 JVM 虚拟机规范,在调试Java版本的JVM,这样就可以非常容易理解整个JVM的加载过程,都做了什么。
  • 如果大家需要文章中一些原图 xmind 或者源码,可以添加作者小傅哥(fustack),或者关注公众号:bugstack虫洞栈进行获取。好了,本章节就扯到这,后续还有很多努力,持续原创,感谢大家的支持!

七、系列推荐

查看原文

赞 10 收藏 7 评论 0

阿宝哥 赞了文章 · 2020-12-29

2020 总结 | 21 张图总结我的 2020 年

海沧湾公园.png

生活不可能像你想象的那么好,但也不会像你想象的那么糟。我觉得人的脆弱和坚强都超乎自己的想像,有时我脆弱得一句话就泪流满面,有时又发现自己咬着牙走了很长的路。

回看 2020,我更加喜爱这句话了,每个小句子都有了不同味道。

一、再见 2020 👋

2020疫情复工后,我便开始进入“战斗模式”,深受公众号“全栈修仙之路”作者“阿宝哥”影响🌟,开始把更多时间和精力用来修炼自身,努力成长和进阶,成为一位“靠谱的人”和一名“T 型人才”。

  • 靠谱的人:让自己靠谱,让别人放心;
  • T 型人才:深挖知识深度,拓展知识广度。

(以下数据统计的时间,全部以 2020-12-19 日截止)

🤗🤗🤗

1. 走过的路

疫情期间为了不给国家添麻烦,咱就天天家里窝着,闲来无事就给“貔貅”拍拍照啥的😎。

貔貅

复工后,骑上我的“绿豆”去了好多地方(快把厦门岛逛透透了),算是把疫情期间没去玩的地方都补上了🤠。

骑行.png

还有这杯意义不同的咖啡,和一句“心之所在即为家”😜。

星巴克的味道

当然,除了玩,这一年也做了很多重要的事情😊。

2020 年,写了很多文章(包含未发布),基本都放在语雀上。数一数,将近 150 篇文档是在 2020 年完成的🤔。

语雀

除了文章,我也画了很多图,慢慢形成自己的画图风格。

画图

当然,代码还是少不了的😎。

程序员嘛,当然要看看代码提交次数,这一年提交了这些代码,感觉 Github + Gitlab + Gitee 三个提交记录合并一下,都快铺满了。

  1. Github 提交记录

gitlab 提交记录.png

  1. Gitlab 提交记录

gitlab 提交记录.png

  1. Gitee(码云) 提交记录

gitlab 提交记录.png

另外自己的微信公众号“前端自习课”也完成“连续推送 810+ 天”的成绩,这一年,我也玩起短视频,视频号了,也开始自己做动画,将一些知识点通过动画和大家分享,视频可以查看《1分钟了解 Axios 拦截器实现原理》

1分钟了解 Axios 拦截器实现原理

2020 这一年还有很多事情想和大家分享,考虑到本文主旨和内容篇幅,就不再多介绍咯~有兴趣的朋友欢迎私聊我(微信:pingan8787)💘。

2. 感谢的人

今年最需要感谢的,是阿宝哥和我们“前端突击队学习小组的每位小伙伴啦💐~

🌰最大感受是:原来前端还能这样玩!
🌰最开心的是:团队学习更有动力,你不是一个人在战斗!

在阿宝哥指导下,整理了一份自己的前端技能树,才知道自己的前端技能有几斤几两重,也才有更多动力和更清晰的方向。

前端技能树

在我们学习小组中,采用“专题学习 + 总结输出”的方式一起学习,目前已经沉淀 200+ 篇文章啦!
小组目前 7 人(不含班主任),平均下来每人将近写了 30+ 篇!为小伙伴们点赞👍~

学习小组

2020 年 11 月的某一天,思考了最近学习的知识和接下来的需要做的事情,于是有了下面的这篇字数少,内容多的笔记(用手机敲的,就是有点手酸🙁):

学习总结

慢慢的,越来越发现,学得越多,发现自己要学的越多。🤣

这里再次感谢阿宝哥,感谢“前端突击队”的小伙伴们。未来继续冲🦆!

3. 遗憾的事

这一年,比较遗憾的事,是自己与阿里插肩而过呀🥺~倒也让我发现更多不足。

遗憾的事

这里也非常感谢内推的小伙伴,还有几位面试官,人都挺不错。😃

我们闽南人嘛,喜欢“爱拼才会赢”,所以,趁年轻多拼多创。

4. 点赞的事

这一年为自己坚持的几件事情点赞~
①自己微信公众号“前端自习课”连续推送 810+ 天文章,为此我把所有文章分类做了一张词云图,如下:

前端自习课

可以看出,我主要分享的内容包括:“JS”、“CSS”、“拓展”和“Web技术”。🔔

②自己坚持的每月学习文章整理,也超过 40+ 个月了,截图如下:

详细请看 github 地址:https://github.com/pingan8787/Leo_Reading

github 学习记录

二、你好 2021 👏

2021 年即将到来,希望新的一年,每一个“下次一定”都能实现完成承诺。

⚽️⚽️⚽️

1. 加油,前端工程师

在这前端生涯的第五年伊始,回想自己踩过的坑,走过的弯路,才慢慢领悟自己的前端生涯应该如何去走。

曾经和多数人一样,时常迷失学习什么知识,看到什么火,就去学什么,到头来,效果并不好。

未来自己的前端生涯,更应该站在巨人肩膀上,看向更远的地方。定个小目标呗,早日晋升技术专家。

接下来的时间里,做好自己在工作中的身份,做一个优秀的前端工程师。

桌面

2. 加油,小儿子

作为家中最小的孩子,被催婚已经成为这一年的常事,哈哈。

也许性格如此,加上独自在外工作,每天只想把事情做得更好,学更多知识,提升自己的价值。

很幸运这一年遇到了女孩 C。

接下来的时间里,做好自己在家里的身份,做一个让父母放心的好儿子。

五店市.png

3. 加油,骑行侠

我这人,兴趣爱好不太多,比如:骑行🚴、足球⚽️、敲代码💻。

骑行让我如此着迷。

换上衣服,12 月的寒风,也依然无法阻挡我的脚步。

接下来的时间里,坚持自己的热爱,做一个勇往直前大胆创的闽南人。

寒风.png

4. 加油,前端自习课

运营公众号“前端自习课”以后,认识了许多小伙伴,看见了许多从前的自己。

后来也慢慢和大家分享一些自己的经验和经历。

深刻记得,我简历中最后一句话:“希望自己的成⻓之路能帮助更多人,也希望在这个世界留下自己的一些足迹”。

接下来的时间里,坚持自己的初心,做一个对这个社区、这个社会有帮助的人。
zhihu.png

三、总结

每一年的总结,都是五味杂陈,才发现这一年来,自己又进步和成长了。

回顾篇头的一句话:“有时又发现自己咬着牙走了很长的路”。有时候一瞬间,一个偶然,发现自己原来咬着牙前进这么久,改变这么多。

最后,再思考一句话,希望对大家能有不同感受:

除去睡眠,人的一生有一万多天。但是人与人之间的区别就在于,你究竟是活了一万多天,还是仅仅活了一天,却重复了一万多次。

希望未来的我们,会感到自己的每一天都是崭新的。
武磊
像武磊一样努力,加油!

最后欢迎关注我呀~

本文参与了 SegmentFault 思否征文「2020 总结」,欢迎正在阅读的你也加入。
查看原文

赞 17 收藏 4 评论 11

阿宝哥 关注了用户 · 2020-12-24

dongzhe3917875 @dongzhe3917875

关注 32

阿宝哥 发布了文章 · 2020-12-24

想要复制图像?Clipboard API 了解一下

在写了 这个 29.7 K 的剪贴板 JS 库有点东西! 这篇文章之后,收到了小伙伴提的两个问题:

1.clipboard.js 这个库除了复制文字之外,能复制图像么?

2.clipboard.js 这个库依赖的 document.execCommand API 已被废弃了,以后应该怎么办?

(图片来源:https://developer.mozilla.org...

接下来,本文将围绕上述两个问题展开,不过在看第一个问题之前,我们先来简单介绍一下 剪贴板 📋。

剪贴板(英语:clipboard),有时也称剪切板、剪贴簿、剪贴本。它是一种软件功能,通常由操作系统提供,作用是使用复制和粘贴操作短期存储数据和在文档或应用程序间转移数据。它是图形用户界面(GUI)环境中最常用的功能之一,通常实现为匿名、临时的数据缓冲区,可以被环境内的大部分或所有程序使用编程接口访问。 —— 维基百科

通过以上的描述我们可以知道,剪贴板架起了一座桥梁,使得在各种应用程序之间,传递和共享信息成为可能。然而美中不足的是,剪贴板只能保留一份数据,每当新的数据传入,旧的便会被覆盖。

了解完 剪贴板 📋 的概念和作用之后,我们马上来看一下第一个问题:clipboard.js 这个库除了复制文字之外,能复制图像么?

关注「全栈修仙之路」阅读阿宝哥原创的 4 本免费电子书(累计下载近2.1万)及 50 几篇 “重学TS” 教程。

一、clipboard.js 能否复制图像?

clipboard.js 是一个用于将 文本 复制到剪贴板的 JS 库。没有使用 Flash,没有使用任何框架,开启 gzipped 压缩后仅仅只有 3kb

(图片来源:https://clipboardjs.com/#exam...

当你看到 “A modern approach to copy text to clipboard” 这个描述,你是不是已经知道答案了。那么实际的情况是怎样呢?下面我们来动手验证一下。在 这个 29.7 K 的剪贴板 JS 库有点东西! 这篇文章中,阿宝哥介绍了在实例化 ClipboardJS 对象时,可以通过 options 对象的 target 属性来设置复制的目标:

// https://github.com/zenorocha/clipboard.js/blob/master/demo/function-target.html
let clipboard = new ClipboardJS('.btn', {
  target: function() {
    return document.querySelector('div');
  }
});

利用 clipboard.js 的这个特性,我们可以定义以下 HTML 结构:

<div id="container">
   <img data-original="http://cdn.semlinker.com/abao.png" width="80" height="80"/>
   <p>大家好,我是阿宝哥</p>
</div>
<button class="btn">复制</button>

然后在实例化 ClipboardJS 对象时设置复制的目标是 #container 元素:

const clipboard = new ClipboardJS(".btn", {
  target: function () {
    return document.querySelector("#container");
  }
});

之后,我们点击页面中的 复制 按钮,对应的效果如下图所示:

观察上图可知,页面中的图像和文本都已经被复制了。对于文本来说,大家应该都很清楚。而对于图像来说,到底复制了什么?我们又该如何获取已复制的内容呢?针对这个问题,我们可以利用 HTMLElement 对象上的 onpaste 属性或者监听元素上的 paste 事件。

这里我们通过设置 document 对象的 onpaste 属性,来打印一下粘贴事件对应的事件对象:

document.onpaste = function (e) {
  console.dir(e);
}

当我们点击 复制 按钮,然后在页面执行 粘贴 操作后,控制台会打印出以下内容:

通过上图可知,在 ClipboardEvent 对象中含有一个 clipboardData 属性,该属性包含了与剪贴板相关联的数据。详细分析了 clipboardData 属性之后,我们发现已复制的图像和普通文本被封装为 DataTransferItem 对象。

为了更方便地分析 DataTransferItem 对象,阿宝哥重新更新了 document 对象的 onpaste 属性:

在上图中,我们可以清楚的看到 DataTransferItem 对象上含有 kindtype 属性分别用于表示数据项的类型(string 或 file)及数据对应的 MIME 类型。利用 DataTransferItem 对象提供的 getAsString 方法,我们可以获取该对象中保存的数据:

相信看完以上的输出结果,小伙伴们就很清楚第一个问题的答案了。那么如果想要复制图像的话,应该如何实现呢?其实这个问题的答案与小伙伴提的第二个问题的答案是一样的,我们可以利用 Clipboard API 来实现复制图像的问题及解决 document.execCommand API 已被废弃的问题。

接下来,我们的目标就是实现复制图像的功能了,因为要利用到 Clipboard API,所以阿宝哥先来介绍一下该 API。

二、Clipboard API 简介

Clipboard 接口实现了 Clipboard API,如果用户授予了相应的权限,就能提供系统剪贴板的读写访问。在 Web 应用程序中,Clipboard API 可用于实现剪切、复制和粘贴功能。该 API 用于取代通过 document.execCommand API 来实现剪贴板的操作。

在实际项目中,我们不需要手动创建 Clipboard 对象,而是通过 navigator.clipboard 来获取 Clipboard 对象:

在获取 Clipboard 对象之后,我们就可以利用该对象提供的 API 来访问剪贴板,比如:

navigator.clipboard.readText().then(
  clipText => document.querySelector(".editor").innerText = clipText);

以上代码将 HTML 中含有 .editor 类的第一个元素的内容替换为剪贴板的内容。如果剪贴板为空,或者不包含任何文本,则元素的内容将被清空。这是因为在剪贴板为空或者不包含文本时,readText 方法会返回一个空字符串。

在继续介绍 Clipboard API 之前,我们先来看一下 Navigator API: clipboard 的兼容性:

(图片来源:https://caniuse.com/mdn-api_n...

异步剪贴板 API 是一个相对较新的 API,浏览器仍在逐渐实现它。由于潜在的安全问题和技术复杂性,大多数浏览器正在逐步集成这个 API。对于浏览器扩展来说,你可以请求 clipboardRead 和 clipboardWrite 权限以使用 clipboard.readText() 和 clipboard.writeText()。

好的,接下来阿宝哥来演示一下如何使用 clipboard 对象提供的 API 来操作剪贴板,以下示例的运行环境是 Chrome 87.0.4280.88

三、将数据写入到剪贴板

3.1 writeText()

writeText 方法可以把指定的字符串写入到系统的剪贴板中,调用该方法后会返回一个 Promise 对象:

<button onclick="copyPageUrl()">拷贝当前页面地址</button>
<script>
   async function copyPageUrl() {
     try {
       await navigator.clipboard.writeText(location.href);
       console.log("页面地址已经被拷贝到剪贴板中");
     } catch (err) {
       console.error("页面地址拷贝失败: ", err);
     }
  }
</script>

对于上述代码,当用户点击 拷贝当前页面地址 按钮时,将会把当前的页面地址拷贝到剪贴板中。

3.2 write()

write 方法除了支持文本数据之外,还支持将图像数据写入到剪贴板,调用该方法后会返回一个 Promise 对象。

<button onclick="copyPageUrl()">拷贝当前页面地址</button>
<script>
   async function copyPageUrl() {
     const text = new Blob([location.href], {type: 'text/plain'});
     try {
       await navigator.clipboard.write(
         new ClipboardItem({
           "text/plain": text,
         }),
       );
       console.log("页面地址已经被拷贝到剪贴板中");
     } catch (err) {
       console.error("页面地址拷贝失败: ", err);
     }
  }
</script>

在以上代码中,我们先通过 Blob API 创建 Blob 对象,然后使用该 Blob 对象来构造 ClipboardItem 对象,最后再通过 write 方法把数据写入到剪贴板。介绍完如何将数据写入到剪贴板,下面我们来介绍如何从剪贴板中读取数据。

对 Blob API 感兴趣的小伙伴,可以阅读 你不知道的 Blob 这篇文章。

四、从剪贴板中读取数据

4.1 readText()

readText 方法用于读取剪贴板中的文本内容,调用该方法后会返回一个 Promise 对象:

<button onclick="getClipboardContents()">读取剪贴板中的文本</button>
<script>
   async function getClipboardContents() {
     try {
       const text = await navigator.clipboard.readText();
       console.log("已读取剪贴板中的内容:", text);
     } catch (err) {
       console.error("读取剪贴板内容失败: ", err);
     }
   }
</script>

对于上述代码,当用户点击 读取剪贴板中的文本 按钮时,如果当前剪贴板含有文本内容,则会读取剪贴板中的文本内容。

4.2 read()

read 方法除了支持读取文本数据之外,还支持读取剪贴板中的图像数据,调用该方法后会返回一个 Promise 对象:

<button onclick="getClipboardContents()">读取剪贴板中的内容</button>
<script>
async function getClipboardContents() {
  try {
    const clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
      for (const type of clipboardItem.types) {
        const blob = await clipboardItem.getType(type);
        console.log("已读取剪贴板中的内容:", await blob.text());
      }
    }
  } catch (err) {
      console.error("读取剪贴板内容失败: ", err);
    }
  }
</script>

对于上述代码,当用户点击 读取剪贴板中的内容 按钮时,则会开始读取剪贴板中的内容。到这里 clipboard 对象中涉及的 4 个 API,阿宝哥都已经介绍完了,最后我们来看一下如何实现复制图像的功能。

五、实现复制图像的功能

在最后的这个示例中,阿宝哥将跟大家一步步实现复制图像的核心功能,除了复制图像之外,还会同时支持复制文本。在看具体代码前,我们先来看一下实际的效果:

在上图对应的网页中,我们先点击 复制 按钮,则图像和文本都会被选中。之后,我们在点击 粘贴 按钮,则控制台会输出从剪贴板中读取的实际内容。在分析具体的实现方式前,我们先来看一下对应的页面结构:

<div id="container">
   <img data-original="http://cdn.semlinker.com/abao.png" width="80" height="80" />
   <p>大家好,我是阿宝哥</p>
</div>
<button onclick="writeDataToClipboard()">复制</button>
<button onclick="readDataFromClipboard()">粘贴</button>

上面的页面结构很简单,下一步我们来逐步分析一下以上功能的实现过程。

5.1 请求剪贴板写权限

默认情况下,会为当前的激活的页面自动授予剪贴板的写入权限。出于安全方面考虑,这里我们还是主动向用户请求剪贴板的写入权限:

async function askWritePermission() {
  try {
    const { state } = await navigator.permissions.query({
      name: "clipboard-write",
    });
      return state === "granted";
  } catch (error) {
      return false;
  }
}

5.2 往剪贴板写入图像和普通文本数据

要往剪贴板写入图像数据,我们就需要使用 navigator.clipboard 对象提供的 write 方法。如果要写入图像数据,我们就需要获取该图像对应的 Blob 对象,这里我们可以通过 fetch API 从网络上获取图像对应的响应对象并把它转化成 Blob 对象,具体实现方式如下:

async function createImageBlob(url) {
  const response = await fetch(url);
  return await response.blob();
}

而对于普通文本来说,只需要使用前面介绍的 Blob API 就可以把普通文本转换为 Blob 对象:

function createTextBlob(text) {
  return new Blob([text], { type: "text/plain" });
}

在创建完图像和普通文本对应的 Blob 对象之后,我们就可以利用它们来创建 ClipboardItem 对象,然后再调用 write 方法把这些数据写入到剪贴板中,对应的代码如下所示:

async function writeDataToClipboard() {
  if (askWritePermission()) {
    if (navigator.clipboard && navigator.clipboard.write) {
        const textBlob = createTextBlob("大家好,我是阿宝哥");
        const imageBlob = await createImageBlob(
          "http://cdn.semlinker.com/abao.png"
        );
        try {
          const item = new ClipboardItem({
            [textBlob.type]: textBlob,
            [imageBlob.type]: imageBlob,
          });
          select(document.querySelector("#container"));
          await navigator.clipboard.write([item]);
          console.log("文本和图像复制成功");
        } catch (error) {
          console.error("文本和图像复制失败", error);
        }
      }
   }
}

在以上代码中,使用了一个 select 方法,该方法用于实现选择的效果,对应的代码如下所示:

function select(element) {
  const selection = window.getSelection();
  const range = document.createRange();
  range.selectNodeContents(element);
  selection.removeAllRanges();
  selection.addRange(range);
}

通过 writeDataToClipboard 方法,我们已经把图像和普通文本数据写入剪贴板了。下面我们来使用 navigator.clipboard 对象提供的 read 方法,来读取已写入的数据。如果你需要读取剪贴板的数据,则需要向用户请求 clipboard-read 权限。

5.3 请求剪贴板读取权限

这里我们定义了一个 askReadPermission 函数来向用户请求剪贴板读取权限:

async function askReadPermission() {
  try {
    const { state } = await navigator.permissions.query({
      name: "clipboard-read",
    });
    return state === "granted";
  } catch (error) {
    return false;
  }
}

当调用 askReadPermission 方法后,将会向当前用户请求剪贴板读取权限,对应的效果如下图所示:

5.4 读取剪贴板中已写入的数据

创建好 askReadPermission 函数,我们就可以利用之前介绍的 navigator.clipboard.read 方法来读取剪贴板的数据了:

async function readDataFromClipboard() {
  if (askReadPermission()) {
    if (navigator.clipboard && navigator.clipboard.read) {
      try {
        const clipboardItems = await navigator.clipboard.read();
        for (const clipboardItem of clipboardItems) {
          console.dir(clipboardItem);
          for (const type of clipboardItem.types) {
            const blob = await clipboardItem.getType(type);
            console.log("已读取剪贴板中的内容:", await blob.text());
          }
        }
      } catch (err) {
         console.error("读取剪贴板内容失败: ", err);
      }
     }
   }
}

其实,除了点击 粘贴 按钮之外,我们还可以通过监听 paste 事件来读取剪贴板中的数据。需要注意的是,如果当前的浏览器不支持异步 Clipboard API,我们可以通过 clipboardData.getData 方法来读取剪贴板中的文本数据:

document.addEventListener('paste', async (e) => {
  e.preventDefault();
  let text;
  if (navigator.clipboard) {
    text = await navigator.clipboard.readText();
  } else {
    text = e.clipboardData.getData('text/plain');
  }
  console.log('已获取的文本数据: ', text);
});

而对于图像数据,则可以通过以下方式进行读取:

const IMAGE_MIME_REGEX = /^image\/(p?jpeg|gif|png)$/i;

document.addEventListener("paste", async (e) => {
  e.preventDefault();
  if (navigator.clipboard) {
    let clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
       for (const type of clipboardItem.types) {
         if (IMAGE_MIME_REGEX.test(type)) {
           const blob = await clipboardItem.getType(type);
           loadImage(blob);
           return;
         }
        }
     }
   } else {
       const items = e.clipboardData.items;
       for (let i = 0; i < items.length; i++) {
         if (IMAGE_MIME_REGEX.test(items[i].type)) {
         loadImage(items[i].getAsFile());
         return;
       }
    }
  }
});

以上代码中的 loadImage 方法用于实现把复制的图片插入到当前选区已选择的区域中,对应的代码如下:

function loadImage(file) {
  const reader = new FileReader();
  reader.onload = function (e) {
    let img = document.createElement("img");
    img.src = e.target.result;

    let range = window.getSelection().getRangeAt(0);
    range.deleteContents();
    range.insertNode(img);
  };
  reader.readAsDataURL(file);
}

在前面代码中,我们监听了 document 对象的 paste 事件。除了该事件之外,与剪贴板相关的常见事件还有 copycut 事件。篇幅有限,阿宝哥就不继续展开介绍了,感兴趣的小伙伴可以自行阅读相关资料。好的,至此本文就已经结束了,希望阅读完本文之后,大家对异步的 Clipboard API 会有些了解,有写得不清楚的地方,欢迎你随时跟阿宝哥交流哟。

关注「全栈修仙之路」阅读阿宝哥原创的 4 本免费电子书(累计下载近 2万)及 9 篇源码分析系列教程。

查看 ”复制图片“ 完整示例(Gist)

六、参考资源

查看原文

赞 6 收藏 4 评论 0

阿宝哥 赞了文章 · 2020-12-21

工作3年,看啥资料能月薪30K?


作者:小傅哥 | https://github.com/fuzhengwei/CodeGuide/wiki

沉淀、分享、成长,让自己和他人都能有所收获!😄

一、前言

月薪30K年薪是多少?

按照月薪30K,年终奖2~3个月来算,再算上季度的绩效奖金、加班费,可能也有一些大小周和节假日的三倍工资等。综合起来的税前年收入整体差不多在46W左右。当然如果你在年会中了个大奖也可以算进去,或者阳光普照个IPhone!

那30K月薪差不多是一个什么级别?不知道大家有没有看过下面这张图,这个图来自一个薪资统计的网站,如下:

互联网薪资对标 duibiao.info

  • 以上这种图的收入除了月薪还包括了,奖金、年终奖、股票,有些公司给的股票是比较多的。股票有一定的解禁期,并不是一次能拿完。
  • 那如果想拿月薪30K,基本是拿到了一个阿里的P6以及横向对标的级别。当然可能有些同学是在内部晋升加薪的,那样可能会略有差别。

30K对于工作3~5年还是蛮香的,但互联网大厂也确实不那么容易进去,如果在传统行业耽误了几年或者头几年做的项目单一,个人技术能力成长缓慢,过了30岁还真的挺难进去的。当然不是说30岁不要,只不过到了30岁,会要求面到更高的级别。

一般面试会从多方面进行考察,判断求职者是否满足招聘要求,如下图:但也有很牛皮的求职者可能就一两个问题的回答,就已经把面试官镇住了!

综上,梳理出七个方向的面试考点,包括:基本功底、常用技术、技术深度、技术经验、学习能力、工作能力、项目经验。

  • 基本功底,是一个程序员的主科目语言的学习程度的一个基本考察,这部分内容需要平时大量积累和总结。否则一本简单的Java书很难全部给你讲透彻,因为Java中包括了太多的内容,远不止API使用。
  • 常用技术,聊的是你的技术广度,和岗位技术匹配度。比如需要用到过RPC,那你用过Dubbo。如果你的公司暂时用的技术不多,或者还是处于单体服务,那么需要自己补充。
  • 技术深入,除了技术广度接下来就是技术深入,在你常用的技术栈中,你有多了解他们,了解源码吗、了解运行机制吗、了解设计原理吗。这部分内容常被人说是造火箭,但这部分内容非常重要,可以承上启下的贯穿个人修为和薪资待遇。
  • 技术经验,什么是技术经验呢?这是落地能力,除了你可能认为上面一些是纸上谈兵,是造火箭。那么接下来这部分内容就是你是否真造过一个火箭,真完成过一个难题。所以这部分是从结果证明,不是你会什么,而是你做过什么。
  • 学习能力,作为程序员你是否保持热情,是否依旧在积极努力的关注技术,是否为自己的成长不断添砖加瓦、是否还有好奇心和较强的求知欲。一般会从这里看你是不是一个真正的Coder!
  • 工作能力,以上的种种能力,最终要体现到工作上,要能看出你的交付能力。否则即使你再优秀,也不能把你当成一个吉祥物。工作能力的体现,才是真的为团队、为部门、为公司,贡献价值的。
  • 项目经验,这项内容会根据不同公司的不同业务线而不同,就像你懂交易、支付,那么面试花呗、借呗、白条等工作岗位就会很吃香。

好! 接下来小傅哥就带着你逐步介绍七个方向中的每一刻具体有哪些内容以及该如何学习。走起!

二、技术大纲

1. 基本功底

图 16-1 基本功底

  • 重要程度:⭐⭐⭐⭐
  • 内容介绍:数据结构讲的就是把数据放在不同形态的结构中,堆栈队列链表数组等。而算法逻辑就是把这些存放在数据结构中的数据按照一定规则进行增删改查,也就是二分、快排、动态规划、搜索等。而一门语言的核心技术就包括了对数据结构和算法的具体实现,像是我们用到的结合框架,ArrayList、HashMap等都是具体的实现。除此之外,在Java的核心技术中还要学习多线程、代理、反射等技术。这不只是面试内容,更是写好代码的基础!
  • 学习资料:算法图解、大话数据结构、数据结构与算法分析、算法导论、算法之美、计算机程序设计艺术
  • 语重心长:学习,从来不只仅仅是为了当下工作需要。简单的CRUD也可能真的不需要复杂的设计,但个人的年龄和能力一直要成正比!

2. 常用技术

图 16-2 常用技术

  • 重要程度:⭐⭐⭐⭐
  • 内容介绍:这部分内容是一个互联网研发中常用的技术栈内容,可能每个公司会有一些同类的其他技术,比如RPC框架就有很多种,但技术核心原理基本一致。可能以上的内容看上去比较杂,也可能有一些是你还没有接触过的,可以从上到下逐步了解。
  • 学习资料:http://tutorials.jenkov.comhttps://tech.meituan.com/http://mysql.taobao.org/monthly/、《面向模式的软件架构》、《设计原本》、《架构之美》、《Clean Architecture》
  • 语重心长:如果你并不想做一个工具人,就给自己的知识架构体系建设的完整一些,也算是风险抵抗了!

3. 技术深度

图 16-3 技术深度

  • 重要程度:⭐⭐⭐⭐⭐
  • 内容介绍:这一部分内容经常在面试求职过程中被称为造火箭、八股文。因为这部分知识探索到了JVM的运行机制,甚至去翻看C++源码,也包括JDK源码,同时还有框架的实现机制。除此之外,还有的公司会拓展到你可能完全没接触过的字节码插桩、全链路监控等等。
  • 学习资料:《java虚拟机规范》、《Java并发编程实战》、《多处理器编程的艺术》、《面经手册》《字节码编程》
  • 语重心长:有人说这叫内卷,那难道高考不卷?车牌号不卷?只要有资源竞争,就一定会有争夺。

4. 技术经验

图 16-4 技术经验

  • 重要程度:⭐⭐⭐⭐⭐
  • 内容介绍:如果你说问你源码、机制是造飞机,那技术的落地才是你真正的本事。这里一部分是框架、架构的搭建,另外一部分是源码和核心组件的使用。也就是你的核心框架源码学习,是否能做到技术迁移运用到你的项目中,做出可落地的程序。学习、沉淀、积累,这更像一盘大棋!
  • 学习资料:CodeGuide
  • 语重心长:不造轮子?对个人来说,轮子越多,车就越稳!

5. 学习能力

图 16-5 学习能力

  • 重要程度:⭐⭐⭐⭐
  • 内容介绍:学习能力主要是输入和输出,一遍吸纳知识,一遍沉淀知识。如果只看不记录不写,早早晚晚也就忘没了。这方便沉淀下来的内容都是个人的技术标签,尤其是参与过开源项目,或者自己有一个项目得到认可。
  • 学习资料:https://github.comhttps://stackoverflow.comhttps://www.csdn.nethttps://www.cnblogs.com
  • 语重心长:写博客真的是一种非常好的学习方式,每当你要输出一个知识的时候,你就需要阅读、收集、整理、汇总。日复一日的沉淀,终究会让你有非常大的提升。

6. 工作能力

图 16-6 工作能力

  • 重要程度:⭐⭐⭐⭐
  • 内容介绍:招聘人你觉得是先看能力还是先看素质?其实很多团队招聘是先看人的,如果你不能表现出一个积极、乐观、抗压、不玻璃心的态度,团队招聘是会有些抗拒的,谁也不希望招聘一个需要哄着的码宝男。但工作能力同样重要,最终是你的担事心态和担事能力来撑起你的工资和职位。
  • 学习资料:《非暴力沟通》、《关键对话-如何高效能沟通》、《逆商:我们该如何应对坏事件》、《人月神话》
  • 语重心长:沟通是解决双方或多方的认知偏差问题最终达成共识,情商是沟通的润滑剂,无论对谁都应该保持自己为追求更好而有的格局。

7. 项目经验

图 16-7 项目经验

  • 重要程度:⭐⭐⭐⭐
  • 内容介绍:项目经验来自于各个不同行业的技术范围,比如:社交、电商、外卖、出行、视频、音乐、汽车、支付、短视频等等,都会在各自的领域有一定的技术壁垒和相同之处。所以一般做游戏开发的可能跳槽到交易支付,还是会有很多不了解的。所以尽可能是在自己的行业内跳槽,或者你可以做到知识的拓展,自己多学习。
  • 语重心长:不要守着自己的一亩三分地,多看看、多了解。

三、30岁程序员占比

本周在群里做了一次简单的《2020年互联网程序员年龄分布统计》,因为人群的关系可能数据是有一些不准。但这份数据可以作为参考,也可以参与投票。

选项票数占比
未满 18 岁 - 19 岁113.9 %
20-25 岁10838.6 %
26-30 岁11139.6 %
31-35 岁279.6 %
36-40 岁113.9 %
41-45 岁93.2 %
46岁及以上31.1 %
  • 主力程序员集中在25~30岁,也就是刚毕业到工作7年左右。
  • 30以后的程序员呢?是不写代码了吗?其实,其实从这数据可以看出30以后的程序可能是晋升做管理,几乎不怎么参与到各种技术群的学习了。但也有另外一个现实,就是30岁以后基本都已经结婚生子,上有老、下有小。基本是没有自己的时间,也就没有了学习新知识的时间,也没有参与到各种技术群的时间。

统计数据

2020年互联网程序员年龄分布统计,截图

参与投票

2020年互联网程序员年龄分布统计,投票

四、总结

  • 与抵抗互联网风险相比能做的,只能是多学习、多沉淀、多积累。让30岁有30岁的能力,35岁有35岁的经历。因为没有所谓的安全,只有拥有留下的本事和走出去的能力才是安全的。
  • 30岁以后面临的不只是学习技术,还有很多原因是没有时间。有家庭、有父母、有妻子,有生活的杂事,有工作的占据,很难拿出一个时间给自己。哪怕是健身、学习,也得要挤时间。
  • 大部分程序员的愿望是什么?做过一次5年后的愿望收集,大部分希望升官发财、家庭美好、买车买房,也有希望一屋两人三餐四季,平平淡淡。其实大家在这个行业都很累,我的愿望可能是以后蜗居在天津,有个大书房、写写书、开车逛逛,有自由的时间。来自:程序员的愿望

五、系列推荐


我的博客:https://bugstack.cn

查看原文

赞 12 收藏 6 评论 0

阿宝哥 发布了文章 · 2020-12-18

这个 29.7 K 的剪贴板 JS 库有点东西!

2020 年即将结束了,不知不觉 源码分析 专题已经写了 9 篇文章,往期的 8 篇文章介绍了 AxiosBetterScrollkoa-composeFileSaver.js 等优秀的开源项目,该专题的每篇文章阿宝哥都花了挺多时间与精力。不过值得欣慰的是,专题中的多篇文章受到了社区小伙伴和公众号粉丝的认可与鼓励,这让阿宝哥有继续写该专题的动力,这里真心地感谢大家的支持。

对往期 8 篇文章感兴趣的小伙伴,可以阅读 如何更好地阅读源码?这八篇文章给你答案 这篇文章。

好的,我们马上回到正题。本期阿宝哥将介绍一个被 157317 个项目引用的 JS 开源库 —— clipboard.js。相信挺多小伙伴在项目中,也用到了这个库。那么这个库背后的工作原理是什么?感兴趣的小伙伴,跟阿宝哥一起来揭开这背后的秘密吧。

一、clipboard.js 简介

clipboard.js 是一个用于将文本复制到剪贴板的 JS 库。没有使用 Flash,没有使用任何框架,开启 gzipped 压缩后仅仅只有 3kb

(图片来源:https://clipboardjs.com/#exam...

那么为什么会有 clipboard.js 这个库呢?因为作者 zenorocha 认为:

将文本复制到剪贴板应该不难。它不需要几十个步骤来配置,也不需要加载数百 KB 的文件。最最重要的是,它不应该依赖于 Flash 或其他任何框架。

该库依赖于 SelectionexecCommand API,几乎所有的浏览器都支持 Selection API,然而 execCommand API 却存在一定的兼容性问题:

(图片来源:https://caniuse.com/?search=e...

(图片来源:https://caniuse.com/?search=e...

当然对于较老的浏览器,clipboard.js 也可以优雅地降级。好的,现在我们来看一下如何使用 clipboard.js。

关注「全栈修仙之路」阅读阿宝哥原创的 3 本免费电子书(累计下载近2万)及 50 几篇 “重学TS” 教程。

二、clipboard.js 使用

在使用 clipboard.js 之前,你可以通过 NPM 或 CDN 的方式来安装它:

NPM

npm install clipboard --save

CDN

<script data-original="https://cdn.jsdelivr.net/npm/clipboard@2.0.6/dist/clipboard.min.js"></script>

clipboard.js 使用起来很简单,一般只要 3 个步骤:

1.定义一些标记

<input id="foo" type="text" value="大家好,我是阿宝哥">
<button class="btn" data-clipboard-action="copy" data-clipboard-target="#foo">复制</button>

2.引入 clipboard.js

<script data-original="https://cdn.jsdelivr.net/npm/clipboard@2.0.6/dist/clipboard.min.js"></script>

3.实例化 clipboard

<script>
  var clipboard = new ClipboardJS('.btn');

  clipboard.on('success', function(e) {
    console.log(e);
  });
    
  clipboard.on('error', function(e) {
    console.log(e);
  });
</script>

以上代码成功运行之后,当你点击 “复制” 按钮时,输入框中的文字会被选中,同时输入框中的文字将会被复制到剪贴板中,对应的效果如下图所示:

除了 input 元素之外,复制的目标还可以是 divtextarea 元素。在以上示例中,我们复制的目标是通过 data-* 属性 来指定。此外,我们也可以在实例化 clipboard 对象时,设置复制的目标:

// https://github.com/zenorocha/clipboard.js/blob/master/demo/function-target.html
let clipboard = new ClipboardJS('.btn', {
  target: function() {
    return document.querySelector('div');
  }
});

如果需要设置复制的文本,我们也可以在实例化 clipboard 对象时,设置复制的文本:

// https://github.com/zenorocha/clipboard.js/blob/master/demo/function-text.html
let clipboard = new ClipboardJS('.btn', {
  text: function() {
    return '大家好,我是阿宝哥';
  }
});

关于 clipboard.js 的使用,阿宝哥就介绍到这里,感兴趣的小伙伴可以查看 Github 上 clipboard.js 的使用示例。由于 clipboard.js 底层依赖于 SelectionexecCommand API,所以在分析 clipboard.js 源码前,我们先来了解一下 SelectionexecCommand API。

三、Selection 与 execCommand API

3.1 Selection API

Selection 对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生。如果要获取用于检查或修改的 Selection 对象,可以调用 window.getSelection 方法。

Selection 对象所对应的是用户所选择的 ranges (区域),俗称 拖蓝。默认情况下,该函数只针对一个区域,我们可以这样使用这个函数:

let selection = window.getSelection();
let range = selection.getRangeAt(0);

以上示例演示了如何获取选区中的第一个区域,其实除了获取选区中的区域之外,我们还可以通过 createRange API 创建一个新的区域,然后将该区域添加到选区中:

<div>大家好,我是<strong>阿宝哥</strong>。欢迎关注<strong>全栈修仙之路</strong></div>
<script>
   let strongs = document.getElementsByTagName("strong");
   let s = window.getSelection();

   if (s.rangeCount > 0) s.removeAllRanges(); // 从选区中移除所有区域
   for (let i = 0; i < strongs.length; i++) {
     let range = document.createRange(); // 创建range区域
     range.selectNode(strongs[i]); // 让range区域包含指定节点及其内容
     s.addRange(range); // 将创建的区域添加到选区中
   }
</script>

以上代码用于选中页面中所有的 strong 元素,但需要注意的是,目前只有使用 Gecko 渲染引擎的浏览器,比如 Firefox 浏览器实现了多个区域。

在某些场景下,你可能需要获取选中区域中的文本。针对这种场景,你可以通过调用 Selection 对象的 toString 方法来获取被选中区域中的纯文本。

3.2 execCommand API

document.execCommand API 允许运行命令来操作网页中的内容,常用的命令有 bold、italic、copy、cut、delete、insertHTML、insertImage、insertText 和 undo 等。下面我们来看一下该 API 的语法:

bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)

相关的参数说明如下:

  • aCommandName:字符串类型,用于表示命令的名称;
  • aShowDefaultUI:布尔类型,用于表示是否展示用户界面,一般为 false;
  • aValueArgument:额外参数,一些命令(比如 insertImage)需要额外的参数(提供插入图片的 URL),默认为 null。

调用 document.execCommand 方法后,该方法会返回一个布尔值。如果是 false 的话,表示操作不被支持或未被启用。对于 clipboard.js 这个库来说,它会通过 document.execCommand API 来执行 copycut 命令,从而实现把内容复制到剪贴板。

那么现在问题来了,我们有没有办法判断当前浏览器是否支持 copycut 命令呢?答案是有的,即使用浏览器提供的 API —— Document.queryCommandSupported,该方法允许我们确定当前的浏览器是否支持指定的编辑命令。

clipboard.js 这个库的作者,也考虑到了这种需求,所以提供了一个静态的 isSupported 方法,用于检测当前的浏览器是否支持指定的命令:

// src/clipboard.js
static isSupported(action = ['copy', 'cut']) {
  const actions = (typeof action === 'string') ? [action] : action;
  let support = !!document.queryCommandSupported;

  actions.forEach((action) => {
    support = support && !!document.queryCommandSupported(action);
  });

  return support;
}

Document.queryCommandSupported 兼容性较好,大家可以放心使用,具体的兼容性如下图所示:

(图片来源:https://caniuse.com/?search=q...

介绍完 SelectionexecCommandqueryCommandSupported API,接下来我们开始分析 clipboard.js 的源码。

如果你想了解阅读源码的思路与技巧,可以阅读 使用这些思路与技巧,我读懂了多个优秀的开源项目 这篇文章。

四、clipboard.js 源码解析

4.1 Clipboard 类

看源码的时候,阿宝哥习惯从最简单的用法入手,这样可以快速地了解内部的执行流程。下面我们来回顾一下前面的示例:

<!-- 定义一些标记 -->
<input id="foo" type="text" value="大家好,我是阿宝哥">
<button class="btn" data-clipboard-action="copy" data-clipboard-target="#foo">复制</button>

<!-- 实例化 clipboard -->
<script>
  let clipboard = new ClipboardJS('.btn');

  clipboard.on('success', function(e) {
    console.log(e);
  });
    
  clipboard.on('error', function(e) {
    console.log(e);
  });
</script>

通过观察以上的代码,我们可以快速地找到切入点 —— new ClipboardJS('.btn')。在 clipboard.js 项目内的 webpack.config 配置文件中,我们可以找到 ClipboardJS 的定义:

module.exports = {
  entry: './src/clipboard.js',
  mode: 'production',
  output: {
    filename: production ? 'clipboard.min.js' : 'clipboard.js',
    path: path.resolve(__dirname, 'dist'),
    library: 'ClipboardJS',
    globalObject: 'this',
    libraryExport: 'default',
    libraryTarget: 'umd'
  },
  // 省略其他配置信息
}

基于以上的配置信息,我们进一步找到了 ClipboardJS 指向的构造函数:

import Emitter from 'tiny-emitter';
import listen from 'good-listener';

class Clipboard extends Emitter {
  constructor(trigger, options) {
    super();
    this.resolveOptions(options);
    this.listenClick(trigger);
  }
}

在示例中,我们并没有设置 Clipboard 的配置信息,所以我们先不用关心 this.resolveOptions(options) 的处理逻辑。顾名思义 listenClick 方法是用来监听 click 事件,该方法的具体实现如下:

listenClick(trigger) {
  this.listener = listen(trigger, 'click', (e) => this.onClick(e));
}

listenClick 方法内部,会通过一个第三方库 good-listener 来添加事件处理器。当目标触发 click 事件时,就会执行对应的事件处理器,该处理器内部会进一步调用 this.onClick 方法,该方法的实现如下:

// src/clipboard.js
onClick(e) {
  const trigger = e.delegateTarget || e.currentTarget;

  // 为每次点击事件,创建一个新的ClipboardAction对象
  if (this.clipboardAction) {
    this.clipboardAction = null;
  }
  this.clipboardAction = new ClipboardAction({
    action    : this.action(trigger),
    target    : this.target(trigger),
    text      : this.text(trigger),
    container : this.container,
    trigger   : trigger,
    emitter   : this
  });
}

onClick 方法内部,会使用事件触发目标来创建 ClipboardAction 对象。当你点击本示例 复制 按钮时,创建的 ClipboardAction 对象如下所示:

相信看完上图,大家对创建 ClipboardAction 对象时,所使用到的方法都有了解。那么 this.actionthis.targetthis.text 这几个方法是在哪里定义的呢?通过阅读源码,我们发现在 resolveOptions 方法内部会初始化上述 3 个方法:

// src/clipboard.js
resolveOptions(options = {}) {
  this.action = (typeof options.action === 'function') 
    ? options.action :  this.defaultAction;
  this.target = (typeof options.target === 'function') 
    ? options.target : this.defaultTarget;
  this.text = (typeof options.text === 'function')
    ? options.text : this.defaultText;
  this.container = (typeof options.container === 'object')   
    ? options.container : document.body;
}

resolveOptions 方法内部,如果用户自定义了处理函数,则会优先使用用户自定义的函数,否则将使用 clipboard.js 中对应的默认处理函数。由于我们在调用 Clipboard 构造函数时,并未设置 options 参数,所以将使用默认的处理函数:

由上图可知在 defaultActiondefaultTargetdefaultText 方法内部都会调用 getAttributeValue 方法来获取事件触发对象上自定义属性,而对应的 getAttributeValue 方法也很简单,具体代码如下:

// src/clipboard.js
function getAttributeValue(suffix, element) {
  const attribute = `data-clipboard-${suffix}`;
  if (!element.hasAttribute(attribute)) {
    return;
  }
  return element.getAttribute(attribute);
}

介绍完 Clipboard 类,接下来我们来重点分析一下 ClipboardAction 类,该类会包含具体的复制逻辑。

4.2 ClipboardAction 类

在 clipboard.js 项目中,ClipboardAction 类被定义在 src/clipboard-action.js 文件内:

// src/clipboard-action.js
class ClipboardAction {
  constructor(options) {
    this.resolveOptions(options);
    this.initSelection();
  }
}

Clipboard 类的构造函数一样,ClipboardAction 类的构造函数会优先解析 options 配置对象,然后调用 initSelection 方法,来初始化选区。在 initSelection 方法中会根据 texttarget 属性来选择不同的选择策略:

initSelection() {
  if (this.text) {
    this.selectFake();
  } else if (this.target) {
    this.selectTarget();
  }
}

对于前面的示例,我们是通过 data-* 属性 来指定复制的目标,即 data-clipboard-target="#foo",相应的代码如下:

<input id="foo" type="text" value="大家好,我是阿宝哥">
<button class="btn" data-clipboard-action="copy" data-clipboard-target="#foo">复制</button>

所以接下来我们先来分析含有 target 属性的情形,如果含有 target 属性,则会进入 else if 分支,然后调用 this.selectTarget 方法:

// src/clipboard-action.js
selectTarget() {
  this.selectedText = select(this.target);
  this.copyText();
}

selectTarget 方法内部,会调用 select 函数获取已选中的文本,该函数是来自 clipboard.js 作者开发的另一个 npm 包,对应的代码如下:

// https://github.com/zenorocha/select/blob/master/src/select.js
function select(element) {
  var selectedText;

  if (element.nodeName === 'SELECT') {
    element.focus();
    selectedText = element.value;
  }
  else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
    var isReadOnly = element.hasAttribute('readonly');

    if (!isReadOnly) {
      element.setAttribute('readonly', '');
    }

    element.select();
    element.setSelectionRange(0, element.value.length);

    if (!isReadOnly) {
      element.removeAttribute('readonly');
    } 
      selectedText = element.value;
    }
  else {
    // 省略相关代码 
  }
  return selectedText;
}

因为在以上示例中,我们复制的目标是 input 元素,所以我们先来分析该分支的代码。在该分支中,使用了 HTMLInputElement 对象的 selectsetSelectionRange 方法:

  • select:用于选中一个 <textarea> 元素或者一个带有 text 字段的 <input> 元素里的所有内容。
  • setSelectionRange:用于设定 <input><textarea> 元素中当前选中文本的起始和结束位置。

在获取选中的文本之后,selectTarget 方法会继续调用 copyText 方法来复制文本:

copyText() {
  let succeeded;
  try {
    succeeded = document.execCommand(this.action);
  } catch (err) {
    succeeded = false;
  }
  this.handleResult(succeeded);
}

前面阿宝哥已经简单介绍了 execCommand API,copyText 方法内部就是使用这个 API 来复制文本。在完成复制之后,copyText 方法会调用 this.handleResult 方法来派发复制的状态信息:

handleResult(succeeded) {
  this.emitter.emit(succeeded ? 'success' : 'error', {
    action: this.action,
    text: this.selectedText,
    trigger: this.trigger,
    clearSelection: this.clearSelection.bind(this)
  });
}

看到这里有些小伙伴可能会问 this.emitter 对象是来自哪里的?其实 this.emitter 对象就是 Clipboard 实例:

// src/clipboard.js
class Clipboard extends Emitter {
  onClick(e) {
    const trigger = e.delegateTarget || e.currentTarget;
    // 省略部分代码
    this.clipboardAction = new ClipboardAction({
      // 省略部分属性
      trigger   : trigger,
      emitter   : this // Clipboard 实例
    });
  }
}

而对于 handleResult 方法派发的事件,我们可以通过 clipboard 实例来监听对应的事件,具体的代码如下:

let clipboard = new ClipboardJS('.btn');

clipboard.on('success', function(e) {
  console.log(e);
});
    
clipboard.on('error', function(e) {
  console.log(e);
});

在继续介绍另一个分支的处理逻辑之前,阿宝哥用一张图来总结一下上述示例的执行流程:

下面我们来介绍另一个分支,即含有 text 属性的情形,对应的使用示例如下:

// https://github.com/zenorocha/clipboard.js/blob/master/demo/function-text.html
let clipboard = new ClipboardJS('.btn', {
  text: function() {
    return '大家好,我是阿宝哥';
  }
});

当用户在创建 clipboard 对象时,设置了 text 属性,则会执行 if 分支的逻辑,即调用 this.selectFake 方法:

// src/clipboard-action.js
class ClipboardAction {
  constructor(options) {
    this.resolveOptions(options);
    this.initSelection();
  }
  
  initSelection() {
    if (this.text) {
      this.selectFake();
    } else if (this.target) {
      this.selectTarget();
    }
  }
}

selectFake 方法内部,它会先创建一个假的 textarea 元素并设置该元素的相关样式和定位信息,并使用 this.text 的值来设置 textarea 元素的内容,然后使用前面介绍的 select 函数来获取已选择的文本,最后通过 copyText 把文本拷贝到剪贴板:

// src/clipboard-action.js
selectFake() {
  const isRTL = document.documentElement.getAttribute('dir') == 'rtl';

  this.removeFake(); // 移除事件监听并移除之前创建的fakeElem

  this.fakeHandlerCallback = () => this.removeFake();
  this.fakeHandler = this.container.addEventListener('click', this.fakeHandlerCallback) || true;

  this.fakeElem = document.createElement('textarea');
  // Prevent zooming on iOS
  this.fakeElem.style.fontSize = '12pt';
  // Reset box model
  this.fakeElem.style.border = '0';
  this.fakeElem.style.padding = '0';
  this.fakeElem.style.margin = '0';
  // Move element out of screen horizontally
  this.fakeElem.style.position = 'absolute';
  this.fakeElem.style[ isRTL ? 'right' : 'left' ] = '-9999px';
  // Move element to the same position vertically
  let yPosition = window.pageYOffset || document.documentElement.scrollTop;
  this.fakeElem.style.top = `${yPosition}px`;

  this.fakeElem.setAttribute('readonly', '');
  this.fakeElem.value = this.text;

  this.container.appendChild(this.fakeElem);

  this.selectedText = select(this.fakeElem);
  this.copyText();
}

为了让大家能够更直观了解 selectFake 方法执行后的页面效果,阿宝哥截了一张实际的效果图:

其实 clipboard.js 除了支持拷贝 inputtextarea 元素的内容之外,它还支持拷贝其它 HTML 元素的内容,比如 div 元素:

<div>大家好,我是阿宝哥</div>
<button class="btn" data-clipboard-action="copy" data-clipboard-target="div">Copy</button>

针对这种情形,在 clipboard.js 内部仍会利用前面介绍的 select 函数来选中目标元素并获取需拷贝的内容,具体的代码如下所示:

function select(element) {
  var selectedText;

  if (element.nodeName === 'SELECT') {
      element.focus();
      selectedText = element.value;
  }
  else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
      // 省略相关代码 
  }
  else {
     if (element.hasAttribute('contenteditable')) {
        element.focus();
     }

     var selection = window.getSelection(); // 获取选取
     var range = document.createRange(); // 新建区域

     range.selectNodeContents(element); // 使新建的区域包含element节点的内容
     selection.removeAllRanges(); // 移除选取中的所有区域
     selection.addRange(range); // 往选区中添加新建的区域
     selectedText = selection.toString(); // 获取已选中的文本
    }

    return selectedText;
}

在获得要拷贝的文本之后,clipboard.js 会继续调用 copyText 方法把对应的文本拷贝到剪贴板。到这里 clipboard.js 的核心源码,我们差不多都分析完了,希望阅读本文后,大家不仅了解了 clipboard.js 背后的工作原理,同时也学会了如何利用事件派发器来实现消息通信 及 SelectionexecCommand API 等相关的知识。

关注「全栈修仙之路」阅读阿宝哥原创的 3 本免费电子书(累计下载近 2万)及 9 篇源码分析系列教程。

五、参考资源

查看原文

赞 14 收藏 7 评论 0

阿宝哥 发布了文章 · 2020-12-10

Math.random() 还能这样玩?

相信大家对 Math.random 函数都不会陌生,调用该函数后会返回一个伪随机数,对应的取值范围是 [0, 1)。在日常工作中,应用的比较多的场景是生成 UUID,比如:

function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

当然除了上述方法外,还有其他的方法可以用来生成 UUID,感兴趣的小伙伴可以参考一下 Stack Overflow 上 “how-to-create-a-guid-uuid” 这一篇问答。Math.random 除了上述的应用场景之外,还可以应用在游戏、动画、随机数据、生成音乐或艺术图片等场景。

好的,废话不多说,接下来我们马上来一起感受一下 Math.random 的魅力。

关注「全栈修仙之路」阅读阿宝哥原创的 3 本免费电子书(累计下载近2万)及 50 几篇 “重学TS” 教程。

霓虹灯六角形粒子动画

(图片来源:https://codepen.io/towc/pen/m...

生成音乐

(图片来源:https://codepen.io/jakealbaug...

文字打乱效果

(图片来源:https://codepen.io/soulwire/p...

石头剪刀布游戏

(图片来源:https://codepen.io/studiojvla...

随机密码生成器

(图片来源:https://codepen.io/nourabusou...

随机背景颜色

(图片来源:https://codepen.io/meodai/pen...

生成艺术图案

(图片来源:https://codepen.io/tjoen/pen/...

看完以上的示例,你是不是觉得很惊讶。其实这些示例是阿宝哥从 “lots-of-ways-to-use-math-random-in-javascript” 这篇文章中介绍的例子从挑选出来的,感谢作者 Jwahir Sundai 为我们提供了那么👍的使用示例。如果你对其他的示例感兴趣的话,可以自行阅读一下该文章哟。

虽然 Math.random 函数能帮助我们实现很酷炫的动画或很好玩的功能,但该函数并不是真的随机,对应的算法被称为 伪随机数生成器(Pseudo Random Number Generator)。因为 Math.random 不能提供像密码一样安全的随机数字,所以不要使用它来处理有关安全的事情。针对信息安全的场景,你可以使用 Web Crypto API 来代替,并使用更精确的 window.crypto.getRandomValues() 方法。

参考资源

查看原文

赞 36 收藏 22 评论 1

阿宝哥 发布了文章 · 2020-12-08

聊一聊 15.5K 的 FileSaver,是如何工作的?

FileSaver.js 是在客户端保存文件的解决方案,非常适合在客户端上生成文件的 Web 应用程序。它简单易用且兼容大多数浏览器,被作为项目依赖应用在 6.3 万的项目中。在近期的项目中,阿宝哥再一次使用到了它,所以就想写篇文章来聊一聊这个优秀的开源项目。

一、FileSaver.js 简介

FileSaver.js 是 HTML5 的 saveAs() FileSaver 实现。它支持大多数主流的浏览器,其兼容性如下图所示:

(图片来源:https://github.com/eligrey/Fi...

关注「全栈修仙之路」阅读阿宝哥原创的 3 本免费电子书(累计下载近2万)及 50 几篇 “重学TS” 教程。

1.1 saveAs API

FileSaver saveAs(Blob/File/Url, optional DOMString filename, optional Object { autoBom })

saveAs 方法支持三个参数,第一个参数表示它支持 Blob/File/Url 三种类型,第二个参数表示文件名(可选),而第三个参数表示配置对象(可选)。如果你需要 FlieSaver.js 自动提供 Unicode 文本编码提示(参考:字节顺序标记),则需要设置 { autoBom: true}

1.2 保存文本

let blob = new Blob(["大家好,我是阿宝哥!"], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, "hello.txt");

1.3 保存线上资源

FileSaver.saveAs("https://httpbin.org/image", "image.jpg");

如果下载的 URL 地址与当前站点是同域的,则将使用 a[download] 方式下载。否则,会先使用 同步的 HEAD 请求 来判断是否支持 CORS 机制,若支持的话,将进行数据下载并使用 Blob URL 实现文件下载。如果不支持 CORS 机制的话,将会尝试使用 a[download] 方式下载。

标准的 W3C File API Blob 接口并非在所有浏览器中都可用,对于这个问题,你可以考虑使用 Blob.js 来解决兼容性问题。

(图片来源:https://caniuse.com/?search=blob

1.4 保存 Canvas 画布内容

let canvas = document.getElementById("my-canvas");
canvas.toBlob(function(blob) {
  saveAs(blob, "abao.png");
});

需要注意的是 canvas.toBlob() 方法并非在所有浏览器中都可用,对于这个问题,你可以考虑使用 canvas-toBlob.js 来解决兼容性问题。

(图片来源:https://caniuse.com/?search=t...

在以上的示例中,我们多次见到 Blob 的身影,因此在介绍 FileSaver.js 源码时,阿宝哥先来简单介绍一下 Blob 的相关知识。

二、Blob 简介

Blob(Binary Large Object)表示二进制类型的大对象。在数据库管理系统中,将二进制数据存储为一个单一个体的集合。Blob 通常是影像、声音或多媒体文件。在 JavaScript 中 Blob 类型的对象表示不可变的类似文件对象的原始数据。

2.1 Blob 构造函数

Blob 由一个可选的字符串 type(通常是 MIME 类型)和 blobParts 组成:

MIME(Multipurpose Internet Mail Extensions)多用途互联网邮件扩展类型,是设定某种扩展名的文件用一种应用程序来打开的方式类型,当该扩展名文件被访问的时候,浏览器会自动使用指定应用程序来打开。多用于指定一些客户端自定义的文件名,以及一些媒体文件打开方式。

常见的 MIME 类型有:超文本标记语言文本 .html text/html、PNG 图像 .png image/png、普通文本 .txt text/plain 等。

在 JavaScript 中我们可以通过 Blob 的构造函数来创建 Blob 对象,Blob 构造函数的语法如下:

var aBlob = new Blob(blobParts, options);

相关的参数说明如下:

  • blobParts:它是一个由 ArrayBuffer,ArrayBufferView,Blob,DOMString 等对象构成的数组。DOMStrings 会被编码为 UTF-8。
  • options:一个可选的对象,包含以下两个属性:

    • type —— 默认值为 "",它代表了将会被放入到 blob 中的数组内容的 MIME 类型。
    • endings —— 默认值为 "transparent",用于指定包含行结束符 \n 的字符串如何被写入。 它是以下两个值中的一个: "native",代表行结束符会被更改为适合宿主操作系统文件系统的换行符,或者 "transparent",代表会保持 blob 中保存的结束符不变。

介绍完 Blob 之后,我们再来介绍一下 Blob URL。

2.2 Blob URL

Blob URL/Object URL 是一种伪协议,允许 Blob 和 File 对象用作图像,下载二进制数据链接等的 URL 源。在浏览器中,我们使用 URL.createObjectURL 方法来创建 Blob URL,该方法接收一个 Blob 对象,并为其创建一个唯一的 URL,其形式为 blob:<origin>/<uuid>,对应的示例如下:

blob:https://example.org/40a5fb5a-d56d-4a33-b4e2-0acf6a8e5f641

浏览器内部为每个通过 URL.createObjectURL 生成的 URL 存储了一个 URL → Blob 映射。因此,此类 URL 较短,但可以访问 Blob。生成的 URL 仅在当前文档打开的状态下才有效。它允许引用 <img><a> 中的 Blob,但如果你访问的 Blob URL 不再存在,则会从浏览器中收到 404 错误。

上述的 Blob URL 看似很不错,但实际上它也有副作用。 虽然存储了 URL → Blob 的映射,但 Blob 本身仍驻留在内存中,浏览器无法释放它。映射在文档卸载时自动清除,因此 Blob 对象随后被释放。但是,如果应用程序寿命很长,那不会很快发生。因此,如果我们创建一个 Blob URL,即使不再需要该 Blob,它也会存在内存中。

针对这个问题,我们可以调用 URL.revokeObjectURL(url) 方法,从内部映射中删除引用,从而允许删除 Blob(如果没有其他引用),并释放内存。

好的,现在我们已经介绍了 Blob 和 Blob URL。如果你还意犹未尽,想深入理解 Blob 的话,可以阅读 你不知道的 Blob 这篇文章,接下来我们开始分析 FileSaver.js 的源码。

如果你想了解阅读源码的思路与技巧,可以阅读 使用这些思路与技巧,我读懂了多个优秀的开源项目 这篇文章。

三、FileSaver.js 源码解析

在 FileSaver.js 内部提供了三种方案来实现文件保存,因此接下来我们将分别来介绍这三种方案。

3.1 方案一

当 FileSaver.js 在保存文件时,如果当前平台中 a 标签支持 download 属性且非 MacOS WebView 环境,则会优先使用 a[download] 来实现文件保存。在具体使用过程中,我们是通过调用 saveAs 方法来保存文件,该方法的定义如下:

FileSaver saveAs(Blob/File/Url, optional DOMString filename, optional Object { autoBom })

通过观察 saveAs 方法的签名,我们可知该方法支持字符串和 Blob 两种类型的参数,因此在 saveAs 方法内部需要分别处理这两种类型的参数,下面我们先来分析字符串参数的情形。

3.1.1 字符串类型参数

在前面的示例中,我们演示了如何利用 saveAs 方法来保存线上的图片:

FileSaver.saveAs("https://httpbin.org/image", "image.jpg");

在方案一中,saveAs 方法的处理逻辑如下所示:

// Use download attribute first if possible (#193 Lumia mobile) unless this is a macOS WebView
function saveAs(blob, name, opts) {
  var URL = _global.URL || _global.webkitURL;
  var a = document.createElement("a");
  name = name || blob.name || "download";

  a.download = name;
  a.rel = "noopener";

  if (typeof blob === "string") {
    a.href = blob;
    if (a.origin !== location.origin) { // (1)
      corsEnabled(a.href)
        ? download(blob, name, opts)
        : click(a, (a.target = "_blank"));
    } else { // (2)
      click(a);
    }
  } else {
    // 省略处理Blob类型参数
  }
}

在以上代码中,如果发现下载资源的 URL 地址与当前站点是非同域的,则会先使用 同步的 HEAD 请求 来判断是否支持 CORS 机制,若支持的话,就会调用 download 方法进行文件下载。首先我们先来分析 corsEnabled 方法:

function corsEnabled(url) {
  var xhr = new XMLHttpRequest();
  xhr.open("HEAD", url, false);
  try {
    xhr.send();
  } catch (e) {}
  return xhr.status >= 200 && xhr.status <= 299;
}

corsEnabled 方法的实现很简单,就是通过 XMLHttpRequest API 发起一个同步的 HEAD 请求,然后判断返回的状态码是否在 [200 ~ 299] 的范围内。接着我们来看一下 download 方法的具体实现:

function download(url, name, opts) {
  var xhr = new XMLHttpRequest();
  xhr.open("GET", url);
  xhr.responseType = "blob";
  xhr.onload = function () {
    saveAs(xhr.response, name, opts);
  };
  xhr.onerror = function () {
    console.error("could not download file");
  };
  xhr.send();
}

同样 download 方法的实现也很简单,也是通过 XMLHttpRequest API 来发起 HTTP 请求,与大家熟悉的 JSON 格式不同的是,我们需要设置 responseType 的类型为 blob。此外,因为返回的结果是 blob 类型的数据,所以在成功回调函数内部会继续调用 saveAs 方法来实现文件保存。

而对于不支持 CORS 机制或同域的情形,它会调用内部的 click 方法来完成下载功能,该方法的具体实现如下:

// `a.click()` doesn't work for all browsers (#465)
function click(node) {
  try {
    node.dispatchEvent(new MouseEvent("click"));
  } catch (e) {
    var evt = document.createEvent("MouseEvents");
    evt.initMouseEvent(
      "click", true, true, window, 0, 0, 0, 80, 20, 
      false, false, false, false, 0, null
    );
    node.dispatchEvent(evt);
  }
}

click 方法内部,会优先调用 node 对象上的 dispatchEvent 方法来派发 click 事件。当出现异常的时候,会在 catch 语句进行相应的异常处理,catch 语句中的 MouseEvent.initMouseEvent() 方法用于初始化鼠标事件的值。但需要注意的是,该特性已经从 Web 标准中删除,虽然一些浏览器目前仍然支持它,但也许会在未来的某个时间停止支持,请尽量不要使用该特性

3.1.2 blob 类型参数

同样,在前面的示例中,我们演示了如何利用 saveAs 方法来保存 Blob 类型数据:

let blob = new Blob(["大家好,我是阿宝哥!"], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, "hello.txt");

blob 类型参数的处理逻辑,被定义在 saveAs 方法体的 else 分支中:

// Use download attribute first if possible (#193 Lumia mobile) unless this is a macOS WebView
function saveAs(blob, name, opts) {
  var URL = _global.URL || _global.webkitURL;
  var a = document.createElement("a");
  name = name || blob.name || "download";

  a.download = name;
  a.rel = "noopener";

  if (typeof blob === "string") {
     // 省略处理字符串类型参数
  } else {
    a.href = URL.createObjectURL(blob);
    setTimeout(function () {
      URL.revokeObjectURL(a.href);
    }, 4e4); // 40s
    setTimeout(function () {
      click(a);
    }, 0);
  }
}

对于 blob 类型的参数,首先会通过 createObjectURL 方法来创建 Object URL,然后在通过 click 方法执行文件保存。为了能及时释放内存,在 else 处理分支中,会启动一个定时器来执行清理操作。此时,方案一我们已经介绍完了,接下去要介绍的方案二主要是为了兼容 IE 浏览器。

3.2 方案二

在 Internet Explorer 10 浏览器中,msSaveBlob 和 msSaveOrOpenBlob 方法允许用户在客户端上保存文件,其中 msSaveBlob 方法只提供一个保存按钮,而 msSaveOrOpenBlob 方法提供了保存和打开按钮,对应的使用方式如下所示:

window.navigator.msSaveBlob(blobObject, 'msSaveBlob_hello.txt');
window.navigator.msSaveOrOpenBlob(blobObject, 'msSaveBlobOrOpenBlob_hello.txt');

了解完上述的知识和方案一中介绍的 corsEnableddownloadclick 方法后,再来看方案二的代码,就很清晰明了。在满足 "msSaveOrOpenBlob" in navigator 条件时, FileSaver.js 会使用方案二来实现文件保存。跟前面一样,我们先来分析 字符串类型参数 的处理逻辑。

3.2.1 字符串类型参数
// Use msSaveOrOpenBlob as a second approach
function saveAs(blob, name, opts) {
  name = name || blob.name || "download";
  if (typeof blob === "string") {
    if (corsEnabled(blob)) { // 判断是否支持CORS
      download(blob, name, opts);
    } else {
      var a = document.createElement("a");
      a.href = blob;
      a.target = "_blank";
      setTimeout(function () {
        click(a);
      });
    }
  } else {
    // 省略处理Blob类型参数
  }
}
3.2.2 blob 类型参数
// Use msSaveOrOpenBlob as a second approach
function saveAs(blob, name, opts) {
  name = name || blob.name || "download";
  if (typeof blob === "string") {
    // 省略处理字符串类型参数
  } else {
    navigator.msSaveOrOpenBlob(bom(blob, opts), name); // 提供了保存和打开按钮
  }
}

3.3 方案三

如果方案一和方案二都不支持的话,FileSaver.js 就会降级使用 FileReader API 和 open API 新开窗口来实现文件保存。

3.3.1 字符串类型参数
// Fallback to using FileReader and a popup
function saveAs(blob, name, opts, popup) {
  // Open a popup immediately do go around popup blocker
  // Mostly only available on user interaction and the fileReader is async so...
  popup = popup || open("", "_blank");
  if (popup) {
    popup.document.title = popup.document.body.innerText = "downloading...";
  }

  if (typeof blob === "string") return download(blob, name, opts);
    // 处理Blob类型参数
}
3.3.2 blob 类型参数

对于 blob 类型的参数来说,在 saveAs 方法内部会根据不同的环境选用不同的方案,比如在 Safari 浏览器环境中,它会利用 FileReader API 先把 Blob 对象转换为 Data URL,然后再把该 Data URL 地址赋值给新开的窗口或当前窗口的 location 对象,具体的代码如下:

// Fallback to using FileReader and a popup
function saveAs(blob, name, opts, popup) {
  // Open a popup immediately do go around popup blocker
  // Mostly only available on user interaction and the fileReader is async so...
  popup = popup || open("", "_blank");
  if (popup) { // 设置新开窗口的标题
    popup.document.title = popup.document.body.innerText = "downloading...";
  }

  if (typeof blob === "string") return download(blob, name, opts);

  var force = blob.type === "application/octet-stream"; // 二进制流数据
  var isSafari = /constructor/i.test(_global.HTMLElement) || _global.safari;
  var isChromeIOS = /CriOS\/[\d]+/.test(navigator.userAgent);

  if (
    (isChromeIOS || (force && isSafari) || isMacOSWebView) &&
    typeof FileReader !== "undefined"
  ) {
    // Safari doesn't allow downloading of blob URLs
    var reader = new FileReader();
    reader.onloadend = function () {
      var url = reader.result;
      url = isChromeIOS
        ? url
        : url.replace(/^data:[^;]*;/, "data:attachment/file;"); // 处理成附件的形式
      if (popup) popup.location.href = url;
      else location = url;
      popup = null; // reverse-tabnabbing #460
    };
    reader.readAsDataURL(blob);
  } else {
    // 省略Object URL的处理逻辑
  }
}

其实对于 FileReader API 来说,除了支持把 File/Blob 对象转换为 Data URL 之外,它还提供了 readAsArrayBuffer()readAsText() 方法,用于把 File/Blob 对象转换为其它的数据格式。在 玩转前端二进制 文章中,阿宝哥详细介绍了 FileReader API 在前端图片处理场景中的应用,阅读完该文章之后,你们将能轻松看懂以下转换关系图:

最后我们再来看一下 else 分支的代码:

function saveAs(blob, name, opts, popup) {
  popup = popup || open("", "_blank");
  if (popup) {
    popup.document.title = popup.document.body.innerText = "downloading...";
  }

  // 处理字符串类型参数
  if (typeof blob === "string") return download(blob, name, opts);

  if (
    (isChromeIOS || (force && isSafari) || isMacOSWebView) &&
    typeof FileReader !== "undefined"
  ) {
    // 省略FileReader API处理逻辑
  } else {
    var URL = _global.URL || _global.webkitURL;
    var url = URL.createObjectURL(blob);
    if (popup) popup.location = url;
    else location.href = url;
    popup = null; // reverse-tabnabbing #460
    setTimeout(function () {
      URL.revokeObjectURL(url);
    }, 4e4); // 40s
  }
}

到这里 FileSaver.js 这个库的源码已经分析完成了,跟着阿宝哥阅读上述源码之后,是不是觉得写一个兼容性好、简单易用的第三方库是多么不容易。在实际项目中,如果你需要保存超过 blob 大小限制的超大文件,或者没有足够的内存空间,你可以考虑使用更高级的 StreamSaver.js 库来实现文件保存功能。

关注「全栈修仙之路」阅读阿宝哥原创的 3 本免费电子书(累计下载近2万)及 8 篇源码分析系列教程。

四、参考资源

查看原文

赞 29 收藏 18 评论 0

认证与成就

  • 获得 4165 次点赞
  • 获得 20 枚徽章 获得 1 枚金徽章, 获得 10 枚银徽章, 获得 9 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • Angular FAQ

    Angular 常见问题汇总(2.x ~ 4.x)

  • Angular 2 & Ionic 2 资料汇总

    Angular 2 & Ionic 2 资料汇总

  • HTTP资源大全

    涉及 B/S、URI、MIME、HTTP请求和响应报文、HTTP 请求方法和状态码,并收录了 HTTP 经典教程和相关工具,如 Cookie 与 Session、HTTP 缓存、CORS、HTTP/2、HTTP爬虫、HTTPS及常用的HTTP抓包工具、Chrome相关插件、各平台HTTP包、压力测试工具等资料

注册于 2017-03-09
个人主页被 43.8k 人浏览