写点什么

滴滴前端一面常考手写面试题整理

  • 2023-03-01
    浙江
  • 本文字数:11265 字

    阅读完需:约 37 分钟

类数组转化为数组

类数组是具有 length 属性,但不具有数组原型上的方法。常见的类数组有 arguments、DOM 操作方法返回的结果。

方法一:Array.from
Array.from(document.querySelectorAll('div'))
复制代码
方法二:Array.prototype.slice.call()
Array.prototype.slice.call(document.querySelectorAll('div'))
复制代码
方法三:扩展运算符
[...document.querySelectorAll('div')]
复制代码
方法四:利用 concat
Array.prototype.concat.apply([], document.querySelectorAll('div'));
复制代码

解析 URL Params 为对象

let url = 'http://www.domain.com/?user=anonymous&id=123&id=456&city=%E5%8C%97%E4%BA%AC&enabled';parseParam(url)/* 结果{ user: 'anonymous',  id: [ 123, 456 ], // 重复出现的 key 要组装成数组,能被转成数字的就转成数字类型  city: '北京', // 中文需解码  enabled: true, // 未指定值得 key 约定为 true}*/
复制代码


function parseParam(url) {  const paramsStr = /.+\?(.+)$/.exec(url)[1]; // 将 ? 后面的字符串取出来  const paramsArr = paramsStr.split('&'); // 将字符串以 & 分割后存到数组中  let paramsObj = {};  // 将 params 存到对象中  paramsArr.forEach(param => {    if (/=/.test(param)) { // 处理有 value 的参数      let [key, val] = param.split('='); // 分割 key 和 value      val = decodeURIComponent(val); // 解码      val = /^\d+$/.test(val) ? parseFloat(val) : val; // 判断是否转为数字      if (paramsObj.hasOwnProperty(key)) { // 如果对象有 key,则添加一个值        paramsObj[key] = [].concat(paramsObj[key], val);      } else { // 如果对象没有这个 key,创建 key 并设置值        paramsObj[key] = val;      }    } else { // 处理没有 value 的参数      paramsObj[param] = true;    }  })  return paramsObj;}
复制代码

滚动加载

原理就是监听页面滚动事件,分析 clientHeightscrollTopscrollHeight 三者的属性关系。


window.addEventListener('scroll', function() {  const clientHeight = document.documentElement.clientHeight;  const scrollTop = document.documentElement.scrollTop;  const scrollHeight = document.documentElement.scrollHeight;  if (clientHeight + scrollTop >= scrollHeight) {    // 检测到滚动至页面底部,进行后续操作    // ...  }}, false);
复制代码

Array.prototype.reduce()

Array.prototype.reduce = function(callback, initialValue) {  if (this == undefined) {    throw new TypeError('this is null or not defined');  }  if (typeof callback !== 'function') {    throw new TypeError(callbackfn + ' is not a function');  }  const O = Object(this);  const len = this.length >>> 0;  let accumulator = initialValue;  let k = 0;  // 如果第二个参数为undefined的情况下  // 则数组的第一个有效值作为累加器的初始值  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.call(undefined, accumulator, O[k], k, O);    }    k++;  }  return accumulator;}
复制代码

实现简单路由

// hash路由class Route{  constructor(){    // 路由存储对象    this.routes = {}    // 当前hash    this.currentHash = ''    // 绑定this,避免监听时this指向改变    this.freshRoute = this.freshRoute.bind(this)    // 监听    window.addEventListener('load', this.freshRoute, false)    window.addEventListener('hashchange', this.freshRoute, false)  }  // 存储  storeRoute (path, cb) {    this.routes[path] = cb || function () {}  }  // 更新  freshRoute () {    this.currentHash = location.hash.slice(1) || '/'    this.routes[this.currentHash]()  }}
复制代码

分片思想解决大数据量渲染问题

题目描述: 渲染百万条结构简单的大数据时 怎么使用分片思想优化渲染


let ul = document.getElementById("container");// 插入十万条数据let total = 100000;// 一次插入 20 条let once = 20;//总页数let page = total / once;//每条记录的索引let index = 0;//循环加载数据function loop(curTotal, curIndex) {  if (curTotal <= 0) {    return false;  }  //每页多少条  let pageCount = Math.min(curTotal, once);  window.requestAnimationFrame(function () {    for (let i = 0; i < pageCount; i++) {      let li = document.createElement("li");      li.innerText = curIndex + i + " : " + ~~(Math.random() * total);      ul.appendChild(li);    }    loop(curTotal - pageCount, curIndex + pageCount);  });}loop(total, index);
复制代码


扩展思考 :对于大数据量的简单 dom 结构渲染可以用分片思想解决 如果是复杂的 dom 结构渲染如何处理?


这时候就需要使用虚拟列表了,虚拟列表和虚拟表格在日常项目使用还是很多的


参考 前端进阶面试题详细解答

使用 ES5 和 ES6 求函数参数的和

ES5:


function sum() {    let sum = 0    Array.prototype.forEach.call(arguments, function(item) {        sum += item * 1    })    return sum}
复制代码


ES6:


function sum(...nums) {    let sum = 0    nums.forEach(function(item) {        sum += item * 1    })    return sum}
复制代码

Promise 实现

基于Promise封装Ajax


  • 返回一个新的Promise实例

  • 创建HMLHttpRequest异步对象

  • 调用open方法,打开url,与服务器建立链接(发送前的一些处理)

  • 监听Ajax状态信息

  • 如果xhr.readyState == 4(表示服务器响应完成,可以获取使用服务器的响应了)

  • xhr.status == 200,返回resolve状态

  • xhr.status == 404,返回reject状态

  • xhr.readyState !== 4,把请求主体的信息基于send发送给服务器


function ajax(url) {  return new Promise((resolve, reject) => {    let xhr = new XMLHttpRequest()    xhr.open('get', url)    xhr.onreadystatechange = () => {      if (xhr.readyState == 4) {        if (xhr.status >= 200 && xhr.status <= 300) {          resolve(JSON.parse(xhr.responseText))        } else {          reject('请求出错')        }      }    }    xhr.send()  //发送hppt请求  })}
let url = '/data.json'ajax(url).then(res => console.log(res)) .catch(reason => console.log(reason))
复制代码

实现 apply 方法

思路: 利用this的上下文特性。apply其实就是改一下参数的问题


Function.prototype.myApply = function(context = window, args) {  // this-->func  context--> obj  args--> 传递过来的参数
// 在context上加一个唯一值不影响context上的属性 let key = Symbol('key') context[key] = this; // context为调用的上下文,this此处为函数,将这个函数作为context的方法 // let args = [...arguments].slice(1) //第一个参数为obj所以删除,伪数组转为数组
let result = context[key](...args); // 这里和call传参不一样
// 清除定义的this 不删除会导致context属性越来越多 delete context[key];
// 返回结果 return result;}
复制代码


// 使用function f(a,b){ console.log(a,b) console.log(this.name)}let obj={ name:'张三'}f.myApply(obj,[1,2])  //arguments[1]
复制代码

setTimeout 与 setInterval 实现

setTimeout 模拟实现 setInterval

题目描述: setInterval 用来实现循环定时调用 可能会存在一定的问题 能用 setTimeout 解决吗


实现代码如下:


function mySetInterval(fn, t) {  let timerId = null;  function interval() {    fn();    timerId = setTimeout(interval, t); // 递归调用  }  timerId = setTimeout(interval, t); // 首次调用  return {    // 利用闭包的特性 保存timerId    cancel:() => {      clearTimeout(timerId)    }  }}
复制代码


// 测试var a = mySetInterval(()=>{  console.log(111);},1000)var b = mySetInterval(() => {  console.log(222)}, 1000)
// 终止定时器a.cancel()b.cancel()
复制代码


为什么要用 setTimeout 模拟实现 setIntervalsetInterval 的缺陷是什么?


setInterval(fn(), N);
复制代码


上面这句代码的意思其实是fn()将会在 N 秒之后被推入任务队列。在 setInterval 被推入任务队列时,如果在它前面有很多任务或者某个任务等待时间较长比如网络请求等,那么这个定时器的执行时间和我们预定它执行的时间可能并不一致


// 最常见的出现的就是,当我们需要使用 ajax 轮询服务器是否有新数据时,必定会有一些人会使用 setInterval,然而无论网络状况如何,它都会去一遍又一遍的发送请求,最后的间隔时间可能和原定的时间有很大的出入
// 做一个网络轮询,每一秒查询一次数据。let startTime = new Date().getTime();let count = 0;
setInterval(() => { let i = 0; while (i++ < 10000000); // 假设的网络延迟 count++; console.log( "与原设定的间隔时差了:", new Date().getTime() - (startTime + count * 1000), "毫秒" );}, 1000)
// 输出:// 与原设定的间隔时差了: 567 毫秒// 与原设定的间隔时差了: 552 毫秒// 与原设定的间隔时差了: 563 毫秒// 与原设定的间隔时差了: 554 毫秒(2次)// 与原设定的间隔时差了: 564 毫秒// 与原设定的间隔时差了: 602 毫秒// 与原设定的间隔时差了: 573 毫秒// 与原设定的间隔时差了: 633 毫秒
复制代码


再次强调 ,定时器指定的时间间隔,表示的是何时将定时器的代码添加到消息队列,而不是何时执行代码。所以真正何时执行代码的时间是不能保证的,取决于何时被主线程的事件循环取到,并执行。


setInterval(function, N)//即:每隔N秒把function事件推到消息队列中
复制代码



上图可见,setInterval 每隔 100ms 往队列中添加一个事件;100ms 后,添加 T1 定时器代码至队列中,主线程中还有任务在执行,所以等待,some event 执行结束后执行 T1定时器代码;又过了 100msT2 定时器被添加到队列中,主线程还在执行 T1 代码,所以等待;又过了 100ms,理论上又要往队列里推一个定时器代码,但由于此时 T2 还在队列中,所以 T3 不会被添加(T3 被跳过),结果就是此时被跳过;这里我们可以看到,T1 定时器执行结束后马上执行了 T2 代码,所以并没有达到定时器的效果


setInterval 有两个缺点


  • 使用setInterval时,某些间隔会被跳过

  • 可能多个定时器会连续执行


可以这么理解 :每个setTimeout产生的任务会直接push到任务队列中;而setInterval在每次把任务push到任务队列前,都要进行一下判断(看上次的任务是否仍在队列中)。因而我们一般用setTimeout模拟setInterval,来规避掉上面的缺点

setInterval 模拟实现 setTimeout

const mySetTimeout = (fn, t) => {  const timer = setInterval(() => {    clearInterval(timer);    fn();  }, t);};
复制代码


// 测试// mySetTimeout(()=>{//   console.log(1);// },1000)
复制代码

实现 Object.create

Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的 __proto__


// 模拟 Object.create
function create(proto) { function F() {} F.prototype = proto;
return new F();}
复制代码

实现一个迷你版的 vue

入口


// js/vue.jsclass Vue {  constructor (options) {    // 1. 通过属性保存选项的数据    this.$options = options || {}    this.$data = options.data || {}    this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el    // 2. 把data中的成员转换成getter和setter,注入到vue实例中    this._proxyData(this.$data)    // 3. 调用observer对象,监听数据的变化    new Observer(this.$data)    // 4. 调用compiler对象,解析指令和差值表达式    new Compiler(this)  }  _proxyData (data) {    // 遍历data中的所有属性    Object.keys(data).forEach(key => {      // 把data的属性注入到vue实例中      Object.defineProperty(this, key, {        enumerable: true,        configurable: true,        get () {          return data[key]        },        set (newValue) {          if (newValue === data[key]) {            return          }          data[key] = newValue        }      })    })  }}
复制代码


实现 Dep


class Dep {  constructor () {    // 存储所有的观察者    this.subs = []  }  // 添加观察者  addSub (sub) {    if (sub && sub.update) {      this.subs.push(sub)    }  }  // 发送通知  notify () {    this.subs.forEach(sub => {      sub.update()    })  }}
复制代码


实现 watcher


class Watcher {  constructor (vm, key, cb) {    this.vm = vm    // data中的属性名称    this.key = key    // 回调函数负责更新视图    this.cb = cb
// 把watcher对象记录到Dep类的静态属性target Dep.target = this // 触发get方法,在get方法中会调用addSub this.oldValue = vm[key] Dep.target = null } // 当数据发生变化的时候更新视图 update () { let newValue = this.vm[this.key] if (this.oldValue === newValue) { return } this.cb(newValue) }}
复制代码


实现 compiler


class Compiler {  constructor (vm) {    this.el = vm.$el    this.vm = vm    this.compile(this.el)  }  // 编译模板,处理文本节点和元素节点  compile (el) {    let childNodes = el.childNodes    Array.from(childNodes).forEach(node => {      // 处理文本节点      if (this.isTextNode(node)) {        this.compileText(node)      } else if (this.isElementNode(node)) {        // 处理元素节点        this.compileElement(node)      }
// 判断node节点,是否有子节点,如果有子节点,要递归调用compile if (node.childNodes && node.childNodes.length) { this.compile(node) } }) } // 编译元素节点,处理指令 compileElement (node) { // console.log(node.attributes) // 遍历所有的属性节点 Array.from(node.attributes).forEach(attr => { // 判断是否是指令 let attrName = attr.name if (this.isDirective(attrName)) { // v-text --> text attrName = attrName.substr(2) let key = attr.value this.update(node, key, attrName) } }) }
update (node, key, attrName) { let updateFn = this[attrName + 'Updater'] updateFn && updateFn.call(this, node, this.vm[key], key) }
// 处理 v-text 指令 textUpdater (node, value, key) { node.textContent = value new Watcher(this.vm, key, (newValue) => { node.textContent = newValue }) } // v-model modelUpdater (node, value, key) { node.value = value new Watcher(this.vm, key, (newValue) => { node.value = newValue }) // 双向绑定 node.addEventListener('input', () => { this.vm[key] = node.value }) }
// 编译文本节点,处理差值表达式 compileText (node) { // console.dir(node) // {{ msg }} let reg = /\{\{(.+?)\}\}/ let value = node.textContent if (reg.test(value)) { let key = RegExp.$1.trim() node.textContent = value.replace(reg, this.vm[key])
// 创建watcher对象,当数据改变更新视图 new Watcher(this.vm, key, (newValue) => { node.textContent = newValue }) } } // 判断元素属性是否是指令 isDirective (attrName) { return attrName.startsWith('v-') } // 判断节点是否是文本节点 isTextNode (node) { return node.nodeType === 3 } // 判断节点是否是元素节点 isElementNode (node) { return node.nodeType === 1 }}
复制代码


实现 Observer


class Observer {  constructor (data) {    this.walk(data)  }  walk (data) {    // 1. 判断data是否是对象    if (!data || typeof data !== 'object') {      return    }    // 2. 遍历data对象的所有属性    Object.keys(data).forEach(key => {      this.defineReactive(data, key, data[key])    })  }  defineReactive (obj, key, val) {    let that = this    // 负责收集依赖,并发送通知    let dep = new Dep()    // 如果val是对象,把val内部的属性转换成响应式数据    this.walk(val)    Object.defineProperty(obj, key, {      enumerable: true,      configurable: true,      get () {        // 收集依赖        Dep.target && dep.addSub(Dep.target)        return val      },      set (newValue) {        if (newValue === val) {          return        }        val = newValue        that.walk(newValue)        // 发送通知        dep.notify()      }    })  }}
复制代码


使用


<!DOCTYPE html><html lang="cn"><head>  <meta charset="UTF-8">  <meta name="viewport" content="width=device-width, initial-scale=1.0">  <meta http-equiv="X-UA-Compatible" content="ie=edge">  <title>Mini Vue</title></head><body>  <div id="app">    <h1>差值表达式</h1>    <h3>{{ msg }}</h3>    <h3>{{ count }}</h3>    <h1>v-text</h1>    <div v-text="msg"></div>    <h1>v-model</h1>    <input type="text" v-model="msg">    <input type="text" v-model="count">  </div>  <script src="./js/dep.js"></script>  <script src="./js/watcher.js"></script>  <script src="./js/compiler.js"></script>  <script src="./js/observer.js"></script>  <script src="./js/vue.js"></script>  <script>    let vm = new Vue({      el: '#app',      data: {        msg: 'Hello Vue',        count: 100,        person: { name: 'zs' }      }    })    console.log(vm.msg)    // vm.msg = { test: 'Hello' }    vm.test = 'abc'  </script></body></html>
复制代码

实现一个迭代器生成函数

ES6 对迭代器的实现

JS 原生的集合类型数据结构,只有Array(数组)和Object(对象);而ES6中,又新增了MapSet。四种数据结构各自有着自己特别的内部实现,但我们仍期待以同样的一套规则去遍历它们,所以ES6在推出新数据结构的同时也推出了一套 统一的接口机制 ——迭代器(Iterator)。


ES6约定,任何数据结构只要具备Symbol.iterator属性(这个属性就是Iterator的具体实现,它本质上是当前数据结构默认的迭代器生成函数),就可以被遍历——准确地说,是被for...of...循环和迭代器的 next 方法遍历。 事实上,for...of...的背后正是对next方法的反复调用。


在 ES6 中,针对ArrayMapSetStringTypedArray、函数的 arguments 对象、NodeList 对象这些原生的数据结构都可以通过for...of...进行遍历。原理都是一样的,此处我们拿最简单的数组进行举例,当我们用for...of...遍历数组时:


const arr = [1, 2, 3]const len = arr.lengthfor(item of arr) {   console.log(`当前元素是${item}`)}
复制代码


之所以能够按顺序一次一次地拿到数组里的每一个成员,是因为我们借助数组的Symbol.iterator生成了它对应的迭代器对象,通过反复调用迭代器对象的next方法访问了数组成员,像这样:


const arr = [1, 2, 3]// 通过调用iterator,拿到迭代器对象const iterator = arr[Symbol.iterator]()
// 对迭代器对象执行next,就能逐个访问集合的成员iterator.next()iterator.next()iterator.next()
复制代码


丢进控制台,我们可以看到next每次会按顺序帮我们访问一个集合成员:



for...of...做的事情,基本等价于下面这通操作:


// 通过调用iterator,拿到迭代器对象const iterator = arr[Symbol.iterator]()
// 初始化一个迭代结果let now = { done: false }
// 循环往外迭代成员while(!now.done) { now = iterator.next() if(!now.done) { console.log(`现在遍历到了${now.value}`) }}
复制代码


可以看出,for...of...其实就是iterator循环调用换了种写法。在 ES6 中我们之所以能够开心地用for...of...遍历各种各种的集合,全靠迭代器模式在背后给力。


ps:此处推荐阅读迭代协议 (opens new window),相信大家读过后会对迭代器在 ES6 中的实现有更深的理解。

Promise.race

Promise.race = function(promiseArr) {  return new Promise((resolve, reject) => {    promiseArr.forEach(p => {      // 如果不是Promise实例需要转化为Promise实例      Promise.resolve(p).then(        val => resolve(val),        err => reject(err),      )    })  })}
复制代码

实现 Array.isArray 方法

Array.myIsArray = function(o) {  return Object.prototype.toString.call(Object(o)) === '[object Array]';};
console.log(Array.myIsArray([])); // true
复制代码

实现一个 compose 函数

组合多个函数,从右到左,比如:compose(f, g, h) 最终得到这个结果 (...args) => f(g(h(...args))).


题目描述:实现一个 compose 函数


// 用法如下:function fn1(x) {  return x + 1;}function fn2(x) {  return x + 2;}function fn3(x) {  return x + 3;}function fn4(x) {  return x + 4;}const a = compose(fn1, fn2, fn3, fn4);console.log(a(1)); // 1+4+3+2+1=11
复制代码


实现代码如下


function compose(...funcs) {  if (!funcs.length) return (v) => v;
if (funcs.length === 1) { return funcs[0] }
return funcs.reduce((a, b) => { return (...args) => a(b(...args))) }}
复制代码


compose创建了一个从右向左执行的数据流。如果要实现从左到右的数据流,可以直接更改compose的部分代码即可实现


  • 更换Api接口:把reduce改为reduceRight

  • 交互包裹位置:把a(b(...args))改为b(a(...args))

二分查找

function search(arr, target, start, end) {  let targetIndex = -1;
let mid = Math.floor((start + end) / 2);
if (arr[mid] === target) { targetIndex = mid; return targetIndex; }
if (start >= end) { return targetIndex; }
if (arr[mid] < target) { return search(arr, target, mid + 1, end); } else { return search(arr, target, start, mid - 1); }}
// const dataArr = [1, 2, 3, 4, 5, 6, 7, 8, 9];// const position = search(dataArr, 6, 0, dataArr.length - 1);// if (position !== -1) {// console.log(`目标元素在数组中的位置:${position}`);// } else {// console.log("目标元素不在数组中");// }
复制代码

实现一个链表结构

链表结构



看图理解 next 层级



// 链表 从头尾删除、增加 性能比较好// 分为很多类 常用单向链表、双向链表
// js模拟链表结构:增删改查
// node节点class Node { constructor(element,next) { this.element = element this.next = next } }
class LinkedList { constructor() { this.head = null // 默认应该指向第一个节点 this.size = 0 // 通过这个长度可以遍历这个链表 } // 增加O(n) add(index,element) { if(arguments.length === 1) { // 向末尾添加 element = index // 当前元素等于传递的第一项 index = this.size // 索引指向最后一个元素 } if(index < 0 || index > this.size) { throw new Error('添加的索引不正常') } if(index === 0) { // 直接找到头部 把头部改掉 性能更好 let head = this.head this.head = new Node(element,head) } else { // 获取当前头指针 let current = this.head // 不停遍历 直到找到最后一项 添加的索引是1就找到第0个的next赋值 for (let i = 0; i < index-1; i++) { // 找到它的前一个 current = current.next } // 让创建的元素指向上一个元素的下一个 // 看图理解next层级 current.next = new Node(element,current.next) // 让当前元素指向下一个元素的next }
this.size++; } // 删除O(n) remove(index) { if(index < 0 || index >= this.size) { throw new Error('删除的索引不正常') } this.size-- if(index === 0) { let head = this.head this.head = this.head.next // 移动指针位置
return head // 返回删除的元素 }else { let current = this.head for (let i = 0; i < index-1; i++) { // index-1找到它的前一个 current = current.next } let returnVal = current.next // 返回删除的元素 // 找到待删除的指针的上一个 current.next.next // 如删除200, 100=>200=>300 找到200的上一个100的next的next为300,把300赋值给100的next即可 current.next = current.next.next
return returnVal } } // 查找O(n) get(index) { if(index < 0 || index >= this.size) { throw new Error('查找的索引不正常') } let current = this.head for (let i = 0; i < index; i++) { current = current.next } return current }}

var ll = new LinkedList()
ll.add(0,100) // Node { ellement: 100, next: null }ll.add(0,200) // Node { element: 200, next: Node { element: 100, next: null } }ll.add(1,500) // Node {element: 200,next: Node { element: 100, next: Node { element: 500, next: null } } }ll.add(300)ll.remove(0)
console.log(ll.get(2),'get')console.log(ll.head)
module.exports = LinkedList
复制代码

模拟 new 操作

3 个步骤:


  1. ctor.prototype为原型创建一个对象。

  2. 执行构造函数并将 this 绑定到新创建的对象上。

  3. 判断构造函数执行返回的结果是否是引用数据类型,若是则返回构造函数执行的结果,否则返回创建的对象。


function newOperator(ctor, ...args) {  if (typeof ctor !== 'function') {    throw new TypeError('Type Error');  }  const obj = Object.create(ctor.prototype);  const res = ctor.apply(obj, args);
const isObject = typeof res === 'object' && res !== null; const isFunction = typeof res === 'function'; return isObject || isFunction ? res : obj;}
复制代码

实现 lodash 的 chunk 方法--数组按指定长度拆分

题目


/** * @param input * @param size * @returns {Array} */_.chunk(['a', 'b', 'c', 'd'], 2)// => [['a', 'b'], ['c', 'd']]
_.chunk(['a', 'b', 'c', 'd'], 3)// => [['a', 'b', 'c'], ['d']]
_.chunk(['a', 'b', 'c', 'd'], 5)// => [['a', 'b', 'c', 'd']]
_.chunk(['a', 'b', 'c', 'd'], 0)// => []
复制代码


实现


function chunk(arr, length) {  let newArr = [];  for (let i = 0; i < arr.length; i += length) {    newArr.push(arr.slice(i, i + length));  }  return newArr;}
复制代码


用户头像

还未添加个人签名 2022-07-31 加入

还未添加个人简介

评论

发布
暂无评论
滴滴前端一面常考手写面试题整理_JavaScript_helloworld1024fd_InfoQ写作社区