记录一个 Vue3 源码压缩错误引出的声明提升知识点

2,461 阅读10分钟

前言

此文是 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 有两种创建函数的方式

  1. 函数声明

  2. 函数表达式

函数声明

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;
};

可以把函数表达式看作两部分

  1. 声明变量 sum

  2. 给变量 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

顺序如下

预编译阶段:

  1. 由于 var 的变量提升,在全局作用域定义变量 a,值为 undefined
  2. 由于函数声明提升,在 b 函数作用域中定义变量 a,值为 undefined
  3. 将 b 函数作用域中的变量 a 赋值为 function() {}

代码执行阶段:

  1. 将全局作用域下的变量 a 赋值为 1
  2. 执行函数 b
  3. 进入 b 函数,将函数作用域下的 a 赋值为 10
  4. 退出函数,销毁函数作用域,此时函数作用域下定义的变量 a 一并销毁
  5. 打印全局作用域下的变量 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))
    }
}

错误发生在第四行

image-20210127022608450

虽然变量名已经无法辨认,但只需关注第四行和第七行

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,最终导致抛错

以小见大,如果没有掌握变量提升的知识点,怕是很难排查错误原因,希望大家不要在追求新颖技术的道路上渐行渐远,最终失去前端的立足之本

共勉