【20230510】面试题每日打卡-JavaScript(一)

97 阅读14分钟

昨天忙着做od机试和外包的笔试,忘记更新了,每日打卡刚开始的第一天就断了。不禁回想起之前考研的时候,用的墨墨背单词,就没有一次连续打卡超过30天的。果然这个东西跟自律性还是有很大的关系。

话不多说了,开干!

let、const和var

  • var与其他两种声明方式的区别:

    • 使用var声明变量会有变量提升的现象,即允许在声明之前被使用且初值为undefined

    • 使用letconst声明变量会产生块级作用域,在块级作用域内使用这两种方式定义的变量不能在声明之前被使用(暂时性死区),如以下例子:

      var a = 0;
      if(true){
        console.log(a); // 报错
        let a = 0;
        console.log(a)
      }
      
  • letconst的一些特点

    • let代表声明变量,可以被多次赋值;const代表声明常量,只允许在声明时进行赋值

    • const声明引用数据类型时,应看作引用不可变,引用指向的数据内容是可变的(如:使用const声明对象,对象内部值可以变)

原型和原型链

  • JavaScript是一门基于原型的语言,对于每个JS对象构造器而言,都会有显式原型(prototype);对于一个实例对象而言,会有隐式原型(_proto_),指向其对应构造器的prototype

  • 当我们对一个对象的属性进行访问时,如果在当前对象找不到,会通过proto一直往上找,直到到达原型链的末尾(Object.prototype.__proto__,值为null),这个过程涉及到的关系链条,称为原型链

  • 手撕instanceof:

    function fakeInstanceOf(obj, constructor) {
      let proto = Object.getPrototypeOf(obj);
      while (proto) {
        if (proto === constructor.prototype) return true;
        proto = Object.getPrototypeOf(proto);
      }
      return false;
    }
    

执行上下文

  • JavaScript代码执行时的环境,每当代码执行时都会创建执行上下文。

  • 分类:全局执行上下文、函数执行上下文、eval执行上下文。

  • 包括三个部分:变量对象、作用域链、this指向。

  • 作用域和作用域链:

    • 作用域分类:全局作用域、函数作用域、块级作用域。

    • 代码执行时通过作用域链寻找变量的过程:当在Javascript中使用一个变量的时候,首先Javascript引擎会尝试在当前作用域下去寻找该变量,如果没找到,再到它的上层作用域寻找,以此类推直到找到该变量或是已经到了全局作用域。

  • this指向问题:

    • 非箭头函数:

      • 基本规则:谁调用,指向谁,如:

        window.a = 123;
        function b() {
            console.log(this.a)
        }
        b() // 123
        const ctx = {
            a: 456,
            b
        } 
        ctx.b()    // 456
        
      • 使用applycallbind改变this指向:

        • callapply作用一样,不过call的第一个参数以后的参数列表是传入待执行函数的参数列表,而apply仅有两个参数,第二个参数为传入待执行函数的参数列表数组。

        • bind返回值为一个新的函数,调用这个函数时,内部的this就是我们手动指定的第一个参数。

        • 问题:如果把通过bind产生的新函数重新进行bind绑定,最终执行的this指向哪里呢?

          答案:指向第一次绑定的对象的值。

          用例:

          const a = function (this: any) {
            console.log(this)
          }
          const b = {
            c: 11
          }
          const d = {
            e: 22
          }
          const f = a.bind(b)
          f()
          const g = f.bind(d)
          g()
          // 输出:
          //{
          //  "c": 11
          //} 
          
        • 手撕applycall同理):

          function apply(fn,ctx,args = []) {
              if(typeof fn !== 'function') 
                  throw new Error("The argument 'fn' must be a function")
              const ctx = ctx || window;
              const key = Symbol();
              ctx[key] = fn;
              const res = ctx[key](...args)
              delete ctx[key]
              return res
          }
          
        • 手撕bind:

          function myBind(fn, context, ...args1) {
            if (typeof fn !== "function") {
              throw new TypeError(`Bind must be called on a function`);
            }
            function F() {}
            const bound = function(...args2) {
              const isNew = this instanceof F;
              const ctx = isNew ? this : context;
              return fn.apply(ctx, [...args1, ...args2]);
            };
            F.prototype = fn.prototype;
            bound.prototype = new F();
            return bound;
          }
          
    • 箭头函数:

      • 内部this指向外部函数的this

      • 无法使用applycallbind改变this指向

    • 使用new创建对象:

      • 调用构造器时内部代码执行的this指向新建的对象。

      • 手撕new

        • 过程:创建一个新对象,并将新对象的原型指向构造函数的 prototype;然后执行构造函数,将 this 绑定到新对象上,并传递参数;如果构造函数返回一个非空对象,则返回该对象,否则,返回新对象。

        • 代码实现:

          function myNew(constructor, ...args) {
            const obj = Object.create(constructor.prototype); 
            const result = constructor.apply(obj, args); 
            return Object(result) === result ? result : obj;
          }
          

事件循环(Event Loop)

  • 理论知识:

    • 设计初衷及基本原理:

      • 因为JavaScript是单线程的,单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务,前一个任务如果耗时非常长,后一个任务将一直没法执行。JavaScript中利用EventLoop机制解决了这个问题。

      • JavaScript中,把任务分为同步任务和异步任务。在执行同步任务的过程中,会产生一部分事件(如定时器创建,I/O等),这部分事件如果继续往下同步执行,就会阻塞后面代码的执行。为了解决这个问题,EventLoop机制引入异步任务的概念,当前代码块执行完实际上是不包括代码本身产生的事件的,这些事件放后面在进行处理。

    • 常见两类异步任务的API

      • 常见的微任务:Promise.thenObject.observeMutationObserverprocess.nextTick(Node 环境)。

      • 常见的宏任务:setTimeoutajaxdom 事件,setImmediate(Node 环境),requestAnimationFrame

    • 事件处理流程:

      • 同步任务执行完毕后会开始从调用栈中去执行异步任务

      • 优先执行微任务队列,当微任务队列清空后才会去执行宏任务

      • 每次单个宏任务执行完毕后会去检查微任务队列是否为空,如果不为空会按照先入先出的原则执行微任务(微任务中也可以产生异步任务),待微任务队列清空后再执行下一个宏任务,如此循环往复。

  • 经典恶心人的输入输出题:

    • 例题1:

      • 题目:

        setTimeout(() => console.log(0));
        new Promise((resolve) => {
          console.log(1);
          resolve(2);
          console.log(3);
        }).then((o) => console.log(o));
        new Promise((resolve) => {
          console.log(4);
          resolve(5);
        })
          .then((o) => console.log(o))
          .then(() => console.log(6));
        
      • 分析:

        • 创建计时器,此时计时器内传入函数放到宏任务队列中等待执行

        • new Promise(fn)中的fn是立即执行的,所以先依次输出1 3 4then中的任务推入微任务队列中等待执行

        • 当前同步执行完成,会去检测微任务队列中是否有任务并按序执行,所以输出2 5,此时第二个promise then产生了新的promise then任务,也是推入微任务队列中等待执行,之后会被执行,输出6

        • 执行宏任务队列中的任务,输出0

    • 例题2:

      • 题目:

        setTimeout(() => {
          console.log("A");
          Promise.resolve().then(() => {
            console.log("B");
          });
        }, 1000);
        
        Promise.resolve().then(() => {
          console.log("C");
        });
        
        new Promise((resolve) => {
          console.log("D");
          resolve("");
        }).then(() => {
          console.log("E");
        });
        
        async function sum(a, b) {
          console.log("F");
        }
        
        async function asyncSum(a, b) {
          await Promise.resolve();
          console.log("G");
          return Promise.resolve(a + b);
        }
        
        sum(3, 4);
        asyncSum(3, 4);
        console.log("H");
        
      • 分析:

        • 首先分析当前代码块的同步代码:setTimeoutPromise.resolvenew Promisesum(3,4)asyncSum(a + b)、 console.log("H")。其中setTimeout会创建一个定时器,其回调会放到下一个宏任务执行;Promise.resolvenew Promise中的参数如果是函数,会被立即调用;sum(3,4)内没有异步语句,是直接执行的;asyncSum(a + b)执行await Promise.resolve()实际上等同如下代码:

          function asyncSum(a, b) {
           Promise.resolve().then(() => {
           console.log("G");
           }); 
          return Promise.resolve(a + b);
          }
          

          会创建微任务,后面语句等待改该微任务被调用后才会执行。所以第一趟输出应该是D F H

        • 之后按序执行第一次产生的微任务队列中的逻辑(此处只展示输出字符部分):console.log("C")console.log("E")console.log("G")。所以之后的输出应该是C E G

        • 最后在3000ms后执行刚刚创建的定时器的回调,输出A。产生的微任务在定时器回调执行完后会被立即执行,输出B

        • 综上,最终结果: D F H C E G A B

    • 例题3:

      • 题目:

        Promise.resolve(console.log(0))
          .then(() => {
            console.log(1);
            Promise.resolve(console.log(5))
              .then(() => console.log(3))
              .then(() => console.log(4))
              .then(() => console.log(6));
          })
          .then(() => console.log(2))
          .then(() => console.log(7));
        
        
      • 分析:

        • console.log(0)立即执行,输出0,将then中的函数推入微任务队列等待执行。

        • 按序执行微任务队列的事件:console.log(1)Promise.resolve(console.log(5)),输出1 5。将() => console.log(3)() => console.log(2)推入微任务队列等待执行。

        • 按序执行微任务队列的事件:() => console.log(3)() => console.log(2),输出3 2,将() => console.log(4)() => console.log(7)推入微任务队列等待执行。

        • 按序执行微任务队列的事件:() => console.log(4)() => console.log(7),输出4 7,将() => console.log(6)推入微任务队列等待执行。

        • 按序执行微任务队列的事件:() => console.log(6)、输出6

        • 结果:0 1 5 3 2 4 7 6

  • 手撕环节:

    • promise.map

      事实上Promise没有这个API,不过有的公司会问关于并发数控制的问题,下面是一道相关的题目:

      实现一个 promise.map,进行并发数控制,有以下测试用例:

      pMap([1, 2, 3, 4, 5], (x) => Promise.resolve(x + 1));
      pMap([Promise.resolve(1), Promise.resolve(2)], (x) => x + 1);
      // 注意输出时间控制
      pMap([1, 1, 1, 1, 1, 1, 1, 1], (x) => sleep(1000), { concurrency: 2 });
      

      首先分析用例的参数,第一个参数是一个数组;第二个参数是调用的方法;最后一个参数是配置对象,根据用例可知第三个参数要有一个属性concurrency来控制并发。如果只看前两个参数的话,其效果有点像Array.prototype.map

      代码实现:

      type pMapType<T = any> = (
        argsArr: any[],
        fn: (...args: any) => Promise<T>,
        concurrency?: number
      ) => Promise<T>;
      
      const pMap: pMapType = (argsArr, fn, concurrency = Infinity) => {
        return new Promise((resolve, reject) => {
          const result = new Array(argsArr.length).fill(0);
          const taskQueue: any[] = [];
          let currentWorkingAmount = 0;
          function run() {
            while (currentWorkingAmount < concurrency) {
              const nextTask = taskQueue.shift();
              if (nextTask) {
                nextTask();
                currentWorkingAmount++;
              }
              if (taskQueue.length === 0) break;
            }
          }
          for (let i = 0; i < argsArr.length; i++) {
            taskQueue.push(() => {
              Promise.resolve(fn(argsArr[i]))
                .then((res) => {
                  result[i] = res;
                  currentWorkingAmount--;
                  if (taskQueue.length === 0 && currentWorkingAmount === 0)
                    return resolve(result);
                  if (currentWorkingAmount < concurrency) run();
                })
                .catch(reject);
            });
          }
          run();
        });
      };
      
      
      
    • Promise.all:

      • 要点:

        • 接收一个promiseiterable类型

        • 只返回一个Promise实例

        • resolve的回调结果是一个数组,执行时机:

          • 所有输入的promiseresolve回调都结束

          • 输入的iterable里没有promise了的时候

        • reject回调执行时机:

          • 只要任何一个输入的promisereject回调执行或者输入不合法的promise就会立即抛出错误,并且reject的是第一个抛出的错误信息
      • 代码:

        const PromiseAll = (taskArr) => {
          return new Promise((resolve, reject) => {
            const res = new Array(taskArr.length).fill(0);
            let finishNum = 0;
            taskArr.forEach((item, index) => {
              if (item instanceof Promise) {
                item
                  .then((_res) => {
                    res[index] = _res;
                    finishNum++;
                    if (finishNum === taskArr.length) resolve(res);
                  })
                  .catch(reject);
              } else {
                res[index] = item;
                finishNum++;
                if (finishNum === taskArr.length) resolve(res);
              }
            });
          });
        };
        
    • Promise.race

      • 要点(摘自MDN):

        Promise.race(iterable)  方法返回一个 promise,一旦迭代器中的某个 promise 解决或拒绝,返回的 promise 就会解决或拒绝。

      • 代码:

        const PromiseRace = (taskArr) => {
          return new Promise((resolve, reject) => {
            const length = taskArr.length;
            for (let i = 0; i < length; i++) {
              if (taskArr[i] instanceof Promise) {
                taskArr[i].then(resolve).catch(reject);
              } else {
                resolve(taskArr[i]);
              }
            }
          });
        };
        
    • promisify

      • 要点:

        • 回调风格的函数形如fn(...args,callback)promisify的返回的新函数的返回值应该是一个Promise对象,且这个对象的then传入的方法会当作原函数的回调被调用。

        • 另外,传入的函数还要遵循nodeCallback的规范:

          • 回调函数在主函数参数的位置是最后一个

          • 回调函数的第一个参数是error

      • 代码实现:

        const promisify = (fn) => {
          return (...args) =>
            new Promise((resolve, reject) => {
              fn(...args, (err, data) => (err ? reject(err) : resolve(data)));
            });
        };
        
    • 手撕promise

      此处的代码实现是笔者闲着没事撕着玩的,存在诸多缺陷,不过应付面试应该够用。

      代码:

      type MyPromiseStatus = "pending" | "fulfilled" | "rejected";
      
      const isFunction = (a: any) =>
        Object.prototype.toString.call(a) === "[object Function]";
      
      class MyPromise<T> {
        status: MyPromiseStatus = "pending";
        taskQueue: Record<
          Exclude<MyPromiseStatus, "pending">,
          ((...args: any) => any)[]
        > = {
          fulfilled: [],
          rejected: [],
        };
        value: any = undefined;
        reason: any;
        constructor(
          fn: (resolve: (val: T) => void, reject: (err: any) => void) => any
        ) {
          if (!isFunction(fn))
            throw new Error("The argument 'fn' must be function type");
          const resolve = (val: T) => {
            if (this.status !== "pending") return;
            this.status = "fulfilled";
            this.value = val;
            this.taskQueue.fulfilled.forEach((item) => item(this.value));
          };
          const reject = (err: any) => {
            if (this.status !== "pending") return;
            this.status = "rejected";
            this.reason = err;
            this.taskQueue.rejected.forEach((item) => item(this.reason));
          };
          if (fn) fn(resolve, reject);
        }
        then = (fulfilledCb?: (val?: T) => any, rejectedCb?: (err?: any) => any) => {
          const _fulfilledCb =
            fulfilledCb && isFunction(fulfilledCb) ? fulfilledCb : (x: any) => x;
          const _rejectedCb =
            rejectedCb && isFunction(rejectedCb) ? rejectedCb : (x: any) => x;
          return new MyPromise((resolve, reject) => {
            switch (this.status) {
              case "fulfilled":
                return resolve(_fulfilledCb(this.value));
              case "rejected":
                return reject(_rejectedCb(this.reason));
              case "pending":
                this.taskQueue.fulfilled.push((value: T) => {
                  setTimeout(() => {
                    resolve(_fulfilledCb(value));
                  });
                });
                this.taskQueue.rejected.push((reason: any) => {
                  setTimeout(() => {
                    reject(_rejectedCb(reason));
                  });
                });
            }
          });
        };
        catch = (rejectedCb: (err?: any) => any) => this.then(undefined, rejectedCb);
      }
      
      new MyPromise<number>((resolve, reject) => {
        console.log(1);
        setTimeout(() => {
          console.log(3);
          resolve(3);
        }, 1000);
      }).then((res = 0) => {
        console.log(res + 1);
      });
      

垃圾回收(GC)

  • 概述:JavaScript的垃圾回收机制用于检测和清除不再使用的对象,以释放内存空间。这种机制的优点就是无需手动释放内存,可以帮助开发人员避免内存泄漏和其他相关问题。

  • 要点:

    • 引用计数(旧的浏览器使用):

      • 此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。标记清除:定期检查内存中的所有变量和对象,标记那些不再被引用的变量和对象,然后将其清除。

      • 处理循环引用的对象会出现问题

    • 标记清除(新的浏览器使用):

      • 假定设置一个叫做根(root)的对象(在Javascript里,根是全局对象)。垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象。该算法把“对象是否不再需要”简化定义为“对象是否可以获得”。

      • 解决了引用计数无法处理循环引用的问题。

    • V8的垃圾回收(来自chatgpt):

      • 分代垃圾回收:V8将内存分为新生代和老生代两个部分。新生代中的对象生命周期较短,而老生代中的对象生命周期较长。V8采用不同的垃圾回收算法对新生代和老生代进行回收。

      • 标记-清除算法:V8采用标记-清除算法对老生代进行垃圾回收。该算法分为标记和清除两个阶段。V8会标记所有仍然在使用的对象,然后清除所有未被标记的对象。

      • 增量标记算法:为了避免长时间的垃圾回收导致的程序卡顿,V8采用了增量标记算法。该算法将标记阶段分为多个小阶段,每个小阶段执行完毕后就会让程序继续执行,从而减少了程序的停顿时间。

      • 空间复制算法:V8采用空间复制算法对新生代进行垃圾回收。该算法将新生代内存空间分为两个相等的部分(fromto),每次只使用其中的一半空间,当其中一半空间被占满后,就将其中还存活的对象复制到另一半空间中,然后清除之前使用的空间。

      • 对象晋升:当一个对象在新生代中经历了多次垃圾回收后仍然存活,就会被晋升到老生代中。

  • 内存泄漏问题常见场景(来自chatgpt):

    • 循环引用:循环引用是指两个或多个对象相互引用,形成闭环,导致垃圾回收器无法回收这些对象的内存。解决方法是使用WeakMapWeakSet来存储对象引用,这些容器可以在对象不再被使用时自动删除引用。

    • DOM对象:在使用JavaScript操作DOM对象时,如果没有正确地释放对象引用,就会导致内存泄漏。解决方法是在不需要使用DOM对象时,手动将其引用设置为null,使其成为垃圾回收的对象。

    • 定时器:在使用定时器时,如果不及时清除定时器,就会导致内存泄漏。解决方法是在不需要使用定时器时,手动清除定时器。

    • 闭包:闭包是指函数中包含对外部变量的引用,导致这些变量无法被垃圾回收器回收。解决方法是在不需要使用闭包时,手动将其引用设置为null

    • 全局变量:全局变量会一直存在于内存中,直到程序结束。解决方法是使用模块化的方式来管理变量,将变量封装在模块内部,避免污染全局命名空间。

    • 大量数据:在处理大量数据时,需要注意及时释放不再使用的数据,避免内存泄漏。解决方法是使用分页或滚动加载等方式,减少一次性加载大量数据的情况。

闭包

  • 定义:JavaScript的闭包机制是指函数可以访问其定义时所在的作用域中的变量,即使函数在定义时已经离开了该作用域。

  • 任何闭包的使用场景都离不开这两点:

    • 创建私有变量

    • 延长变量的生命周期

    一般函数的词法环境在函数返回后就被销毁,但是闭包会保存对创建时所在词法环境的引用,即便创建时所在的执行上下文被销毁,但创建时所在词法环境依然存在,以达到延长变量的生命周期的目的

  • 手撕环节:

    • 函数柯里化:

      function curry(fn) {
        const length = fn.length
        return function curried(...args) {
          if (args.length >= length) {
            return fn.apply(this, args);
          } else {
            return function(..._args) {
              return curried.apply(this, [...args, ..._args]);
            }
          }
        }
      }
      
    • 防抖和节流:

      function debounce(fn, time) {
        let timer = null;
        return function (...args) {
          if (timer) clearTimeout(timer);
          timer = setTimeout(() => {
            fn.apply(this, args);
          }, time);
        };
      }
      
      function throttle(fn, time) {
        let lock = false;
        return function (...args) {
          if (lock) return;
          lock = true;
          setTimeout(() => {
            fn.apply(this, args);
            lock = false;
          }, time);
        };
      }
      
  • 结合上面的垃圾回收部分的内容,我们可以知道,在创建闭包时所在词法环境的引用不会被GC清理掉。如果没有及时释放,就会导致垃圾回收器无法回收这些对象,造成内存泄漏,进而影响程序的性能和稳定性。