写点什么

2022 秋招前端面试题(六)(附答案)

  • 2022 年 8 月 08 日
  • 本文字数:10397 字

    阅读完需:约 34 分钟

代码输出结果

console.log(1);
setTimeout(() => { console.log(2); Promise.resolve().then(() => { console.log(3) });});
new Promise((resolve, reject) => { console.log(4) resolve(5)}).then((data) => { console.log(data);})
setTimeout(() => { console.log(6);})
console.log(7);复制代码
复制代码


代码输出结果如下:


1475236复制代码
复制代码


代码执行过程如下:


  1. 首先执行 scrip 代码,打印出 1;

  2. 遇到第一个定时器 setTimeout,将其加入到宏任务队列;

  3. 遇到 Promise,执行里面的同步代码,打印出 4,遇到 resolve,将其加入到微任务队列;

  4. 遇到第二个定时器 setTimeout,将其加入到红任务队列;

  5. 执行 script 代码,打印出 7,至此第一轮执行完成;

  6. 指定微任务队列中的代码,打印出 resolve 的结果:5;

  7. 执行宏任务中的第一个定时器 setTimeout,首先打印出 2,然后遇到 Promise.resolve().then(),将其加入到微任务队列;

  8. 执行完这个宏任务,就开始执行微任务队列,打印出 3;

  9. 继续执行宏任务队列中的第二个定时器,打印出 6。

事件流

事件流是网页元素接收事件的顺序,"DOM2 级事件"规定的事件流包括三个阶段:事件捕获阶段、处于目标阶段、事件冒泡阶段。首先发生的事件捕获,为截获事件提供机会。然后是实际的目标接受事件。最后一个阶段是时间冒泡阶段,可以在这个阶段对事件做出响应。虽然捕获阶段在规范中规定不允许响应事件,但是实际上还是会执行,所以有两次机会获取到目标对象。


<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>事件冒泡</title></head><body>    <div>        <p id="parEle">我是父元素    <span id="sonEle">我是子元素</span></p>    </div></body></html><script type="text/javascript">var sonEle = document.getElementById('sonEle');var parEle = document.getElementById('parEle');parEle.addEventListener('click', function () {    alert('父级 冒泡');}, false);parEle.addEventListener('click', function () {    alert('父级 捕获');}, true);sonEle.addEventListener('click', function () {    alert('子级冒泡');}, false);sonEle.addEventListener('click', function () {    alert('子级捕获');}, true);
</script>复制代码
复制代码


当容器元素及嵌套元素,即在捕获阶段又在冒泡阶段调用事件处理程序时:事件按 DOM 事件流的顺序执行事件处理程序:


  • 父级捕获

  • 子级捕获

  • 子级冒泡

  • 父级冒泡


且当事件处于目标阶段时,事件调用顺序决定于绑定事件的书写顺序,按上面的例子为,先调用冒泡阶段的事件处理程序,再调用捕获阶段的事件处理程序。依次 alert 出“子集冒泡”,“子集捕获”。

为什么需要浏览器缓存?

对于浏览器的缓存,主要针对的是前端的静态资源,最好的效果就是,在发起请求之后,拉取相应的静态资源,并保存在本地。如果服务器的静态资源没有更新,那么在下次请求的时候,就直接从本地读取即可,如果服务器的静态资源已经更新,那么我们再次请求的时候,就到服务器拉取新的资源,并保存在本地。这样就大大的减少了请求的次数,提高了网站的性能。这就要用到浏览器的缓存策略了。


所谓的浏览器缓存指的是浏览器将用户请求过的静态资源,存储到电脑本地磁盘中,当浏览器再次访问时,就可以直接从本地加载,不需要再去服务端请求了。


使用浏览器缓存,有以下优点:


  • 减少了服务器的负担,提高了网站的性能

  • 加快了客户端网页的加载速度

  • 减少了多余网络数据传输

事件触发的过程是怎样的

事件触发有三个阶段:


  • window 往事件触发处传播,遇到注册的捕获事件会触发

  • 传播到事件触发处时触发注册的事件

  • 从事件触发处往 window 传播,遇到注册的冒泡事件会触发


事件触发一般来说会按照上面的顺序进行,但是也有特例,如果给一个 body 中的子节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行。


// 以下会先打印冒泡然后是捕获node.addEventListener(  'click',  event => {    console.log('冒泡')  },  false)node.addEventListener(  'click',  event => {    console.log('捕获 ')  },  true)复制代码
复制代码


通常使用 addEventListener 注册事件,该函数的第三个参数可以是布尔值,也可以是对象。对于布尔值 useCapture 参数来说,该参数默认值为 falseuseCapture 决定了注册的事件是捕获事件还是冒泡事件。对于对象参数来说,可以使用以下几个属性:


  • capture:布尔值,和 useCapture 作用一样

  • once:布尔值,值为 true 表示该回调只会调用一次,调用后会移除监听

  • passive:布尔值,表示永远不会调用 preventDefault


一般来说,如果只希望事件只触发在目标上,这时候可以使用 stopPropagation 来阻止事件的进一步传播。通常认为 stopPropagation 是用来阻止事件冒泡的,其实该函数也可以阻止捕获事件。


stopImmediatePropagation 同样也能实现阻止事件,但是还能阻止该事件目标执行别的注册事件。


node.addEventListener(  'click',  event => {    event.stopImmediatePropagation()    console.log('冒泡')  },  false)// 点击 node 只会执行上面的函数,该函数不会执行node.addEventListener(  'click',  event => {    console.log('捕获 ')  },  true)复制代码
复制代码

如果一个构造函数,bind 了一个对象,用这个构造函数创建出的实例会继承这个对象的属性吗?为什么?

不会继承,因为根据 this 绑定四大规则,new 绑定的优先级高于 bind 显示绑定,通过 new 进行构造函数调用时,会创建一个新对象,这个新对象会代替 bind 的对象绑定,作为此函数的 this,并且在此函数没有返回对象的情况下,返回这个新建的对象

浏览器本地存储方式及使用场景

(1)Cookie

Cookie 是最早被提出来的本地存储方式,在此之前,服务端是无法判断网络中的两个请求是否是同一用户发起的,为解决这个问题,Cookie 就出现了。Cookie 的大小只有 4kb,它是一种纯文本文件,每次发起 HTTP 请求都会携带 Cookie。


Cookie 的特性:


  • Cookie 一旦创建成功,名称就无法修改

  • Cookie 是无法跨域名的,也就是说 a 域名和 b 域名下的 cookie 是无法共享的,这也是由 Cookie 的隐私安全性决定的,这样就能够阻止非法获取其他网站的 Cookie

  • 每个域名下 Cookie 的数量不能超过 20 个,每个 Cookie 的大小不能超过 4kb

  • 有安全问题,如果 Cookie 被拦截了,那就可获得 session 的所有信息,即使加密也于事无补,无需知道 cookie 的意义,只要转发 cookie 就能达到目的

  • Cookie 在请求一个新的页面的时候都会被发送过去


如果需要域名之间跨域共享 Cookie,有两种方法:


  1. 使用 Nginx 反向代理

  2. 在一个站点登陆之后,往其他网站写 Cookie。服务端的 Session 存储到一个节点,Cookie 存储 sessionId


Cookie 的使用场景:


  • 最常见的使用场景就是 Cookie 和 session 结合使用,我们将 sessionId 存储到 Cookie 中,每次发请求都会携带这个 sessionId,这样服务端就知道是谁发起的请求,从而响应相应的信息。

  • 可以用来统计页面的点击次数

(2)LocalStorage

LocalStorage 是 HTML5 新引入的特性,由于有的时候我们存储的信息较大,Cookie 就不能满足我们的需求,这时候 LocalStorage 就派上用场了。


LocalStorage 的优点:


  • 在大小方面,LocalStorage 的大小一般为 5MB,可以储存更多的信息

  • LocalStorage 是持久储存,并不会随着页面的关闭而消失,除非主动清理,不然会永久存在

  • 仅储存在本地,不像 Cookie 那样每次 HTTP 请求都会被携带


LocalStorage 的缺点:


  • 存在浏览器兼容问题,IE8 以下版本的浏览器不支持

  • 如果浏览器设置为隐私模式,那我们将无法读取到 LocalStorage

  • LocalStorage 受到同源策略的限制,即端口、协议、主机地址有任何一个不相同,都不会访问


LocalStorage 的常用 API:


// 保存数据到 localStoragelocalStorage.setItem('key', 'value');
// 从 localStorage 获取数据let data = localStorage.getItem('key');
// 从 localStorage 删除保存的数据localStorage.removeItem('key');
// 从 localStorage 删除所有保存的数据localStorage.clear();
// 获取某个索引的KeylocalStorage.key(index)复制代码
复制代码


LocalStorage 的使用场景:


  • 有些网站有换肤的功能,这时候就可以将换肤的信息存储在本地的 LocalStorage 中,当需要换肤的时候,直接操作 LocalStorage 即可

  • 在网站中的用户浏览信息也会存储在 LocalStorage 中,还有网站的一些不常变动的个人信息等也可以存储在本地的 LocalStorage 中

(3)SessionStorage

SessionStorage 和 LocalStorage 都是在 HTML5 才提出来的存储方案,SessionStorage 主要用于临时保存同一窗口(或标签页)的数据,刷新页面时不会删除,关闭窗口或标签页之后将会删除这些数据。


SessionStorage 与 LocalStorage 对比:


  • SessionStorage 和 LocalStorage 都在本地进行数据存储

  • SessionStorage 也有同源策略的限制,但是 SessionStorage 有一条更加严格的限制,SessionStorage 只有在同一浏览器的同一窗口下才能够共享

  • LocalStorage 和 SessionStorage 都不能被爬虫爬取


SessionStorage 的常用 API:


// 保存数据到 sessionStoragesessionStorage.setItem('key', 'value');
// 从 sessionStorage 获取数据let data = sessionStorage.getItem('key');
// 从 sessionStorage 删除保存的数据sessionStorage.removeItem('key');
// 从 sessionStorage 删除所有保存的数据sessionStorage.clear();
// 获取某个索引的KeysessionStorage.key(index)复制代码
复制代码


SessionStorage 的使用场景


  • 由于 SessionStorage 具有时效性,所以可以用来存储一些网站的游客登录的信息,还有临时的浏览记录的信息。当关闭网站之后,这些信息也就随之消除了。

实现数组原型方法

forEach

语法:arr.forEach(callback(currentValue [, index [, array]])[, thisArg])

参数:

callback:为数组中每个元素执行的函数,该函数接受 1-3 个参数currentValue: 数组中正在处理的当前元素index(可选): 数组中正在处理的当前元素的索引array(可选): forEach() 方法正在操作的数组 thisArg(可选): 当执行回调函数 callback 时,用作 this 的值。

返回值:undefined


Array.prototype.forEach1 = function(callback, thisArg) {    if(this == null) {        throw new TypeError('this is null or not defined');    }    if(typeof callback !== "function") {        throw new TypeError(callback + 'is not a function');    }    // 创建一个新的 Object 对象。该对象将会包裹(wrapper)传入的参数 this(当前数组)。    const O = Object(this);    // O.length >>> 0 无符号右移 0 位    // 意义:为了保证转换后的值为正整数。    // 其实底层做了 2 层转换,第一是非 number 转成 number 类型,第二是将 number 转成 Uint32 类型    const len = O.length >>> 0;    let k = 0;    while(k < len) {        if(k in O) {            callback.call(thisArg, O[k], k, O);        }        k++;    }}复制代码
复制代码

map

语法: arr.map(callback(currentValue [, index [, array]])[, thisArg])

参数:与 forEach() 方法一样

返回值:一个由原数组每个元素执行回调函数的结果组成的新数组。


Array.prototype.map1 = function(callback, thisArg) {    if(this == null) {        throw new TypeError('this is null or not defined');    }    if(typeof callback !== "function") {        throw new TypeError(callback + 'is not a function');    }    const O = Object(this);     const len = O.length >>> 0;    let newArr = [];  // 返回的新数组    let k = 0;    while(k < len) {        if(k in O) {            newArr[k] = callback.call(thisArg, O[k], k, O);        }        k++;    }    return newArr;}复制代码
复制代码

filter

语法:arr.filter(callback(element [, index [, array]])[, thisArg])

参数:

callback: 用来测试数组的每个元素的函数。返回 true 表示该元素通过测试,保留该元素,false 则不保留。它接受以下三个参数:element、index、array,参数的意义与 forEach 一样。

thisArg(可选): 执行 callback 时,用于 this 的值。

返回值:一个新的、由通过测试的元素组成的数组,如果没有任何数组元素通过测试,则返回空数组。


Array.prototype.filter1 = function(callback, thisArg) {    if(this == null) {        throw new TypeError('this is null or not defined');    }    if(typeof callback !== "function") {        throw new TypeError(callback + 'is not a function');    }    const O = Object(this);     const len = O.length >>> 0;    let newArr = [];  // 返回的新数组    let k = 0;    while(k < len) {        if(k in O) {            if(callback.call(thisArg, O[k], k, O)) {                newArr.push(O[k]);            }        }        k++;    }    return newArr;}复制代码
复制代码

some

语法:arr.some(callback(element [, index [, array]])[, thisArg])

参数:

callback: 用来测试数组的每个元素的函数。接受以下三个参数:element、index、array,参数的意义与 forEach 一样。

thisArg(可选): 执行 callback 时,用于 this 的值。返回值:数组中有至少一个元素通过回调函数的测试就会返回 true;所有元素都没有通过回调函数的测试返回值才会为 false


Array.prototype.some1 = function(callback, thisArg) {    if(this == null) {        throw new TypeError('this is null or not defined');    }    if(typeof callback !== "function") {        throw new TypeError(callback + 'is not a function');    }    const O = Object(this);     const len = O.length >>> 0;    let k = 0;    while(k < len) {        if(k in O) {            if(callback.call(thisArg, O[k], k, O)) {                return true            }        }        k++;    }    return false;}复制代码
复制代码

reduce

语法:arr.reduce(callback(preVal, curVal[, curIndex [, array]])[, initialValue])

参数:

callback: 一个 “reducer” 函数,包含四个参数:

preVal:上一次调用 callback 时的返回值。在第一次调用时,若指定了初始值 initialValue,其值则为 initialValue,否则为数组索引为 0 的元素 array[0]

curVal:数组中正在处理的元素。在第一次调用时,若指定了初始值 initialValue,其值则为数组索引为 0 的元素 array[0],否则为 array[1]

curIndex(可选):数组中正在处理的元素的索引。若指定了初始值 initialValue,则起始索引号为 0,否则从索引 1 起始。

array(可选):用于遍历的数组。initialValue(可选): 作为第一次调用 callback 函数时参数 preVal 的值。若指定了初始值 initialValue,则 curVal 则将使用数组第一个元素;否则 preVal 将使用数组第一个元素,而 curVal 将使用数组第二个元素。返回值:使用 “reducer” 回调函数遍历整个数组后的结果。


Array.prototype.reduce1 = function(callback, initialValue) {    if(this == null) {        throw new TypeError('this is null or not defined');    }    if(typeof callback !== "function") {        throw new TypeError(callback + 'is not a function');    }    const O = Object(this);    const len = O.length >>> 0;    let k = 0;    let accumulator = initialValue;    // 如果第二个参数为undefined的情况下,则数组的第一个有效值(非empty)作为累加器的初始值    if(accumulator === undefined) {        while(k < len && !(k in O)) {            k++;        }        // 如果超出数组界限还没有找到累加器的初始值,则TypeError        if(k >= len) {            throw new TypeError('Reduce of empty array with no initial value');        }        accumulator = O[k++];    }    while(k < len) {        if(k in O) {            accumulator = callback(accumulator, O[k], k, O);        }        k++;    }    return accumulator;}复制代码
复制代码

对浏览器的理解

浏览器的主要功能是将用户选择的 web 资源呈现出来,它需要从服务器请求资源,并将其显示在浏览器窗口中,资源的格式通常是 HTML,也包括 PDF、image 及其他格式。用户用 URI(Uniform Resource Identifier 统一资源标识符)来指定所请求资源的位置。


HTML 和 CSS 规范中规定了浏览器解释 html 文档的方式,由 W3C 组织对这些规范进行维护,W3C 是负责制定 web 标准的组织。但是浏览器厂商纷纷开发自己的扩展,对规范的遵循并不完善,这为 web 开发者带来了严重的兼容性问题。


浏览器可以分为两部分,shell 和 内核。其中 shell 的种类相对比较多,内核则比较少。也有一些浏览器并不区分外壳和内核。从 Mozilla 将 Gecko 独立出来后,才有了外壳和内核的明确划分。


  • shell 是指浏览器的外壳:例如菜单,工具栏等。主要是提供给用户界面操作,参数设置等等。它是调用内核来实现各种功能的。

  • 内核是浏览器的核心。内核是基于标记语言显示内容的程序或模块。

事件总线(发布订阅模式)

class EventEmitter {    constructor() {        this.cache = {}    }    on(name, fn) {        if (this.cache[name]) {            this.cache[name].push(fn)        } else {            this.cache[name] = [fn]        }    }    off(name, fn) {        let tasks = this.cache[name]        if (tasks) {            const index = tasks.findIndex(f => f === fn || f.callback === fn)            if (index >= 0) {                tasks.splice(index, 1)            }        }    }    emit(name, once = false, ...args) {        if (this.cache[name]) {            // 创建副本,如果回调函数内继续注册相同事件,会造成死循环            let tasks = this.cache[name].slice()            for (let fn of tasks) {                fn(...args)            }            if (once) {                delete this.cache[name]            }        }    }}
// 测试let eventBus = new EventEmitter()let fn1 = function(name, age) { console.log(`${name} ${age}`)}let fn2 = function(name, age) { console.log(`hello, ${name} ${age}`)}eventBus.on('aaa', fn1)eventBus.on('aaa', fn2)eventBus.emit('aaa', false, '布兰', 12)// '布兰 12'// 'hello, 布兰 12'复制代码
复制代码

数据类型判断

typeof 可以正确识别:Undefined、Boolean、Number、String、Symbol、Function 等类型的数据,但是对于其他的都会认为是 object,比如 Null、Date 等,所以通过 typeof 来判断数据类型会不准确。但是可以使用 Object.prototype.toString 实现。


function typeOf(obj) {-   let res = Object.prototype.toString.call(obj).split(' ')[1]-   res = res.substring(0, res.length - 1).toLowerCase()-   return res// 更好的写法+   return Object.prototype.toString.call(obj).slice(8, -1).toLowerCase()}typeOf([])        // 'array'typeOf({})        // 'object'typeOf(new Date)  // 'date'复制代码
复制代码

代码输出结果

function SuperType(){    this.property = true;}
SuperType.prototype.getSuperValue = function(){ return this.property;};
function SubType(){ this.subproperty = false;}
SubType.prototype = new SuperType();SubType.prototype.getSubValue = function (){ return this.subproperty;};
var instance = new SubType();console.log(instance.getSuperValue());复制代码
复制代码


输出结果:true


实际上,这段代码就是在实现原型链继承,SubType 继承了 SuperType,本质是重写了 SubType 的原型对象,代之以一个新类型的实例。SubType 的原型被重写了,所以 instance.constructor 指向的是 SuperType。具体如下:

实现节流函数和防抖函数

函数防抖的实现:


function debounce(fn, wait) {  var timer = null;
return function() { var context = this, args = [...arguments];
// 如果此时存在定时器的话,则取消之前的定时器重新记时 if (timer) { clearTimeout(timer); timer = null; }
// 设置定时器,使事件间隔指定事件后执行 timer = setTimeout(() => { fn.apply(context, args); }, wait); };}复制代码
复制代码


函数节流的实现:


// 时间戳版function throttle(fn, delay) {  var preTime = Date.now();
return function() { var context = this, args = [...arguments], nowTime = Date.now();
// 如果两次时间间隔超过了指定时间,则执行函数。 if (nowTime - preTime >= delay) { preTime = Date.now(); return fn.apply(context, args); } };}
// 定时器版function throttle (fun, wait){ let timeout = null return function(){ let context = this let args = [...arguments] if(!timeout){ timeout = setTimeout(() => { fun.apply(context, args) timeout = null }, wait) } }}复制代码
复制代码

网络劫持有哪几种,如何防范?

⽹络劫持分为两种:


(1)DNS 劫持: (输⼊京东被强制跳转到淘宝这就属于 dns 劫持)


  • DNS 强制解析: 通过修改运营商的本地 DNS 记录,来引导⽤户流量到缓存服务器

  • 302 跳转的⽅式: 通过监控⽹络出⼝的流量,分析判断哪些内容是可以进⾏劫持处理的,再对劫持的内存发起 302 跳转的回复,引导⽤户获取内容


(2)HTTP 劫持: (访问⾕歌但是⼀直有贪玩蓝⽉的⼴告),由于 http 明⽂传输,运营商会修改你的 http 响应内容(即加⼴告)


DNS 劫持由于涉嫌违法,已经被监管起来,现在很少会有 DNS 劫持,⽽http 劫持依然⾮常盛⾏,最有效的办法就是全站 HTTPS,将 HTTP 加密,这使得运营商⽆法获取明⽂,就⽆法劫持你的响应内容。

如何减少 Webpack 打包体积

(1)按需加载

在开发 SPA 项目的时候,项目中都会存在很多路由页面。如果将这些页面全部打包进一个 JS 文件的话,虽然将多个请求合并了,但是同样也加载了很多并不需要的代码,耗费了更长的时间。那么为了首页能更快地呈现给用户,希望首页能加载的文件体积越小越好,这时候就可以使用按需加载,将每个路由页面单独打包为一个文件。当然不仅仅路由可以按需加载,对于 loadash 这种大型类库同样可以使用这个功能。


按需加载的代码实现这里就不详细展开了,因为鉴于用的框架不同,实现起来都是不一样的。当然了,虽然他们的用法可能不同,但是底层的机制都是一样的。都是当使用的时候再去下载对应文件,返回一个 Promise,当 Promise 成功以后去执行回调。

(2)Scope Hoisting

Scope Hoisting 会分析出模块之间的依赖关系,尽可能的把打包出来的模块合并到一个函数中去。


比如希望打包两个文件:


// test.jsexport const a = 1// index.jsimport { a } from './test.js'复制代码
复制代码


对于这种情况,打包出来的代码会类似这样:


[  /* 0 */  function (module, exports, require) {    //...  },  /* 1 */  function (module, exports, require) {    //...  }]复制代码
复制代码


但是如果使用 Scope Hoisting ,代码就会尽可能的合并到一个函数中去,也就变成了这样的类似代码:


[  /* 0 */  function (module, exports, require) {    //...  }]复制代码
复制代码


这样的打包方式生成的代码明显比之前的少多了。如果在 Webpack4 中你希望开启这个功能,只需要启用 optimization.concatenateModules 就可以了:


module.exports = {  optimization: {    concatenateModules: true  }}复制代码
复制代码

(3)Tree Shaking

Tree Shaking 可以实现删除项目中未被引用的代码,比如:


// test.jsexport const a = 1export const b = 2// index.jsimport { a } from './test.js'复制代码
复制代码


对于以上情况,test 文件中的变量 b 如果没有在项目中使用到的话,就不会被打包到文件中。


如果使用 Webpack 4 的话,开启生产环境就会自动启动这个优化功能。

怎么加事件监听,两种

onclick 和 addEventListener

事件传播机制(事件流)

冒泡和捕获

事件循环机制 (Event Loop)

事件循环机制从整体上告诉了我们 JavaScript 代码的执行顺序 Event Loop即事件循环,是指浏览器或Node的一种解决javaScript单线程运行时不会阻塞的一种机制,也就是我们经常使用异步的原理。


先执行 Script 脚本,然后清空微任务队列,然后开始下一轮事件循环,继续先执行宏任务,再清空微任务队列,如此往复。


  • 宏任务:Script/setTimeout/setInterval/setImmediate/ I/O / UI Rendering

  • 微任务:process.nextTick()/Promise


上诉的 setTimeout 和 setInterval 等都是任务源,真正进入任务队列的是他们分发的任务。


优先级


  • setTimeout = setInterval 一个队列

  • setTimeout > setImmediate

  • process.nextTick > Promise


for (const macroTask of macroTaskQueue) {    handleMacroTask();      for (const microTask of microTaskQueue) {          handleMicroTask(microTask);    }}复制代码
复制代码

Vue 路由守卫有哪些,怎么设置,使用场景等

常用的两个路由守卫:router.beforeEach 和 router.afterEach
每个守卫方法接收三个参数:
to: Route: 即将要进入的目标 路由对象
from: Route: 当前导航正要离开的路由
next: Function: 一定要调用该方法来 resolve 这个钩子。
在项目中,一般在beforeEach这个钩子函数中进行路由跳转的一些信息判断。判断是否登录,是否拿到对应的路由权限等等。
复制代码
复制代码


用户头像

还未添加个人签名 2022.07.31 加入

还未添加个人简介

评论

发布
暂无评论
2022秋招前端面试题(六)(附答案)_前端面试题_helloworld1024fd_InfoQ写作社区