玩转经典十大Top10之手撕实现

11,201 阅读9分钟

最近发现很多时候,在面试时,总会被问到一些手写实现的题目,然后我也无聊的搜索了一番关于这样的题目都有哪些

结果惊人的相似,于是乎,我决定来给这些“老朋友们”来一次排行榜,排序按照先后,上榜各凭本事

不为别的,只为大家能在熟悉代码的同时,真正的去理解为什么会涉及到相关的问题,并以此为契机,好好努力,发愤图强下去,哈哈哈

那么,让我们闲言少叙,开始今天的top10吧!!!

第10名

手写cookie

出现指数:★☆☆☆☆ 考察指数:★☆☆☆☆ 综合评定:★☆☆☆☆ 整体评分:1.0

说到cookie想必前端小伙伴们绝对不会太陌生的,它是服务器发送到用户浏览器并保存在本地的一小块数据,会在浏览器下次向同一服务器发送请求时携带的数据

很小很强大的作用

  1. 会话状态管理(如用户登录状态、购物车、游戏分数或其它需要记录的信息)
  2. 个性化设置(如用户自定义设置、主题等)
  3. 浏览器行为跟踪(如跟踪分析用户行为等)

知道了cookie是干嘛的,我们就来看看cookie的格式是什么样子的吧 name=jay_chou; age=41; uid=19790118175; ,通过key=value的格式来书写

因为工作当中需要获取和设置cookie的情况比较多,所以需要了解怎么去实现这样的方法

const Cookie = {
    getCookie(key) {
        let match = document.cookie.match(new RegExp('(^| )' + key + '=([^;]*)(;|$)'));
        
        if (match && match.length) {
            return decodeURIComponent(match[2]);
        }
        return null;
    },
    setCookie(key, value, opts = {}) {
        const { maxAge, domain, path, secure, sameSite } = opts;
        const arr = [];

        if (maxAge) {
            arr.push(`max-age=${maxAge}`);
        }
        if (domain) {
            arr.push(`domain=${domain}`);
        }
        if (path) {
            arr.push(`path=${path}`);
        }
        if (secure) {
            arr.push('secure=true');
        }
        if (sameSite) {
            arr.push(`samesite=${sameSite}`);
        }

        document.cookie = `${key}=${encodeURIComponent(value)}; ${arr.join('; ')}`;
    }
};

简单分析:

getCookie

  1. document.cookie可以获取到对应cookie的值(非httpOnly:true的情况可以拿到)
  2. match匹配正则
    • 如age=41,它前面可能是空格,也可能是age为开头的字符,对应(^| )
    • key的后面跟着是=号,=号后面跟着是除了;号外的任意字符([^;]*)
    • ([^;]*)*表示0到多次,因为会出现有key没有value值的情况
    • 最后会以;结尾或者value结尾的情况,对应(;|$)
  3. 返回解码后匹配到的第2个分组,就是key对应的value值
  4. 没找到就返回null

setCookie

  1. 设置一个数组arr用来存放所有要设置的参数
  2. max-age用来设置cookie过期时间,以秒为单位
  3. domain设置域名,指定域名可以接受cookie,包含子域名(如.baidu.com设置,下面子域都可以接受cookie)
  4. path指定路径接受cookie(如path=/web,那么/web/fe,/web/login子路径也可以接受)
  5. secure指定只能在https协议下接受cookie
  6. sameSite在跨域的时候不发送cookie
  7. document.cookie直接设置对应的key和value以及配置参数即可

考点: 这道题,考察的是对cookie的熟悉程度,以及cookie都可以设置哪些参数并且要知道每个参数都是用来做什么的

第9名

手写数组展平

出现指数:★★☆☆☆ 考察指数:★☆☆☆☆ 综合评定:★☆☆☆☆ 整体评分:2.0

[1,2,[3,[4,[5,[6,[7,[8,[9]]]]]]]]遇到这种变态的多维数组结构也是醉了,虽然工作中很少出现,但这确实是一个考点

不废话,直接来看第一种实现方式,全部all in,展平成一维数组

const flatten1 = arr => {
    return arr.reduce((res, cur) => {
        // 如果当前项cur为数组,就继续递归展平
        if (Array.isArray(cur)) {
            // 返回新数组包括展开的原数组元素和新展平的数组元素
            return [...res, ...flatten1(cur)];
        } else {
            // 返回新数组包括展开的原数组元素和当前项
            return [...res, cur];
        }
    }, []);
};

上面的代码会将多维数组直接展平为一维数组,其实就已经完成了数组展平的工作了

But,有时候需要你展平一定的深度,不用统统展平成一维,这时候就需要加入一定参数来处理了

来看下第二种实现

const flatten2 = (arr, depth = 1, res = []) => {
    let i = -1, len = arr.length;

    while (++i < len) {
        // 选择当前项来进行判断
        let cur = arr[i];
        // 如果depth大于0并且当前项cur是数组
        if (depth > 0 && Array.isArray(cur)) {
            // 并且depth深度大于1的时候,继续递归处理,深度-1
            if (depth > 1) {
                flatten2(cur, depth - 1, res);
            } else {
                res.push(cur);
            }
        } else {
            // 当前项cur不是数组,就直接加到res的后面
            res[res.length] = cur;
        }
    }
    // 返回结果数组
    return res;
};

考点:数组展平,其实考察的是对于对象类型(数组)的情况判断,是否会调用递归来继续进行展平的问题

手写数组reduce

出现指数:★☆☆☆☆ 考察指数:★★☆☆☆ 综合评定:★☆☆☆☆ 整体评分:2.0

上题中实现数组展平的第一种实现,就用到了reduce方法,都知道reduce包含两个参数,第一个参数是传递的函数fn,第二个参数是初始化的值val

很多人都知道fn里常用的两个参数,1是累加器累加返回的值total,2是当前数组正在处理的元素cur

除此之外还有另外两个参数,3是当前数组元素的索引值index,4是数组本尊array

好了,介绍完了,那么就来实现一下吧

Array.prototype.reduce = function(fn, val) {
    // 很好理解,就判断val是否有传入值
    for (let i = 0; i < this.length; i++) {
        // 没有传入值
        if (typeof val === 'undefined') {
            val = this[i];
        } else { // 有传入val值
            // total就是初始值val,之后的依次传入对应
            val = fn(val, this[i], i, this);
        }
    }
    return val;
};

提示: 函数不能简写成箭头函数(fn, val) => {},不然会找不到this

考点:这是考察对reduce的熟练程度,以及是否了解第一参数fn所包含有哪些参数及其含义

手写sleep

出现指数:★★☆☆☆ 考察指数:★☆☆☆☆ 综合评定:★☆☆☆☆ 整体评分:2.0

因为js中一直没有休眠语法,所以就需要处理那种在一定时间后再做某事的需求

直接上代码就能理解了

function sleep(fn, time) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(fn);
        }, time);
    }); 
}

// 测试用例
let index = 0;

function fn() {
    console.log('我要吃饭了', index++);
}

async function play() {
    let a = await sleep(fn, 1000);
    a();
    let b = await sleep(fn, 2000);
    b()
    let c = await sleep(fn, 3000);
    c()
}

play();

考点:本题考察的对于Promise以及async/await的用法和应用场景

第8名

手写继承

出现指数:★☆☆☆☆ 考察指数:★★☆☆☆ 综合评定:★★☆☆☆ 整体评分:3.0

现如今继承还是会被当做考点来进行考察的,继承也有多种方式

  1. 原型链继承
  2. 借用构造函数
  3. 组合继承
  4. 原型式继承
  5. 寄生组合继承

继承方式也是多种多样,即便是组合继承也有一定的缺点(会调用两次父类的构造函数,一次创建子类原型的时候new父类,一次是子类构造函数中),对于趋于完善的继承还是有些要改进的地方的

接下来我们直接说寄生组合继承的情况

  • 为了解决组合继承的缺点就是会调用两次父类的构造函数问题
  • 直接创建父类的原型副本,然后再赋值到子类的原型上(这步算做寄生式继承)
  • 组合还是在子类构造函数中调用父类构造函数
// 寄生组合继承
function inherit(subs, supers) {
    let proto = Object.create(supers.prototype); // 父类原型副本
    proto.constructor = subs;   // constructor指向子类
    subs.prototype = proto;     // 赋给子类的prototype原型
}
// 父类
function Super(name) {
    this.name = name;
    this.colors = ['爱,很简单', '寂寞的季节'];
}
Super.prototype.sayName = function() {
    console.log(this.name);
};

// 子类
function Sub(name, age) {
    // 组合还是Super.call(this, ...args)构造函数继承实例上的属性和方法
    Super.call(this, name);
    this.age = age;
}
// 继承
inherit(Sub, Super);

Sub.prototype.sayAge = function() {
    console.log(this.age);
}

// 测试用例
let sub = new Sub('陶喆', 18);
sub.colors.push('就是爱你');
sub.sayName();
sub.sayAge();
console.log(sub.colors);

let super1 = new Super('JJ');
console.log(super1.colors);

考点:关于继承的考点主要围绕着多种继承方式的熟悉程度以及相应的缺点,并得出一套较为完善的继承方式

第7名

手写柯里化函数

出现指数:★★☆☆☆ 考察指数:★★☆☆☆ 综合评定:★★☆☆☆ 整体评分:4.0

柯里化就是把接受多个参数的函数变换成接受一个单一参数的函数,并且返回接受余下参数返回结果的技术

简而言之,你可以在一个函数中填充几个参数,然后再返回一个新函数最后进行求值

说的再简单都不如几行代码演示的清楚明白

function sum(a, b, c, d) {
    return a + b + c + d;
}
// 柯里化函数
function curry(fn, ...args) {
    // 如果传递的参数还没有达到要执行的函数fn的个数
    // 就继续返回新的函数(高阶函数)
    // 并且返回curry函数传递剩下的参数
    if (args.length < fn.length) {
        return (...newArgs) => curry(fn, ...args, ...newArgs);
    } else {
        return fn(...args);
    }
}

// 测试用例
let add = curry(sum);
console.log(add(1)(2)(3)(4));
console.log(add(1, 2, 3)(4));
console.log(add(1, 2)(3, 4));
console.log(add(1)(2, 3)(4));

考点:主要考察对高阶函数的理解,以及柯里化是部分求值的过程

第6名

EventEmitter

出现指数:★★☆☆☆ 考察指数:★★☆☆☆ 综合评定:★★☆☆☆ 整体评分:5.0

曾几何时,是不是有人问过你知道发布订阅么,当你回答知道的时候并且噼里啪啦的解释一遍后,那人微微一笑,给我写一个EventEmitter吧

不要慌,不过就是on, emit, off, once方法而已了

既问之,则写之

// 我们以ES6类的形式写出来
class EventEmitter {
    constructor() {
        // 事件对象,存储订阅的type类型
        this.events = Object.create(null);
    }
    on(type, cb) {
        let events = this.events;
        // 如果该type类型存在,就继续向数组中添加回调cb
        if (events[type]) {
            events[type].push(cb);
        } else {
            // type类型第一次存入的话,就创建一个数组空间并存入回调cb
            event[type] = [cb];
        }
    },
    emit(type, ...args) {
        // 遍历对应type订阅的数组,全部执行
        if (this.events[type]) {
            this.events[type].forEach(listener => {
                listener.call(this, ...args);
            });   
        }
    }
    off(type, cb) {
        let events = this.events;
        if (events[type]) {
            events[type] = events[type].filter(listener => {
                // 过滤用不到的回调cb
                return listener !== cb && listener.listen !== cb;
            });
        }
    }
    once(type, cb) {
        function wrap() {
            cb(...arguments);
            this.off(type, wrap);
        }
        // 先绑定,调用后删除
        wrap.listen = cb;
        // 直接调用on方法
        this.on(type, wrap);
    }
}

考点:考察对发布订阅是否有真正的理解,本质是简单的(创建数组,往数组里添加回调,等执行的时候遍历数组依次执行即可),遇到这样的考题不要慌张,想想原理和思路

第5名

手写Promise.all与race

出现指数:★★★★☆ 考察指数:★★☆☆☆ 综合评定:★★★☆☆ 整体评分:6.0

如果提到了Promise,那么多半情况这两兄弟出场就八九不离十了

Promise.all传入一组以promise为实例的数组,all方法会按照传入数组内的顺序依次执行,直到那个耗时最久的resolve返回,才能算做全部成功。中间环节如果有一个出现reject就直接中断掉

Promise.race顾名思义,传递的参数和all一样,它是看谁先resolve返回了就结束执行了,并返回的是最先成功的那个

应用场景

  • all
    • 并发请求按顺序执行;多个异步结果合并到一起
  • race
    • 判断接口是否超时
// Promise.all和Promise.race本质都是返回一个新的Promise实例

Promise.all = function(arr) {
    return new Promise((resolve, reject) => {
        // 用结果数组记录,每个promise实例传递的数据
        let res = [];
        // 索引记录是否全部完成
        let index = 0;

        for (let i = 0; i < arr.length; i++) {
            // p为每一个promise实例,调用then方法
            let p = arr[i];
            p.then(data => {
                // 把data数据赋给结果数组对应的索引位置
                res[i] = data;
                
                if (++index === arr.length) {
                    // 全部遍历完毕后,再把结果数组统一返回
                    resolve(res);
                }
            }, reject);
        }
    });
};

Promise.race = function(arr) {
    return new Promise(() => {
        for (let i = 0; i < arr.length; i++) {
            arr[i].then(resolve, reject);
        }
    });  
};

// 测试用例
let p1 = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(1)
    }, 2000);
});
let p2 = new Promise((resolve) => {
    resolve(2);
});
let p3 = new Promise((resolve) => {
    setTimeout(() => {
        resolve(3);
    });
});

Promise.all([p3, p1, p2]).then(data => {
    // 按传入数组的顺序打印
    console.log(data);  // [3, 1, 2]
});

Promise.race([p1, p2, p3]).then(data => {
    // 谁快就是谁
    console.log(data);  // 2
})

考点:考察对Promise API熟练程度,熟知all和race的区别是什么,以及要知道它们各自应用在什么场景下

第4名

手写instanceOf

出现指数:★★★☆☆ 考察指数:★★★☆☆ 综合评定:★★★☆☆ 整体评分:7.0

instanceOf我们用来判断某个实例是否所属某个类,这里肯定是对于原型和原型链的一个理解,那么话不多说,直接看代码

function instanceOf(A, B) {
    B = B.prototype;
    A = A.__proto__;
    while(true) {
        if (A === null) {
            return false
        }
        if (A === B) {
            return true;
        }
        A = A.__proto__;
    }
}

考点:这道题主要考察的就是原型和原型链之间的关系,实例的原型链就是所属类的原型,要知道这样的一层关系就比较好理解了

手写jsonp

出现指数:★★★☆☆ 考察指数:★★★☆☆ 综合评定:★★★☆☆ 整体评分:7.0

说到跨域,那大名鼎鼎的当属jsonp了,虽然现在CORS解决起来很方便,但是那多数是后台同学给配置的事,前端儿遇到跨域情况,jsonp也是用的较多的了

相信很多同学都知道jsonp的实现原理,实际就是依靠script的src属性就轻松解决了,当然后端同学是通过我们传递的callback参数来进行了数据的包裹并最终返回给我们的,简单看一下是什么样的

  • 后端返回出来的jsonp数据
app.get('/jsonp', (req, res) => {
    const {callback, wd, from} = req.query;
    let data = {
        msg: '这是jsonp数据',
        word: wd,
        referer: from,
        data: [1, 2, 3]
    };
    data = JSON.stringify(data);
    // 约定好的callback函数,然后把数据填充到里面返回给前端
    res.end(callback + '(' + data + ')');
});

看到这里应该明白jsonp在后端那边是怎么给处理并返回出来的了,那么现在我们直击内部,开始手写实现了

const jsonp = (opts = {}) => {
    // 通过一个callback参数所对应的函数名来把数据进行写入
    opts.url = `${opts.url}?callback=${opts.callback}`;
    // 在你需要传递其他参数时,需要遍历后拼接到url上
    for (let key in opts.data) {
        if (opts.data.hasOwnProperty(key)) {
            opts.url += `&${key}=${opts.data[key]}`;
        }
    }
    // 主要是依靠script的src属性加载内容没有跨域情况
    const script = document.createElement('script');
    script.src = opts.url;
    // 在script脚本执行完毕后,再删除此脚本
    script.onload = () => {
        document.body.removeChild(script);
    }
    // 把创建好的script脚本添加到body中
    document.body.appendChild(script);
};

// 测试用例
jsonp({
    url: 'http://localhost:8888/cors',
    data: {
        wd: 'nba',
        from: 'home'
    },
    // 接收数据的函数
    callback: 'getData'
});

function getData(data) {
    // 通过jsonp拿到的真实数据
    console.log(data);
}

考点:对于jsonp都知道它是通过script脚本的src属性加载时不存在跨域的情况,所以通过创建script并且src加载获取到了数据,当然后端是如何填充数据的过程我们也要清楚一些,这样才能学会知其然还要知其所以然

第3名

手写深拷贝

出现指数:★★★★☆ 考察指数:★★★☆☆ 综合评定:★★★★☆ 整体评分:8.0

深拷贝也可以叫深克隆,whatever叫什么不重要,重要的是这个问题,真的是经典中的经典了,也是防止共用内存地址所导致的数据错乱问题,那么不绕弯子了,直接开整

等等,由于工作中使用的数据类型当中多以对象和数组居多,所以也不会针对像正则,日期这些进行处理了,如果有感兴趣的可以私下再去研究一番

let o = {a: 1, b: 2, c: [1,2,3], d:{age:18}};
o.o = o;  // 循环引用的情况

const deepClone = (obj, map = new Map()) => {
    // 说白了,只有对象类型({}, [])才需要处理深拷贝
    if (typeof obj === 'object' && obj !== null) {
        // 处理循环引用的情况,如果之前已经有了该数据了
        // 就直接返回,没必要再重新处理了
        if (map.has(obj)) {
            return map.get(obj);
        }
        // 主要就是两种情况,数组和对象
        // 创建一个新的对象or数组
        let data = Array.isArray(obj) ? [] : {};
        // 设置数据,防止循环引用
        map.set(obj, data);
        
        for (let key in obj) {
            if (obj.hasOwnProperty(key)) {
                // 通过对obj下的所有数据类型进行递归处理
                data[key] = deepClone(obj[key], map);
            }
        }
        // 返回data,由于是新的对象or数组
        // 所以会开辟新的内存空间,不会和老的obj共用空间
        // 就不会导致数据错乱干扰的情况了
        return data;
    }
    // 基础类型之间返回即可
    return obj;
}

// 测试用例
let o2 = deepClone(o);
o2.c.pop();
o2.a = 110;
o2.d.name = '小白';
console.log(o, o2);

考点:首先要知道为什么要有深拷贝的出现,其次要知道哪些数据类型需要进行深拷贝并且理解递归都做了哪些事情

手写new

出现指数:★★★☆☆ 考察指数:★★★★☆ 综合评定:★★★★☆ 整体评分:8.0

对于如何创建一个实例,我们简直是太清楚了,new一下就可以了,比如没有对象,我们就new一个对象,哈哈,开个玩笑

我们来回顾一下new的过程

  1. 创建一个新对象
  2. 把新对象的prototype原型指向该构造函数的原型
  3. this指向创建出来的实例上
  4. 如果没有返回其他对象,就返回这个新对象

有时候文字总是苍白无力的,那就直接用代码说明原因吧

function Dog(name) {
    this.name = name;
    
    // 返回对象
    // return {
    //     name,
    //     age: 5   
    // }
    
    // 返回函数
    // return function() {
    //    return name;
    // }
}

Dog.prototype.say = function() {
    console.log(this.name);
    return this.name;
};

// 手写new
function New(Super, ...args) {
    // 创建一个继承构造函数原型的对象
    let obj = Object.create(Super.prototype);
    // res是表示构造函数返回的结果
    let res = Super.call(obj, ...args);
    // 第一个条件是返回对象
    // 第二个条件是返回函数
    if ((res !== null && typeof res === 'object') || typeof res === 'function') {
        return res;
    }
    // 返回新对象
    return obj;
}

// 测试用例
let dog = New(Dog, '小白');
dog.say();
// console.log(dog.name, dog.age);
// console.log(dog());

考点:很多人都知道new一个实例的时候,对应类会在this上挂很多属性和方法,但是有的时候会考察如果类的构造函数中直接返回的是对象或者函数是什么情况呢。而且也要通过new的过程来去更好的思考,它到底做了什么事

第2名

手写call和apply

出现指数:★★★★☆ 考察指数:★★★★☆ 综合评定:★★★★☆ 整体评分:9.0

关于call和apply问的更多的是它们之间的区别,除此之外,偶尔有人会问它们两个的性能谁更胜一筹,也许你会有些思考,不过没关系,通过下面的代码实现,就能轻而易举的发现,call比apply会更快一些哦

  • call和apply
// call实现
Function.prototype.call = function(context, ...args) {
    // 执行上下文都保证是对象类型,如果不是就是window
    context = Object(context) || window;
    // 创建一个额外的变量当做context的属性
    const fn = Symbol();
    // 给这个fn属性赋值为当前的函数
    context[fn] = this;
    // 执行函数把...args传入
    const result = context[fn](...args);
    // 删除使用过的fn属性
    delete context[fn];
    // 返回函数执行结果
    return result;
};

// 测试用例 - call
let o = { a: 1 };
function fn(b) {
    console.log(this.a, b);
}
fn.call(o, '你好');

// apply实现
Function.prototype.apply = function(context, arrArgs) {
    context = Object(context) || window;
    const fn = Symbol();
    context[fn] = this;
    // 需要把传入apply的数组进行展开运算
    // 所以在这里性能会有些消耗相比call来讲
    const result = context[fn](...arrArgs);
    delete context[fn];
    return result;
}

// 测试用例 - apply
let o = { a: 1 };
function fn(...b) {console.log(this.a, ...b)}
fn.apply(o, [2, 3, 4]);

考点:这道题首先考察的是对于两个方法的使用是否熟悉,区别点是传递的第2个参数的形式,apply必须是数组,call则是任意类型而且可传多个参数。

除此之外,给执行上下文设置私有属性并且得到函数的执行结果,最终删除使用过的私有属性且返回结果也是一个考察的地方

手写bind

出现指数:★★★★☆ 考察指数:★★★★☆ 综合评定:★★★★☆ 整体评分:9.0

bind方法其实你可以理解为高阶函数的一种实际应用,bind和call与apply的区别就在于,它并不是立马执行函数,而是有一个延迟执行的操作,就是所谓的生成了一个新的函数

那么,还是看下代码更容易理解吧

Function.prototype.bind = function(context, ...args) {
    // context为要改变的执行上下文
    // ...args为传入bind函数的其余参数
    return (...newArgs) => {
        // 这里返回一个新的函数
        // 通过调用call方法改变this指向并且把老参和新参一并传入
        return this.call(context, ...args, ...newArgs);
    }
};
// 测试用例
let o = {name: 'chd'};
function f(...args) {
    console.log(this.name, ...args);
}
let o2 = f.bind(o, 1, '222', null, [1,2,3], {age: 5});
o2();

考点:考察对bind的熟悉及理解,以及是否了解高阶函数,并且还要知道和call与apply的区别

第1名

手写节流和防抖

出现指数:★★★★★ 考察指数:★★★★★ 综合评定:★★★★★ 整体评分:10.0

有一种优化方式叫做体验优化,其中使用频率较高的就是节流和防抖了

节流是一定时间内执行一次函数,多用在scroll事件上

防抖是在一定时间内执行最后一次的函数,多用在input输入操作的时候

下面,就来看看我们的Top1是如何书写的吧

// 节流
function throttle(fn, time) {
    // 设置初始时间
    let pre = 0;
    // 返回一个新的函数
    return (...args) => {
        // 记录当前时间
        let now = Date.now();
        // 通过时间差来进行节流
        if (now - pre > time) {
            // 执行fn函数
            fn.call(this, ...args);
            // 更新pre的时间
            pre = now;
        }
    }
}

// 防抖
function debounce(fn, time, isNow) {
    // 设置定时器变量
    let timer;
    return (...args) => {
        // 默认首次是立即触发的,不应该一上来就延迟执行fn
        if (isNow) {
            fn.call(this, ...args);
            isNow = false;
            return;
        }
        // 如果上一个定时器还在执行,就直接返回    
        if (timer) return;
        // 设置定时器
        timer = setTimeout(() => {
            fn.call(this, ...args);
            // 清除定时器
            clearTimeout(timer);
            timer = null;
        }, time);
    }
}

考点:节流和防抖绝对是老生常谈的话题,通过这两种优化手段,我们能在用户体验上有很大的提升。通过setTimeout来设定时间,在指定时间内进行函数fn的执行,并且要知道防抖的首次触发时机

结束

很感谢大家不辞辛苦的看到这里了,虽然是Top10,但是前端在面试过程中要被考察到的知识点也是非常庞大的,要保证系统的学习以及多思考一个为什么都是非常有意义的事情

那么,这次就写到这里吧,886