前端开发踩坑与记录


devTools

  • 仅当开启 DevTools 时,右键刷新网页按钮才可以弹出上下文菜单。来使用清空缓存并硬性加载来清除页面缓存
  • 当使用移动端设备视图时,不支持访问浏览器自带的 pdf 查看器。将会显示警告注释:"no enabled plugin supports this MIME type"

CSS

一句话整理

  • inset 属性是 top、right、bottom、left 的缩写,使用方法同 margin
  • max-height 会覆盖 height, 而 min-height 会覆盖 max-height
  • 浮动元素会脱离网页文档,与其他元素发生重叠,但是,不会与文字内容发生重叠。所以可以实现文字环绕效果
  • 当元素设置了 border-widthborder-style 但并未设置 border-color 时,自动使用元素的 color 属性
  • 当需要点击页面元素不会自动清除文字选中状态时,需要该元素拥有user-select: none;样式。
  • flex 容器中,设置 flex: 1 时,会计算剩余内容宽(高)度。但当计算出来的总宽(高)度超过父容器时,则 flex: 1 失效,其他元素被挤压。此时可以同时对元素设置 width: 0height: 0 来避免计算剩余空间时溢出。

可替换元素

css 中存在一种元素叫做可替换元素,又称为置换元素
这种元素是一种外部对象,其渲染独立于 css,css 可以影响其的位置,但不会影响它的内容

可替换元素有如下几种:

  • 典型可替换元素: <iframe><video><embed><img>标准元素content 属性插入的为标准可替换元素(目前仅支持url())等
  • 仅特殊情况下时被认为可替换元素: <option><audio><canvas><object>
  • 匿名可替换元素: 伪元素 css content 属性所插入的元素

匿名元素

在 CSS 中,没有明确的 HTML 标签的东西称之为匿名元素,例如下面这段 HTML:

1
2
3
4
<p>
Asuka
<span>FEE</span>
</p>

其中:Asuka 为匿名内联元素,而 FEE 因为有明确的 <span> 包裹,为标准内联盒子

除此外,伪元素 css content 属性所插入的元素也同样为匿名可替换元素。

匿名元素与标准元素的区别

由于匿名元素没有显示的标签包裹,其的宽高也无法被修改,这是与标准元素之间最显著的不同。

例如如下情况:

1
2
3
4
5
6
.app-container::before {
content: url("img.png");
display: block;
width: 100px;
height: 100px;
}

其中伪元素的宽高被定义为 100px,但伪元素内部由 content 属性注入的图片匿名元素扔按照自身原本宽高展示,不受伪元素宽高影响

content 图片从伪元素中溢出

这比较类似于如下的 html 结构,但无法修改 img 的宽高,只能修改 div 的宽高

1
2
3
<div>
<img src="img.png" alt="">
</div>

JavaScript

一句话整理

  • 函数参数只有传递的参数值为 undefined 时,才会使用默认值,null'' 不会采用默认值
  • NaN 与任何值都不相等,包括 NaN 本身
  • es5 中的 parserInt() 默认不具备解析 8 进制的能力,需要传入第二个参数来解析
  • parserFloat() 只能转换 10 进制,168 进制格式字符串始终会被转换为 0
  • 几乎所有类型都有 tostring() 方法,但 nullundefined 没有。因此在不知道目标类型时,可以使用转型方法 String()String() 会自动调用 toString 方法(如果有),对于 nullundefined 则会返回 "null""undefined"
  • offsetTop 不是距离父元素的距离,而是指向最近的 offsetParent 元素的内边距边界。
  • thtdmin-height 无效,直接使用 height 即可,等同于 min-height,超出会被自动撑高
  • flex-direction 反向时,justify-content 也一并反向
  • reverse()sort() 会变更原始数组
  • 使用 calcscss 变量进行处理时,需要使用 #{} 包裹,如:height: calc(100% - #{$header-height});
  • switch 语句中的对比为严格相等运算符(===
  • typeof 可以检查一个未定义的变量而不报错,如 typeof marry 结果为 undefined
  • echart -- legend.itemWidth 设置为 "15" 而非 15 时会出现文字跑开现象
  • vue3 - setup 外使用 router 对象时可以直接 import 引入 createRouter 生成的对象
  • 当元素为绝对定位,且不指定宽高时(如内含图片),若 left 的值使其贴至父容器右壁或 right 的值使其贴近父元素的左壁,便会自动缩小自身宽度,使其永远不会跑出父容器。
  • vue2 的响应式系统针对对象时,不会监听一开始不存在的属性。
  • 全局匹配模式下对同一个正则连续调用 test(),第二次会在第一次匹配的位置后面开始查找
  • Array.prototype.find 遇到返回的结果为 false""、等会被自动转换为 false 的情况,可能会出现误判,建议查找元素存不存在时使用 Array.prototype.findIndex
  • 页面存在 iframe 时,f12 选择元素会自动跳转到 iframe 内部,就会出现同样的选择代码本来选不中f12点了一下就选中了的诡异情况
  • 可以使用 Response.clone() 来对 Fetchresponse 对象进行克隆,解决 response 对象仅能读取一次的问题
  • 后端向前端设置 cookie 时,若设置了 HttpOnly 为 true,则前端无法通过任何方式获取到此 cookie 的内容。
  • 当页面 url 发生了变化导致页面跳转时,未响应完毕的 ajax 请求将全部中断,状态变为 cancel
  • Infinity * 0 结果为 NaN
  • 可通过设置数组的 length 属性来截断数组,例如 arr.length = 0 可以置空数组
  • parseInt()parseFloat()Number() 都会自动去除首尾空格,一些字符串自动转换为数字类型的操作也遵守这种规则
  • Error 对象中只有 message 属性是标准中要求必须要存在的,namestack 属性都是非标准的。
  • 时间戳代表从 1970年1月1日00:00:00 UTC 到指定日期的毫秒数,因此时间戳允许为负数,此时为 1970年1月1日00:00:00 UTC 之前的时间。
  • 利用事件循环特性,可以将操作转为异步操作,来实现延后操作执行顺序的效果。
  • 如果一个节点没有父节点,设置 outerHTML 属性会报错
  • clientHeightclientTopclient 系列属性只对块级元素有效,对于行内元素,其值为 0
  • 当元素节点存在溢出,且溢出部分隐藏时,scrollWidth 依旧返回元素总宽度,scrollHeight 同理。
  • 使用 JS 修改 HTML 节点的 for 属性时,需要使用 htmlFor 来代替,因为 for 是保留字。
  • 因为保留字原因,通过 el.style.xx 修改部分 css 属性时,需要加上字符串 css。比如 float 需要改写为 cssFloat
  • 只有在当前节点的 dragover 事件中手动阻止默认行为,drop 事件才会被触发。这是因为浏览器默认不允许在元素上放置其他元素。
  • frameswindow 的别名
  • 对于顶层窗口,windowselftopparent 是相等的

HTML 注释

由于历史上 JavaScript 可以兼容 HTML 代码的注释,所以 <!-- --> 也是合法注释

需要注意的是,--> 起始时,会注释掉当前行

1
2
x = 1; <!-- x = 2;
--> x = 3;

虽然编辑器中未将 x = 3 置灰,但其实它也被注释掉了,因此此处 x 的最终结果为 1

表达式还是代码块?

1
{ name: "Mari" }

对于上面这种情况,存在两种含义:

  • 如果这是一个代码块,那么这个代码块中的内容是一个名称为 name标签语句,但是这个标签语句没有任何效果
  • 如果这是一个对象字面量,那么这个对象字面量中有一个属性 name,其值为 "Mari"

为了避免这种歧义,JavaScript 引擎的做法是,将以 { 开头的语句一律解析为代码块,而不是对象字面量

如果确实想要将其解析为对象字面量,可以使用 () 进行包裹,如:

1
( { name: "Mari" } )

arguments 是否可以修改?

arguments 对象是一个类数组对象,它的值可以通过索引来访问。

默认非严格模式下,修改 arguments 内元素的值会影响到函数的参数值。

1
2
3
4
5
function test( a ) {
arguments[0] = 2;
console.log( a );
}
test( 1 ); // 输出 2

但是在严格模式下,对 arguments 对象的修改并不会影响到函数的参数值。

1
2
3
4
5
6
7
function test( a ) {
"use strict";
arguments[0] = 2;
console.log( arguments[0] );
console.log( a );
}
test( 1 ); // 输出 2 1

可以得到结果,严格模式下,修改 arguments 内元素的值是有效的,但并不会影响到函数的参数值。

ES6 的模块自动启用了严格模式,因此在 NodeJS 中定义了 "type": "module" 时同样会默认启用严格模式,习惯使用 NodeJS 来测试代码时需要注意这一点。

eval()

eval() 函数可以将字符串当作 JavaScript 代码来执行,并返回执行后的返回值。通常用来直接执行得到的语句字符串。

1
2
eval( "console.log( 1 )" ); // 输出 1
console.log( eval( "1 + 2" ) ); // 输出 3

安全风险

eval() 的本质就是在当前作用域下插入执行一段 JavaScript 代码,其所执行的代码块具有与当前作用域相同的权限
这就代表着这段代码块可以在当前作用域下定义变量,甚至是修改当前作用域下的变量值,这就存在很严重的安全问题。

1
2
3
let a = 1;
eval( "var b = 2; a += 2" );
console.log( a, b ); // 输出 3 2

可以看到当前作用域下的变量 a 的值被修改了,同时后续代码中也接收到了 eval 中定义的变量 b

而在严格模式下,eval 函数会创建一个新的作用域,因此 eval 中定义的变量不会影响到当前作用域。
不过此时 eval 内部的代码块依然可以修改当前作用域下的变量值。

1
2
3
4
5
"use strict";
let a = 1;
eval( "var b = 2; a += 2" );
console.log( a ); // 输出 3
console.log( b ); // 报错,ReferenceError: b is not defined

除了严格模式以外,也可以通过使用 let 而非 var 定义变量,来避免 eval 对当前作用域的影响。
这是由于 let 定义的变量为块级作用域,在此处即表现为只能在 eval 内部访问,eval 执行完毕即销毁(产生闭包时除外)。

1
2
eval( "let b = 2" );
console.log( b ); // 报错,ReferenceError: b is not defined

别名

尽管很少遇到,但会存在将 eval 重新赋值给一个新的变量名的情况,这会导致 JavaScript 引擎在静态分析代码阶段无法判断执行的是否为 eval 函数。

1
2
const e = eval;
e( "console.log( 1 )" ); // 输出 1

为了避免这种行为影响代码的优化,JavaScript 标准规定,在这种情况下 eval 内部的代码块的作用域统一为全局作用域。

1
2
3
4
5
6
7
let a = 1;
( function() {
let a = 2;
const e = eval;
e( "console.log( a )" );
} )();
// 输出 1,而非 2

注:在 NodeJS 中,上面的代码会直接报错 “a is not defined”。这是因为在上面这种情况下,会尝试在全局对象 global 上寻找属性 a。
将最外层的 let a = 1 修改为 global.a = 1 即可正常得到输出结果 1。

switch 中的表达式

switchcase 语句部分都可以使用表达式

1
2
3
4
5
6
7
8
9
const a = 10, b = "20";

switch ( a + 10 ) {
case Number.parseInt( b ):
console.log( true )
break;
default:
console.log( false );
}

null 与 undefined

语义上讲,null 代表空值,而 undefined 代表未定义。

而在实现上,他们有如下几种不同的行为:

  • 对于有默认值的的函数参数,传递 undefined 时函数依旧会使用默认值,而传递 null 则会覆盖参数默认值
  • 使用 Number()+ 转换为 Number 类型时,undefined 被转为 NaNnull 则被转为 0
  • typeof 检测未定义变量是可行的,返回 undefined,而null 从逻辑上来说表示一个空对象指针,因此使用 typeof 判断会得到 object

undefined 的值是派生自 null 值的,因此有如下结果

1
2
null == undefined // true
null === undefined // false

建议如果想要声名一个计划将要保存值的对象,就将其初始化为 null

NaN

NaN 有如下个特点:

  1. 他还是一个 Number 类型
  2. 任何数值计算都会得到 NaN,包括 Infinity
  3. 和自身比较永远为 false
  4. 转换为布尔值的时候为 false
  5. 任何值与 NaN 进行比较都会返回 false
  6. 0 / 0 = NaN

Infinity

Infinity 有如下特点,其他表现行为和正常 Number 类型一致:

  1. 对普通数字,Infinity +-*/ 的时候统一为 Infinity。其中 */ 遵循基本的运算正负号原则,Infinity 被当作除数时会得到一个 +-0(符号看被除数和除数的符号)
  2. 0 相乘得到 NaN,和 0 的其他运算遵循第一条
  3. 减去或除去自身时得到 NaN
  4. 与普通数字判断大小时,遵循字面意思的正负无穷大
  5. Infinity === Infinity 结果为 true
  6. Infinity / Infinity = NaNInfinity - Infinity = NaN
  7. 除了 0NaN 以外,Number 类型 / 0 都得到对应符号Infinity

数组的格式转换

数组转换为 String 类型时,表现形式与 join() 类似,结果为 , 拼接的字符串。

1
2
[].toString(); // ""
[1, 2].toString(); // "1,2"

数组转换为 Number 类型时,当数组内小于等于 1 个元素时,将此元素转为 Number 类型。反之得到 NaN

1
2
3
4
5
Number( [] ); // 0
Number( [1] ); // 1
Number( [1, 2] ); // NaN
// 多维数组同理
Number( [[1]] ); // 1

[]+{}与{}+[]

1
2
[] + {}; // '[object Object]'
{} + []; // 0

第一种表达式为 x + y 表达式,会将 xy 强制转换为 String 类型。空数组 [] 被转换为 ''{} 被转换为 '[object Object]',结果为 [object Object]

在第二种表达式中,{} 被认作是空代码块,不执行,代码块不需要分号终结。因此该式为 +x 表达式,将 x 强转为Number类型,[] 被转换为 0,结果为 0

Number 的数值范围

当超出 Number 的数值范围时,会输出无法继续参与计算的 Infinity(无穷)值,获取数值范围的方法如下:

1
2
3
4
// 最小值
Number.MIN_VALUE
// 最大值
Number.MAX_VALUE

进制

计算时,所有进制表示的数值都会被转换为十进制数值。

八进制

0 开头,如 070 解析为 56,当超出范围时前面的 0 失效,当作十进制解析,如 079 解析为 79

在严格模式下是禁止整数位首位为 0 的,此时会抛出一个错误:Legacy octal literals cannot be used in strict mode。
严格模式下应该使用 0o 前缀来表示八进制数值,例如 0o70 解析为 56

十六进制

0x开头,如 0xA 解析为 10

浮点数

浮点数的最高精度是 17 位小数,它计算的精确度远不如整数,会有微小的误差,例如

1
0.1 + 0.2; // 0.30000000000000004

因此永远不要做如下判断:

1
if ( a + b == 0.3 ) {}

保存浮点数值需要的内存空间是整数值的两倍,因此 JavaScript 会尽可能的将浮点数转换为整数

默认情况,JavaScript 会将小数点后面带有连续 6 个及以上的零的浮点数转换为以 e 表示的科学计数法,如:

1
2
3
0.0000003
// 默认转为
3e-7

数值精度

本段落参考文章JavaScript 浮点数陷阱及解法

根据 IEEE 754 标准,即:1 个符号位、11 个指数位、52个尾数位。
详细内容不再说明,由于其 52 个尾数位再加上省略的一位,可以得到 JS 最多能表示的精度: 2^53=9007199254740992,使用科学计数法可以得到 9.007199254740992e+15,它的长度为 16 位。
因此 JavaScript 选择在保存时,小数部分保留最多 16有效数字的精度。

而我们知道 0.1 的二进制是无限循环的,溢出 52 位后存在精度丢失,之所以依旧能够正常显示为 0.1,就是因为这个自动保留 16 位精度导致的。
我们可以通过 toPrecision() 方法来验证。

1
2
0.1.toPrecision( 16 ); // "0.1000000000000000"
0.1.toPrecision( 20 ); // "0.10000000000000000555"

需要注意的是,尽管小数是可以显示到 17 位有效数字的,但第 17 位将会遵循一定的情况存在较大的误差,该误差与整数超过 2^53 之后表现行为同理,例如:

1
2
3
4
0.30000000000000001 // 0.3
0.30000000000000002 // 0.3
0.30000000000000003 // 0.3
0.30000000000000004 // 0.30000000000000004

toFixed() 的影响

toFixed() 通常用来将数值四舍五入为指定小数位数的字符串,但贸然使用它会出现一些意外的结果。

1
1.005.toFixed( 2 ); // "1.00"

由于数值精度与 16 位有效数字四舍五入的机制影响,1.005 并不是我们真实看到的 1.005,我们可以借助 toPrecision() 来查看真实的数值。

1
2
1.005.toPrecision( 16 ); // "1.005" 在机制干扰下我们看到的正常结果
1.005.toPrecision( 17 ); // "1.0049999999999999" 实际的真实结果

我们可以借助 Math.round() 通过转为整数并重新计算回小数的方式来解决

1
Math.round( 1.005 * 100 ) / 100; // 1

然而我们并没有得到理想中的结果,这是因为 1.005 * 100 本身就得到了错误的结果 100.49999999999999。这个问题我们会在下文进行解答。

使用 toPrecision() 来尝试解决

toPrecision() 方法用于将数值展示为指定精度的字符串。绝大多数情况下,toPrecision(16) 即为我们正常看到的结果。

1
1.0049999999999999.toPrecision( 10 ); // "1.005000000

当我们希望使用这个方法来解决精度问题时,可以封装为如下方法

1
2
3
function strip( num, precision = 15 ) {
return Number.parseFloat( num.toPrecision( precision ) );
}

至于为什么默认精度是 15 呢,因为这样可以解决绝大多数的 00010009 问题,更多的是一种经验之谈。如果需要,可以设置为更低的精度。

现在我们可以重新尝试解决上面的四舍五入问题,使用新封装的方法

1
Math.round( strip( 1.005 * 100 ) ) / 100; // 1.01

比较运算符的作用方式

比较运算符并非是简单的将两侧值转换为数字后进行比较,而是根据待比较目标的类型来区分不同的情况考虑。

满足此部分内容特性的比较运算符为: ==!=><>=<=,并不包括严格相等运算符 ===!==

字符串的比较

当待比较两者均为字符串时,会从左到右依次比较字符的 Unicode 码值。若比较结果相同,则继续比较第二个,以此类推。

例如下面的实例,前三个字符码值相同,第四个字符中 i 的码值小于 r 的码值,得到比较结果。

1
2
3
"i".codePointAt(0); // 105
"r".codePointAt(0); // 114
"mari" > "marry" // false

数字字符串也不能绕过这个问题,因为比较的是字符的码值,而非数字的大小。

1
2
"2" < "10" // false
2 < 10 // true

非字符串的比较

当两边的值中有一个不是字符串时,则又延伸出两种情况:

两者均为原始类型

此时会将两者转换为数字后进行比较。

1
2
3
4
5
10 > "4" // true
// 等同于 10 > Number( "4" ) 即 10 > 4

true > false // true
// 等同于 Number( true ) > Number( false ) 即 1 > 0

两者中存在对象

此时会调用对象的 valueOf() 来对对象进行处理。若得到的还是对象,继续调用 toString() 方法。

其实这就是 Number() 在将对象转换为数值之前的处理方式

将待比较项中的对象处理为原始类型后,再次根据上述规则(是否都是字符串)进行比较。

1
2
3
4
5
6
7
8
9
"2" < [10]; // false
// 首先将对象处理为原始类型,即 [10].valueOf() -> [10] -> [10].toString() -> "10"
// 实际为 "2" < "10"
// 因为此时两者均为字符串,因此比较码点值,得到结果 false

2 < [10]; // true
// 同理先转换对象,得到实际为 2 < "10"
// 因为两者并不全是字符串,因此将 "10" 转换为数字
// 最终实际为 2 < 10,得到结果 true

void 运算符

void 运算符可以对修饰的表达式进行求值,但是恒定返回 undefined。利用这个特性,可以对一些操作进行省略。

1
2
3
4
1 + 1;
return undefined;
// 等价于
return void( 1 + 1 );

逗号运算符

逗号 , 其实也是运算符,其作用是对两个表达式进行求值,返回最后一个表达式的值。

1
1 + 1, 2 + 2; // 4

delete 操作符的返回值

delete 操作符用于删除对象的属性,其返回值为 truefalse,表示是否删除成功。

不过即使是对于不存在的属性,delete 也会返回 true

1
2
3
const obj = { name: "Mari" };
delete obj.name; // true
delete obj.age; // true

仅有一种情况会使 delete 返回 false,那就是尝试删除一个不可配置的属性

1
2
3
4
5
6
const obj = Object.defineProperty( {}, "name", {
value: "Mari",
configurable: false
} );

delete obj.name; // false

需要注意的是,delete 操作符只能操作对象的自有属性
因此在操作原型链上的属性时,生效方式与删除不存在的属性一致,即不会有任何效果,且返回 true

1
2
3
4
const obj = { name: "Mari" };
delete obj.toString; // true
// in 操作符可以判断属性是否存在于对象中,包括原型链上的属性
"toString" in obj; // true

余数运算符计算结果的符号问题

对于余数运算符 %,其计算结果的符号与被除数的符号一致。

1
2
-114 % 10; // -4
114 % -10; // 4

因此,为了得到正确的余数结果,建议使用绝对值函数进行处理。

1
Math.abs( -114 % 10 ); // 4

变量提升

介于 JavaScript 引擎的工作方式为:先解析代码,再逐条执行。因此 var 声明变量的语句将会提升到当前作用域的头部,造成如下结果

1
2
3
4
5
6
7
8
// 编写代码
console.log(a);
var a = 1;

// 真正运行的代码
var a;
console.log(a);
a = 1;

结果为 undefined。

var 声明的变量为函数作用域,块作用域无法约束 var 声明变量的作用提升效果

抛出错误的建议操作

在使用 throwreject() 抛出错误时,建议使用 Error 对象包裹。

1
2
3
4
5
throw "出错了"; // 不建议
throw new Error( "出错了" ); // 建议

reject( "出错了" ); // 不建议
reject( new Error( "出错了" ) ); // 建议

这样抛出的错误可以在 try catch 中通过 err.message 获取抛错内容。

1
2
3
4
5
6
7
8
9
function foo() {
throw throw new Error( "出错了" );
}

try {
foo();
} catch ( err ) {
console.log( err.message ); // "出错了"
}

try-catch 的 finally 代码块

try-catch 语句中的 finally 代码块会在 trycatch 代码块执行完毕后执行,哪怕是在 trycatch 代码块中使用了 return 语句。

下面的例子可以看到,try 代码块中的 return 语句正常返回了当前的结果。但是 finally 代码块中的语句依然正常执行了。

1
2
3
4
5
6
7
8
9
10
11
let count = 0;
function foo() {
try {
return count;
} finally {
count++;
}
}

console.log( foo() ); // 0
console.log( count ); // 1

finally 中的 return/throw 语句还会覆盖 trycatch 中的 return/throw 语句。

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
try {
console.log( "try 语句" );
return 0;
} finally {
console.log( "finally 语句" );
return 1;
}
}

console.log( foo() );
// 输出结果:try 语句 finally 语句 1

标签(label)

JavaScript 允许语句前面存在标签,类似于定位符,格式如下

1
label: statement

标签通常与 continuebreak 配合使用,用于跳出指定循环。默认情况下,continue 与 break 只跳出当前循环

1
2
3
4
5
6
7
8
9
10
11
12
foo: for (const numList of [[1, 2, 3], [4, 5, 6], [7, 8, 9]]) {
for (const num of numList) {
if (num === 2) {
continue foo
}
if (num === 5) {
break foo
}
console.log(num)
}
}
// 输出结果:1 4

ES5 中 Number 相关方法自动转换问题

在 ES5 中存在一些操作 Number 类型的方法,他们对符合既定要求的的参数会自动进行类型转换

  • parseIntparseFloat 第一个参数不是 String 类型时,会自动调用 toString() 方法将其转换为字符串
  • isNaNisFinite 参数不是 Number 类型时,会先尝试使用 Number() 转换为数字再进行判断

这就会导致一些奇怪的现象:

1
2
parseInt( 011, 8 ); // NaN,011 直接得到 9,然后被转换为字符串 "9","9" 不是 8 进制的合法数字,因此得到结果 NaN
isNaN( "mari" ); // true, "mari" 先被转换为 NaN

建议使用 ES6 中新增的 Number 上的同名静态方法来避免这种问题

1
Number.isNaN( "mari" ); // false

向 iframe 内部插入样式

iframe 不会加载外部 css,因此需要特殊操作来插入 css 样式表内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* 添加嵌入组件的样式 */
function appendInsertCss() {
const iframe = document.querySelector("iframe");
// 获取目标 iframe 内部的 document 对象
const dom = iframe.contentWindow.document;
const id = "asuka_insert_style";
// 检查是否已插入过 style 样式表,如果有 不再插入
const hasInsertCss = dom.head.querySelector(`link#${id}`);
if (hasInsertCss) {
console.log("Already inserted")
return;
}
// 这里使用 ESM 的方式获取指定样式表地址
const insertCssPath = new URL(`../assets/style/insert.css`, import.meta.url).href;
const cssLink = document.createElement("link");
cssLink.id = id;
cssLink.href = insertCssPath;
cssLink.rel = "stylesheet";
cssLink.type = "text/css";
dom.head.appendChild(cssLink);
console.log(`Insert css success`);
}

需要注意的是,获取 iframe 内 window 对象的操作受到同源策略影响,非同源时受到浏览器安全限制,无法获取。

split() 与 正则表达式

split() 方法可以接受一个正则表达式作为参数,此时会将正则表达式匹配到的内容作为分隔符进行分割。

1
"mari".split( /a/ ); // [ "m", "ri" ]

需要注意的是,正则表达式中的捕获组会被保留在结果数组中。

1
2
3
"mari".split( /(a)/ ); // [ "m", "a", "ri" ]
"mari".split( /(a)r/ ); // [ "m", "a", "i" ]
"mari".split( /(?:a)r/ ); // [ "m", "i" ]

JSON.stringify() 与 toJSON()

JSON.stringify() 方法在序列化对象时,若目标对象存在 toJSON() 方法,将会优先使用这个方法的返回值作为参数,忽略目标对象的其他属性。
反之若不存在 toJSON() 方法,则会正常对目标进行序列化。

1
2
3
4
5
6
7
8
9
const obj = {
name: "Mari",
toJSON() {
return { name: "Asuka" + this.name };
}
};

JSON.stringify( obj ); // '{"name": "AsukaMari"}'
// 实际上序列化的是 JSON.stringify( obj.toJSON() )

这一特点的一个实践就是 Date 对象,因为 Date 对象内置 toJSON() 方法,因此序列化会得到这个方法的执行结果。

1
2
3
const date = new Date();
date.toJSON(); // "2024-03-30T10:03:15.732Z"
JSON.stringify( date ); // "2024-03-30T10:03:15.732Z"

对函数二次使用 bind

MDN 中的原话:

绑定函数可以通过调用 boundFn.bind(thisArg, /* more args */) 进一步进行绑定,从而创建另一个绑定函数 boundFn2。
新绑定的 thisArg 值会被忽略,因为 boundFn2 的目标函数是 boundFn,而 boundFn 已经有一个绑定的 this 值了。

通俗解释即:this 会永远指向第一次 bind 时所定义的目标。

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
console.log( this.a ++ );
}

const obj1 = { a: 0 };
const obj2 = { a: 0 };

const bind1 = foo.bind( obj1 );
const bind2 = bind1.bind( obj2 );

bind1(); // obj1 { a: 1 }
bind2(); // obj1 { a: 2 }

上面两次函数执行均不会修改 obj2,因为两个函数的 this 均指向 obj1

new 关键词的原理

new 关键词实际由如下几步完成

  1. 创建一个新对象,原型指向构造函数的 prototype
  2. 将构造函数的 this 指向新对象
  3. 执行构造函数内部代码
1
2
3
4
5
6
7
8
function Author( name ) {
this.name = name;
}

const auth1 = new Author( "mari" );
// 等效于(此处不考虑 return 关键字的影响)
const auth2 = Object.create( Author.prototype );
Author.call( auth2, "mari" );

instanceof 检查的范围

instanceof 操作符用于检查构造函数的 prototype 是否存在于目标对象的原型链上。
因此同一个实例对象可能会对多个构造函数都返回 true

1
2
[] instanceof Array; // true
[] instanceof Object; // true

instanceof 操作符只能检查对象是否是某个构造函数的实例,无法对基本数据类型进行检测。

1
2
3
"mari" instanceof String; // false
// 包装对象是可以被检测的
new String( "mari" ) instanceof String; // true

需要注意的是,尽管 null 是一个特殊的对象,但 instanceof 处理 null 时总会返回 false

1
null instanceof Object; // false

构造函数中的 return

如果构造函数中存在 return 语句,且返回值为对象类型,则会返回这个对象,而非构造函数中的 this

1
2
3
4
5
6
function Author( name ) {
this.name = name;
return { age: 17 };
}

new Author( "mari" ); // { age: 17 }

如果返回值为非对象类型,则会无视并照旧返回构造函数中的 this

1
2
3
4
5
6
function Author( name ) {
this.name = name;
return 114514;
}

new Author( "mari" ); // Author { name: "mari" }

ES6 种 Class 的构造函数与此同理

this 严格模式下的指向限制

通常情况下,函数内部的 this 可能会指向全局对象。严格模式下禁止这种情况的发生,此时 this 会指向 undefined

1
2
3
4
5
6
7
8
9
10
// 正常模式
( function() {
console.log( this ); // window
} )();

// 严格模式
( function() {
"use strict";
console.log( this ); // undefined
} )();

偷懒使用 moment.js

moment 是以当前对象进行 add()date() 等运算的,所以计算多个值时,不要偷懒提取公共部分。例如:

1
2
3
4
const date = moment();
const end = date.date(date.date() - 1).endOf("date").toDate();
const start = date.date(date.date() - 1).startOf("date").toDate();
picker.$emit('pick', [start, end]);

这样会导致 startend 早了一天即多减了一天。

跳转拦截与获取目标地址

1
2
3
4
5
6
window.navigation.onnavigate = function (e) {
// 跳转目标地址
const url: string = e.destination.url;
// 拦截,禁止跳转
e.returnValue = false;
}

定时器方法的其他参数

setTimeOutsetInterval 不仅仅只有两个参数,还可以跟随接受多个参数,这些参数会被传递给回调函数。

1
2
3
setTimeout( ( ...args ) => {
console.log( args ); // [ 1, 2, 3 ]
}, 1000, 1, 2, 3 );

addEventListener 的第三个参数

该参数除了可以设置为 truefalse(指代捕获/冒泡阶段执行) 之外,还可以设置为一个对象,用于设置事件的一些属性。
对象拥有四个属性:

  • capture:布尔值,是否在捕获阶段执行,默认 false
  • once:布尔值,是否只执行一次,默认 false
  • passive:布尔值,是否不允许阻止默认行为,默认 false
  • signal:值为一个 AbortSignal 对象,当发出中断信号时,移除事件监听器

通过这种方式可以更加灵活的设置事件监听。

避免方法自行阻止默认行为

设置 passivetrue 后,将会将此事件注册为被动事件
若事件回调函数中调用了 preventDefault() 方法,当事件触发时,不会阻止默认行为,且会抛出报错(报错不会影响事件回调方法内部执行)。

1
2
3
document.addEventListener( "click", e => e.preventDefault(), { passive: true } );
// 触发时报错:
// Unable to preventDefault inside passive event listener invocation

批量移除事件监听器

通过设置同一个 signal 对象,可以在一次中断信号中移除多个事件监听器。

1
2
3
4
5
6
7
const controller = new AbortController();

document.addEventListener( "click", () => console.log( "click1" ), { signal: controller.signal } );
document.addEventListener( "click", () => console.log( "click2" ), { signal: controller.signal } );

// 一次性移除所有事件监听器
controller.abort();

事件的传播

事件的传播机制通常被错误的认为是单向传播,真实情况是一个“进去再出来”的流程。分为三个阶段:

  1. 捕获阶段:事件从根节点目标节点传播
  2. 目标阶段:事件到达目标节点
  3. 冒泡阶段:事件从目标节点根节点传播

对于目标节点,浏览器总是假定为触发位置的最深层嵌套节点
例如,<div> 嵌套 <p> 嵌套 <span>,点击 <span> 时,浏览器会认为 <span>目标节点

基于这个理论基础,我们可以编写一个案例:

1
2
3
4
5
6
// 为子元素绑定
el.addEventListener( "click", e => console.log( "子:捕获" ), true );
el.addEventListener( "click", e => console.log( "子: 冒泡" ), false );
// 为父元素绑定
el.parentElement.addEventListener( "click", e => console.log( "父: 捕获" ), true );
el.parentElement.addEventListener( "click", e => console.log( "父:冒泡" ), false );

点击子元素,可以得到输出结果: 父:捕获 子:捕获 子:冒泡 父:冒泡

若点击的位置子元素就是最深层嵌套节点,那么子元素的这两次触发均为目标阶段

不能冒泡的事件

目标阶段的事件监听器,即使是不能冒泡的事件,也依旧会执行绑定的冒泡阶段监听器。
这是因为不能冒泡的事件影响的是冒泡阶段,而不能影响目标阶段

我们可以通过创建自定义事件来模拟这种情况。

1
2
3
4
5
6
7
8
9
10
11
12
// 默认情况下,自定义事件是不允许冒泡的,即冒泡阶段不会执行
const mariEv = new Event( "mari" );

// 为子元素绑定
el.addEventListener( "mari", e => console.log( "子:捕获" ), true );
el.addEventListener( "mari", e => console.log( "子: 冒泡" ), false );
// 为父元素绑定
el.parentElement.addEventListener( "mari", e => console.log( "父: 捕获" ), true );
el.parentElement.addEventListener( "mari", e => console.log( "父:冒泡" ), false );

// 触发子元素的自定义事件
el.dispatchEvent( mariEv );

得到输出结果:父:捕获 子:捕获 子:冒泡,捕获阶段的 父:捕获 和目标阶段的 子:捕获 子:冒泡 均被执行,而冒泡阶段的监听器 父:冒泡 没有被执行。

阻止事件传播

通过 stopPropagation() 方法可以阻止事件的传播,对此我们可以修改一下上方的案例。

1
2
3
4
5
6
7
8
el.addEventListener( "click", e => console.log( "子:捕获" ), true );
el.addEventListener( "click", e => console.log( "子: 冒泡" ), false );
// 为父元素绑定
el.parentElement.addEventListener( "click", e => {
console.log( "父: 捕获" )
e.stopPropagation()
}, true );
el.parentElement.addEventListener( "click", e => console.log( "父:冒泡" ), false );

我们在传播最开始的父元素捕获阶段就调用了 stopPropagation() 方法,此时整个事件传播会被立即终止,因此后续一系列传播均不会再触发。
此时仅输出 父:捕获

阻止元素上的其他事件监听器

若同一个元素上的绑定了多个事件触发器,则会按照绑定的顺序依次执行。

此时可以通过在回调函数中执行 stopImmediatePropagation() 来阻止自身之后的其他事件监听器的执行。

1
2
3
4
5
6
el.addEventListener( "click", e => console.log( "回调1" ) );
el.addEventListener( "click", e => {
console.log( "回调2" );
e.stopImmediatePropagation();
} );
el.addEventListener( "click", e => console.log( "回调3" ) );

点击后可以得到结果:回调1 回调2,回调3 没有被执行。

stopImmediatePropagation() 同样也会阻止事件传播,也就是说,该方法比 stopPropagation() 更加彻底。

鼠标进入离开事件的区别

鼠标进入离开事件有两对,分别是 mouseentermouseleave 以及 mouseovermouseout,这两组有两个本质区别:

  • mouseovermouseout 会冒泡,而 mouseentermouseleave 不会
  • mouseovermouseout 针对子元素覆盖父页面的可视区域问题,在进入子元素时同样会被判定为离开了父元素

根据这两点特性,我们针对一个案例来分别讨论捕获与冒泡时发生的现象。以 mouseentermouseover 作为案例讨论,mouseleavemouseout 同理。

1
<p id="target">监听元素<span>子元素</span></p>

捕获阶段

捕获阶段注册事件监听器

1
2
target.addEventListener( "mouseover", e => console.log( "mouseover" ), true );
target.addEventListener( "mouseenter", e => console.log( "mouseenter" ), true );

此时处于捕获阶段,第一条冒泡与否的区别不会对结果产生影响,仅第二条特性会影响结果:

  • 进入 target 元素时,同时打印 mouseentermouseover
  • 进入子元素时,以子元素为目标节点触发事件,事件传递会先通过 target 元素的两个捕获阶段事件再到达目标节点子元素,此时会同时打印 mouseentermouseover
  • 离开子元素回到父元素时,由于第二条特性影响,仅打印一次 mouseover
  • 离开父元素时,同时打印 mouseleavemouseout

冒泡阶段

冒泡阶段注册事件监听器

1
2
target.addEventListener( "mouseout", e => console.log( "mouseover" ) );
target.addEventListener( "mouseleave", e => console.log( "mouseenter" ) );

此时两条特性均会对结果造成影响:

  • 进入 target 元素时,同时打印 mouseentermouseover
  • 进入子元素时,由于 mouseover 可以冒泡,此时以子元素为目标节点触发事件,事件传递会冒泡传递到 target 元素上绑定的 mouseover 事件,此时仅会打印 mouseover
  • 离开子元素回到父元素时,由于第二条特性影响,仅打印一次 mouseover
  • 离开父元素时,同时打印 mouseleavemouseout

鼠标事件的定位属性

鼠标事件存在有几个定位属性:clientXclientYpageXpageYscreenXscreenY,这些属性的区别在于坐标的参照对象不同,参考下图。

鼠标事件的定位属性

浏览器重绘

浏览器重绘简单来说就是浏览器根据 DOM 树的变化,重新绘制页面的过程。不作太多解释,仅说明一下特殊特性。

重绘的频率

通常来讲,重绘频率以屏幕刷新率为准,以 60Hz 屏幕为例,两次重绘的间隔不会小于 1/60s16.67ms

如果连接有多个副屏,以主屏幕刷新率为准

什么时候触发重绘

对于浏览器来说,它会累计所有的重绘操作,然后在下一次重绘时一次性执行。而不是每次修改均进行重绘。
也就是说,无论在例如 16.67ms 内进行了多少修改 DOM 的操作,浏览器都只会进行一次重绘。

例如下面的示例中虽然修改了两次 DOM,但只会进行一次重绘。

1
2
3
4
5
console.time( "start" );
const el = document.querySelector( "div" );
el.style.color = "red";
el.style.backgroundColor = "black";
console.timeEnd( "start" );

强制同步布局

尽管重绘的发生频率是有限制的,但浏览器重新计算样式是可以随时发生的。一些不当的代码操作会导致频繁多次的重新计算样式,会极大的对性能造成影响。

例如对于如下代码:

1
2
div1.style.height = "200px";
div2.style.height = "100px";

devtools-performance

可以看到,重绘前只发生了一次重新计算样式。这次重新计算样式是因为元素高度改变导致了布局变更,需要在重绘前重新计算一次样式。

而我们只增加一条打印:

1
2
3
div1.style.height = "200px";
console.log( div1.clientHeight );
div2.style.height = "100px";

devtools-performance

而这次在重绘前发生了两次重新计算样式。且第一次在摘要中明确表示了已强制重新计算

之所以会发生这种原因,是因为当我们进行了对布局有影响的操作后,若此时执行 clientHeightgetBroundingClientRect() 等获取元素尺寸的方法,
浏览器为了获取真实的数据结果,会强制进行一次同步布局,即重新计算样式。

重新计算样式的开销是较大的,因为这种机制存在,我们尤其需要避免下面这种写法:

1
2
3
elList.forEach( el => {
el.style.height = el.clientHeight * 2 + "px";
} );

进行两次遍历,先批量获取高度,再批量写入是性能更优的方式

1
2
3
4
const heightList = elList.map( el => el.clientHeight );
elList.forEach( ( el, index ) => {
el.style.height = heightList[ index ] * 2 + "px";
} );

requestAnimationFrame

requestAnimationFrame 是一个专门用于执行动画的方法,该方法会将回调函数推迟到下一次重绘时执行,浏览器在每次准备重绘时,
会检查是否存在 requestAnimationFrame 的回调函数,若存在则会执行。

因此该方法的执行频率与浏览器的重绘频率一致,即与屏幕刷新率有关。

1
2
3
requestAnimationFrame( () => {
console.log( "动画" );
} );

它的回调函数参数是一个异步执行的函数,其不属于微任务队列。既然是异步任务就要老实按照事件循环的规矩来,主线程执行完成之前没有他什么事。

因此下面的代码会造成死锁,在 while 执行完毕之前,rAF 的回调函数永远都不可能被执行。不可以想当然的认为下一次重绘也就是 16.67ms60HZ 为例) 后就会执行。

1
2
3
4
5
6
7
let i = 0;
while ( i < 5 ) {
requestAnimationFrame( () => {
i++;
console.log( "动画" + i );
} );
}

同时执行多个 rAF

当我们同时执行多次 requestAnimationFrame 时,浏览器会将他们集体保留到下一次重绘前,然后全部执行,执行完再进行重绘,哪怕他们非常耗时。

1
2
3
4
5
for ( let i = 0; i < 100; i++ ) {
requestAnimationFrame( () => {
console.log( "动画" + i );
} );
}

上面代码将会在下一次重绘时一次性全部输出。

嵌套调用 rAF

可以通过嵌套调用 requestAnimationFrame 来实现一个与屏幕刷新率相同的逐帧动画。

1
2
3
4
function animate() {
requestAnimationFrame( animate );
console.log( "动画" );
}

上面的代码并不会造成死锁。我们以 60Hz 屏幕为例,每一次执行方法内操作后都会等待下次重绘即 16.67ms 后再次执行,因此这是一种安全的自递归调用。

闪烁动画

你可能希望实现一个毫秒级的逐帧动画,但因为浏览器的重绘频率限制,并不能很好的通过 setTimeout 来实现。

1
2
3
4
5
6
setTimeout( () => {
document.body.style.backgroundColor = "red";
setTimeout( () => {
document.body.style.backgroundColor = "black";
}, 3 );
} );

实际运行结果是我们有时候会看到红闪黑,有时候直接就是黑色。这是因为我们并不能保证这两次的执行间隔会不会刚好在一个重绘周期内。
而且由于我们无法确定用户屏幕的刷新率,也就无法得知重绘周期的具体时间,因此这个方法并不是一个很好的选择。

requestAnimationFrame 则可以保证每次修改结果均会紧跟浏览器的重绘,可以实现一个稳定的闪烁动画

1
2
3
4
5
6
7
function blink() {
document.body.style.backgroundColor = "red";
requestAnimationFrame( () => {
document.body.style.backgroundColor = "black";
requestAnimationFrame( blink );
} );
}

啊,这闪瞎眼的效果

批量修改元素高度优化

对于 浏览器重绘-强制同步布局 中的案例,尽管已经把读写拆离开来,但依旧不能保证后续在重绘前是否有其他的读操作。
此时依旧会产生无意义的重新计算样式

可以使用 requestAnimationFrame 来优化这个问题,将所有的写操作集中到一次重绘前执行。

1
2
3
4
5
6
elList.forEach( el => {
const height = el.clientHeight;
requestAnimationFrame( () => {
el.style.height = height * 2 + "px";
} );
} );

res.cookie() 所设置的 ck,虽然在浏览器 response header 中的 Set-Cookies 已经得到显示,但会被浏览器同源策略拦截,无法写入到浏览器 cookies 中。

此时需要前端调用接口时设置 { withCredentials: true }(xhr) 或 { credentials: "include" }(fetch)。

1
2
3
4
5
6
7
fetch( targetUrl, {
credentials: "include"
} )

axios( targetUrl, {
withCredentials: true
} )

同时后端也需要设置 Access-Control-Allow-Origin 为前端的源地址,例如 http://localhost:8080,不能是 *,而且还要设置 res.header("Access-Control-Allow-Credentials", true)

1
2
3
4
5
app.use( "*", (req, res, next) => {
res.header( "Access-Control-Allow-Origin", req.headers.origin );
res.header("Access-Control-Allow-Credentials", "true");
next();
} );

若无法设置该请求属性,则必须让前后端域名/ip保持一致,不允许 127.0.0.1 访问 localhost 或相反。

location 的相关方法

location.assign()

立即跳转到指定地址,与修改 location.href 相同。

1
location.assign( "https://marrydream.top/" );

location.replace()

同样是立即跳转到指定地址,但与 assign() 不同的是,replace() 会在 history 中替换当前地址记录,无法再通过浏览器的后退按钮返回。

1
location.replace( "https://marrydream.top/" );

location.reload()

重新加载当前页面,相当于按下刷新按钮,可接受一个布尔值参数,用于控制是否强制刷新。

1
location.reload( true );

location.toString()

返回当前页面的完整 URL,相当于读取 location.href

1
location.toString() === location.href; // true

URL的编码与解码

网页 URL 只能包含合法字符,合法字符分为两类:

  • 语义字符:a-zA-Z0-9-_.~!*'()
  • 保留字符:$&+,/:;=?@#

除了这些字符以外,其他字符都需要进行编码转义,编码后的字符以 % 开头,后面跟着两位十六进制数。

JavaScript 提供了四个方法用于 URL 的编码与解码。

encodeURI()

语义字符保留字符之外的字符进行编码,因此常用来编码整个 URL。

1
encodeURI( `https://marrydream.top/web/Web前端` ); // https://marrydream.top/web/Web%E5%89%8D%E7%AB%AF

encodeURIComponent()

仅不对语义字符进行编码,常用来编码 URL 的组成部分,例如查询参数与哈希值。

1
encodeURIComponent( `https://marrydream.top/web/Web前端` ); // https%3A%2F%2Fmarrydream.top%2Fweb%2FWeb%E5%89%8D%E7%AB%AF

decodeURI() 与 decodeURIComponent()

分别是 encodeURI()encodeURIComponent() 的解码方法。

URLSearchParams 对象

其构造函数不仅可以传入一个对象,还可以是一个 URL 参数字符串,或是二维数组

1
2
3
4
// 参数字符串,有没有 ? 都可以
new URLSearchParams( "?name=mari&age=17" );
// 二维数组
new URLSearchParams( [ [ "name", "mari" ], [ "age", 17 ] ] );

使用 toString() 可以输出不带 ? 的完整参数字符串,会自动对参数进行编码,不需要手动处理。

1
new URLSearchParams( "?game=窝窝屎" ).toString(); // game=%E7%AA%9D%E7%AA%9D%E5%B1%8E

参数被存入 URLSearchParams 对象时,会自动进行解码,不需要自己手动处理

1
2
3
4
5
const params = new URLSearchParams( "?game=窝窝屎" );
params.get( "game" ); // 窝窝屎
params.append( "home", decodeURIComponent( "https://marrydream.top/" ) );
params.get( "home" ); // https://marrydream.top/
[...params.values()]; // [ "窝窝屎", "https://marrydream.top/" ]

get() 与 getAll()

URL 参数这种东西是有可能存在多个同名参数的,因此便有了这两个方法。

get() 方法会返回第一个匹配的参数值,而 getAll() 方法会返回所有匹配的参数值。

1
2
3
4
const params = new URLSearchParams( "?age=17&age=18" );
params.append( "age", 19 );
params.get( "age" ); // 17
params.getAll( "age" ); // [ 17, 18, 19 ]

与 fetch 的结合

fetch 可以直接使用 URLSearchParams 对象作为参数,会自动将参数拼接到 URL 中。

1
2
3
4
fetch( "http://xxx.example.top/api", {
method: "post",
body: new URLSearchParams( { name: "mari", age: 17 } )
} )

也可以利用字符串拼接自动调用 toString() 方法的特性,来直接拼接 url 地址。

1
fetch( "https://api-kozakura.marrydream.top/public/pic/random?" + new URLSearchParams( { format: "100y50" } ) );

blob 对象

blob 对象是一种特殊的二进制对象,通常用于存储二进制数据,例如图片、音频、视频等。

创建 blob 对象

Blob 的构造函数接受两个参数:

1
new Blob(array [, options])
  • array: 数组,内部的元素可以是字符串二进制数据,表示新生成的 Blob 实例对象的内容
  • options: 对象,目前只有一个属性 type, 用于设置 blob 对象数据的 MIME 类型,默认是空字符串
1
new Blob( [ "Hello, World!" ], { type: "text/plain" } );

这里分别创建一个 html 和 JSON 数据,结合 createObjectURL() 方法将 blob 对象转换为临时地址,方便查看结果。

1
2
3
4
5
const htmlBlob = new Blob( [ "<h1>Hello, World!</h1>" ], { type: "text/html" } );
const jsonBlob = new Blob( [ JSON.stringify( { name: "mari", age: 17 } ) ], { type: "application/json" } );

URL.createObjectURL( htmlBlob );
URL.createObjectURL( jsonBlob );

打开链接,分别可以看到一个设定好内容的 html 页面和一个 json 数据。

实例属性和方法

Blob 实例对象有两个属性:

  • size: 表示 blob 对象的大小,单位是字节
  • type: 表示 blob 对象的 MIME 类型
1
2
3
const blob = new Blob( [ JSON.stringify( { name: "mari", age: 17 } ) ], { type: "application/json" } );
blob.size; // 27
blob.type; // application/json

存在一个方法 slice(),用于截取 blob 对象的一部分,返回一个新的 blob 对象,可以起到拷贝的作用。

1
blob.slice( [start [, end [, contentType]]] )
  • start: 数值,表示截取的起始位置,默认为 0
  • end: 数值,表示截取的结束位置,默认为 blob.size
  • contentType: 字符串,表示新 blob 实例的 MIME 类型,默认为空字符串

File

File 继承自 Blob,是 Blob 的一种特殊形式,所有可以使用 Blob 的地方都可以使用 File
最常见的应用场景是在上传文件时,通过 input[type="file"] 获取到的文件对象就是 File 对象。

创建 File 对象

File 的构造函数接受三个参数:

1
new File(array, name [, options])
  • array: 与 Blob 构造函数的 array 参数相同
  • name: 字符串,表示文件名
  • options: 有两个属性 typelastModified,分别表示文件的 MIME 类型与最后修改时间

实例属性

相比 BlobFile 多了两个属性:

  • name: 文件名或文件路径
  • lastModified: 文件的最后修改时间

FileReader

FileReader 是一个用于读取文件的对象,可以读取 BlobFile 对象的内容。

1
const reader = new FileReader();

实例属性

FileReader 有下面一系列实例属性:

  • readyState: 表示读取状态,有三个值:0(未读取)、1(正在读取)、2(读取完成)
  • result: 表示读取结果,结果类型取决于读取的方法
  • error: 表示读取错误,如果读取失败则会有一个错误对象

实例方法

实例有四个读取方法,读取方法均接受一个 blobfile 对象,在读取完成后会将相应类型的值存入 result 属性中:

  • readAsArrayBuffer(): 得到一个 ArrayBuffer 对象
  • readAsBinaryString(): 得到一个原始二进制字符串
  • readAsDataURL(): 得到一个 base64 编码的字符串
  • readAsText(): 得到一个文本字符串

还有一个手动中断的方法:

  • abort(): 中断读取操作,会将 readyState 设置为 2result 设置为 null

监听事件

这些事件的监听均可以通过 on 属性前缀或 addEventListener 方法来实现:

  • loadstart: 开始读取时触发
  • loadend: 读取结束时触发
  • progress: 读取过程中触发
  • load: 读取完成时触发
  • error: 读取失败时触发
  • abort: 用户手动中断时触发

案例

我们来实现一个拖拽后读取图片,并显示在页面上的案例。

1
2
<div id="container"></div>
<div id="show"></div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const container = document.getElementById( "container" );
const show = document.getElementById( "show" );
container.addEventListener( "dragover", e => e.preventDefault() );
container.addEventListener( "drop", async e => {
e.preventDefault();
for ( const file of e.dataTransfer.files ) {
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.src = reader.result;
show.appendChild( img );
};
reader.readAsDataURL( file );
}
} );

form HTML 标签的默认提交行为

此时我们有如下 form 表单:

1
2
3
4
5
<form action="https://api-kozakura.marrydream.top/public/pic/random" method="get">
<input type="text" name="resize" value="100y50">
<input type="text" name="format" value="png">
<input type="submit" value="Get Image">
</form>

在上面这个 HTML 结构中,点击 Get Image 按钮后,会触发 submit 事件,并进行浏览器所定义的默认提交行为。

若表单中的 <button> 元素没有设置 type 属性,那么也会触发默认提交行为。

这个默认提交行为根据 method 值的不同分为两种情况。

get

此时将忽略掉 action 中 URL 的参数部分,将每一项表单的 name 作为键,value 作为值,以 name=value 的形式拼接到 action 的 URL 后面作为查询参数。

然后把当前页面的 URL 替换为拼接后的 URL,完成页面的跳转。

例如上面的案例,最终将会把当前页面替换为 https://api-kozakura.marrydream.top/public/pic/random?resize=100y50&format=png 页面。

post

此时会分为几步进行:

  • 将请求头的 Content-Type 设置为 application/x-www-form-urlencoded
  • 依旧是将每一项表单的 name 作为键,value 作为值,以 name=value 的形式拼接为键值对,使用 & 连接所有键值对
  • 作为请求体发送给 action 所指向的地址

例如上面的案例,最终将会向 https://api-kozakura.marrydream.top/public/pic/random 发送一个 POST 请求,请求体为 resize=100y50&format=png

自定义提交行为

可以通过在 submit 事件监听器中调用 preventDefault() 方法来阻止默认提交行为。
如果再需要使用默认的提交行为,手动调用 formEl.submit() 方法。

1
2
3
4
5
6
7
8
formEl.addEventListener( "submit", e => {
e.preventDefault();
// 自定义处理
// ...
// 使用默认方式提交表单
formEl.submit();
} );

canvas 绘制跨域图片

canvas 绘制跨域图片时会出现画布污染,禁止对该画布使用 toDataURL 等方法,可通过给图片设置跨域属性解决

1
images[src].setAttribute( "crossOrigin", "Anonymous" );

阻止浏览器自动填充

通常情况下,可设置 autocomplete="off" 来阻止浏览器自动填充,但该操作仅对普通输入框有效。

1
<input type="text" autocomplete="off" />

对于密码框,则应该使用 autocomplete="new-password"

1
<input type="password" autocomplete="new-password" />

vue-router

动态添加路由后页面空白

通常会在 beforeRouter 中添加动态路由,若停留在被添加的动态路由地址刷新页面的话,会出现白屏的现象。

这是 addRoute 还未设置完路由导致的,此时应当在添加动态路由后重新进入一次当前页面。

1
2
3
4
router.beforeEach( ( to, from, next ) => {
router.addRoute( route );
return next( { ...to, replace: true } )
} );

设置 replace: true 来避免生成路由历史记录。

vite

文件系统的静态资源引入路径

使用文件系统路径的别名时,需要始终使用绝对路径。此时 vite 不再对相对路径别名进行动态转换。

此时由于网站 url 的原因,相对路径将会被解析为错误的路径。而别名则会原封不动的被引入,导致路径错误。

文件系统指代图片资源外的普通文件,如 .mp3 等。

解决方案

使用 import.meta.urlimport from 静态资源引入来得到正确的绝对路径。

1
2
3
new URL( "assets/test.mp3", import.meta.url ).href;
// 或
import audioUrl from "@/assets/test.mp3?url";