本文放在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
})();
应用场景:
- 拖拽场景,固定时间内只执行一次,防止超高频次触发位置变动
- 缩放场景,监控浏览器resize
- 无限滚动,避免频繁向服务端发请求
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
})();
应用场景:
- 按钮提交场景,防止多次提交按钮,只执行最后提交的一次
- 搜索框联想场景,防止联想发送请求,只发送最后一次输入
- 实时保存场景,比如在线文档的实时保存
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
问题:
- 函数丢失,无法拷贝
- 循环引用会报错
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)); // 正常
问题:
- 没有考虑其它类型的复制,如Map、Set、Symbol,这些需要特殊处理
- 没有考虑函数的复制(其实大部分场景下函数都不用复制)
二、手写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
参考答案
- 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] 为什么?
原型链继承有两个问题:
- 当原型链中包含引用类型值的原型时,该引用类型值会被所有实例共享;
- 在创建子类型(例如创建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]
组合继承解决了原型链继承的两个问题,但也带来两个新的问题
- 组合继承最大的问题就是无论什么情况下,都会调用两次超类型构造函数
- 虽然子类型最终会包含超类型对象的全部实例属性,但我们不得不在调用子类型构造函数时重写这些属性
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) {
}
参考答案
- 遍历一次数组,用map对象维护id与item的关系,并用数组保存根节点
- 再次遍历数组,如果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) {
}
参考答案
- 递归节点,区分文本节点和元素节点
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' }
*/
参考答案:
- 实现:洋葱调用模型
- 实现:在一个中间件多次调用next()会报错
- 实现:最后一个中间可以不调用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…