ZiuChen.github.io/docs/note/Front-end Engineering.md
2023-02-11 01:22:58 +08:00

26 KiB
Raw Blame History

前端工程化

Node.js

  • 什么是Node.JS Node的应用场景
  • JS代码执行
  • Node的输入和输出
  • Node的全局对象

什么是Node.js

Node.js是一个基于V8 JavaScript引擎JavaScript运行时环境

  • V8可以嵌入到任何C++应用程序中无论是Chrome还是Node.js事实上都嵌入了V8引擎来执行JavaScript代码
  • 在Chrome浏览器中还需要解析、渲染HTML、CSS等相关渲染引擎另外还需要支持浏览器操作的API、浏览器自己的事件循环
  • 在Node.js中我们也需要进行一些额外操作文件系统读写、网络IO、加密、压缩解压文件等

可以简单总结出Node.js和浏览器的区别

  • Chrome浏览器

    • Blink负责解析HTML文档遇到JavaScript标签时将内容交给V8引擎

    • Blink 是 Google Chrome 浏览器的渲染引擎V8 是 Blink 内置的 JavaScript 引擎

      • 预分析检查语法错误但不生成AST树
      • 生成AST语法分析、词法分析后生成抽象语法树AST
        • AST 为每一行代码定义键值对。初始类型标识符定义 AST 属于一个程序,然后所有代码行将定义在主体内部,主体是一个对象数组。
      • 生成字节码基线编译器Ignition将 AST 转换为字节码
      • 生成机器代码:优化编译器 (Turbofan) 将字节码转换为优化的机器代码。另外,在逐行执行字节码的过程中,如果一段代码经常被执行V8会直接将这段代码转换并保存为机器码,下次执行不需要经过字节码,优化了执行速度
  • Node.js

    • 只处理JavaScript代码 内部V8引擎负责JS代码的执行
    • JavaScript代码 -> V8 -> Node.js Bindings -> LibUV
    • LibUV是使用C语言编写的库,提供了事件循环、文件系统读写、网络IO、线程池等等内容

![The Node.js System](Front-end Engineering.assets/The Node.js System.jpeg)

Node.js的应用场景

  • 前端开发的库都是以node包形式管理的
  • npm yarn pnpm成为前端开发使用最多的工具
  • 使用Node.js作为Web服务器开发、中间件、代理服务器
  • 借助Node.js完成前后端渲染的同构应用
  • 编写脚本工具 构建项目 打包代码等
  • Electron桌面应用程序

Node.js的参数传递

process.argv

process.argv返回一个数组

  • 在代码中通过process.argv[2]读取来自命令行的额外参数
  • process.argv[0] process.argv[1]分别为node.exe的绝对路径和目标文件的绝对路径
// sum.js
const x = process.argv[2]
const y = process.argv[3]
console.log(x + y)
# 通过命令行运行node执行脚本 并传入参数
node sum.js 5 10 # 15

console

  • console.log 打印内容到stdout并加上换行符
  • console.clear 清空当前stdout中的内容
  • console.trace 打印字符串Trace: 到stderr
    • 将堆栈跟踪打印到代码中的当前位置

REPL

在浏览器的控制台选项卡中我们可以通过输入JS代码与之交互在Node.js中同样提供了类似的功能

  • REPL是Read-Eval-Print Loop的简称翻译为读取-求值-输出循环
  • REPL是一个简单的、交互式的编程环境
  • 在命令行窗口中输入node即可进入

Node中的全局对象

在浏览器中我们可以在JS代码中访问全局对象window,代表当前标签窗口

在Node.js中的全局对象名为global,在控制台输出global对象:

> global
<ref *1> Object [global] {
  global: [Circular *1],
  clearInterval: [Function: clearInterval],
  clearTimeout: [Function: clearTimeout],
  setInterval: [Function: setInterval],
  setTimeout: [Function: setTimeout] {
    [Symbol(nodejs.util.promisify.custom)]: [Getter]
  },
  queueMicrotask: [Function: queueMicrotask],
  performance: Performance {
    nodeTiming: PerformanceNodeTiming {
      name: 'node',
      entryType: 'node',
      startTime: 0,
      duration: 2245.9675999991596,
      nodeStart: 1.7120999991893768,
      v8Start: 7.749699998646975,
      bootstrapComplete: 56.47019999846816,
      environment: 28.44789999909699,
      loopStart: 97.62589999847114,
      loopExit: -1,
      idleTime: 2070.0206
    },
    timeOrigin: 1675854922619.539
  },
  clearImmediate: [Function: clearImmediate],
  setImmediate: [Function: setImmediate] {
    [Symbol(nodejs.util.promisify.custom)]: [Getter]
  }
}

常见的全局对象

  • Buffer
  • clearImmediate
  • clearInterval
  • clearTimeout
  • console
    • 和控制台交互
  • process
    • 提供了Node进程中相关的信息
    • Node的运行环境、系统环境变量、参数等
  • queueMicrotask(callback)
  • setImmediate(callback, [, ...args])
  • setInterval(callback, delay[, ...args])
  • setTimeout(callback, delay[, ...args])
  • TextDecoder
  • TextEncoder
  • URL
  • URLSearchParams
  • WebAssembly

特殊的全局对象

__dirname __filename exports module require()

  • 这些变量看起来是全局的,其实并不是(它们仅存在于模块范围内),只是每个模块中都有
  • 它们在命令行交互中是不可使用的
  • __dirname 当前模块的目录名
  • __filename 当前模块的文件名
  • exports module require()将在模块章节中讲解

global对象

global是一个全局对象

  • 在Node.js环境下之前的 process console setTimeout等都有被放入到global
  • 而在浏览器中这些全局API是被放到window对象上的

这无异于增加了开发者的心智负担所以在最新的ECMA标准中出现了globalThis,指向全局对象

  • 在浏览器中的globalThis指向window对象
  • 在Node.js中的globalThis指向global对象

两个全局对象的区别:在浏览器中通过var定义的变量会被放到window对象上而Node.js不会

模块化开发

  • 认识模块化开发
  • CommonJS和Node
  • require函数解析
  • AMD和CMD已经被时代淘汰 了解即可)
  • ESModule用法详解
  • ESModule运行原理

模块化的初衷

  • 将大的程序拆分成一个个小的易于维护的代码
  • 每个模块负责程序中的一部分逻辑,拥有自己的作用域定义变量名时不会发生冲突
  • 模块可以暴露变量、函数、对象等导出
  • 模块可以导入其他模块的变量、函数、对象
// moduleA.js
const moduleA = (function(){
  const name = "Ziu"
  const age = 18
  const run = () => {
    console.log(name + age + 'is running.')
  }
  return {
    name,
    age,
    run
  }
})()

// moduleB.js
console.log(moduleA.name) // 在其他模块中调用

CommonJS

CommonJS是一种规范当初命名为ServerJS旨在浏览器以外的地方使用后为体现其广泛性改名为CommonJS简称CJS

规范 是用来指导 实现的

  • Node 是CommonJS在服务端的代表实现
  • Browserify 是CommonJS在浏览器中的一种实现 (正在被淘汰)
  • WebPack 打包工具具备支持CommonJS的支持和转换

所以Node.js对CommonJS进行了支持和实现让JavaScript在Node上运行时可以实现模块化开发

  • 每个.js文件都是一个单独的模块
  • 每个模块中都包含变量exports module.exports require

::: code-group

// env.js
exports.name = 'Ziu'
exports.age = 18
// utils.js
module.exports = {
  sum: function(x, y) {
    return x + y
  }
}
// index.js
const utils = require('utils.js')
utils.sum(1, 2) // 3

const { sum } = require('utils.js')
sum(1, 2) // 3

const { name, age } = require('env.js')
console.log(name, age) // Ziu 18

:::

exports的本质

exportsrequire在Node中的本质

  • exports是一个对象,我们可以在这个对象中添加很多属性,添加的属性则会被导出
    • 在没有向该对象添加任何属性之前,它是一个空对象
  • 当通过require导入时:const env = require('env.js')
    • env这个变量等于env.js中的exports对象
    • 本质上是envexports对象的引用赋值
    • { id: '...', exports: { ... }, loaded: true, ... }
  • 后续即使再次执行require导入模块,模块中的代码也不会重新执行(module.loaded属性)
    • 当从模块中取值时,会从已经加载的exports对象缓存上取值

::: code-group

// utils.js
exports.a = 0

// 1s后修改a值
setTimeout(() => {
  exports.a = 1
}, 1000)

// 2s后检查a值
setTimeout(() => {
  console.log(exports.a) // 2
}, 2000)
// index.js
const utils = require('./utils')

console.log(utils.a) // 0

setTimeout(() => {
  console.log(utils.a) // 1
  utils.a = 2 // 反过来修改a值
}, 1500)

:::

在上述代码中,utils对象中的属性a在一秒后被赋值为1因此在index.js中输出utils.a得到了两次不同的结果

反过来在index.js中修改导入的utils.a的值后,修改结果也会反映在exports.a上,输出的值为2

实际开发中不要修改导入模块中的变量,改变原模块中变量的值并不规范

module.exports

在Node.js中真正常用的导出方式是module.exports

  • module.exports本质上就是exports对象(同一个内存地址)
  • 可以直接给exports对象赋值,将需要导出的内容统一导出
  • module.exports重新赋值,即改变了exports对象的指向,后续的修改不再影响原模块中的变量
const name = 'Ziu'
const run = () => console.log(name + 'is running.')

module.exports = {
  name,
  run
}

二者的区别

既然如此,为什么还要存在exports这个概念呢?

  • 在CommonJS中是没有module.exports的概念的
  • 为了实现模块的导出Node.js使用的是Module类,每一个模块都是Module的实例,也就是module
  • 所以在Node.js中真正用于导出的并不是exports,而是module.exports
  • module对象中的exports属性是exports对象的一个引用
    • module.exports === exports === utils

如果module.exports不再引用exports对象了,修改exports对象也就没有意义了

::: code-group

// utils.js
module.exports = {
  name: 'Ziu'
}
exports.age = 18
// index.js
const utils = require('utils.js')
console.log(utils.name) // Ziu
console.log(utils.age) // undefined

:::

当使用module.exports = { ... }后,模块中原有的exports不再被导入识别,导入的内容将变为module.exports指定的对象内容

require的本质

require是一个函数,可以帮助我们导入一个文件(模块)中导出的对象

  • 为什么可以省略掉.js后缀,直接使用require('./utils')
  • 为什么可以省略掉index.js,直接使用require('./tools')导入tools/index.js

这涉及到require在匹配路径后的查找规则:

分为三种情况:内置模块、自定义路径、包名

  • 导入Node.js内置的模块const path = require('path')
    • 直接返回该内置模块 并停止后续的查找
  • 根据路径导入自定义的模块,如const utils = require('./{filename}')
    • 按照路径寻找该模块./ ../ /
    • 如果指定了后缀名,则按照后缀名查找
    • 如果未指定后缀名,则:
      1. 直接查找该文件
      2. 查找{filename}.js文件
      3. 查找{filename}.json文件
      4. 查找{filename}.node文件
    • 如果按照上述方式没找到文件,则{filename}作为路径继续查找
    • 查找目录下的index文件 {filename}/index
      1. 查找{filename}/index.js文件
      2. ··· ···
    • 没找到:报错Cannot find module 'xxx'
  • 包名,如const lodash = require('lodash')
    • 到项目根目录的node_modules中查找
    • node_modules/{package_name}/index.js
    • 当前项目目录的node_modules找不到则继续向上查找,直到查找到根目录的node_modules

模块的加载过程

  • 模块在被第一次引入时模块中的JS代码会被运行一次
    • 代码执行顺序与require的位置相关
  • 模块如果被多次引入,会被缓存,最终只加载一次
    • 这是因为每个模块对象module上都有一个属性loaded
    • loaded === false表示该模块尚未被加载
    • 第二次被require引入时会检查该属性是否为true
  • 如果有循环引用,加载顺序如何?
    • 数据结构图结构graph遍历时有深度优先搜索DFS、广度优先搜索BFS两种算法
    • Node采用的是深度优先算法

CommonJS的缺点

  • 加载模块是同步加载的
    • 只有等到对应的模块加载完毕,当前模块中的内容才能被执行
    • 当然在服务器中加载JS文件都是本地文件加载速度非常快不会受影响
  • 但是在浏览器中使用CommonJS
    • 需要先从服务器下载JS文件后加载运行
    • 阻塞JS执行 阻塞页面加载
  • 在WebPack中使用CommonJS
    • CommonJS会被WebPack解析
    • 将CommonJS代码转化为bundle 浏览器可以直接运行

ESModule

  • ES6 模块采用编译时加载,使得编译时就能确定模块的依赖关系,有助于静态优化
  • CommonJS模块在运行时加载且必须借助对象加载模块内容

exportimport用法概览

ESModule借助exportimport导入导出内容,需要注意的是导入导出的并不是对象

export定义的是当前模块导出的接口import可以导入来自其他不同模块的接口

  • export default可以设置默认导出对象
  • export { ... }可以统一导出多个内容
  • exportimport都可以使用as关键字重命名导出/导入的接口
  • import * from 'xxx' export * from 'xxx'批量导入/导出

::: code-group

// utils.js
export function sum(a, b) {
  return a + b
}
export function sub(a, b) {
  return a - b
}
export default function log(...args) {
  console.log(...args)
}
export {
  name: 'Ziu',
  age: 18
}
export const ENV_VARIABLE = 'Hello, World!'
// index.js
import { sum, sub, name, age, ENV_VARIABLE } from './utils'
import log from './utils.js'

sum(1, 2) // 3
sub(2, 3) // -1
log(name, age, ENV_VARIABLE) // 'Ziu' 18 'Hello, World!'

:::

需要注意的是在浏览器中要使用ESModule需要为<script>标签添加module标记:

<script src="index.js" type="module"></script>

  • 当浏览器解析到type="module"的JS代码后分析模块中导入的ESModule模块
  • 每导入一个ESModule模块浏览器都会发起一个HTTP请求去加载它
  • 在本地运行时加载不同协议头的文件会遇到跨域问题需要开启本地Web服务器

另外,exportimport必须位于模块的顶层,如果位于作用域内会报错,因为这就无法对代码进行静态分析优化了

export详解

export有两种导出方式:

  • 命名导出 export const name = 'Ziu' export { v1, v2 } export * from 'xxx'
    • 导出时需要指定名字
    • 导入时也需要知道对应的名字
  • 默认导出 export default AGE = 18
    • 在从其他位置导入时需要为此默认导出指定新的名字
    • 给用户方便:不必阅读文档就可以加载模块

值的动态绑定

  • ESModule模块通过export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值
  • CommonJS模块输出的是值的缓存不存在动态更新

我们援引之前介绍CJS时的案例将后缀名改为mjs即可在Node中运行ESModule模块代码

初始获得的a值为0经过1s后utils.mjs中修改了a的值这时导入utils.mjs模块的其他模块可以获取到a最新的值

::: code-group

// utils.mjs
export let a = 0

// 1s后修改a值
setTimeout(() => {
  a = 1
}, 1000)
// index.mjs
import { a } from './utils.mjs'

console.log(a) // 0

setTimeout(() => {
  console.log(a) // 1
}, 1500)

:::

  • 需要注意的是,导入的其他模块的变量是不允许被修改的,因为index.mjs导入的本质是一个接口
  • 如果从其他模块导入的是一个对象,也不推荐修改导入内容的任何值,最好将其当做完全只读

拓展阅读CommonJS与ESModule加载模块的异同

import详解

检查下述代码:

foo()

import { foo } from 'foo'
  • import命令具有提升效果,会提升到整个模块的顶部
  • import的执行早于函数的调用,import命令是在编译阶段执行的,在代码运行之前
  • 由于import是静态执行,所以不能使用表达式和变量(只有运行时才有值)
import 'lodash'
import 'lodash'
  • 如果仅仅导入了一个模块,那么该模块的代码会被执行,但是没有任何变量被导入
  • 如果同一模块被导入多次,那么导入操作只会被执行一次
import * from 'utils'
add(1, 2)

export * from 'utils'
  • 可以通过*一次性导入模块中所有导出的变量、函数、类
  • 也可以实现二者的复合操作:导入全部模块的同时导出全部模块

import()函数

通过import命令导入的模块是静态的,会被提升到模块顶部,并不支持条件导入

ES2020引入了import()函数,可以通过import()函数实现条件导入动态加载ESModule模块

const main = document.querySelector('main');

import(`./section-modules/${someVariable}.js`)
  .then(module => {
    module.loadPageInto(main);
  })
    .catch(err => {
    main.textContent = err.message;
  })
  • 返回值是一个Promise对象可以通过await同步地操作它
  • import()函数可以在模块外的JS脚本中使用用于在运行时加载外部模块,类似于require()
  • 区别于require()import()是异步加载模块

通过.then函数处理导入的模块时,行为和import是相同的:

  • 如果有默认导出对象,则.then入参为默认导出对象
  • 可以通过解构直接取到模块中导出的变量或函数:.then(({ add, sub }) => { ... })

应用场景

按需加载按钮点击后才加载相关的JS文件

btn.addEventListener('click', () => {
  import('./dialogBox.js')
    .then(dialogBox => {
      dialogBox.open()
    })
    .catch(err => console.log(err))
})

条件加载根据主题色加载不同JS文件

if(darkMode) {
  import('dark.js').then(() => ...)
} else {
  import('light.js').then(() => ...)
}

传入动态值

let moduleName = () => ['Home', 'History', 'User'][0]
import(`./${moduleName()}.js`)

import.meta

ES2020引入了import.meta,它仅能在模块内部使用,包含一些模块自身的信息,即模块元信息

  • import.meta.url 返回当前模块的URL路径
    • 浏览器加载ESModule都是通过HTTP发起请求
      • 例如当前模块为fetchData.js,要在模块内引入一个名为data.json的数据:
      • import( new URL('data.json', import.meta.url) )
    • Node.js环境下该值都是file://协议的链接
  • import.meta.scriptElement
    • 浏览器特有的属性
    • 返回加载模块的<script>标签,相当于document.currentScript

规范中并未规定import.meta中包含哪些属性,一般包括上面两个属性

深入理解模块加载

ESModule的解析过程

ESModule的解析过程可以分为三个阶段

  • 构建 Construction
    • 根据地址查找JS文件并发起HTTP请求下载将其解析为模块记录 Module Record
  • 实例化 Instatiation
    • 对模块记录进行实例化,并为其分配内存空间
    • 解析ESModule模块的导入和导出语句,将模块指向对应的内存地址
    • 例如export const name = 'Ziu',会将变量name添加到模块环境记录中 Module Enviroment Record
  • 运行 Evaluation
    • 运行代码,计算值,并且将值填充到内存地址中
    • 将导入导出的赋给对应的变量name = 'Ziu'

ESModule解析过程

文章推荐:ES modules: A cartoon deep-dive

MJS和CJS的区别

  • CommonJS模块输出的是值的拷贝而ESModule模块输出的是值的引用
    • CJS导出的变量其值如果在模块内发生变化外部导入是不会同步更新的除非导出的是一个取值函数
    • MJS导出变量外部模块每次访问时都会得到该变量最新的值即使变量在模块内被修改了
  • CommonJS模块是运行时加载而ESModule是编译时输出接口
    • CJS是通过对象实现的导入导出,它在运行时才被确定依赖关系和其值
    • MJS则是通过静态定义,在代码运行之前的静态解析阶段即可确定模块的导入导出内容
  • CommonJS模块的require()是同步加载模块而ESModule模块的import命令是异步加载模块
    • import命令拥有一个独立的模块依赖的解析阶段

CJS中的循环加载

设想有以下两文件 a.jsb.js

::: code-group

// a.js
exports.done = false
const b = require('./b.js')
console.log('在 a.js 之中b.done = %j', b.done)
exports.done = true
console.log('a.js 执行完毕')
// b.js
exports.done = false
const a = require('./a.js')
console.log('在 b.js 之中a.done = %j', a.done)
exports.done = true
console.log('b.js 执行完毕')
// main.js
const a = require('./a')
const b = require('./b')
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done)

:::

执行脚本main.js,先执行a.js

  • 第一行 导出done值为false
  • 第二行 a.js的代码暂停执行 进入b.js并等待其执行完毕

b.js中:

  • 第一行 导出done值为false
  • 第二行 执行a.jsa.js模块中取exports对象
  • 取到其缓存值为falsea.js执行已经执行的部分)
  • 随后b.js继续向下执行 执行完毕后 将执行权交还给a.js

回到a.js中:

  • 继续向后执行 直到代码执行完毕

最终输出:

在 b.js 之中a.done = false
b.js 执行完毕
在 a.js 之中b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true

总结:

  • CJS的模块导出是输出值的拷贝而不是引用值的变化不是动态的而是会被缓存的
  • 循环加载时CJS模块导出的值是当前已经执行部分代码产生的结果的值而不是模块代码完全执行完后的最终值

MJS中的循环加载

ESModule的导入和导出与CommonJS有本质不同

::: code-group

// a.mjs
import { bar } from './b.mjs'
console.log('a.mjs')
console.log(bar)
export let foo = 'foo'
// b.mjs
import { foo } from './a.mjs'
console.log('b.mjs')
console.log(foo)
export let bar = 'bar'

:::

执行a.mjs后发现报错了:ReferenceError: Cannot access 'foo' before initialization,变量foo未定义

  • MJS模块在代码执行前会进行静态分析
  • 分析a.mjs的依赖关系时,发现其依赖了b.mjs
  • 于是加载b.mjs并解析它的依赖关系
  • 解析b.mjs的过程中,发现它又依赖了a.mjs
  • 这时引擎不会再去加载a.mjs 而是认为a.mjs这个模块的Module Record已经存在了
  • 继续向下执行,执行到console.log(foo)时发现foo未定义 抛出错误

要实现预期效果,可以将foobar改写为取值函数,这时执行就不会报错了:

::: code-group

// a.mjs
import { bar } from './b.mjs'
console.log('a.mjs')
console.log(bar())
export function foo() {
  return 'foo'
}
// b.mjs
import { foo } from './a.mjs'
console.log('b.mjs')
console.log(foo())
export function bar() {
  return 'bar'
}

:::

这是因为函数function具有提升作用,在a.mjs中执行import { bar } from './b.mjs'之前,foo就有定义了。

因此在进入b.mjs执行console.log(foo())时可以取到foo,代码可以顺利执行

另:如果将foo定义为函数表达式export const foo = () => 'foo',由于没有变量提升,代码仍然会报错

内部变量差异

ESModule和CommonJS另一个重要区别就是

ESModule模块是在浏览器与服务端通用的之前在解读CommonJS时介绍了它拥有的一些内部变量模块变量

  • arguments
  • require
  • module
  • exports
  • __filename
  • _dirname

这些变量在ESModule模块中都是不存在的且顶层的this不再指向当前模块,而是undefined

拓展内容

在Node.js中使用ESModule

在Node.js中普通的.js文件会被默认解析为CommonJS要使用ESModule有两种方式

  • 所有ESModule的后缀名都使用.mjs并且不可省略
    • 这样引擎在解析到.mjs结尾的文件时将按照ESModule的规则解析其导入导出关系
  • package.json中的type字段修改为module
    • 此时项目中所有.js文件都将被作为ESModule模块解析
    • 要在此项目中使用CommonJS则需要将后缀名修改为.cjs

解读package.json中的字段

  • main字段

    • 指定一个npm包的main字段为一个JS模块
    • 当我们从其他位置通过import { something } from 'es-module-package'导入时
    • Node.js将从main字段指定的模块查找导出内容
  • exports字段

    • exports字段优先级高于main字段,它具有多种用法:
    • 子目录别名
      • 假设如是定义exports字段:exports: { "./submodule": "./src/submodule.js" }
      • 当执行import submodule from 'es-module-package/submodule'时,会按照以下路径查找模块:
      • ./node_modules/es-module-package/src/submodule.js
    • main的别名
    • 条件加载

参考:package.json 的 exports 字段