面试复习总结
记录自己为面试做的准备和复习,以自己口语化的表达来理解,欢迎勘误。
从原型到闭包:js核心机制复盘
认识js之从“对象怎么连一起”,到“函数怎么执行”,再到“内存如何回收”。
原型
在javascript里所有函数都有一个prototype属性,这个属性是一个对象,指向它作为构造函数时的原型。通过new调用该函数可以生成一个实例,在这个实例的内部有一个[[Prototype]](浏览器里一般可以通过__proto__访问到)指向它的构造函数的prototype原型。
比如:
const obj = {};
obj.__proto__ === Object.prototype;
在查找对象上的属性时,如果当前对象上本身没有这个属性,就会顺着它的原型(也就是__proto__)上找,一直找到原型链的尽头,如果整条链上都找不到,最终会返回undefined,这一层层向上查找过程就是原型链。
原型链的顶点是null:
Object.prototype.__proto__ === null
this指向
关于在js里this指向问题,this指向并不总是固定的,而是由调用方式决定!
默认绑定
默认情况下,在js里谁调用我(this),我(this)就指向谁。
比如:
const examp = {
name: 'kaka',
getName() {
return this.name
}
}
examp.getName(); // kaka
在这个案例里,getName是由examp调用的,那么理所当然getName里this指向example。
默认绑定发生在独立函数调用的场景。
隐式绑定
依然是上面的案例examp,怎么隐式的绑定this呢?基于默认绑定的原理,那如果我们直接执行getName方法呢?
const { getName } = examp;
getName(); // ?
此时如果是全局环境非严格模式,在执行getName时会去window身上找name。
严格模式下this === undefined,此时会输出undefined
我上面举的例子有问题,这样的场景是隐式丢失,函数被单独拿出来调用时,会丢失原来的this。真正的隐式绑定应该是:
var name = 'global';
function foo() {
console.log(this.name);
}
foo(); // global
const obj = {
name: 'obj',
foo
};
obj.foo(); // obj
显式绑定
我们可以通过call/apply/bind来改变getName里this的指向,
const exampb = {
name: 'anthor object'
}
examp.getName.call(exampb);
这里要区分一下:
call接收的参数是参数列表,即call(this, a,b,c...);
apply接收的参数是一个数组;
而bind则与上面两个都不同,它是一个curry化的函数,并不会在改变this后立即执行,而是需要手动去执行这个函数。
所以,bind返回的是一个永久绑定this的新函数
透过现象看本质,实战来一下,所以这3个的实现方式可以是:
call实现方式
function _call(target, ...rest) {
const key = Symbol();
target[key] = this;
const result = target[key](...rest);
delete target[key];
return result
}
apply实现方式
同上,唯一的区别只是接收的参数是一个数组。
bind实现方式
function bind(fn, ctx, ...args) {
return function (...extra) {
return fn.apply(ctx, [...args, ...extra]);
};
}
new 绑定
通过new创建并初始化一个对象,此时this就会指向这个对象,参考上面原型章节
const child = new Children('amy');
child.getName(); // amy
new的过程是:
创建一个新对象,将新对象的原型指向目标原型,将目标this指向这个新对象,最后返回这个新对象。
实战演示一下
function _new(target, ...rest) {
const obj = {};
obj.__proto__ = target.prototype;
const result = target.call(obj, ...rest);
return result;
}
不推荐直接写__proto__,所以更规范的写法是:
function _new(target, ...rest) {
const obj = Object.create(target.prototype);
const result = target.call(obj, ...rest);
return result instanceof Object ? result : obj;
}
箭头函数
es6的箭头函数并没有自己的this,它的this是在定义时继承自外层作用域,它会捕获声明时所在的作用域的this。
const examp = {
name: 'kaka',
getName: () => {
return this.name
}
}
examp.getName(); // ?
同上面隐式绑定里说的那样,这里由于是箭头函数,this并不会被指向examp而是外层环境,在非严格模式里会指向window,严格模式则是undefined
绑定优先级
new > 显式 > 隐式 > 默认
继承
继承是指让一个类拥有另一个类上的属性和方法,继承作为js的基本特性之一。
es5 寄生组合式继承
在es6之前只能通过寄生组合式继承的方式来实现继承效果,即将子类的原型指向父类的原型实现方法的继承,并Parent.call(this)在子类构造函数中继承父类实例属性。
function Parent(name) {
this.name = name;
}
Parent.prototype.getName = function() {
return this.name;
}
function Children(name) {
Parent.call(this, name)
}
Children.prototype = Object.create(Parent.prototype);
Children.prototype.constructor = Children;
const child = new Children('kaka');
const parent = new Parent('abc');
补充一点:Children.prototype = Object.create(Parent.prototype); 这里用create而不是new,因为只需要继承Parent原型上的方法,不需要执行Parent()构造函数,所以并不需要整个new。如果new Parent() 就会多跑一次构造函数,可能意外赋值实例属性。
补充二点 Object.create的执行过程是:创建一个新对象,将新对象的原型指向目标原型,返回这个新对象。简单来说是:
function _create(proto) {
const obj = {};
obj.__proto__ = proto;
return obj
}
更规范的实现方式是:
function _create(proto, prot) {
function F() {}
F.prototype = proto;
const obj = new F();
if (prot) {
Object.defineProperties(obj, prot)
}
return obj;
}
es6 extends继承
而在es6有了官方的class和extends关键词,在constructor里调用super方法继承父类的属性。
Class Parent {}
Class Children extends Parent {}
extends本质就是es5 寄生组合式继承思想的语法糖,super用来调用父类构造函数,创建并绑定this。在constructor里必须先super(),才能使用this,在普通方法里,super指向父类的原型。
执行上下文 & 作用域
javascript采用的是词法作用域(静态作用域),函数的作用域在函数定义时就已经确定了。
什么是作用域
作用域是指函数,变量所能访问的区域,或者说是在哪些范围里可以访问到变量和函数。作用域又分为全局作用域和局部作用域:
全局作用域
顾名思义,是在全局的环境里,比如最外层window。
console.log(a); // undefined
var a = 1;
在这段代码里先输出a竟然不会报错?因为js是从上到下执行的,正常来说应该会是先输出a,再执行a赋值,可实际上输出a只是undefined而不是ReferenceError。 这其实是变量提升问题,只有var声明的变量会存在这个情况,js实际执行的顺序是:
var a;
console.log(a);
a = 1;
所以a输出undefined是对的。
正是由于var声明的变量存在奇奇怪怪的特性,所以在es6之后官方更推荐使用const/let声明变量,它们虽然也存在提升,但是不会初始化,它们会在声明前进入暂时性死区。
局部作用域
与之全局相对的,是局部作用域。在es6之前只存在函数作用域,即函数内部的作用域。而es6之后有了块级作用域,即{}包裹的,使用const,let声明的变量。
函数作用域
function fn() {
var b = 2;
console.log(b)
}
fn();
console.log(b); // ReferenceError
这个例子里b只存在于函数fn里,在外部输出b就会报错。
块级作用域
{
let c = 3;
const d = 4;
}
console.log(c); // ReferenceError
只有在当前块级作用域内才能访问到c和d的变量
作用域链
作用域也和原型链一样,当访问一个变量时,会在当前作用域里查找,找不到就会向上一个作用域里查找,一直到最顶层的全局作用域环境,像这样多个作用域相互引用的过程就是作用域链。
执行上下文
每次函数执行,都会创建一个执行上下文。执行上下文包含了变量环境,作用域链和this指向。
执行上下文/调用栈/VO & LE等我还讲不明白,后续再补充。
事件循环(Event Loop)
在浏览器环境里,Javascript 会从上到下执行,遇到异步任务会将其push到后台任务队列里,等同步任务执行完成就会开始执行异步任务。异步任务又分为宏任务和微任务,宏任务比如定时器,异步请求等,而微任务则有promise, async await, nextTick等。在任务队列里会优先执行微任务,再执行宏任务。
JavaScript 本身是单线程的,同一时间只能执行一个任务。
但浏览器并不是单线程,浏览器内部存在:
- JS 引擎线程(主线程)
- 网络线程
- 定时器线程
- 渲染线程
- GPU 线程等
所以像定时器、网络请求这类异步任务,并不是由 JS 主线程本身执行,而是交由浏览器对应线程处理。
例如:
setTimeout(() => {
console.log('success');
}, 1000);
当js执行到setTimeout时,会将计时任务交给浏览器的定时器线程。
1s后,定时器线程会将回调放入任务队列(而不是立即执行)。
JS主线程会持续执行同步代码。
当调用栈为空时,Event Loop会从任务队列中取出回调并执行。
任务队列又分为:
宏任务 (Macro Task)
- setTimeout
- setInterval
- I/O
- UI rendering
微任务 (Micro Task)
- Promise.then
- MutationObserver
- queueMicrotask
每轮宏任务执行结束后,都会清空微任务队列,然后再进行下一轮宏任务。
闭包
闭包是指:函数可以访问其词法作用域里的变量,即使那个作用域已经执行完毕。
也就是说,函数执行完成后,它的执行上下文会被销毁,但是只要还有变量被引用,这些变量就不会被垃圾回收。
这也是javascript的核心特性之一。
function a() {
let b = 1;
return function() {
return ++b;
}
}
const c = a();
console.log(c()); // 1
console.log(c()); // 2
const d = a();
console.log(d()); // 1
每次调用a(),都会创建一个新的作用域和新的b,它们之间互不影响。
闭包的典型应用
- 防抖/节流
- React
useState - Vue
setup
本质上都是函数在离开定义它的环境后,依然还记得那个环境。
虽然闭包很好用,但是滥用闭包会导致变量长期无法释放,内存占用增加,严重时会导致内存泄漏。
垃圾回收(GC)
javascript的垃圾回收是自动的,开发者通常不需要手动管理内存。
核心思想
判断一个变量是否还在使用,如果不再被引用,就可以被垃圾回收。
标记清除
现代的浏览器基本都使用标记清除算法
首先会从全局对象出发,标记所有“还在使用的变量”,清除所有没有被标记的变量。
V8 引擎会把内存分为新生代和老生代两个区域,新生代存放生命周期短的变量,而老生代存放长期存活的变量。会频繁回收新生代,偶尔回收老生代,以此来提高性能。
结合闭包实战来一下
function fn() {
let a = 1;
return () => a;
}
const f = fn();
在这个例子里只要f还在,变量a就一直会被引用而不会进入垃圾回收。这就是我上面说的滥用闭包可能会导致内存长期被占用!
虽然日常开发不需要太关注垃圾回收,但是还是要注意:
- 不需要的引用及时置空
- 长生命周期对象(全局,缓存)要谨慎
- 避免无意义的闭包嵌套
CSS
基础的就不说了自行看文档,主要挑选一些面试过程中可能会问到的问题。
BFC
也叫块级格式化上下文,主要特性是外部样式不会影响到内部,内部样式也不会影响到外部样式,比如overflow,flex/grid,position: absolute,float等;BFC是一个独立的渲染区域,它的作用是清除浮动,防止margin重叠等。BFC的本质是解决布局污染的问题。
重绘(Paint)
当元素外观发生变化但不会影响布局时,会触发重新绘制元素,比如重新设置颜色/背景颜色/阴影,设置transform等。重绘不一定会触发重排!
勘误:transform/opacity通常只触发合成阶段,不回触发重排和重绘,因此动画性能更好。
重排(Reflow)
当元素尺寸、位置或者结构发生变化时,会触发重排。比如重新设置宽高/margin/padding,脱离文档流,修改布局等。甚至读取offsetWidth/scrollTop等
比如:
// 错误
element.style.width = '100px';
console.log(element.offsetWidth);
// 正确
requestAnimationFrame(() => {
element.style.width = '100px'
})
像这样连续读写布局属性时,会触发多次重排!
补充CSS渲染流程:
先解析HTML生成DOM tree,然后解析CSS生成CSSOM tree,合并DOM tree和CSSOM tree生成Render tree,接着重排reflow计算节点的几何位置,触发重绘Paint填充像素(颜色,阴影等),最后Composite合成,交给GPU显示。
CSS看起来只是样式,背后却是浏览器的一场精密计算,所以理解渲染流程,才能真正写出高性能的页面。
总结
从地址栏输入网址到页面呈现,这个过程发生了什么?
1. URL解析
浏览器解析输入的url,识别协议、域名、端口、路径等;
2. DNS解析
将域名解析为真实的IP地址:
- 浏览器缓存
- 系统缓存
- 路由器缓存
- DNS 服务器
3. 建立连接
http
- tcp三次握手
https
- tcp三次握手
- Ssl/tls握手(证书校验 + 密钥协商)
发送请求 & 接收响应
浏览器向服务器发送http请求,服务器返回HTML资源。
解析 & 渲染页面
- 解析html生成DOM tree
- 解析css生成cssom tree
- 根据dom tree和cssom tree生成render tree
- Reflow 计算元素节点的几何位置和大小等
- Paint 填充像素
- Composite 合成并显示页面
后续加载
- 加载JS / CSS / 图片
- 执行JS
- 可能触发重绘/重排
浏览器是边下载边解析边渲染的,而不是等全部资源下载完成才开始。
从敲下回车到页面出现
本质是:网络请求 + 浏览器内核解析 + 渲染的流水线
理解这一步,才能看懂前面的JS / CSS / 性能优化