JavaScript 核心原理剖析 (一)

2,647 阅读9分钟

写在最前

本文针对2类人群。 1. 面试总挂在js基础上的人 2. 想夯实js基础

我这也算一个JS基础的体系了。 既然是体系肯定比盲目去网上捞资源来得靠谱,放心入坑。

了解一门语言先从数据类型开始

Q1: js有哪些数据类型? 6种还是8种?

答: 直接上张图你就明白了。

image.png

喏,你是不是回答漏了其中一两个?7 种基础类型, 1 种引用类型。请注意以下两点:

  1. 基础类型存储在栈内存被引用或拷贝时,会创建一个完全相等的变量
  2. 引用类型存储在堆内存存储的是地址,多个引用指向同一个地址。

Q2: 你知道哪些判断数据类型的方法?

typeof

看代码:

typeof null // 'object'

typeof 1 // 'number'

typeof '1' // 'string'

typeof undefined // 'undefined'

typeof true // 'boolean'

typeof Symbol() // 'symbol'

噢吼,虽然知道typeof 可以用来判断基本数据类型,但为啥 typeof null === 'object'? 再看代码:

Object.prototype.__proto__
Object.prototype.__proto__.a

打开你的控制台,输入以下,你会得到:

image.png

所以得到结论:null 可以是顶层对象Object的上层描述,但null也是‘objcet’,因为null在Object的链上。 所以判断变量是否为null,请直接 使用 xxx === null 判断。

instanceof

好吧,继续看代码

const A = function() {}

let a = new A()

a instanceof A // true

const str1 = new String('字符串')

str1 instanceof String // true

const str2 = '字符串'

str2 instanceof String // false

仔细看,str2 instanceof String === false。 可以得出以下结论:

  1. typeof 能判断 除null 外的基础数据类型 和 function
  2. instanceof 能判断复杂引用数据类型,不能判断基础数据类型

同意下滑。

Object.prototype.toString

直接访问Object的原型方法 tostring 。 我给你上代码了:

Object.prototype.toString.call(window)   // ‘[object Window]’

Object.prototype.toString.call(document)  // ’[object HTMLDocument]‘

Object.prototype.toString.call(null)   //‘[object Null]’

Object.prototype.toString({})       // ‘[object Object]’

Object.prototype.toString.call({})  // ‘[object Object]’

Object.prototype.toString.call(10086)    // ‘[object Number]’

Object.prototype.toString.call('10086')  // ‘[object String]’

Object.prototype.toString.call(true)  // ‘[object Boolean]’ 

Object.prototype.toString.call(()=>{})  // ‘[object Function]’

Object.prototype.toString.call(undefined) // ‘[object Undefined]’

Object.prototype.toString.call(/re g/)    // ‘[object RegExp]’

Object.prototype.toString.call(new Date()) // ‘[object Date]‘

Object.prototype.toString.call([])       // ’[object Array]’

所以,我相信你肯定看明白了。 总结成一个方法就是:

function validityType(obj){

  let type  = typeof obj;

  if (type !== "object") {    // 基础数据类型,直接返回

    return type;

  }

  return Object.prototype.toString.call(obj).slice(8,-1)

}

为什么还要用typeof ? 因为 typeof 性能上优于 toString

强制类型转换

Number()
Number(true);        // 1

Number(false);       // 0

Number('00010086');  // 10086

Number(null);        // 0

Number('');          // 0

Number('1a');        // NaN

Number('0X11')       // 34

Number(-0X22);       // -34

我相信你又懂了。 读书百遍不如自己亲自验证一下?

Boolean()

Boolean({})         // true

Boolean(0)          // false

Boolean(null)       // false

Boolean(undefined)  // false

Boolean(NaN)        // false

Boolean(10086)      // true

Boolean('12')       // true

+ 号 隐式转换

1 + 10085        // 10086  

'1' + '10085'    // '110085' 

'1' + true        // "1true"  

'1' + undefined   // "1undefined" 

'1' + null        // "1null" 

'1' + 10086n      // '110086' 字符串+ BigInt,BigInt被转换为字符串

1 + undefined     // NaN  undefined转换数字

1 + null          // 1  null转换为0

1 + true          // 2  true转换为1 

1 + 1n            // 错误  VM995:1 Uncaught TypeError: Cannot mix BigInt and other types, use explicit conversions

'1' + {}          // '1[object Object]'

1 + {}            // '1[object Object]'

'1' + ()=>{}      // Uncaught SyntaxError: Malformed arrow function parameter list

Object

Q2: 1 + {} = '1[object Object]' 对吗? 控制台试试?


const obj = {

  value: 0,

  valueOf() {

    return 10085;

  },

  toString() {

    return '10087'

  },

  [Symbol.toPrimitive]() {

    return 10086

  }

}

console.log(obj + 1); // 10087

10086 + {}  // '10[object Object]'

[1,2,undefined,4,5] + 10086;  // '1,2,,4,510086'

好吧。 这个解释不能再精简了,还是得写写。

console.log(obj + 1); // 10087 因为obj有Symbol.toPrimitive方法,如果木有它则执行valueOf

 10 + {}; //  "10[object Object]",{}会默认调用valueOf是{},不会进行基础类型继续转换。 接着调用{}的toString方法,返回"[object Object]",

[1,2,undefined,4,5] + 10086;  //  [1,2,undefined,4,5]会默认先调用valueOf结果还是这个数组,不是基础数据类型继续转换,接着调用toString 得到"1,2,,4,5",然后再和10086进行运算得到最终结果

最后学废了嘛?

new、apply、call、bind 的实现

new

如果你【new】 一下,会经历:

1. 创建一个新对象(Object);

2. 将构造函数的作用域赋给新对象(也就是 this 要指向新对象)

3. 执行构造函数中的代码(给对象挂上属性);

4. return newObject。

但实际上这个过程背地里要做以下三件事情。

  1. 让new出来的实例拥有访问私有成员属性的权限
  2. 实例可以访问构造函数原型所在原型链上的属性(也就是 constructor.prototype 上的属性);、
  3. 构造函数返回的对象必须是引用数据类型

上个代码,其实不想上,网上答案一大堆。 但是还是希望有空自己手写一下……

function _new(_function, ...args) {

    if(typeof _function !== 'function') {

      throw '_function must be a function';

    }

    let obj = new Object();

    obj.__proto__ = Object.create(_function.prototype);

    let res = _function.apply(obj,  [...args]);

    let isObject = typeof res === 'object' && res !== null;

    let isFunction = typeof res === 'function';

    return isObject || isFunction ? res : obj;

};

call / apply

先给个提醒,大家来找茬。

Function.prototype.call = function (context, ...args) {

  const context = context || window;

  context.fn = this;

  const result = eval('context.fn(...args)');

  delete context.fn

  return result;

}

Function.prototype.apply = function (context, args) {

  const context = context || window;

  context.fn = this;

  const result = eval('context.fn(...args)');

  delete context.fn

  return result;

}


仔细看看,除了入参有一点点区别,其它就好像一样噢? 其中 eval 是为了立即执行。

bind

Function.prototype.bind = function (context, ...args) {

    if (typeof this !== "function") {
      throw new Error("this must be a function");
    }
    const self = this;
    const res = function () {
        self.apply(this instanceof self ? this : context, args.concat(Array.prototype.slice.call(arguments)));
    }

    if(this.prototype) {
      res.prototype = Object.create(this.prototype);
    }

    return res;

}

唉。。 还是稍微解释一下。 bind 和 call、apply的区别就在于一个要返回函数,另外两个需要返回eval的执行结果。

js 闭包

我接触过很多面试求职者, 没500也有800了吧。 闭包往往是我想快速结束无聊的面试环节而抛出的一个问题。

Q3: 啥是闭包?

答案1: 函数里面内嵌一个函数

答案2: 内部函数可以访问外部函数的属性

答案3: 函数 return 一个函数

所以,你觉得这两种答案对吗? 别着急回,因为可能回光速打脸。

看代码:


var f1;

function f2() {

  var f1 = 2

  f2 = function() {

    console.log(f1);

  }

}


f2();

这个看起来有点怪? 为什么 能在最外层直接访问 f2 ? 然后好像f2 内部又访问了 f1? 这个 f1 是哪个f1

想不清楚就再来看个例子:


var a = 1;

function foo(){

  var a = 2;

  function inner(){

    console.log(a);

  }

  bar(inner);

}

function bar(_fn){
  _fn();
}

foo();  // 最终得到的结果是 2,不是1

很明显,这个例子中 执行 foo 最终访问到了 foo 函数作用域下的 a,并拿到了结果 2.

所以,你觉着答案1、2、3 还能完全hold住问题 Q3: 啥是闭包? 好像有点没解释清楚。

那么,当你需要回答这个问题的时候,请回答这两句话。 回答的时候记得要保持十分沉稳的态度!

1. 闭包产生的本质当前环境中存在指向父级作用域的引用

2. 要产生闭包只需要 在当前环境中使其存在指向父级作用域的引用

至于你用什么手段来保证 2 成立,那是你的本事。再例如:


setTimeout(function(){

  console.log('我想签约');

},1000);

// 事件监听

document.addEventListener('click',function(){})

实际上,只要是异步回调,基本上都是闭包。

那么,既然讲到了 异步 这个词,那又不得不问: Q4: 浏览器是咋实现异步操作的?

EventLoop

时间循环,分谁的。 浏览器 Or nodejs。 js 引擎如何处理诸多同步、异步任务的?

浏览器的 Eventloop

容易绕,用顺序来标一下。先看以下 todo list:

  1. 需要一个js执行器一行行的去解释你的代码
  2. 读到一个作用域就丢进一个 先进后出的 堆栈结构( call stack ,调用堆栈
  3. 好的,当调用堆栈遇到了需要异步处理的某个作用域函数,会把它丢给浏览器的API处理(API独立于JS线程之外)
  4. 浏览器API会等待时机将接收到的函数内容交给另一个角色处理( 事件队列
  5. 事件循环 用来控制 事件队列 中的任务,一旦任务空了,则会往里加入新的任务。

是的,这就是个循环,空了加,继续空继续加。 来张图吧,没有图文字有点绕。用来解释这个现象: 事件循环(Eventloop)

image.png

再辅以执行代码解读:

// 全局上下文作用域
const fn1 = () => { console.log('执行fn1')}
setTimeout(fn1,3000)
const fn2 =() =>{
console.log('我想签约')
}
fn2()
const fn3_1 = () => { console.log('执行fn3-1')}
const fn3_2 = () => {console.log('执行fn3-2')}
const fn3 = () => {
    fn3_1()
    fn3_2()
}
setTimeout(fn3,2000)

解释下顺序。

  1. call stack 压入全局上下文。等待释放ing。 循环第一轮开始
  2. 代码解析器读到fn1,创建fn1 的上下文,压入 call stack
  3. 读到第一个setTimeout,压入call stack, callstack 识别是异步任务,将setTimeout交给 浏览器API去处理。
  4. fn2 压入 call stack。
  5. 读到fn2。fn2是同步任务,属于当前循环,执行完毕。 释放fn2的上下文(V8 执行垃圾回收)
  6. 同理 生成fn3-1的执行上下文,压入stack
  7. fn3-2 压入stack
  8. fn3 压入stack
  9. setTimeout(fn3,2000) 压入stack,同理交由浏览器API处理。
  10. 代码执行完毕,判断没有新的任务需要被执行,第一轮循环结束。
  11. 消息队列被浏览器处理当前要被处理宏任务:setTimeout(fn3,2000), 处理后,fn3-1,fn3-2依次被推入消息队列
  12. Even Loop 检测到消息队列有新的微任务,fn3-1 被压入call stack 执行。 执行完毕释放fn3-1.
  13. 同理执行完毕释放fn3-2.
  14. 消息队列空了,call stacl没有要执行的任务。当前第二轮循环结束。
  15. 进入第三轮循环,消息队列被压入了fn1. 直到call stack 执行完fn1. 第三轮循环结束
  16. 消息队列空了,且无新的任务被压入。
  17. 最终,call stack 释放全局上下文。

到此三轮循环结束,声明过的对象都被释放掉。

image.png 好好理解,做个总结。

  1. 一次 EventLoop 循环中,只会处理一个宏任务和本次循环中产生的微任务。
  2. call stack 每次执行任务都会释放掉不存在引用的变量。

所以,针对本个例子,得出结论:

1. JavaScript 引擎首先从宏任务队列(macrotask queue)中取出第一个任务;

2. 检测微任务(microtask queue)中的任务,有则取出,按照顺序分别全部执

3. 执行微任务过程中产生新的微任务,也需要执行,且在当前循环内执行;

4. 从宏任务队列中取下一个,重复1、2、3。 当宏任务队列(macrotask queue) 和 微任务(microtask queue)都没有新任务产生的时候,整个循环结束。

写文不易,手动🐶 下篇文开始就会涉及 异步编程以及nodejs Event Loop。 下期见~

( 我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。 )