10个JS手写题,挑战你的编程技巧!

260 阅读8分钟

概览:在日常前端开发中,我们经常使用到各种JS库和框架,但是这些工具的背后都是JS语言的基础和核心。所以,为了更好地掌握JS技能,我们需要不断地去学习和练习。今天,我将带你死磕10个JS手写题,成为真正的JS高手!

一、缓存(Memoization)

在一些特殊的场景下,特别是一些大数据量的计算当中,如果有一些方法会反复执行很多次,就需要对其进行优化。优化有很多种方法,其中一个就是我们可以考虑利用记忆化方法(Memoization)进行优化,核心思想是存储之前的执行结果,后续再执行的时候直接使用缓存的结果,从而减少重复计算,提高效率。

以下是一个利用记忆化方法实现的手写缓存的代码实现:

function memo(func) {
   // 建立缓存对象
  const cache = {}; 
  return function () {
    // 将函数参数转换成字符串,作为缓存对象的键
    const key = JSON.stringify(arguments);  
     if (cache[key] === undefined) {
       // 缓存中已经没有相同参数的结果,调用原函数计算结果,并将结果缓存起来
      cache[key] = func.apply(this, arguments);
    }
    return cache[key]; //返回缓存结果
  };
}

上述代码实现了一个 memoize 函数,它可以接收一个函数作为参数,然后返回一个新的函数,这个新函数具有记忆化的功能,可以缓存之前执行的结果,并在后续执行时直接使用缓存结果,从而减少重复计算,提高效率。

具体实现方式是使用一个缓存对象来存储函数的参数和对应的结果,每次调用新的函数时,先将参数序列化成字符串,然后作为缓存对象的键。如果缓存对象中已经有了相同参数的结果,则直接返回缓存结果,否则调用原函数计算结果,并将结果缓存起来。使用示例:

function pythagorean(a, b) {
  console.log('Doing the job ...');
  return Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
}

const mPythagorean = memo(pythagorean);

console.log(mPythagorean(4, 3)); // "Doing the job ..." followed by "5"
console.log(mPythagorean(4, 3)); // only "5"
console.log(mPythagorean(4, 3)); // only "5"
console.log(mPythagorean(4, 3)); // only "5"

上述代码定义了pythagorean函数, 然后利用 memo 函数对它进行了优化,得到了一个新的函数 mPythagorean。可以多次调用 mPythagorean 函数,它会利用缓存来避免重复计算。

二、深浅拷贝

深浅拷贝的主要区别在于是否会拷贝对象中的引用类型数据,如数组或对象。

1. 浅拷贝

浅拷贝只会复制对象的引用,而不是复制对象本身。因此,如果对浅拷贝得到的对象进行修改,原始对象也会受到影响。

(1) 扩展运算符(...)

const originalObj = { a: 1, b: 2 };
const copyObj = { ...originalObj };

(2) Object.assign()

const originalObj = { a: 1, b: 2 };
const copyObj = Object.assign({}, originalObj);

(3)数组的 slice() 或 concat()

const originalArr = [1, 2, 3];
const copyArr = originalArr.slice();
// 或者
const copyArr = originalArr.concat();

(4)手写实现

const shallowCopy = (obj) => {
  if (typeof obj !== 'object' || obj === null) {
    return obj;
  }

  return Array.isArray(obj) ? [...obj] : {...obj};
};

上述代码中,浅拷贝函数使用了扩展运算符和对象展开语法,如果传入的是数组则使用[...obj],否则使用{...obj}来进行浅拷贝。

2. 深拷贝

深拷贝则会复制整个对象及其引用的对象,这样就可以在新对象上进行修改而不会影响原始对象。

(1)递归实现深拷贝

// 定义一个深拷贝函数,接收两个参数,obj 表示要拷贝的对象,hash 是一个 WeakMap 对象,用于存储已拷贝过的对象
const deepCopy = (obj, hash = new WeakMap()) => {
  if (typeof obj !== 'object' || obj === null) {
    return obj;
  }

  // 如果 obj 已经被拷贝过,直接返回拷贝后的对象
  if (hash.has(obj)) {
    return hash.get(obj);
  }

  // 根据 obj 的类型,创建一个空对象或空数组
  const result = Array.isArray(obj) ? [] : {};

  // 将 obj 和 result 存入 hash 中
  hash.set(obj, result);

  // 遍历 obj 的所有属性,将它们深拷贝后放入 result 中
  Object.keys(obj).forEach((key) => {
    result[key] = deepCopy(obj[key], hash);
  });

  return result;
};

深拷贝函数deepCopy使用了递归来遍历对象并复制其属性及其引用的对象。由于在对象中可能会有循环引用的情况,使用hash来记录已经拷贝过的对象,避免无限递归。在拷贝完成后,将拷贝后的对象保存在hash中以便后续使用。

(2) JSON.parse() 和 JSON.stringify()

const originalObj = { a: 1, b: { c: 2 } };
const copyObj = JSON.parse(JSON.stringify(originalObj));

需要注意的是,使用JSON.parse()JSON.stringify()进行深拷贝时,会忽略原对象中的函数、正则表达式和 Symbol 类型的属性。此外,如果原对象中存在循环引用,会导致拷贝失败。

(3)structuredClone

上述的相信大家都非常熟悉,除此之外,在js中,有一个常见的需求是要深拷贝一个对象,其中一些方法有些 hacky,一些需要第三方库,如果有一个内置函数可以为你完成深拷贝,是不是很惊喜。

structuredClone 是 HTML5 中提供的一个 API,用于实现对象的深拷贝。它能够在保持原对象数据结构和类型的前提下,完整地拷贝整个对象,包括对象的所有属性、方法、原型链等。

const obj1 = { a: 1, b: { c: 2 } };
const obj2 = structuredClone(obj1);
console.log(obj2); // { a: 1, b: { c: 2 } }

三、 防抖、节流

防抖和节流都是用于控制函数执行频率的技术。

1. 防抖

防抖指的是在短时间内多次触发同一个函数时,只执行一次,并且在指定时间后执行,如果在指定时间内再次触发,则重新计时。适用于例如搜索框输入时,随着输入不断发请求,而使用防抖可以减少请求次数,减轻服务器压力,提升性能。

// 防抖函数
const debounce = (func, delay) => {
  let timerId;

  return function(...args) {
    if (timerId) {
      clearTimeout(timerId);
    }

    timerId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
};

2. 节流

指的是在一段时间内只能执行一次函数,比如每隔一定时间执行一次,适用于例如页面滚动时,限制事件触发的频率,提升性能和用户体验。

// 节流函数
const throttle = (func, delay) => {
  let timerId;
  let lastTime = 0;

  return function(...args) {
    const now = Date.now();

    if (now - lastTime >= delay) {
      func.apply(this, args);
      lastTime = now;
    } else {
      clearTimeout(timerId);
      timerId = setTimeout(() => {
        func.apply(this, args);
      }, delay - (now - lastTime));
    }
  };
};

四、观察者模式

如果让我挑选一个前端应用的最广泛的设计模式,毫无疑问我会选择观察者模式,几乎绝大多的的前端库的实现都使用了观察者模式,无论是读源码还是平时代码的编写,学习和理解观察者模式都是及其重要的。

class Observer {
  constructor() {
     // 订阅列表,用于存储所有订阅了该主题的观察者对象
    this.observers = [];
  }
  // 订阅方法,将一个观察者对象加入到订阅列表中
  subscribe(observer) {
    this.observers.push(observer);
  }
  // 取消订阅方法,将一个观察者对象从订阅列表中删除
  unsubscribe(observer) {
    this.observers = this.observers.filter(obs => obs !== observer);
  }
  // 通知方法,遍历所有观察者对象,并调用它们的 update() 方法,将最新的数据传递给它们
  notify(data) {
    this.observers.forEach(observer => observer.update(data));
  }
}
// 观察者对象(Observer)
class ConcreteObserver {
  // update() 方法用于处理主题对象通知的数据
  update(data) {
    console.log(`Received data: ${data}`);
  }
}

// Usage
const observer = new Observer();

// 创建两个观察者对象
const observer1 = new ConcreteObserver();
const observer2 = new ConcreteObserver();

// 将两个观察者对象加入到主题对象的订阅列表中
observer.subscribe(observer1);
observer.subscribe(observer2);

// 通知所有观察者对象有关数据的更改
observer.notify('Hello, world!');

// 将一个观察者对象从订阅列表中删除
observer.unsubscribe(observer2);

// 再次通知所有观察者对象有关数据的更改
observer.notify('Goodbye, world!');

观察者模式能够帮助我们在对象之间建立一种松散的耦合关系,实现更好的模块化和可重用性,并且观察者模式也符合“开闭原则”,使得我们能够更加容易地添加和修改观察者,而不必修改被观察者的代码。因此,观察者模式是一种非常有用和重要的设计模式。

五、树的扁平化

我们经常会使用树形的组件,而不同的组件库,树形组件的数据可能是一致的,有的时扁平的,有的则是递归嵌套的,今天我们就来写两个方法进行树形数据的转换

function flattenTree(node, parent = null) {
  const result = [];
  result.push({
    id: node.id,
    name: node.name,
    parent: parent
  });
  if (node.children) {
    node.children.forEach(child => {
      result.push(...flattenTree(child, node.id));
    });
  }
  return result;
}

function flatToRecursive(flatTree) {
  // 创建一个空对象来存储递归树
  const recursiveTree = {};

  // 遍历扁平树中的每个节点
  flatTree.forEach((node) => {
    // 获取节点的id和parent_id
    const nodeId = node.id;
    const parentId = node.parent_id;

    // 如果节点没有父节点,则将其作为根节点添加到递归树中
    if (parentId === null) {
      recursiveTree[nodeId] = {};
    } else {
      // 否则,找到父节点并将当前节点添加为其子节点
      const parent = recursiveTree[parentId];
      parent[nodeId] = {};
    }
  });

  // 返回递归树
  return recursiveTree;
}

// Usage
const tree = {
  id: 1,
  name: 'root',
  children: [
    {
      id: 2,
      name: 'child1',
      children: [
        {
          id: 3,
          name: 'grandchild1',
          children: []
        },
        {
          id: 4,
          name: 'grandchild2',
          children: []
        }
      ]
    },
    {
      id: 5,
      name: 'child2',
      children: [
        {
          id: 6,
          name: 'grandchild3',
          children: []
        },
        {
          id: 7,
          name: 'grandchild4',
          children: []
        }
      ]
    }
  ]
};

const result = flattenTree(tree);
console.log(result);

六、实现一个Promise.All

Promise.all是一个非常实用的方法,它可以并行处理多个异步操作,等待所有异步操作完成后再一起返回结果。在实际开发中,经常会遇到需要同时请求多个接口并且等待所有请求完成后再处理的情况,此时Promise.all就派上了用场。

const PromiseAll = (promises) => {
  return new Promise((resolve, reject) => {
    const results = []; // 用于存放所有 Promise 对象的返回值
    let count = 0; // 记录已经处理的 Promise 对象的数量

    const processResult = (index, result) => {
      results[index] = result; // 将 Promise 对象的返回值存入 results 数组
      count++; // 增加计数器

      // 如果所有 Promise 对象都已经处理完毕,则调用 resolve 方法,将 results 数组作为参数传入
      if (count === promises.length) {
        resolve(results);
      }
    };

    // 遍历参数中的所有 Promise 对象
    promises.forEach((promise, index) => {
      // 对每个 Promise 对象进行处理
      Promise.resolve(promise)
        .then((result) => {
          // 如果该 Promise 对象状态变为 resolved,则调用 processResult 方法将其返回值存入 results 数组中
          processResult(index, result);
        })
        .catch((error) => {
          // 如果其中有一个 Promise 对象状态变为 rejected,则直接调用 reject 方法,将该 Promise 对象的返回值作为参数传入
          reject(error);
        });
    });
  });
};

七、实现责任链模式

责任链模式是一种行为设计模式,用于将多个对象组成一条链,并依次处理请求,直到其中一个对象处理请求为止。以下是一个简单的责任链模式的实现:

class Handler {
  constructor() {
    this.next = null;
  }

  setNext(handler) {
    this.next = handler;
    return handler;
  }

  handle(request) {
    if (this.canHandle(request)) {
      this.doHandle(request);
    } else if (this.next) {
      this.next.handle(request);
    } else {
      console.log('No handler found for request: ' + request);
    }
  }

  canHandle(request) {
    throw new Error('Abstract method. Must be overridden.');
  }

  doHandle(request) {
    throw new Error('Abstract method. Must be overridden.');
  }
}

class ConcreteHandler1 extends Handler {
  canHandle(request) {
    return request >= 0 && request < 10;
  }

  doHandle(request) {
    console.log('Request ' + request + ' handled by ConcreteHandler1');
  }
}

class ConcreteHandler2 extends Handler {
  canHandle(request) {
    return request >= 10 && request < 20;
  }

  doHandle(request) {
    console.log('Request ' + request + ' handled by ConcreteHandler2');
  }
}

class ConcreteHandler3 extends Handler {
  canHandle(request) {
    return request >= 20 && request < 30;
  }

  doHandle(request) {
    console.log('Request ' + request + ' handled by ConcreteHandler3');
  }
}

const handler1 = new ConcreteHandler1();
const handler2 = new ConcreteHandler2();
const handler3 = new ConcreteHandler3();

handler1.setNext(handler2).setNext(handler3);

handler1.handle(5); // Output: Request 5 handled by ConcreteHandler1
handler1.handle(15); // Output: Request 15 handled by ConcreteHandler2
handler1.handle(25); // Output: Request 25 handled by ConcreteHandler3
handler1.handle(35); // Output: No handler found for request: 35

八、 箭头函数this指向问题

由于箭头函数的 this 指向固定,无法通过调用方式或者 call()apply()bind() 方法来改变,因此在手写源码时只需要定义一个箭头函数并使用即可。以下是一个简单的示例:

const arrowFn = () => {
  console.log(this);
};

// 定义上下文对象
const obj1 = { foo: 'bar' };

// 直接调用箭头函数,this 指向定义时的外层作用域,即全局作用域
arrowFn(); // 输出全局对象(浏览器中是 window)

// 将箭头函数作为对象的方法调用,this 仍然指向定义时的外层作用域
const obj2 = {
  baz: arrowFn
};
obj2.baz(); // 输出全局对象

// 将箭头函数作为回调函数传递给普通函数,this 仍然指向定义时的外层作用域
function foo(callback) {
  callback();
}
foo(arrowFn); // 输出全局对象

// 将箭头函数作为回调函数传递给定时器,this 仍然指向定义时的外层作用域
setTimeout(arrowFn, 1000); // 输出全局对象

// 在对象方法中定义箭头函数,this 指向对象本身
const obj3 = {
  bar: 'baz',
  arrowFn: () => {
    console.log(this.bar);
  }
};
obj3.arrowFn(); // 输出 'baz'

九、 数据类型判断,类型相等判断

1. typeof

对于对象和null,typeof返回的都是"object",无法区分它们的类型。

typeof 'hello'; // "string"
typeof true; // "boolean"
typeof undefined; // "undefined"
typeof null; // "object"
typeof Symbol(); // "symbol"

2. instanceof

可以判断一个对象是否属于某个类的实例,也可以判断一个对象是否属于某个父类的实例。

class Person {}
const p = new Person();
p instanceof Person; // true

3. Object.prototype.toString

在实际开发中,通常使用该方法来判断数据类型,因为它能够区分出所有的数据类型,并且可以通过调用原型方法来判断自定义对象的类型。

const getType = (value) => { 
  const typeStr = Object.prototype.toString.call(value); 
  return typeStr.slice(8, -1).toLowerCase(); 
}

//调用示例:
getType('hello'); // 'string'
getType(123); // 'number'
getType(true); // 'boolean'
getType({}); // 'object'
getType([]); // 'array'
getType(null); // 'null'
getType(undefined); // 'undefined'
getType(() => {}); // 'function'

类型相等判断

(1)Object.is 方法会根据值的“语义相等性”(semantic equality)来进行比较。具体来说,它会判断两个值是否具有相同的值和类型,例如:

Object.is(1, 1); // true
Object.is(1, '1'); // false
Object.is(NaN, NaN); // true

(2)Object.prototype.toString.call 方法则是根据对象的内部属性 [[Class]] 来进行比较。这个属性可以反映出对象的类型信息。例如:

Object.prototype.toString.call(1); // "[object Number]"
Object.prototype.toString.call('foo'); // "[object String]"
Object.prototype.toString.call({}); // "[object Object]"
function isTypeEqual(value1, value2) {
  return Object.prototype.toString.call(value1) === Object.prototype.toString.call(value2);
}

十、 中间件系统实现

一个中间件系统可以在请求到达最终目的地之前,对请求进行一系列的处理和转换。在 JavaScript 中,我们可以通过函数组合来实现中间件系统

// 定义一个处理请求的函数
function handleRequest(request) {
  return `处理请求: ${request}`;
}

// 定义一个中间件函数,它可以将请求转换成大写字母
function uppercaseMiddleware(request, next) {
  request = request.toUpperCase();
  // 将处理权交给下一个中间件或最终处理函数
  next(request);
}

// 定义一个中间件函数,它可以给请求添加一些元数据
function metadataMiddleware(request, next) {
  request = `${request},元数据: {key: value}`;
  // 将处理权交给下一个中间件或最终处理函数
  next(request);
}

// 定义一个中间件函数,它可以对请求进行加密
function encryptMiddleware(request, next) {
  request = `加密请求: ${request}`;
  // 将处理权交给下一个中间件或最终处理函数
  next(request);
}

// 定义一个中间件函数,它可以对响应进行解密
function decryptMiddleware(response, next) {
  response = `解密响应: ${response}`;
  // 将处理权交给下一个中间件或最终处理函数
  next(response);
}

// 定义一个函数,它可以将一组中间件组合成一个处理函数
function applyMiddleware(...middlewares) {
  // 返回一个接受初始请求并将处理权交给第一个中间件的函数
  return function(request) {
    // 定义一个变量,用于存储当前处理权所在的中间件的索引
    let index = 0;
    
    // 定义一个函数,它将处理权交给下一个中间件或最终处理函数
    function next(data) {
      // 如果所有中间件已经处理完毕,则返回最终的响应
      if (index === middlewares.length) {
        return data;
      }
      // 否则,将处理权交给下一个中间件
      const middleware = middlewares[index++];
      return middleware(data, next);
    }
    
    // 将处理权交给第一个中间件
    return next(request);
  };
}

// 使用 applyMiddleware 函数将所有中间件组合成一个处理函数
const handler = applyMiddleware(
  uppercaseMiddleware,
  metadataMiddleware,
  encryptMiddleware,
  decryptMiddleware
)(handleRequest);

// 发起一个请求,并将结果打印到控制台
console.log(handler('my request'));

这只是JavaScript编程中的一小部分,编程之路充满挑战和机遇,我们需要不断学习、探索和提高自己的技能。本文仅是提供一些小技巧和思路,仍有许多更深入、更复杂的主题需要进一步学习和掌握。希望本文对您有所帮助,也希望您能在日后的编程中不断成长和进步。谢谢!

我的更多前端资讯

欢迎大家技术交流 资料分享 摸鱼 求助皆可 —链接