代码的编译与执行
传统语言代码的编译分为 分词/词法分析--> 解析/语法解析(生成抽象语法树AST)--> 代码生成(AST转化为一组机器指令),再执行
js引擎要复杂的多,在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化。
先编译:如 var a = 2 编译器会在当前作用域中声明一个变量a(如果之前没有声明过) 再执行:运行时引擎会在作用域中查找改变量,如果能找到就会对赋值
执行上下文
执行上下文是评估和执行Javascript代码的环境的一个抽象概念。任何代码在JavaScript中运行时,都在执行上下文中运行
全局执行上下文在执行前创建,函数的执行上下文在该函数被调用时创建(函数执行:创建函数上下文+执行)
在JavaScript中有三种类型的执行上下文。
- 全局执行上下文——这是默认的或基本的执行上下文。任何不在函数内部的代码位于全局执行上下文中。它执行两件事:它创建一个全局对象,它是一个window对象(在浏览器的情况下),并将this的值设置为等于全局对象。一个程序中只能有一个全局执行上下文。
- 函数执行上下文——每次调用函数时,都会为该函数创建一个全新的执行上下文。每个函数都有自己的执行上下文,但它是在调用或调用(原文是it’s created when the function is invoked or called)函数时创建的。可以有任意数量的函数执行上下文。每当创建一个新的执行上下文时,它都会按照已定义的顺序执行一系列步骤,我将在本文后面讨论这些步骤。
- Eval函数执行上下文——在
Eval函数内部执行的代码也会获得它自己的执行上下文,但JavaScript开发人员通常不使用Eval,所以我在这里不讨论它。
执行栈(Execution Stack)
函数在执行的时候入栈
执行栈,在其他编程语言中也被称为“调用栈”,是一个具有后进先出结构的栈,它用于存储代码执行期间创建的所有执行上下文。
当JavaScript引擎第一次遇到脚本时,它会创建一个全局执行上下文,并将其推入当前执行栈。每当引擎发现一个函数调用时,它就会为该函数创建一个新的执行上下文,并将其推到栈的顶部。
引擎会执行那些执行上下文位于栈顶部的函数。当这个函数完成时,它的执行栈从栈中弹出,控件到达当前栈中被弹出的上下文的下面的上下文。
让我们通过下面的代码示例来理解这一点:
let a = 'Hello World!';
function first() {
console.log('Inside first function');
second();
console.log('Again inside first function');
}
function second() {
console.log('Inside second function');
}
first();
console.log('Inside Global Execution Context');
复制代码
图片就是上面代码的执行上下文堆栈。
如何创建执行上下文?
到目前为止,我们已经看到了JavaScript引擎是如何管理执行上下文的,现在让我们来理解JavaScript引擎是如何创建执行上下文的。
执行上下文的创建分为两个阶段:1)创建阶段和2)执行阶段。
对于每个执行上下文,都有三个重要属性
- 变量对象(Variable object,VO) 存储上下文中定义的变量和函数声明
- 作用域链(Scope chain)
- this
作用域
作用域是指程序源代码中定义变量的区域。
作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。
JavaScript 采用词法作用域(lexical scoping),也就是静态作用域。在编译阶段确定执行上下文和作用域,执行函的时候是在编译阶段确定的上下文和作用域中执行
静态作用域与动态作用域
因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。
而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。
作用域链
当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链
(1)、全局作用域 document window等
(2)、函数作用域 在一个函数中的变量只能在这个函数中使用
(3)、块级作用域 {} 块内声明的变量不能在外部使用,如java、c语言都有这个功能
但是, javascript的{}本没有块级作用域的能力,是let,const让它有了块级作用域的能力,let声明的变量的作用域在{}内。(let关键字可以将变量绑定到所在的任何作用域中,通常是{....}内部,换句话说,let为其声明的变量隐式的劫持了所在的块作用域),其他语言都有块级作用域
没有let的时候 {}不构成单独的作用域,对象字面量也不构成单独的作用域
(4)、自由变量 一个变量在当前作用域没有被定义,但使用了,会向上级作用域一层一层的寻找,直到找到为止,如果到全局作用域都没有找到,就会报错XX is not defined
this
this取值 谁调用函数,函数中的this就指向谁!!!而不是函数定义的地方的作用域的this。
但箭头函数中的this是定义的地方的上级作用域 的 this!!!
根据上面的js作用域,全局作用域中的this指向window,函数中的this指向调用这个函数的对象,{ }对this来说不是作用域所以this的指向有以下几种情况
(1)、当做普通函数调用this window
(2)、作为对象方法调用 指向对象本身
(3)、class 实例本身
(4)、箭头函数 取它上级作用域的this 也就是说这个箭头函数外面的this!!this指向的固定化,并不是因为箭头函数内部有绑定this的机制,实际原因是箭头函数根本没有自己的this,导致内部的this就是外层代码块的this。正是因为它没有this,所以也就不能用作构造函数
var handler = {
id: '123456',
init: function() {
document.addEventListener('click',
// 箭头函数中的this指向所在作用域的上级作用域 init函数的this,init的this指向handler,document.addEventListener不构成作用域
event => this.doSomething(event.type), false);
},
doSomething: function(type) {
console.log('Handling ' + type + ' for ' + this.id);
}
};
handler.init(); // 上面代码的init方法中,使用了箭头函数,这导致这个箭头函数里面的this,总是指向handler对象。
否则,回调函数运行时,this.doSomething这一行会报错,因为此时this指向document对象。
但如果 init: function()改成箭头函数就错了,此时init中的this指向init上级作用域是window!!!
const cat = {
lives: 9,
jumps: () => {
this.lives--;
console.log('this.lives',this.lives)
}
}
cat.jumps() // jumps函数是箭头函数 他的this指向 jump函数外面的作用域,jumps函数在cat中,cat对象字面量不构成作用域,所以jumps函数上级作用域在往上一层是window !!!。如果jumps改成普通函数cat.jumps()的this就指向了cat
(5)、使用call、apply、bind的时候可以改变this的指向,不再指向调用者,而是指向call、apply、bind函数中传入的对象,call apply bind 传入啥 this指向啥,但这些对箭头函数无效
(6)、setTimeout触发的执行 this是window 但可以用箭头函数 箭头函数中this的取值是取它执行时的上级作用域的值
const zhangsan = {
name: "张三",
sayHi() {
// this 即当前对象
console.log('sayHi this', this);
},
wait(){
setTimeout(function() {
// this 为 window
// setTimeout里的函数的执行是settimeout 触发的执行,当作普通函数执行 , 如果setTimeout里的函数用箭头函数 箭头函数里的this取上级作用域的this 就会指向 当前对象
console.log('wait this', this);
})
}
}
zhangsan.sayHi(); // this 是 zhangsan 当前对象
zhangsan.wait(); // this 是 window
(7)、作为参数的函数的this指向调用的地方。 p.eat(ff); ff中的this指向p外面的this而不是p,eat的this才取p!!!
只要xx.bb,bb中的this执行xx,只要没有. 或者.xx,都是指向最外层window或者undefined,xxx.addEventlistener特殊,它的回调函数中的this指向xxx
var name = "windowsName";
function fn() {
var name = 'Cherry';
innerFunction();
function innerFunction() {
console.log(this.name); // windowsName
}
}
fn()
settimeout 和 xxx.addEventlistener 异步特殊 他们的参数的函数在执行的时候是当作普通函数执行
class P {
constructor(name) {
this.name = name
}
eat (fn){
console.log(`${this.name} eat something`)
fn()
}
}
function ff(){
console.log('ff this.name', this.name)
}
var name = 'hhhhh'
var p = new P('xiaoma')
p.eat(ff);
// xiaoma eat something
// ff this.name hhhhh
const {eat} = p;
eat(ff); //由于es6 class内部的严格模式 这里this 是 undefined 所以这里会报错!!
var button = document.getElementById('press');
button.addEventListener('click', () => {
this.classList.toggle('on');
});
上面代码运行时,点击按钮会报错,因为button的监听函数是一个箭头函数,导致里面的this就是全局对象。如果改成普通函数,this就会动态指向被点击的按钮对象。
// 手写bind函数
Function.prototype.bind1 = function(){
const args = Array.prototype.slice.call(arguments)
const _this = args.shift();
const self = this;
return function () {
self.apply(_this, args)
}
}
function print3(a){
console.log('this.name', this.name)
console.log('a', a)
}
const print4 = print3.bind1({name: 'mh'},'wwww')
print4()
闭包
为什么要哟闭包
1、保护私有变量:闭包可以捕获它定义时作用域内的变量,并将其封装在闭包内部,这样外部代码就无法直接访问到这些变量,从而可以保护私有变量的安全。
2、创建独立作用域: 闭包提供了独立的作用域来定义变量和函数, 减少全局变量的使用,避免命名空间的冲突和污染。
3、实现高阶函数:闭包可以让你将函数作为参数传递给另一个函数,或者将函数作为返回值返回,这样就可以实现高阶函数(即函数可以作为另一个函数的参数或返回值)。
4、模块化:闭包可以用来实现模块化,将一些功能划分到不同的闭包中,避免全局作用域污染,更好维护代码。
闭包:一个函数内部有外部变量的引用,比如函数嵌套函数时,内层函数引用了外层函数作用域下的变量,就形成了闭包(闭包是内部函数)。最常见的场景为:函数作为一个函数的参数,或函数作为一个函数的返回值时
定义在A方法内部的方法,可以访问定义在这个A方法内部的变量。
定义在A方法外的方法,无法访问这个A方法外部的变量。
能不能访问关键看在哪里定义,而不是在哪里调用,调用方法的时候,会跳转到定义方法时候的环境里,而不是调用方法的那一行代码所在的环境。
这个变量的声明周期取决于闭包的声明周期,被闭包引用的外部作用域中的变量将一直存储到闭包函数被销毁,如果一个变量被多个闭包所引用,那么知道所有闭包被垃圾回收后,该变量才被销毁
当函数执行完毕,本作用域内的局部变量会销毁,但是如果这个函数内部还有一个函数,且内部的函数用了这个函数作用域中的变量,那么这个函数执行完内部的变量就不能销毁,这就出现了闭包
作用域应用的特殊情况,有两种情况 函数作为参数被传递 和 函数作为返回值被返回 这两种形式的函数就是闭包,总之函数定义的地方和函数执行的地方不一样
自由变量的查找 在函数定义的地方寻找,向上级作用域查找,而不是在执行的地方!!
function print(fn){
const a = 100;
fn()
}
const a =200;
function fn(){
console.log('a:',a)
}
print(fn) //fn函数定义的地方 作用域 200
function print2(){
const a = 300
const fn2 = ()=>{
console.log('a:',a)
}
return fn2;
}
const fn2 = print2();
fn2();//fn2函数定义的地方 作用域 300
fn2的作用域当中访问到了fn1函数中的num这个局部变量 ,所以此时fn1 就是一个闭包函数(被访问的变量所在的函数就是一个闭包函数)
也有人说,闭包是一种现象,一个作用域访问了另外一个函数中的局部变量,如果有这种现象的产生,就有了闭包的发生
闭包的使用:1延伸变量作用域范围,读取函数内部的变量 ,2让这些变量的值始终保持在内存中 隐藏内部数据,
如做一个cache工具
function createCache(){
const data = {}
return {
set :(key, value)=>{
data[key] = value
},
get: (key)=>{
return data[key]
}
}
}
// 使用,使用的地方页面关闭时,createCache的作用域销毁 data销毁
const cc = createCache();
cc.set('name', 'mh')
console.log(cc.get('name'))
var fn =function(){
var sum = 0
return function(){
sum++
console.log(sum);
}
}
fn()() //1
fn()() //1
//fn()进行sum变量申明并且返回一个匿名函数,第二个()意思是执行这个匿名函数
这里出现了一个小问题,sum为什么没有自增?如果想要实现自增怎么操作?
回答这个问题需要先了解一下js中内存回收机制。(详细内容可以看文章后面的3 Js内存回收机制)
我这里直接简单解释一下,执行fn()() 后,fn()()已经执行完毕,没有其他资源在引用fn,此时内存回收机制会认为fn不需要了,就会在内存中释放它。
那如何不被回收呢?
**
var fn =function(){
var sum = 0
return function(){
sum++
console.log(sum);
}
}
fn1=fn()
fn1() //1
fn1() //2
fn1() //3
这种情况下,fn1一直在引用fn(),此时内存就不会被释放,就能实现值的累加。那么问题又来了,这样的函数如果太多,就会造成内存泄漏(内存泄漏、内存溢出的知识点在文章后面4 内存溢出、内存泄漏)
内存泄漏了怎么办呢?我们可以手动释放一下
var fn =function(){
var sum = 0
return function(){
sum++
console.log(sum);
}
}
fn1=fn()
fn1() //1
fn1() //2
fn1() //3
fn1 = null // fn1的引用fn被手动释放了
fn1=fn() //num再次归零
fn1() //1
闭包导致内存泄漏
什么是内存泄露?内存泄漏,也就是代码执行完了不释放内存的流氓行为。。内存泄露是指new了一块内存,但无法被释放或者被垃圾回收。new了一个对象之后,它申请占用了一块堆内存,当把这个对象指针置为null时或者离开作用域导致被销毁,那么这块内存没有人引用它了在JS里面就会被自动垃圾回收。但是如果这个对象指针没有被置为null,且代码里面没办法再获取到这个对象指针了,就会导致无法释放掉它指向的内存,也就是说发生了内存泄露。
局部变量导致的内存增长 这不是内存泄漏
es6模块:
每个模块都有自己的上下文,每一个模块内声明的变量都是局部变量,不会污染全局作用域。
每一个模块只加载一次(是单例的), 若再去加载同目录下同文件,直接从内存中读取。
// module date.js
let date = null;
export default {
init () {
date = new Date();
}
}
// main.js
import date from 'date.js'; // 引入date在进行编译的时候作用域会找到let date = null
date.init();复制代码
在main.js模块初始化了date之后,date这个变量就一会直存在了,直到你把页面关了,因为date的引用是在另一个module里面,可以理解为模块(类似一个单例)。所以如果你是希望这个date对象一直存在、需要一直使用的话,那么没有问题,但是如果想用一次就不用了那就会有问题,这个对象一直在内存里面没有被释放就发生了内存泄露。
内存泄漏
引用者不被回收,被引用者就不会被回收,所以要内存溢出也很简单:
函数内部声明一个局部变量(包含闭包本身),把它传递给一个不被回收的对象以产生引用即可,如全局变量、模块局部变量、setInterval、给某个不打算删除的html元素加个监听事件等,都能引起内存溢出。
所以综合上面的分析,造成内存泄露的可能会有以下几种情况:
(1)、减少组件DOM渲染
大数据渲染始终是前端的一大难题,DOM 渲染会占用很大的内存,非常吃性能
根据笔者的以往案例,这往往是导致页面崩溃的首要原因,特别是对于电脑或手机配置低的用户
可以通过:数据懒加载、组件懒加载、虚拟滚动、数据分页等方式,来减少组件的 DOM 渲染
(2)、 window/body上的监听事件没有解绑移除或移除错误
在 window 上添加的监听事件,在页面卸载时要主动移除,并注意移除的正确性
<template>
<div id="about">这里是about页</div>
</template>
<script>
export default {
mounted () {
window.addEventListener('resize', this.fn) // window对象引用了about页面的方法
}
}
</script>
复制代码
在页面销毁的时候,主动解绑,释放内存
mounted () {
window.addEventListener('resize', this.fn)
},
beforeDestroy () {
window.removeEventListener('resize', this.fn)
}
复制代码
对于函数节流与防抖的场景,要特别注意:确保移除的是同一个事件,如果姿势不对,可能依旧会造成内存泄漏
// 版本一
mounted() {
window.addEventListener('resize', debounce(this.fn, 100))
},
beforeDestroy() {
window.removeEventListener('resize', debounce(this.fn, 100))
}
复制代码
这段代码的写法是错误的,因为每次调用debounce(this.fn, 100)时, 其实都会返回一个新的函数,导致 addEventListener 和 removeEventListener 方法传入的回调函数已经不是同一个函数,监听器没有被正确移除
正确的写法:
// 版本二
data() {
return {
debounceFn: null
}
},
mounted() {
this.debounceFn = debounce(this.fn, 100)
window.addEventListener('resize', this.debounceFn)
},
beforeDestroy() {
window.removeEventListener('resize', this.debounceFn)
}
复制代码
(3)、 console 导致的内存泄漏
我们总会在调试代码时,加上很多 console 打印。以下代码,因为 list 数组被 console 所引用,导致 list 内存不能被释放
function fn () {
let list = new Array(10 * 1024 * 1024).fill(1); // 约占42M内存
return function () {
console.log(list)
}
}
fn()()
复制代码
经过验证,只有 devtools 打开时,console 打印才会引起内存泄漏的,如果不打开控制台,console 是不会引起内存变化的。稳妥起见,需要在生产环境时使用插件过滤掉 console 打印
// vue.config.js
const TerserPlugin = require('terser-webpack-plugin')
module.exports = {
configureWebpack: (config) => {
if (process.env.NODE_ENV === 'production') {
// 返回一个将会被合并的对象
return {
optimization: {
minimizer: [
new TerserPlugin({
sourceMap: false,
terserOptions: {
compress: {
drop_console: true
}}})
]}};
}}};
复制代码
(4)、 闭包的错误使用
上一篇文章 闭包用多了会造成内存泄露?90%的人都理解错了 解释了项目中大量使用闭包,并不会造成内存泄漏,除非是错误的写法
错误的写法:闭包所引用的变量在函数外部。因为开发者需要非常小心,否则稍有不慎就会造成内存泄露,我们总是希望可以通过 gc 自动回收,避免人为干涉
正确的写法:闭包引用的变量定义在函数中。这样随着外部引用的销毁,该闭包就会被 gc 自动回收 (推荐),无需人工干涉
// 错误的写法: 闭包所引用的info变量在函数外部
let info = {
arr: new Array(10 * 1024 * 1024).fill(1),
timer: null
};
export const debounce = (fn, time) => {
// 正确的写法: 闭包所引用的info变量在函数内部
let info = {
arr: new Array(10 * 1024 * 1024).fill(1),
timer: null
};
return function (...args) {
info.timer && clearTimeout(info.timer);
info.timer = setTimeout(() => {
fn.apply(this, args);
}, time);
};
};
复制代码
或者手动把对闭包的引用置为null
var fn =function(){
var sum = 0
return function(){
sum++ console.log(sum);
}
}
fn1=fn()
fn1() //1
fn1() //2
fn1() //3
fn1 = null // fn1的引用fn被手动释放了
fn1=fn() //num再次归零
fn1() //1
(5)、绑在 EventBus 的事件没有解绑
举个例子
<template>
<div id="home">这里是首页</div>
</template>
<script>
export default {
mounted () {
this.$EventBus.$on('homeTask', res => this.fn(res))
}
}
</script>
复制代码
复制代码
在页面卸载的时候可以考虑解除引用
mounted () {
this.$EventBus.$on('homeTask', res => this.fn(res))
},
destroyed () {
this.$EventBus.$off()
}
复制代码
(6)、弱引用:weakset、weakmap
有些情况,需要手动清除引用。但有时候一疏忽就忘了,所以才有那么多内存泄漏
ES6 推出了两种新弱引用的数据结构: weakset 和 weakmap。
WeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSet 之中。
这是因为垃圾回收机制根据对象的可达性(reachability)来判断回收,如果对象还能被访问到,垃圾回收机制就不会释放这块内存。结束使用该值之后,有时会忘记取消引用,导致内存无法释放,进而可能会引发内存泄漏。WeakSet 里面的引用,都不计入垃圾回收机制,所以就不存在这个问题。因此,WeakSet 适合临时存放一组对象,以及存放跟对象绑定的信息。只要这些对象在外部消失,它在 WeakSet 里面的引用就会自动消失。
由于上面这个特点,WeakSet 的成员是不适合引用的,因为它会随时消失。另外,由于 WeakSet 内部有多少个成员,取决于垃圾回收机制有没有运行,运行前后很可能成员个数是不一样的,而垃圾回收机制何时运行是不可预测的,因此 ES6 规定 WeakSet 不可遍历
const weak = new WeakMap()
const element = document.getElementById('#app')
weak.set(element, 'app')
复制代码
注册监听事件的 listener 对象很适合用 WeakMap 来实现。
// 代码1
element.addEventListener('click', handler, false)
// 代码2
weak.set(element, handler)
element.addEventListener('click', weak.get(element), false)
复制代码
代码 2 比起代码 1 的好处是:由于监听函数是放在 WeakMap 里面,一旦 element 对象的其他引用消失,与它绑定的监听函数 handler 所占的内存也会被自动释放
(7)、Vuex的$store watch了之后没有unwatch
(8)、使用第三方库创建,没有调用正确的销毁函数
(9)、当不需要setInterval或者setTimeout时,定时器没有被clear,定时器的回调函数以及内部依赖的变量都不能被回收,造成内存泄漏。
timeout = setTimeout(() => {
var node = document.getElementById('node');
if(node){
fn();
}
}, 1000);
复制代码
解决方法: 在定时器完成工作的时候,手动清除定时器。timeout=null
并且可以借助Chrome的内存分析工具进行快速排查,本文主要是用到了内存堆快照的基本功能,读者可以尝试分析自己的页面是否存在内存泄漏,方法是做一些操作如弹个框然后关了,拍一张堆快照,搜索detached,按distance排序,把非空的节点展开父级,找到标黄的字样说明,那些就是存在没有释放的引用。也就是说这个方法主要是分析仍然存在引用的游离DOM节点。因为页面的内存泄露通常是和DOM相关的,普通的JS变量由于有垃圾回收所以一般不会有问题,除非使用闭包把变量困住了用完了又没有置空。
用chrome浏览器performance和memory查看性能
变量提升
// var 一变量提升,代码解析的时候 var 变量提升 声明但未赋值
console.log(a) // undefined
var a = 20
console.log(b) // 报错 let不会变量提升
let b = 20
var tmp = new Date();
function f() {
console.log(tmp);
if (false) {
var tmp = 'hello world'; // 代码解析的时候 变量提升到函数f的顶部
}
}
f(); // undefined
函数声明 和函数表达式
函数提升
// 函数声明 由于js预解析的时候会把声明提前,所以可以把函数声明写到使用后面
fn2(1,2) //3
function fn2 (a,b){
console.log(a+b)
}
fn1() // fn1 is not a function 由于变量提升有声明fn 但fn未赋值
var fn1 = function(a,b){
console.log(a+b)
}
fn1() // fn1 is not defined
let fn1 = function(a,b){
console.log(a+b)
}
function a() {}
var a;
console.log(typeof a)
输出结果:function
function a() {}
var a = 1;
console.log(typeof a)
输出结果:number
说明:函数提升优先级高于变量提升,且不会被同名变量声明时覆盖,但是会被同名变量赋值后覆盖。
一个常见问题 打印1、2、3、4、5
// 打印5、5、5、5、5 var变量提升到了最外层
var liList = document.querySelectorAll('li') // 共5个li
for( var i=0; i<liList.length; i++){
liList[i].onclick = function(){
console.log(i)
}
}
// 打印出1、2、3、4、5
var liList = document.querySelectorAll('li') // 共5个li
for( let i=0; i<liList.length; i++){ // let 无法便力量提升,每个{}一个单独的作用域
// let i = 隐藏作用域中的i // 看这里看这里看这里
liList[i].onclick = function(){
console.log(i)
}
}