在写这篇前端散记之前有写过另外两篇散记,可点击 前端散记前端散记 2 访问。所谓散记,东西都比较零散,更谈不上什么深入,但是至少可以让读者知道一些概念理论,如果深入可以自行去查询相关知识。

async await 实现原理

async 函数的实现,就是将 Generator 函数和自动执行器,包装在一个函数里。

1
2
3
4
5
6
7
8
9
10
11
async function fn(args){
// ...
}

// 等同于

function fn(args){
return spawn(function*() {
// ...
});
}

所有的 async 函数都可以写成上面的第二种形式,其中的 spawn 函数就是自动执行器。

下面给出 spawn 函数的实现,基本就是前文自动执行器的翻版。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

function spawn(genF) {
return new Promise(function(resolve, reject) {
var gen = genF();

function step(nextF) {
try {
var next = nextF();
} catch(e) {
return reject(e);
}
if(next.done) {
return resolve(next.value);
}
Promise.resolve(next.value).then(function(v) {
step(function() { return gen.next(v); });
}, function(e) {
step(function() { return gen.throw(e); });
});
}
step(function() { return gen.next(undefined); });
});
}

async 函数是非常新的语法功能,新到都不属于 ES6,而是属于 ES7。目前,它仍处于提案阶段,但是转码器 Babel 和 regenerator 都已经支持,转码后就能使用。

async await 注意点

await 命令后面的 Promise 对象,运行结果可能是 rejected,所以最好把 await 命令放在 try…catch 代码块中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sync function myFunction() {
try {
await somethingThatReturnsAPromise();
} catch (err) {
console.log(err);
}
}

// 另一种写法

async function myFunction() {
await somethingThatReturnsAPromise().catch(function (err){
console.log(err);
});
}

await 命令只能用在 async 函数之中,如果用在普通函数,就会报错。

1
2
3
4
5
6
7
8
async function dbFuc(db) {
let docs = [{}, {}, {}];

// 报错
docs.forEach(function (doc) {
await db.post(doc);
});
}

上面代码会报错,因为 await 用在普通函数之中了。但是,如果将 forEach 方法的参数改成 async 函数,也有问题。

1
2
3
4
5
6
7
8
async function dbFuc(db) {
let docs = [{}, {}, {}];

// 可能得到错误结果
docs.forEach(async function (doc) {
await db.post(doc);
});
}

上面代码可能不会正常工作,原因是这时三个 db.post 操作将是并发执行,也就是同时执行,而不是继发执行。正确的写法是采用 for 循环。

1
2
3
4
5
6
7
async function dbFuc(db) {
let docs = [{}, {}, {}];

for (let doc of docs) {
await db.post(doc);
}
}

如果确实希望多个请求并发执行,可以使用 Promise.all 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async function dbFuc(db) {
let docs = [{}, {}, {}];
let promises = docs.map((doc) => db.post(doc));

let results = await Promise.all(promises);
console.log(results);
}

// 或者使用下面的写法

async function dbFuc(db) {
let docs = [{}, {}, {}];
let promises = docs.map((doc) => db.post(doc));

let results = [];
for (let promise of promises) {
results.push(await promise);
}
console.log(results);
}

ES5 实现继承

第一种方式是借助call实现继承:

1
2
3
4
5
6
7
8
function Parent1(){
this.name = 'parent1';
}
function Child1(){
Parent1.call(this);
this.type = 'child1'
}
console.log(new Child1);

复制代码这样写的时候子类虽然能够拿到父类的属性值,但是问题是父类原型对象中一旦存在方法那么子类无法继承。那么引出下面的方法。

第二种方式借助原型链实现继承:

1
2
3
4
5
6
7
8
9
10
function Parent2() {
this.name = 'parent2';
this.play = [1, 2, 3]
}
function Child2() {
this.type = 'child2';
}
Child2.prototype = new Parent2();

console.log(new Child2());

看似没有问题,父类的方法和属性都能够访问,但实际上有一个潜在的不足。举个例子:

1
2
3
4
var s1 = new Child2();
var s2 = new Child2();
s1.play.push(4);
console.log(s1.play, s2.play);

可以看到控制台:

明明我只改变了s1的play属性,为什么s2也跟着变了呢?很简单,因为两个实例使用的是同一个原型对象。

那么还有更好的方式么?

第三种方式:将前两种组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Parent3 () {
this.name = 'parent3';
this.play = [1, 2, 3];
}
function Child3() {
Parent3.call(this);
this.type = 'child3';
}
Child3.prototype = new Parent3();
var s3 = new Child3();
var s4 = new Child3();
s3.play.push(4);
console.log(s3.play, s4.play);

可以看到控制台:

之前的问题都得以解决。但是这里又徒增了一个新问题,那就是Parent3的构造函数会多执行了一次(Child3.prototype = new Parent3();)。这是我们不愿看到的。那么如何解决这个问题?

第四种方式: 组合继承的优化1

1
2
3
4
5
6
7
8
9
10
function Parent4 () {
this.name = 'parent4';
this.play = [1, 2, 3];
}
function Child4() {
Parent4.call(this);
this.type = 'child4';
}
Child4.prototype = Parent4.prototype;

这里让将父类原型对象直接给到子类,父类构造函数只执行一次,而且父类属性和方法均能访问,但是我们来测试一下:

1
2
3
var s3 = new Child4();
var s4 = new Child4();
console.log(s3)

子类实例的构造函数是Parent4,显然这是不对的,应该是Child4。

第五种方式(最推荐使用):优化2

1
2
3
4
5
6
7
8
9
10
11
function Parent5 () {
this.name = 'parent5';
this.play = [1, 2, 3];
}
function Child5() {
Parent5.call(this);
this.type = 'child5';
}
Child5.prototype = Object.create(Parent5.prototype);
Child5.prototype.constructor = Child5;

这是最推荐的一种方式,接近完美的继承。

因此,小小的继承虽然ES6当中简化了很多,但毋庸置疑的是,js是一门基于原型的语言,所以,用ES5来完成继承非常考验面试者对JS语言本身的理解,尤其是对于原型链是否理解清楚。

虚拟DOM和Diff算法

虚拟DOM

虚拟Dom(virtual dom)到底是什么,简单来讲,就是将真实的dom节点用JavaScript来模拟出来,而Dom变化的对比,放到 Js 层来做。

  • 传统DOM节点
1
2
3
4
<ul id="list">
<li class="item">jelon1</li>
<li class="item">jelon2</li>
</ul>
  • 对应虚拟DOM
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
tag: 'ul',
attrs: {
id: 'list'
},
children: [
{
tag: 'li',
attrs: {
className: 'item'
},
children: [ 'jelon1' ]
},
{
tag: 'li',
attrs: {
className: 'item'
},
children: [ 'jelon2' ]
}
]
}
  • render方法

diff算法

关键词:patch

可参考详解vue的diff算法

工作中项目优化实践

前端优化主要基于两个目的:

  1. 提升加载速度;
  2. 用户操作起来流畅。
    因此,以下优化主要是围绕这两个目的展开。
  1. css sprite(雪碧图),iconfont;
  2. 资源懒加载、异步路由;
  3. 前端缓存 LocalStorage 及混合应用 Storage;
  4. 使用 CDN(JS、CSS等资源使用外部域名);
  5. Tree Shaking (Webpack 4.0+);
  6. 预请求;
  7. 资源按需加载;
  8. gzip (服务器);
  9. 减少 DOM 操作;
  10. 防抖节流;
  11. 尽量少用iframe;
  12. 尽量规避 CSS 计算。

参考思路

雅虎军规

Best Practices for Speeding Up Your Web Site

defineProperty vs proxy

  • Proxy可以直接监听对象而非属性;
  • Proxy直接可以劫持整个对象,并返回一个新对象,不管是操作便利程度还是底层功能上都远强于Object.defineProperty;
  • Proxy可以直接监听数组的变化;
  • Proxy有多达13种拦截方法,不限于apply、ownKeys、deleteProperty、has等等是Object.defineProperty不具备的。

手写 call

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Function.prototype.call2 = function (context) {
var context = context || window;
context.fn = this;

var args = [];
for(var i = 1, len = arguments.length; i < len; i++) {
args.push('arguments[' + i + ']');
}

var result = eval('context.fn(' + args +')');

delete context.fn
return result;
}

手写 apply

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Function.prototype.apply = function (context, arr) {
var context = Object(context) || window;
context.fn = this;

var result;
if (!arr) {
result = context.fn();
}
else {
var args = [];
for (var i = 0, len = arr.length; i < len; i++) {
args.push('arr[' + i + ']');
}
result = eval('context.fn(' + args + ')')
}

delete context.fn
return result;
}

手写bind

1
2
3
4
5
6
Function.prototype.bind2 = function (asThis, ...args1) {
let fn = this; // 函数调用时,原this其实就是这个调用函数
return function(...args2) { // 同时,返回的新函数也可以接受参数
return fn.call(asThis, ...args1, ...args2);
}
}

preload和prefetch

preload特点

preload加载的资源是在浏览器渲染机制之前进行处理的,并且不会阻塞onload事件;
preload可以支持加载多种类型的资源,并且可以加载跨域资源;
preload加载的js脚本其加载和执行的过程是分离的。即preload会预加载相应的脚本代码,待到需要时自行调用;
prefetch

prefetch是一种利用浏览器的空闲时间加载页面将来可能用到的资源的一种机制;通常可以用于加载非首页的其他页面所需要的资源,以便加快后续页面的首屏速度;

prefetch特点

prefetch加载的资源可以获取非当前页面所需要的资源,并且将其放入缓存至少5分钟(无论资源是否可以缓存);并且,当页面跳转时,未完成的prefetch请求不会被中断。

对比

Chrome有四种缓存:http cache、memory cache、Service Worker cache和Push
cache。在preload或prefetch的资源加载时,两者均存储在http
cache。当资源加载完成后,如果资源是可以被缓存的,那么其被存储在http
cache中等待后续使用;如果资源不可被缓存,那么其在被使用前均存储在memory cache;
preload和prefetch都没有同域名的限制;
preload主要用于预加载当前页面需要的资源;而prefetch主要用于加载将来页面可能需要的资源;
不论资源是否可以缓存,prefecth会存储在net-stack cache中至少5分钟;
preload需要使用as属性指定特定的资源类型以便浏览器为其分配一定的优先级,并能够正确加载资源;

如何比较两个 DOM 树的差异?

两个树的完全 diff 算法的时间复杂度为 O(n^3) ,但是在前端中,我们很少会跨层级的移动元素,所以我们只需要比较同一层级的元素进行比较,这样就可以将算法的时间复杂度降低为 O(n)。

算法首先会对新旧两棵树进行一个深度优先的遍历,这样每个节点都会有一个序号。在深度遍历的时候,每遍历到一个节点,我们就将这个节点和新的树中的节点进行比较,如果有差异,则将这个差异记录到一个对象中。

在对列表元素进行对比的时候,由于 TagName 是重复的,所以我们不能使用这个来对比。我们需要给每一个子节点加上一个 key,列表对比的时候使用 key 来进行比较,这样我们才能够复用老的 DOM 树上的节点。