前端面试之手撕代码

207 阅读9分钟

本文放在github仓库:github.com/jackshen310…

一、手写常用函数

1. 实现 promisify

常用于node环境,什么是promisify, 参考:nodejs.cn/api/util/ut…

function promisify(fn) {

}

// 测试,在Node环境下测试
let fs = require('fs');
// 普通callback写法
fs.readFile('./test', (err, data) => {
    if(err) {
        console.error(err);
    } else {
        console.log(data);
    }
});
// 转成promise写法
let readFile = promisify(fs.readFile);
readFile('./test').then(console.log).catch(console.log);

参考代码

function promisify(fn) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      fn.call(this, ...args, (err, ...values) => {
        if (err) {
          reject(err);
        } else {
          resolve(values);
        }
      });
    });
  };
}

2. 实现 sleep

这个比较简单

function delay(ms) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, ms);
  });
}

3. 实现节流与防抖

3.1 节流 throttle

// Guaranteeing a constant flow of executions every X milliseconds
function throttle(fn, delay) {
  // previous 是上一次执行 fn 的时间
  // timer 是定时器
  let previous = 0;
  let timer = null;
  return function (...args) {
    let now = +new Date();
    if (now - previous < delay) {
      // 如果小于,则为本次触发操作设立一个新的定时器
      // 定时器时间结束后执行函数 fn
      if (timer) clearTimeout(timer);
      timer = setTimeout(() => {
        previous = now;
        fn.apply(this, args);
      }, delay - (now - previous)); // 是剩余执行时间,而不是重新计时
    } else {
      previous = now;
      fn.apply(this, args);
    }
  };
}

// 测试代码
(async() => {
    function sleep(delay) {
        return new Promise((resolve) => {
            setTimeout(() => {
                resolve()
            }, delay);
        });
    }
    let count = 0;
    let callArgs = 0;
    let fn = throttle((args) => {
        count++;
        callArgs = args;
    }, 1000);

    fn(1); // 首次调用,立即执行
    console.log(count, callArgs); // 1,1
    fn(2); // 第二次调用,至少1000毫秒后才会执行
    console.log(count, callArgs); // 1,1
    await sleep(500);
    fn(3); // 第三次调用,只过了500毫秒,函数还未执行
    console.log(count, callArgs); // 1,1
   
    // 再过600毫秒,加上前面的500毫秒,已超过1000毫秒,触发第二次执行
    // 注意,这里第二次调用fn就被废弃了
    await sleep(600); 
    console.log(count, callArgs); // 2,3 第二次执行fn(3)后,count+1
    fn(4);
    fn(5);
    await sleep(1000); // 第三次执行 fn(5), 第四次调用fn(4)被废弃了
    console.log(count, callArgs); // 3,5
})();

应用场景:

  1. 拖拽场景,固定时间内只执行一次,防止超高频次触发位置变动
  2. 缩放场景,监控浏览器resize
  3. 无限滚动,避免频繁向服务端发请求

3.2 防抖 debounce

function debounce(fn, delay, immediate = false) {
  let timer = null;
  return function (...args) {
    if (timer) clearTimeout(timer);
    let callNow = immediate && !timer;
    if (callNow) {
      fn.apply(this, args);
      // 这里为什么要设置一个空的timeout函数,其实只是为了让timer不再为空
      // 这里的立即执行只会在首次调用生效,以后调用都不会生效了
      timer = setTimeout(() => {
      }, 0);
    } else {
        timer = setTimeout(() => {
            fn.apply(this, args);
        }, delay);
    }
  };
}

// 测试代码
(async () => {
  function sleep(delay) {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve();
      }, delay);
    });
  }
  let count = 0;
  let callArgs = 0;
  let fn = debounce((args) => {
    count++;
    callArgs = args;
  }, 1000, true);

  fn(1); // 首次调用,立即执行
  console.log(count, callArgs); // 1,1
  fn(2); // 第二次调用,至少1000毫秒后才会执行
  console.log(count, callArgs); // 1,1
  await sleep(500);
  fn(3); // 第三次调用,取消fn(2)的执行
  console.log(count, callArgs); // 1,1

  await sleep(1500); // 执行fn(3)
  console.log(count, callArgs); // 2,3 第二次执行fn(3)后,count+1
  fn(4);
  fn(5);
  await sleep(1000); // 第三次执行 fn(5), 第四次调用fn(4)被废弃了
  console.log(count, callArgs); // 3,5
})();

应用场景:

  1. 按钮提交场景,防止多次提交按钮,只执行最后提交的一次
  2. 搜索框联想场景,防止联想发送请求,只发送最后一次输入
  3. 实时保存场景,比如在线文档的实时保存

4. 实现深拷贝

4.1 乞丐版本

function clone(obj) {
    return JSON.parse(JSON.stringify(obj));
}

// 测试
let obj = {
    a: 1,
    b: [2,3],
    c: {x: 1},
    f: () => {}
}
console.log(clone(obj)); // { a: 1, b: [ 2, 3 ], c: { x: 1 } }
obj.d = obj; // 循环引用
console.log(clone(obj)); // throw error

问题:

  1. 函数丢失,无法拷贝
  2. 循环引用会报错

4.2 基础版本

用Map解决循环循环引用问题,为什么使用WeakMap,没有科学依据,听说更好...

function clone(target) {
  let map = new WeakMap();
  function _clone(target) {
    if (typeof target === "object") {
      if (map.has(target)) {
        return map.get(target);
      }
      let cloneTarget = Array.isArray(target) ? [] : {};
      map.set(target, cloneTarget);
      for (let key in target) {
        cloneTarget[key] = _clone(target[key]);
      }
      return cloneTarget;
    } else {
      return target;
    }
  }
  return _clone(target);
}

// 测试
let obj = {
  a: 1,
  b: [2, 3],
  c: { x: 1 },
  f: () => {},
};
console.log(clone(obj)); // { a: 1, b: [ 2, 3 ], c: { x: 1 }, f: [Function: f] }

obj.d = obj; // 循环引用
console.log(clone(obj)); // 正常

问题:

  1. 没有考虑其它类型的复制,如Map、Set、Symbol,这些需要特殊处理
  2. 没有考虑函数的复制(其实大部分场景下函数都不用复制)

二、手写JavaScript原型方法

1. 手写 Function.prototype.bind

Function.prototype.bind = function (context, ...args) {
  let fn = this;
  context = context || global; // 如果在浏览器上就是 window
  let res = function (...args2) {
    // this只和运行的时候有关系,所以这里的this和上面的fn不是一码事,new res()和res()在调用的时候,res中的this是不同的东西
    if (this instanceof res) {
      fn.apply(this, [...args, ...args2]);
    } else {
      fn.apply(context, [...args, ...args2]);
    }
  }; // 如果绑定的是构造函数 那么需要继承构造函数原型属性和方法 // 实现继承的方式: 使用Object.create
  res.prototype = Object.create(this.prototype);
  return res;
};

// 以下为测试代码
function foo(a) {
    this.a = a;
    console.log(this);
}

let fn = foo.bind({x: 1});
fn(1); // { x: 1, a: 1 }

// 作为构造函数,会忽略之前绑定的this对象
new fn(1); // foo { a: 1 }

2. 实现一个 new 操作符

function mynew(Func, ...args) {
  // 1.创建一个新对象
  const obj = {}; // 2.新对象原型指向构造函数原型对象
  obj.__proto__ = Func.prototype; // 3.将构建函数的this指向新对象
  let result = Func.apply(obj, args); // 4.根据返回值判断
  return result instanceof Object ? result : obj;
}

// 测试代码
function Foo(name) {
    this.name = name;
}
let foo = mynew(Foo, 'jack');
console.log(foo instanceof Foo); // true
console.log(foo.name); // jack

3. 实现 instanceof 操作符

写一个instance_of函数,模拟instanceof 操作符

function instance_of(L, R) {
}

// 以下为测试代码
class Foo {
}
class Bar extends Foo {
}
var foo = new Foo();
var bar = new Bar();
console.log(instance_of(foo, Foo)); // true
console.log(instance_of(bar, Foo)); // true
console.log(instance_of(Function, Function)); // true, Function是一个特殊的对象
console.log(instance_of(Foo, Foo)); // false

参考答案

  1. instanceof 操作符的原理,沿着 L 的__proto__这条线来找,同时沿着 B 的 prototype 这条线来找, 如果两条线能找到同一个引用,即同一个对象,那么就返回 true。
// instanceof 的内部实现
function instance_of(L, R) {
  //L 表左表达式,R 表示右表达式,即L为变量,R为类型
  // 取 R 的显示原型
  var prototype = R.prototype; // 取 L 的隐式原型
  L = L.__proto__; // 判断对象(L)的类型是否严格等于类型(R)的显式原型
  while (true) {
    if (L === null) {
      return false;
    } // 这里重点:当 prototype 严格等于 L 时,返回 true

    if (prototype === L) {
      return true;
    }

    L = L.__proto__;
  }
}

4. 实现继承 extends

实现继承有多种方式,这里仅列举常见的几种方式

4.1 原型链继承

function Parent() {
  this.name = "parent";
  this.data = [];
}
Parent.prototype.getName = function () {
  return this.name;
};
Parent.prototype.setData = function(value) {
    this.data.push(value);
}
function Child() {
  this.name = "child";
}
// 核心代码,子类的prototype指向父类的实例
Child.prototype = new Parent();

// 测试case
let child = new Child();
child.setData(1);
let child2 = new Child();
child2.setData(2);
console.log(child instanceof Parent); // true
console.log(child.getName()); // child
console.log(child.data); // [1,2] 为什么?

原型链继承有两个问题:

  1. 当原型链中包含引用类型值的原型时,该引用类型值会被所有实例共享;
  2. 在创建子类型(例如创建Son的实例)时,不能向超类型(例如Father)的构造函数中传递参数

4.2 组合继承(伪经典继承)

function Parent(name) {
  this.name = name
  this.data = [];
}
Parent.prototype.getName = function () {
  return this.name;
};
Parent.prototype.setData = function(value) {
    this.data.push(value);
}
function Child(name) {
    // 1. 借用父类构造函数,对父类的属性进行初始化
    Parent.call(this, name);
}
// 2. 子类的prototype指向父类的实例
Child.prototype = new Parent();
Child.prototype.constructor = Child;

// 测试case
let child = new Child('child');
child.setData(1);
let child2 = new Child('child2');
child2.setData(2);
console.log(child instanceof Parent); // true
console.log(child.getName()); // child
console.log(child2.data); // [2] 

组合继承解决了原型链继承的两个问题,但也带来两个新的问题

  1. 组合继承最大的问题就是无论什么情况下,都会调用两次超类型构造函数
  2. 虽然子类型最终会包含超类型对象的全部实例属性,但我们不得不在调用子类型构造函数时重写这些属性

4.3 寄生组合继承

function Parent(name) {
  this.name = name;
  this.data = [];
}
Parent.prototype.getName = function () {
  return this.name;
};
function Child(name) {
    Parent.call(this, name);
}

// 1. 原型式继承,本质上是对参数对象的一个浅复制
function object(o) {
    function F() {}
    F.prototype = o;
    return new F();
}
// 2. 这里跟组合继承唯一不同的是,用object(Parent.prototype)代替 new Parent()
//    这样可以避免两次调用构造函数
Child.prototype = object(Parent.prototype);
Child.prototype.constructor = Child;

// 子类新增的方法必须在继承之后设置,否则会因为子类的prototype被重新而导致方法丢失
Child.prototype.setData = function (value) {
    this.data.push(value);
};


// 测试case
let child = new Child("child");
child.setData(1);
let child2 = new Child("child2");
child2.setData(2);
console.log(child instanceof Parent); // true
console.log(child.getName()); // child
console.log(child2.data); // [2]

寄生组合继承完美解决两次调用父类的构造函数造成浪费的缺点

三、其它常见题目

1. 多层嵌套的对象转换成一级对象

写一个flattenObj函数,实现如下转换

/*
// 输入:
{
  a: {
    b: {
      c: {
        d: 1,
      },
      e: 2,
    },
    f: [1, 2],
  },
  g: 2,
}
// 输出:
{
  "a.b.c.d": 1,
  "a.b.e": 2,
  "a.f": [1, 2],
  g: 2,
}
*/ 
function flattenObj(obj) {
   // ...
}

参考答案,用递归

function flattenObj(obj) {
  const result = {};
  function dfs(obj, arr) {
    if (Object.prototype.toString.call(obj) !== "[object Object]") {
      result[arr.join(".")] = obj;
    } else {
      for (let p in obj) {
        dfs(obj[p], [...arr, p]);
      }
    }
  }
  dfs(obj, []);
  return result;
}

2. 一级对象转换成多层嵌套对象

写一个nestedObj函数,实现如下转换

/*
// 输入:
{
  "a.b.c.d": 1,
  "a.b.e": 2,
  "a.f": [1, 2],
  g: 2,
}
// 输出:
{
  a: {
    b: {
      c: {
        d: 1,
      },
      e: 2,
    },
    f: [1, 2],
  },
  g: 2,
}
*/ 
function nestedObj(obj) {
   // ...
}

参考答案

// 将一级对象转换成多层嵌套对象
function nestedObj(obj) {
  const result = {};
  for (let p in obj) {
    const paths = p.split(".");
    let tmp = result;
    for (let i = 0; i < paths.length; i++) {
      let path = paths[i];
      if (i == paths.length - 1) {
        tmp[path] = obj[p];
        continue;
      }
      if (!tmp.hasOwnProperty(path)) {
        tmp[path] = {};
      }
      tmp = tmp[path];
    }
  }
  return result;
}

3. 将数组转成tree结构

写一个toTreeObj函数,实现如下转换

/*
// 输入
[
  {
    id: 1,
    pid: null,
  },
  {
    id: 2,
    pid: 1,
  },
  {
    id: 3,
    pid: 1,
  },
  {
    id: 4,
    pid: 2,
  },
  {
    id: 5,
    pid: 4,
  },
  {
    id: 6,
    pid: null,
  }
]
// 输出
[
  {
    "id": 1,
    "pid": null,
    "child": [
      {
        "id": 2,
        "pid": 1,
        "child": [
          {
            "id": 4,
            "pid": 2,
            "child": [
              {
                "id": 5,
                "pid": 4
              }
            ]
          }
        ]
      },
      {
        "id": 3,
        "pid": 1
      }
    ]
  },
  {
    "id": 6,
    "pid": null
  }
]
*/
function toTreeObj(arr) {

}

参考答案

  1. 遍历一次数组,用map对象维护id与item的关系,并用数组保存根节点
  2. 再次遍历数组,如果pid不为空,将当前节点挂到父节点的child下
function toTreeObj(arr) {
  const map = new Map();
  const result = [];
  arr.forEach((v) => {
    map.set(v.id, { ...v });
    if (v.pid == null) {
      result.push(map.get(v.id));
    }
  });

  arr.forEach((v) => {
    if (v.pid) {
      let item = map.get(v.pid);
      if (item.child) {
        item.child.push(map.get(v.id));
      } else {
        item.child = [map.get(v.id)];
      }
    }
  });
  return result;
}

4. 将tree结构转成数组

写一个toTreeArray函数,实现如下转换

/*
// 输入
[
  {
    "id": 1,
    "pid": null,
    "child": [
      {
        "id": 2,
        "pid": 1,
        "child": [
          {
            "id": 4,
            "pid": 2,
            "child": [
              {
                "id": 5,
                "pid": 4
              }
            ]
          }
        ]
      },
      {
        "id": 3,
        "pid": 1
      }
    ]
  },
  {
    "id": 6,
    "pid": null
  }
]
// 输出
[
  {
    id: 1,
    pid: null,
  },
  {
    id: 2,
    pid: 1,
  },
  {
    id: 3,
    pid: 1,
  },
  {
    id: 4,
    pid: 2,
  },
  {
    id: 5,
    pid: 4,
  },
  {
    id: 6,
    pid: null,
  }
]
*/
function toTreeArray(arr) {

}

参考答案,递归思路

function toTreeArray(treeObj) {
  const result = [];

  function dfs(obj) {
    result.push({
      id: obj.id,
      pid: obj.pid,
    });
    if (obj.child) {
      obj.child.forEach(dfs);
    }
  }
  treeObj.forEach(dfs);

  return result;
}

5. 虚拟DOM转真实DOM

写一个render函数,实现如下转换

/*
// 输入一个virtualDom
{
  tag: "DIV",
  attrs: {
    id: "app",
  },
  children: [
    {
      tag: "SPAN",
      children: ["Hello World"],
    },
    {
      tag: "SPAN",
      children: [
        { tag: "A", attrs: { href: "https://baidu.com" }, children: ["百度"] },
      ],
    },
  ],
}
// 输出一个dom对象
<div id="app">
    <span>Hello World</span>
    <span>
       <a href="https://baidu.com">百度</a>
    </span>
</div>
*/
function render(node) {

}

参考答案

  1. 递归节点,区分文本节点和元素节点
function render(node) {
  // 创建文本元素
  if (typeof node === "string" || typeof node === "number") {
    return document.createTextNode(String(node));
  }
  let child = [];
  if (node.children) {
    // 递归处理子节点
    node.children.forEach((v) => {
      child.push(render(v));
    });
  }
  // 创建普通元素
  let dom = document.createElement(node.tag);
  if (node.attrs) {
    for (let p in node.attrs) {
      // 设置节点属性
      dom.setAttribute(p, node.attrs[p]);
    }
  }
  // append子节点
  if (child.length) {
    child.forEach((v) => {
      dom.appendChild(v);
    });
  }
  return dom;
}

四、进阶

1. 实现Node.js的模块加载器

参考:juejin.cn/post/686697… 这篇文章实现的

完整代码已上传GitHub:github.com/jackshen310…

2. 手撕Promise

参考 juejin.cn/post/684516… 这篇文章实现的

完整代码已上传GitHub:github.com/jackshen310…

class MyPromise {

}
// 以下为测试case
(async () => {
  let r1 = await new MyPromise((resolve, reject) => {
    setTimeout(() => {
      resolve("hello");
    }, 1000);
  });
  console.log(r1); // hello

  // then链式调用
  let r2 = await new MyPromise((resolve, reject) => {
    resolve("hello");
  }).then((res) => {
    return res + " world";
  });
  console.log(r2); // hello world

  // 异常处理
  let r3 = await new MyPromise((resolve, reject) => {
    reject("err");
  }).then(
    (res) => {
      return res + " world";
    },
    (err) => {
      return err.toString();
    }
  );
  console.log(r3); // err

  // 异常处理 - catch
  let r4 = await new MyPromise((resolve, reject) => {
    reject("err2");
  })
    .then((res) => {
      return res + " world";
    })
    .catch((err) => {
      return err.toString();
    });
  console.log(r4); // err2
})();

3. 实现Koa的compose方法

洋葱模型

function compose(middlewares) {
}

// 以下为测试代码
// 增加3个中间件
let middlewares = [];
middlewares.push(async (ctx, next) => {
  console.log(1);
  await next();
  console.log(6);
});

middlewares.push(async (ctx, next) => {
  console.log(2);
  await new Promise((resolve) => {
    setTimeout(() => {
      resolve();
      console.log("hello");
    }, 3000);
  });
  await next();
  console.log(5);
});

middlewares.push(async (ctx, next) => {
  console.log(3);
  ctx.body = "hello world";
  console.log(4);
});

const ctx = {};
compose(middlewares)(ctx)
  .then(() => {
    console.log(ctx);
  })
  .catch((err) => {
    console.log(err);
  });
/*
按顺序输出
1
2
hello
3
4
5
6
{ body: 'hello world' }
*/

参考答案:

  1. 实现:洋葱调用模型
  2. 实现:在一个中间件多次调用next()会报错
  3. 实现:最后一个中间可以不调用next()
function compose(middlewares) {
  return (ctx) => {
    let lastIndex = 0;
    function dispatch(i) {
      // 防止next() 在一个中间件中调用多次
      if (lastIndex != i) {
        return Promise.reject(new Error("next() called multiple times"));
      } else {
        lastIndex = i + 1;
      }
      let middleware = middlewares[i];
      if (i == middlewares.length) {
        return Promise.resolve();
      }
      try {
        // 这里需注意,next函数只能用dispatch.bind(this,i+1)写法
        // 不能用 () => {dispatch(i+1)} 写法
        return Promise.resolve(middleware(ctx, dispatch.bind(this, i + 1)));
      } catch (err) {
        return Promise.reject(err);
      }
    }
    return dispatch(0);
  };
}

本文放在github仓库:github.com/jackshen310…