前端学习之路


  • 首页

  • 归档

  • 分类

  • 标签

  • 搜索
close
小朱

小朱

前端学习之路

168 日志
37 分类
37 标签
RSS
GitHub
友情链接
  • 极客学院
  • caniuse
  • codepen
  • JS Bin
  • Babel在线编译
  • Iconfinder图标
  • 在线JSON格式化
  • 智能图像压缩

JavaScript-内存分配

发表于 2020-07-22   |   分类于 JavaScript

原始数据类型值,比如Undefined,Null,Boolean,Number,String,是存储在栈(stack)中的简单数据段,也就是说,它们的值直接存储在变量访问的位置。这是因为这些原始类型占据的空间是固定的,所以可将他们存储在较小的内存区域 – 栈中。这样存储便于迅速查寻变量的值。

引用类型值,也就是对象类型 Object type,比如Object,Array,Function,Date等,是存储在堆(heap)中的对象,也就是说,存储在变量处的值是一个指针(point),指向存储对象的内存地址。这是因为:引用值的大小会改变,所以不能把它放在栈中,否则会降低变量查寻的速度。相反,放在变量的栈空间中的值是该对象存储在堆中的地址,地址的大小是固定的,所以把它存储在栈中对变量性能无任何负面影响。

在javascript中是不允许直接访问保存在堆内存中的对象的,所以在访问一个对象时,首先得到的是这个对象在堆内存中的地址,然后再按照这个地址去获得这个对象中的值,这就是传说中的按引用访问。而原始类型的值则是可以直接访问到的。

JavaScript-垃圾回收

发表于 2020-07-22   |   分类于 JavaScript

垃圾回收机制

如果一个对象不再被引用, 那么这个对象就会被垃圾回收机制回收;如果两个对象互相引用, 且不再被第3者所引用, 那么这两个互相引用的对象也会被回收。在闭包中,父函数被子函数引用,子函数又被外部的一个变量引用,这就是父函数不被回收的原因。

如果不再用到的内存,没有及时释放,我们就称之为内存泄漏。大多数语言都有它自身的垃圾回收机制,这样的好处是自动帮我们清理不必要的内存占用,但是我们的可控性却比较差,而C语言就无法自动清理垃圾,但它的可控性较强。

在 Javascript 中,垃圾回收机制会定期(周期性)找出那些不再用到的内存(变量),然后释放其内存,以此来解决内存泄漏的问题。但并不是说有了垃圾回收机制,程序员就轻松了。你还是需要关注内存占用:那些很占空间的值,一旦不再用到,你必须检查是否还存在对它们的引用,如果没有引用就必须手动解除引用。 现在各大浏览器通常采用的垃圾回收机制有两种方法:标记清除,引用计数。js中最常用的垃圾回收方式就是标记清除。

标记清除

首先给所有的变量或者对象添加一个标记,当变量进入环境(引用变量)的时候,把上一步标记的内容清除,当变量离开环境(不再需要引用变量)的时候,再重新给这些变量添加标记,这些重新添加上标记的变量或对象会回收到垃圾回收机器里面,垃圾回收机制会周期性的清除这些垃圾回收机器里面的所有对象或属性。

引用计数

语言引擎有一张”引用表”,保存了内存里面所有资源(通常是各种值)的引用次数。如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放。但是引用计数有个最大的问题循环引用,最好是在不使用它们的时候手动将它们设为空。

1
2
3
4
5
6
7
8
9
10
11
function func() {
let obj1 = {};
let obj2 = {};
obj1.a = obj2; // obj1 引用 obj2
obj2.a = obj1; // obj2 引用 obj1
// 手动清除
obj1 = null;
obj2 = null;
}

闭包的垃圾回收

由于闭包时建立在一个函数内部的子函数,由于其可访问上级作用域的原因,即使上级函数执行完,作用域也不会随之销毁,这时的子函数—也就是闭包,便拥有了访问上级作用域中的变量的权限,即使上级函数执行完后,作用域内的值也不会被销毁,这个函数的作用域就会一直保存到闭包不存在为止。

1
2
3
4
5
6
7
8
9
function fn3(){
var a = 10;
return function(){
a--;
console.log(a);
}
}
fn3()(); // 9
fn3()(); // 9

当fn3()()第一次执行完后,整个fn3()被销毁,第二次fn3()相当于重新开辟了一块新的空间,所以第二次fn3()()和第一次打印的结果无关。

1
2
3
4
5
6
7
8
9
10
11
function fn3(){
var a = 10;
return function(){
a--;
console.log(a);
}
}
var val = fn3();
val(); // 9
val(); // 8

当fn3第一次执行完后,val并没有被销毁,第二次是在第一次基础之上执行的,val指向的对象会永远存在堆内存中,即使是fn3已经执行完毕,需要 val=null 将其指向的对象释放。

CSS-border-image

发表于 2020-07-14   |   分类于 CSS

border-image-slice 用来分解引入进来的背景图片,使图像边界向内偏移,取值为 number | percentage 其中 number 是没有单位的,专指像素 px,number 或percentage 都可取 1~4 个值,类似于 border-width 的取值方式。如果取值上还可以加上 fill,如果使用这个关键字,图片边界的中间部分将保留下来,默认情况下是为空的。

border-image-slice 把通过 border-image-source 取到的图片切成了九份,中间一份为内容区域,其他分别对应边框的对应部分,如图 1 所示。

其中,1、2、3、4 区域和内容区域水平和垂直方向均被拉伸。a、b、c、d 区域根据 border-image-repeat 属性值展示,是否应重复(repeat)、拉伸(stretch)或铺满(round),可取两个值,分别表示水平和垂直方向。
border-image-width 指定图像边界的宽度,不改变元素本身大小。
border-image-outset 用于指定在边框外部绘制的量,向元素外显示图片边框,不影响其它元素。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Untitled Document</title>
<style type="text/css">
.container {
display: flex;
}
.box {
margin: 20px;
width: 200px;
height: 200px;
background-color: orange;
border: 100px solid;
border-image-source: url('slice.png');
}
.box1 {
border-image-slice: 100;
}
.box2 {
border-image-slice: 200 100 100 100;
}
.box3 {
border-image-slice: 200 200 100 100;
}
.box4 {
border-image-slice: 200 100 50;
}
.box5 {
border-image-slice: 100;
border-image-repeat: stretch;
}
.box6 {
border-image-slice: 100;
border-image-repeat: round;
}
.box7 {
border-image-slice: 100;
border-image-repeat: repeat;
}
.box8 {
border-image-slice: 100;
border-image-repeat: space;
}
.box9 {
border-image-slice: 100;
border-image-width: 20px;
}
.box10 {
border-image-slice: 100;
border-image-width: 20px;
border-image-outset: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="box box1">内容</div>
<div class="box box2">内容</div>
<div class="box box3">内容</div>
<div class="box box4">内容</div>
</div>
<div class="container">
<div class="box box5">内容</div>
<div class="box box6">内容</div>
<div class="box box7">内容</div>
<div class="box box8">内容</div>
</div>
<div class="container">
<div class="box box9">内容</div>
<div class="box box10">内容</div>
</div>
</body>
</html>

NPM-授权安装

发表于 2020-07-06   |   分类于 NPM

问题描述:工作中遇到一个特殊的需求,部门自己研发的平台,纯前端项目,没有后端,发布为私有包,希望可以通过授权安装控制项目分摊成本。

任何客户端都存在同样的问题,无论如何代码都要在客户端运行,所以就目前的技术而言是无法避免文件拷贝的。但可以通过混淆、加密等手段增加对方使用代码的成本。webpack 的 uglifyjs-webpack-plugin 插件可以进行代码混淆,但是还是能搜索到一些关键字。如果要对整个 js 文件进行加密,浏览器是无法识别加密后的文件的,可以考虑只对关键信息进行加密,解密后再执行。

方式一:输出版权信息

思路:这是收费 js 的通用做法,只能限制商业运用,但任何人都可以得到你的源码。下载源码后,搜索版权信息中的关键字,很容易就能定位到这段代码,然后自己删除掉。为了不让用户轻易找到输出版权信息的代码,可以对这部分输出代码进行加密,减小用户找到这段代码的可能性。

借助 webpack 的 copy-webpack-plugin 插件,可以在打包时对静态资源进行加密,使用 axios 加载打包后的静态资源再进行解密,这样就可以在代码中写明文的输出信息,方便维护,打包后就是进行了加密的代码。

实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// encrypt.js 加密文件
var CryptoJS = require('crypto-js');
var config = require('../config');
function encrypt(content, path) {
var str = content.toString();
// 密钥 16 位
var key = config.encrypt.key;
// 初始向量 initial vector 16 位
var iv = config.encrypt.iv;
// key 和 iv 可以一致
key = CryptoJS.enc.Utf8.parse(key);
iv = CryptoJS.enc.Utf8.parse(iv);
var encrypted = CryptoJS.AES.encrypt(str, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
// 转换为字符串
encrypted = encrypted.toString();
// mode 支持 CBC、CFB、CTR、ECB、OFB, 默认 CBC
// padding 支持 Pkcs7、AnsiX923、Iso10126
// 、NoPadding、ZeroPadding, 默认 Pkcs7, 即 Pkcs5
return encrypted;
}
module.exports = encrypt;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// decrypt.js 解密文件
var CryptoJS = require('crypto-js');
var config = require('../config');
function decrypt(content) {
var key = config.encrypt.key;
var iv = config.encrypt.iv;
key = CryptoJS.enc.Utf8.parse(key);
iv = CryptoJS.enc.Utf8.parse(iv);
// DES 解密
var decrypted = CryptoJS.AES.decrypt(content, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
// 转换为 utf8 字符串
decrypted = CryptoJS.enc.Utf8.stringify(decrypted);
return decrypted;
}
module.exports = decrypt;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// webpack.config.js webpack 配置
const CopyWebpackPlugin = require('copy-webpack-plugin')
...
plugins: [
new CopyWebpackPlugin([
{
from: path.resolve(__dirname, '../../static/info'),
to: 'static',
transform (content, path) {
return encrypt(content, path)
}
}
]),
]
// static/info 输入版权信息的静态文件
console.log(
'%c未授权 xxxx公司 版权所有 copyright@2020',
'\n\tfont-size: 16px;\n\tcolor: red;\n'
);
// 代码中读取加密后的静态文件,解密后执行
axios.get('static/info').then(result => {
eval(decrypt(result.data));
});

方案二:授权安装

思路:利用 NPM 的 preinstall 钩子,可以在输入 npm install 后,安装包之前,执行一个 Node.js 脚本,在这个脚本中,可以要求用户输入一个 token 到后端进行校验,也可以拿到 mac 地址到后端请求进行验证,也可以拿项目目录下的授权文件,解密后校验,如果可以安装则继续,如果不可以安装则中断安装。

实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const os = require('os');
const chalk = require('chalk');
const getMAC = require('getmac');
const path = require('path');
// 输出 mac 地址,可做校验
console.log(getMAC.default());
// 读取项目目录下 static/config.json 文件,可做校验
const configJSON = require(path.resolve(
process.cwd(),
'../../static/config.json'
));
console.log('====', configJSON);
// 用户输入指定信息,可做校验
process.stdout.write(`${chalk.cyan('Please input token:')}`);
process.stdin.on('data', (input) => {
const token = input.toString().trim();
if (token !== 'aaa') {
console.log('认证失败,结束安装!');
process.exit(1);
}
console.log('认证成功,开始安装...');
process.stdin.pause();
});

但是这种方式还是存在问题,主要问题是在不同环境下执行安装包命令时获取的 mac 地址可能不正确,如在容器中执行,或 CI 的线上环境。而且包一旦被安装了,就在项目的 node_modules 目录下了,一是可以通过拷贝的方式泄露,二是安装到本地后执行解密的代码就泄露了。

方式三:授权运行

思路:不控制安装,所有人都可以进行安装包的操作,使用时需要给项目一个加密的授权文件,运行时拿到这个授权文件,解密后进行校验,如果正确则向下运行,否则终止运行。这就要求授权文件中被加密的信息要是运行时能拿到的信息,并且具有唯一性,由于客户端是没办法获取 mac 地址的,可以使用域名和时间的方式进行加密。运行时获取授权文件,判断如果是生产环境授权文件,进行域名和端口号的校验,如果是开发环境授权文件,则校验授权日期和过期天数。

生成授权文件时我们向用户要了域名,那用户就知道我们是使用了域名进行校验,肯定有获取域名的代码,在代码中搜索 location.host 很容易定位到进行校验的代码,仔细读读就可以进行破解,因此希望能对这段校验的代码进行加密。我们使用方案一中对静态资源加密的方式,把校验逻辑的代码写在一个静态文件中。注意这里不能把整个校验逻辑写成一个完整的方法放在静态文件中导出使用,因为这个文件没有被 babel 处理,解密后的文件中会存在 import 等浏览器无法识别的关键字。

最后为了使用方便,还需要一个能生成授权文件的脚本。

实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// generateLicense.js 生成授权文件
var fs = require('fs');
var path = require('path');
var chalk = require('chalk');
var encrypt = require('./encrypt');
/*
* static/license 为加密前的明文文件
* 生产环境指定 type 为 production,添加 domains
* 开发环境指定 type 为 development,添加 created 创建时间、expired 过期天数
*/
const buffer = fs.readFileSync(path.resolve(__dirname, '../static/license'));
// 写入文件,项目根目录下 license,不会被提交
var ws = fs.createWriteStream('./license');
ws.write(encrypt(buffer));
ws.end();
console.log(chalk.green('已生成 license 文件'));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// webpack.config.js webpack 配置
const CopyWebpackPlugin = require('copy-webpack-plugin')
...
plugins: [
new CopyWebpackPlugin([
{
from: path.resolve(__dirname, '../../static/validate'),
to: 'static',
transform (content, path) {
return encrypt(content, path)
}
}
]),
]
// 项目入口进行授权校验
/* eslint-disable */
let validate = false;
try {
// 获取授权文件
const result = await axios.get('static/license');
const FORMAT = 'YYYY-MM-DD';
const str = decrypt(result.data);
const data = JSON.parse(str);
const nowDate = moment().format(FORMAT);
// 获取加密后的校验逻辑文件,执行校验
const validate = await axios.get('static/validate')
eval(decrypt(validate.data));
} catch (error) {
console.error(error);
}
/* eslint-enable */
if (!validate) {
// 校验失败处理
return;
}
// 校验通过,向下运行
...
// static/validate 校验逻辑静态文件
if (data.type === 'production' && data.domains.includes(window.location.host)) {
validate = true;
}
if (data.type === 'development') {
const expiredDate = moment(data.created, 'YYYY-MM-DD')
.add(data.expired, 'days')
.format('YYYY-MM-DD');
if (nowDate <= expiredDate) {
validate = true;
}
}

GitLab 代码审核

发表于 2020-06-12   |   分类于 Git

设置钉钉 GitLab 机器人

打开钉钉,在需要配置 GitLab 机器人的群中,点击【群设置】→【智能群助手】→【添加机器人】→【GitLab】→【添加】,复制生成的 Webhook 地址备用。

进行如下 GitLab 配置。

GitLab 代码审核配置

开发人员创建一个 merge request,然后线下 @审核人 进行代码审核,添加评论,开发人员解决评论中的问题,重复这个步骤直至所有评论解决后,线下 @Maintainer 进行合并。

代码审核要求:

  • 格式规范
  • 变量名称要是单词,符合语义
  • 局部逻辑,尽量简化
  • 复杂逻辑要补充注释
  • 一个组件不要写的太复杂,适当拆分
  • 公共模块需要提取

VSCode 插件

发表于 2020-06-12   |   分类于 VSCode

常用插件

Auto Close Tag 自动闭合 HTML 标签

Auto Rename Tag 自动修改匹配的标签

AutoFileName 输入的文件路径的智能补全

Path Autocomplete 文件路径补全,可设置别名

Path Intellisense 文件路径补全

Better Comments 美化注释

Bracket Pair Colorizer 为括号提供颜色高亮

Code Spell Checker 拼写检查

Color Highlight 颜色值在代码中高亮显示

Document This 注释文档生成

DotENV DotENV 插件

EditorConfig for VS Code EditorConfig 插件

ESLint ESLint 插件,高亮提示

Git History 查看 Git log

gitignore 更好的使用 gitignore

GitLens — Git supercharged 显示当前行commit信息

indent-rainbow 突出显示代码缩进

markdownlint markdown 格式检查

Material Icon Theme 设置文件图标

Polacode 代码截图工具

Prettier - Code formatter Prettier 插件

stylelint stylelint 插件

Sort lines 排序选中行

Vetur Vue 语法高亮

Visual Studio IntelliCode 代码智能提示

vue-beautify vue 格式化

vue-helper Vue2 代码片段

Settings Sync 设置同步

Search node_modules 搜索node_modules下目录

Settings Sync 使用

上传设置

  • 需要 GitHub 账号
  • 安装 Settings Sync 插件
  • cmd + shift + p,输入 Sync:Advanced Options,LOGIN WITH GITHUB
  • 生成 Github Token
  • Gist ID,输入上步获取的 Token
  • cmd + shift + p,输入 Sync:Upload/Update Settings 上传设置

下载设置

  • cmd + shift + p,输入 Sync:Advanced Options,LOGIN WITH GITHUB
  • 输入 Gist ID 和 获取令牌
  • cmd + shift + p,输入 Sync:Download Settings 下载设置
  • 若未下载成功,多尝试几次,注意确认 Gist ID 和 获取令牌是否正确

JSZip 简单使用

发表于 2020-06-12   |   分类于 JavaScript

JSZip 是一个用于创建、读取和编辑.zip文件的JavaScript库,且API的使用也很简单。如下是使用 JSZip 压缩一个文件夹到指定目录的例子。

zip.js 文件中内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
let fs = require('fs');
let path = require('path');
let JsZip = require('jszip');
let zip = new JsZip();
// 读取目录及文件
function readDir(obj, nowPath, nowFolder) {
// 读取目录中的所有文件及文件夹
let files = fs.readdirSync(nowPath);
files.forEach(function (fileName, index) {
// 遍历检测目录中的文件
let fillPath = nowPath + '/' + fileName;
let file = fs.statSync(fillPath);
if (file.isDirectory()) {
// 如果是目录的话,继续查询
let folder = nowFolder + '/' + fileName;
// 压缩对象中生成该目录
let dirList = zip.folder(folder);
// 重新检索目录文件
readDir(dirList, fillPath, folder);
} else {
// 如果是文件压缩目录添加文件
obj.file(fileName, fs.readFileSync(fillPath));
}
});
}
// 开始压缩文件
function startZIP() {
var currPath = __dirname;
// 要压缩的文件夹目录
var sourceDir = path.join(currPath, '../../dist:source/');
// 生成压缩包的目录
var targetDir = path.join(currPath, '../../dist:app/static/source.zip');
readDir(zip, sourceDir, '');
zip
.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: {
level: 9,
},
})
.then(function (content) {
fs.writeFileSync(targetDir, content, 'utf-8');
console.log('压缩完成');
});
}
startZIP();

命令:

1
node zip.js

Webpack 打包

发表于 2020-06-12   |   分类于 Webpack

安装包后自动将文件从软件包复制到本地目录

npm 的 postinstall 脚本会在安装完包后自动执行。

1
2
3
"scripts": {
"postinstall": "node bin/copy.js"
},

webpack 打包后可通过 import 引入、script 标签引入

  • output.libraryTarget 打包类库的发布模式,使用 umd 可通过 import 方式引入
  • output.library 为导出的库指定义一个全局使用的名称变量,主要用于直接引用的方式,如用 script 标签
  • output.libraryExport 库中被导出的项,如对外暴露 default 属性,就可以直接调用 default 里的属性

配置以下内容即可实现 webpack 打包后的包可通过 import 和 script 标签引入。

1
2
3
4
5
6
7
output: {
path: config.buildSource.assetsRoot,
filename: 'index.js',
libraryTarget: 'umd',
libraryExport: 'default',
library: config.buildSource.name,
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// import 方式
import Library from 'lib';
// script 标签方式
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="stylesheet" href="./lib.css"/>
<script src="./lib.js"></script>
<title>测试包引入</title>
</head>
<body>
<div id="app"></div>
<script>
const viewer = new Library.App.Viewer("app");
</script>
</body>
</html>

添加对下载时需要的静态资源的处理,下载页面

类库提供了下载链接可直接下载使用,这就需要维护所有版本的下载压缩包,放在指定的文件夹中,在 webpack.config.js 中,通过 node 的 fs 模块读取文件夹中的文件列表,放到一个变量中,再通过 webpack.DefinePlugin 暴露成全局变量,页面中就可以取到压缩文件列表了。注意,在项目启动后修改下载列表中的文件需要重启。

1
2
3
4
5
6
7
8
9
10
11
12
13
let files = fs.readdirSync(path.join(__dirname, '../../static/app/source/'));
files.forEach(function (fileName) {
if (fileName.endsWith('.zip')) {
sourceZipList.push(fileName);
}
});
...
plugins: [
new webpack.DefinePlugin({
'sourceZipList': JSON.stringify(sourceZipList),
}),
...
]

JSDoc javascript 注释

发表于 2020-06-12   |   分类于 JavaScript

JSDoc 是根据 JavaScript 文件中的注释信息,生成静态文件的工具,使用简单方便。

JSDoc 官方文档
JSDoc 中文文档

在使用 JSDoc 时,发现无法定义指向项目内的链接。@link 和 @see 语法创建内联标签连接到文档中的其它项 或 外部URL,无法使用相对路径连接到项目内的地址,但可以通过自定义插件实现该功能。

plugins/demo.js 文件中内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
// demo{/#/cesium|参考样例}
exports.handlers = {
beforeParse: (e) => {
const result = e.source.match(/demo\{[^\}]+\}/);
if (result && result[0]) {
const value = result[0].replace('demo{', '').replace('}', '').split('|');
if (value && value.length > 1) {
e.source = e.source.replace(result[0], `<a href="${value[0]}">${value[1]}</a>`);
}
}
},
};

conf.json 文件中内容如下:

1
2
3
{
"plugins": ["plugins/demo"]
}

命令

1
jsdoc -c conf.json source/**/* -d static/doc

ES6-Module

发表于 2020-05-22   |   分类于 ES6

历史上,JavaScript 一直没有模块(module)体系,无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西,导致完全没办法在编译时做“静态优化”。

1
2
3
4
5
6
7
8
9
10
11
// CommonJS 模块就是对象,输入时必须查找对象属性。这种加载称为“运行时加载”,因为只有运行时才能得到这个对象。
let { stat, exists, readfile } = require('fs');
// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;
// ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,再通过 import 命令输入指定方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载。
import { stat, exists, readFile } from 'fs';

ES6 模块还有以下好处:

  • 静态加载
  • 不再需要 UMD 模块格式了,将来服务器和浏览器都会支持 ES6 模块格式
  • 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者 navigator 对象的属性
  • 不再需要对象作为命名空间(比如 Math 对象),未来这些功能可以通过模块提供

模块功能主要由两个命令构成:export 和 import。export 命令用于规定模块的对外接口,import 命令用于输入其他模块提供的功能。import 和 export 命令只能在模块的顶层,不能在代码块之中。

export 命令用于规定模块的对外接口,必须与模块内部的变量建立一一对应关系,export 语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。export 命令可以出现在模块的任何位置,只要处于模块顶层就可以。可以用 as 重命名,可以用不同的名字输出两次。export default 命令,为模块指定默认输出,一个模块只能有一个默认输出,export default 就是输出一个叫做 default 的变量或方法,然后系统允许你为它取任意名字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;
var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;
export { firstName, lastName, year };
export {
firstName as f,
lastName as l,
lastName as L
};
export function multiply(x, y) {
return x * y;
};
// 默认输出
export default function () {
console.log('foo');
}
// 因为 export default 命令的本质是将后面的值,赋给 default 变量,所以可以直接将一个值写在 export default 之后
export default 42;

import 命令用于输入其他模块提供的功能。如果想为输入的变量重新取一个名字,import 命令要使用 as 关键字,将输入的变量重命名。import 命令输入的变量都是只读的,因为它的本质是输入接口。如果输入的变量是一个对象,改写属性是允许的。import 命令具有提升效果,会提升到整个模块的头部,首先执行。由于 import 是静态执行,所以不能使用表达式和变量、if 语句,这些只有在运行时才能得到结果的语法结构。import 语句会执行所加载的模块。如果多次重复执行同一句 import 语句,那么只会执行一次,而不会执行多次。除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面,这个对象应该是可以静态分析的,所以不允许运行时改变。加载默认模块时,import 命令可以为该匿名函数指定任意名字。

1
2
3
4
5
6
7
8
9
import { firstName, lastName, year } from './profile.js';
import { lastName as surname } from './profile.js';
import 'lodash';
import * as circle from './circle';
// 加载默认输出
import customName from './export-default';

如果在一个模块之中,先输入后输出同一个模块,import 语句可以与 export 语句写在一起。但需要注意的是,写成一行以后,foo 和 bar 实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用 foo 和 bar。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export { foo, bar } from 'my_module';
export { foo as myFoo } from 'my_module';
// 忽略 my_module 模块的 default 方法
export * from 'my_module';
export { default } from 'foo';
export { es6 as default } from './someModule';
export { default as es6 } from './someModule';
export * as ns from "mod";
// 等同于
import * as ns from "mod";
export {ns};

ES2020提案 引入 import() 函数,支持动态加载模块。import 命令能够接受什么参数,import() 函数就能接受什么参数,两者区别主要是后者为动态加载。import() 返回一个 Promise 对象。 import() 函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。import() 函数与所加载的模块没有静态连接关系,这点也是与 import 语句不相同。import() 类似于 Node 的 require 方法,区别主要是前者是异步加载,后者是同步加载。import() 适用于按需加载、条件加载、动态的模块路径。import() 加载模块成功以后,这个模块会作为一个对象,当作 then 方法的参数。

1
2
3
4
5
6
7
8
9
const main = document.querySelector('main');
import(`./section-modules/${someVariable}.js`)
.then(module => {
module.loadPageInto(main);
})
.catch(err => {
main.textContent = err.message;
});

HTML 网页中,浏览器通过 <script> 标签加载 JavaScript 脚本,默认情况下,浏览器是同步加载 JavaScript 脚本。这显然是很不好的体验,所以浏览器允许脚本异步加载,下面就是两种异步加载的语法。

1
2
<script src="path/to/myModule.js" defer></script>
<script src="path/to/myModule.js" async></script>

defer 与 async 的区别是:defer 要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;async 一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。一句话,defer 是“渲染完再执行”,async 是“下载完就执行”。另外,如果有多个 defer 脚本,会按照它们在页面出现的顺序加载,而多个 async 脚本是不能保证加载顺序的。

浏览器加载 ES6 模块,也使用 <script> 标签,但是要加入 type="module" 属性。浏览器对于带有 type="module" 的 <script>,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了 <script> 标签的 defer 属性。<script> 标签的 async 属性也可以打开,这时只要加载完成,渲染引擎就会中断渲染立即执行。

1
<script type="module" src="./foo.js"></script>

代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。模块脚本自动采用严格模式,不管有没有声明use strict。模块之中,顶层的 this 关键字返回 undefined,而不是指向 window。也就是说,在模块顶层使用 this 关键字,是无意义的。同一个模块如果加载多次,将只执行一次。利用顶层的 this 等于 undefined 这个语法点,可以侦测当前代码是否在 ES6 模块之中。

ES6 模块与 CommonJS 模块完全不同:

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口

Node.js 要求,.mjs 文件总是以 ES6 模块加载,.cjs 文件总是以 CommonJS 模块加载,.js 文件的加载取决于 package.json 里面 type 字段的设置,取值为 module 或 commonjs。

package.json 文件有两个字段可以指定模块的入口文件:main 和 exports,exports 字段的优先级高于 main 字段。exports 有多种用法:

  • 可以指定脚本或子目录的别名
  • 别名如果是.,就代表模块的主入口,优先级高于 main 字段,可以简写成 exports字段的值
  • 条件加载,利用 . 这个别名,可以为 ES6 模块和 CommonJS 指定不同的入口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 指定脚本或子目录的别名
{
"exports": {
"./submodule": "./src/submodule.js"
}
}
import submodule from 'es-module-package/submodule';
// 模块的主入口
{
"exports": {
".": "./main.js"
}
}
// 等同于
{
"exports": "./main.js"
}
// 条件加载
{
"type": "module",
"exports": {
".": {
"require": "./main.cjs",
"default": "./main.js"
}
}
}
// 等同于,如果同时还有其他别名,就不能采用简写
{
"exports": {
"require": "./main.cjs",
"default": "./main.js"
}
}

CommonJS 的一个模块,就是一个脚本文件。require 命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象,以后需要用到这个模块的时候,就会到这个对象上面取值,即使再次执行 require 命令,也不会再次执行该模块,而是到缓存之中取值。也就是说,CommonJS 模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。CommonJS 模块的重要特性是加载时执行,即脚本代码在 require 的时候,就会全部执行。一旦出现某个模块被”循环加载”,就只输出已经执行的部分,还未执行的部分不会输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// a.js
exports.done = false;
var 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;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');
// main.js
var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);
// 在 b.js 之中,a.done = false
// b.js 执行完毕
// 在 a.js 之中,b.done = true
// a.js 执行完毕
// 在 main.js 之中, a.done=true, b.done=true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';
// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';
// $ node --experimental-modules a.mjs
// b.mjs
// ReferenceError: foo is not defined

ES6 处理循环加载时,首先,执行 a.mjs 以后,引擎发现它加载了 b.mjs,因此会优先执行 b.mjs,然后再执行 a.mjs。接着,执行 b.mjs 的时候,已知它从 a.mjs 输入了 foo 接口,这时不会去执行 a.mjs,而是认为这个接口已经存在了,继续往下执行。执行到第三行 console.log(foo) 的时候,才发现这个接口根本没定义,因此报错。解决这个问题的方法,就是让 b.mjs 运行的时候,foo 已经有定义了,这可以通过将 foo 写成函数来解决,因为函数具有提升作用。

1…345…17
© 2021 小朱
由 Hexo 强力驱动
主题 - NexT.Pisces