前言
此文是 2 年前撰写的第一篇博客,对我来说颇有纪念意义
最近在使用 Vue3 时候遇到了类似变量提升的问题,在翻阅笔记的同时,整理了下文章排版发布至掘金账号,我将实际问题放在了文章最后解析
正文
在吃饭的时候看到一道关于函数声明提升的问题
var a = 1;
function b() {
a = 10;
return;
function a() {}
}
b();
console.log(a) //1
大部分初学者第一眼会认为答案为 10,我作为小白第一反应也这么认为
b 函数中变量 a 为全局变量,因此在执行 b 函数时 a 的值被赋值为10,最后 return 退出函数,控制台打印 10
而后看了答案颇为震惊,b 函数执行后 a 的值依然是 1
查阅资料进行分析和总结后,琢磨写下人生中第一篇博客
写的不好请见谅,如有错误欢迎指出
作用域
在提变量声明之前,需要简述一下 JavaScript 另外一个核心知识
作用域
作用域的作用在于隔断变量,给变量增加命名空间。在作用域里定义的变量,作用域外无法使用
在 ES6 之前,JavaScript 就已存在两种作用域
- 全局作用域
- 函数作用域
ES6 之后新增了一个块级作用域,简而言之就是 let/const 存在的花括号内代码会单独作为一个作用域
举个例子
if (true) {
let color = "blue";
}
console.log(color); // Uncaught ReferenceError: color is not defined
第二行 let 的出现,会使得花括号内成为了一个块级作用域,在块级作用域外打印 color 变量,会抛出引用错误,因为外层的作用域无法访问里层的作用域
作用域另一个作用在于避免了无用变量的定义。设想,若没有作用域,所有定义的变量都能自由访问,这会导致高额的内存占用,通过销毁作用域,可以将其内部定义的变量一并销毁,释放内存
作用域的特点在于,它是静态的,通俗的说在写代码的时就能看出某个变量处于哪个作用域下
JavaScript 查找一个变量会沿当前作用域逐层向上查找,直至全局作用域,如果仍未找到该变量,会抛出引用错误
Uncaught ReferenceError: a is not defined
变量声明提升
通过 var 声明变量会提升到当前
作用域(全局/函数)顶部,举个例子
if (true) {
var color = "blue";
}
console.log(color); // blue
此时 if 语句的变量声明会将 color 变量提升到当前作用域的顶部
由于没有使用 let/const 生成块级作用域,所以 color 变量直接被提升到了最顶层的全局作用域
并且以上操作在 JavaScript 运行前的预编译阶段就执行了的,代码实质如下
// 预编译阶段
var color;
// 执行阶段
if (true) {
color = "blue";
}
console.log(color); //"blue"
再举一道经典面试题
for (var i = 0; i < 10; i++) {
// ...
}
console.log(i); // 10
预编译阶段定义了 i 变量,它会被提升到全局作用域
随后代码开始运行,在执行 for 循环后,i 的值被赋值为最后一次循环的 10,同时它依然存在于全局作用域中,最终打印 10
// 预编译阶段
var i;
// 执行阶段
for (i = 0; i < 10; i++) {
// ...
}
console.log(i); // 10
可以发现全局的 window 对象中多了一个 i 属性
接着回到第一个例子,如果 if 语句为 false
if (false) {
var color = "blue";
}
console.log(color); //undefined
那么经过变量声明提升后代码实质如下
//预编译阶段
var color;
//执行阶段
if (false) {
color = "blue";
}
console.log(color); //undefined
在预编译阶段声明了变量 color,由于 if 的条件为 false,所以声明了 color 变量却没有赋值,最后显示 undefined
但如果单纯的把 if 语句注释,会直接抛错
// if (false) {
// color = "blue";
// }
console.log(color) // ReferenceError: color is not defined
因为 JavaScript 会尝试沿作用域逐级向上寻找 color 变量,直到在最外层的全局作用域中都没有发现 color 变量,最终抛出变量引用错误
JavaScript 会把变量声明看成两个部分,分别为
- 声明:var color
- 赋值:color="blue"
声明操作在预编译阶段进行,将变量声明提升到当前作用域顶部,默认赋值为 undefined
赋值操作则会被留在原地等待代码执行
变量提升是在 JavaScript 预编译时进行,在代码开始运行之前
函数声明提升
JavaScript 有两种创建函数的方式
-
函数声明
-
函数表达式
函数声明
function sum(num1, num2) {
return num1 + num2;
};
函数也存在声明提升,和变量的异同在于:
- 相同点:都会提升到
当前
作用域(全局/函数)顶部 - 不同点:函数声明提升会在预编译阶段把
函数和函数体整体
都提前到当前作用域顶部
这使得我们可以在函数声明之前调用这个函数
sum(10, 10); //20
function sum(num1, num2) {
return num1 + num2;
};
等同于以下代码
// 预编译阶段
var sum;
sum = function (num1, num2) {
return num1 + num2;
};
// 执行阶段
sum(10, 10); //20
函数表达式
var sum = function (num1, num2) {
return num1 + num2;
}
通过将匿名函数赋值给变量的操作叫做函数表达式
通过函数表达式创建的函数和函数声明不同,函数本身不会被提升
但 sum 变量是通过 var 来声明,因此会存在变量声明,代码实质如下
// 预编译阶段
var sum;
// 执行阶段
sum = function (num1, num2) {
return num1 + num2;
}
如果在函数表达式声明之前使用函数会抛错
sum(10, 10); // Uncaught TypeError: sum is not a function
var sum = function (num1, num2) {
return num1 + num2;
}
代码实质如下
//预编译阶段
var sum;
//执行阶段
sum(10, 10); // Uncaught TypeError: sum is not a function
sum = function (num1, num2) {
return num1 + num2;
};
可以把函数表达式看作两部分
-
声明变量 sum
-
给变量 sum 赋值匿名函数
通过函数表达式创建的函数并没有函数提升,执行 sum(10,10) 时会沿作用域逐级向上查找
直到最外层的全局作用域发现了 var 定义的 sum 变量,其值为 undefined,尝试对 sum 作为函数执行并传入参数,最终导致抛错
两者声明提升的顺序
当同时存在函数声明和变量声明时,来看这个例子
getName(); //1
var getName = function () {
console.log(2);
}
function getName() {
console.log(1);
}
getName(); //2
同时存在函数声明和变量声明时,函数声明在前,变量声明在后
代码实质如下
// 预编译阶段
// 优先函数声明提升
var getName;
getName = function () {
console.log(1);
};
// 变量声明提升
// 但因此前已声明过 getName 变量,通过 var 多次声明变量,后续声明会被忽略
var getName;
//执行阶段
getName(); //1
getName = function () {
console.log(2);
};
getName(); //2
var 声明变量的另一个特点:同一个作用域下
,多次声明同名变量,后续声明会被忽略
题外话:使用 ES6 的 let/const 重复同名声明变量,会抛错
var a = 1
var a = 2 // success
let b = 1
let b = 2 // Uncaught SyntaxError: Identifier 'b' has already been declared
题题外话:较新版本的 chrome 可以在控制台多次 let,但仅供调试使用,并不是标准的规范
回到最初的面试题
var a = 1;
function b() {
a = 10;
return;
function a() {}
}
b();
console.log(a); //1
这里引出第二种作用域,函数作用域
,b 函数本身会作为一个函数作用域
同时 b 函数内部有两处使用了变量 a,一个是赋值,另一个是函数声明
a 的函数声明会被提升到 b 函数的函数作用域顶层,并且发生在预编译阶段
单独来看函数 b,代码实质如下
function b(){
// 预编译
var a
a = function(){}
// 代码执行
a = 10
return
}
return 后面的 function a() {} 在预编译时被提升到了 b 函数顶部
不同于最外层的全局作用域,由于 b 是一个新的作用域,前面提到作用域有隔断变量的作用,所以在此作用域内声明的变量,外层无法访问
,变量 a 会在这个作用域下会被重新定义
完整代码如下:
// 预编译
var a
// 代码执行
a = 1
function b() {
// 预编译
var a;
a = function () {}
// 代码执行
a = 10;
return;
}
b();
console.log(a); // 1
顺序如下
预编译阶段:
- 由于 var 的变量提升,在全局作用域定义变量 a,值为 undefined
- 由于函数声明提升,在 b 函数作用域中定义变量 a,值为 undefined
- 将 b 函数作用域中的变量 a 赋值为 function() {}
代码执行阶段:
- 将全局作用域下的变量 a 赋值为 1
- 执行函数 b
- 进入 b 函数,将函数作用域下的 a 赋值为 10
- 退出函数,销毁函数作用域,此时函数作用域下定义的变量 a 一并销毁
- 打印全局作用域下的变量 a,值为第一步定义的 1
写在后面
一道看似简单的面试题,背后包含声明提升,var 的特点,作用域的功能,作为前端基础的题目,还是比较经典的
曾经一度认为明明有更好的 ES6 的语法,为啥依然会有关于 var 的考点
这几天处理老版本 babel ES6 转 ES5 后,遇到变量提升导致调用提前使用抛错的问题
以下是 Vue3 的一段源码
export function traverseStaticChildren(n1: VNode, n2: VNode, shallow = false) {
const ch1 = n1.children
const ch2 = n2.children
if (isArray(ch1) && isArray(ch2)) {
for (let i = 0; i < ch1.length; i++) {
// this is only called in the optimized path so array children are
// guaranteed to be vnodes
const c1 = ch1[i] as VNode
let c2 = ch2[i] as VNode
if (c2.shapeFlag & ShapeFlags.ELEMENT && !c2.dynamicChildren) {
if (c2.patchFlag <= 0 || c2.patchFlag === PatchFlags.HYDRATE_EVENTS) {
c2 = ch2[i] = cloneIfMounted(ch2[i] as VNode)
c2.el = c1.el
}
if (!shallow) traverseStaticChildren(c1, c2)
}
}
}
}
经过 webpack 压缩插件代码如下
function qe(t, e, n = !1) {
const r = t.children
const o = e.children
if (Object(i.m)(r) && Object(i.m)(o))
for (var t = 0; i < r.length; t++) {
const e = r[t];
let i = o[t];
1 & i.shapeFlag && !i.dynamicChildren && ((i.patchFlag <= 0 || i.el = e.el),
n || qe(e,i))
}
}
最后会经过 babel ES6 转 ES5 转译输出最终代码
function qe(t, e, n = !1) {
var r = t.children
var o = e.children
if (Object(i.m)(r) && Object(i.m)(o))
for (var t = 0; i < r.length; t++) {
var e = r[t];
var i = o[t];
1 & i.shapeFlag && !i.dynamicChildren && ((i.patchFlag <= 0 || i.el = e.el),
n || qe(e,i))
}
}
错误发生在第四行
虽然变量名已经无法辨认,但只需关注第四行和第七行
if (Object(i.m)(r) && Object(i.m)(o))
var i = o[t];
第四行中的变量 i 和第七行的变量 i 并不是同一个变量,分别对应源码中的 isArray 和 c2,webpack 压缩插件修改了变量名,但因为有 let 块级作用域作隔离,并没有什么问题
但老版本的 babel 转译时,直接将 ES6 的 let 转为 ES5 的 var,并没有做特殊处理,导致这两个同名变量失去作用域的限制,发生变量冲突
同时通过 var 声明的变量 i,存在变量声明提升,在预编译时提升到 qe 函数的函数作用域顶部,默认赋值为 undefined
所以当执行第四行代码尝试访问 m 属性时,由于 i 为 undefined,最终导致抛错
以小见大,如果没有掌握变量提升的知识点,怕是很难排查错误原因,希望大家不要在追求新颖技术的道路上渐行渐远,最终失去前端的立足之本
共勉